Guard signed-out shell bootstrap services
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# Sprint 20260307-014 - FE Signed-Out Shell Bootstrap Auth Guards
|
||||
|
||||
## Topic & Scope
|
||||
- Eliminate signed-out `/welcome` shell requests that currently hit authenticated context and Doctor endpoints before a session exists.
|
||||
- Start background shell services only when the authenticated shell is active, and stop them when the user leaves that state.
|
||||
- Add focused Angular tests for the bootstrap gating, then verify `/welcome` with live Playwright to prove the browser stops emitting `401` bootstrap noise.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Expected evidence: focused Angular tests, live Playwright request tracing on `https://stella-ops.local/welcome`, and sprint execution log updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the just-landed release-health iteration (`SPRINT_20260307_012` and `SPRINT_20260307_013`) because the new defect was discovered during the topology Playwright sweep that followed those fixes.
|
||||
- Safe parallelism: stay inside `src/Web/StellaOps.Web` plus sprint updates; do not touch unrelated navigation/settings/sidebar work already in progress from other agents.
|
||||
- Scope is limited to signed-out shell bootstrap behavior, not broader auth UX or welcome-page visual work.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
- `src/Web/StellaOps.Web/src/app/app.component.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-AUTH-001 - Reproduce signed-out shell bootstrap traffic
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Replay `/welcome` and a signed-out transition into the app shell with real Playwright.
|
||||
- Capture the exact unauthorized requests instead of treating console errors as generic flake.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live Playwright captures the concrete `401` request set.
|
||||
- [x] Root cause is reduced to specific shell bootstrap call sites.
|
||||
|
||||
### FE-AUTH-002 - Gate shell background services behind authenticated shell state
|
||||
Status: DONE
|
||||
Dependency: FE-AUTH-001
|
||||
Owners: Developer
|
||||
Task description:
|
||||
- Update app-shell bootstrap behavior so Platform context sync and Doctor background services only start when the authenticated shell is active.
|
||||
- Ensure background polling stops cleanly when the user leaves authenticated shell state.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Signed-out `/welcome` no longer starts protected Platform context bootstrap.
|
||||
- [x] Signed-out shell no longer starts Doctor trend/report background polling.
|
||||
- [x] Authenticated shell still starts the required background services.
|
||||
|
||||
### FE-AUTH-003 - Add focused Angular coverage for bootstrap gating
|
||||
Status: DONE
|
||||
Dependency: FE-AUTH-002
|
||||
Owners: Test Automation
|
||||
Task description:
|
||||
- Add focused tests around `AppComponent` bootstrap behavior and any touched shell services.
|
||||
- Verify the tests assert start/stop behavior, not just component creation.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Focused Angular tests fail before the fix and pass after it.
|
||||
- [x] Tests cover both signed-out suppression and authenticated-shell startup.
|
||||
|
||||
### FE-AUTH-004 - Replay `/welcome` with live Playwright
|
||||
Status: DONE
|
||||
Dependency: FE-AUTH-003
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Re-run a live Playwright trace on `/welcome` and capture console plus network evidence after the fix.
|
||||
- Confirm the page remains interactive and the previous unauthorized bootstrap requests are gone.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live Playwright on `/welcome` shows no `401` bootstrap requests from Platform context or Doctor endpoints.
|
||||
- [x] The sign-in action remains available and the page stays responsive.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-07 | Sprint created after live Playwright on topology/release-health showed repeated signed-out `401` requests on `/welcome` for `/api/v2/context/*` and `/api/v1/doctor/scheduler/trends/categories/*`, traced to unconditional shell bootstrap startup. | QA |
|
||||
| 2026-03-07 | Gated shell bootstrap startup behind authenticated shell state in `AppComponent`, added stop semantics to the Doctor background services, and guarded `PlatformContextStore` against unauthenticated protected requests. Focused Angular coverage passed via `npx ng test --watch=false --include src/app/app.component.spec.ts --include src/app/core/context/platform-context.store.spec.ts`. | Developer |
|
||||
| 2026-03-07 | Replayed signed-out `/welcome` with live Playwright on the rebuilt bundle. The page rendered with no console errors, no `401` responses, and the primary sign-in action still navigated to the Authority `/connect/authorize` flow. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: fix the startup sequencing at the shell bootstrap layer instead of swallowing `401` responses, because the browser should not emit protected requests before auth exists.
|
||||
- Decision: add defense-in-depth guards in both `AppComponent` and the background/context services so signed-out routes remain quiet even if a future caller starts those services too early.
|
||||
- Risk: the app currently starts long-lived background services from `AppComponent`, so the fix must preserve authenticated behavior while preventing duplicate timers or stale polling after logout.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-07: continue the Playwright page/action sweep from the signed-out/authenticated shell surfaces outward.
|
||||
@@ -15,6 +15,10 @@ import { PolicyPackStore } from './features/policy-studio/services/policy-pack.s
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { signal } from '@angular/core';
|
||||
import { DOCTOR_API, MockDoctorClient } from './features/doctor/services/doctor.client';
|
||||
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
|
||||
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
|
||||
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
|
||||
import { AUTHORITY_CONSOLE_API } from './core/api/authority-console.client';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
@@ -33,7 +37,25 @@ class OfflineModeServiceStub {
|
||||
readonly offlineBannerMessage = signal<string | null>(null);
|
||||
}
|
||||
|
||||
class DoctorTrendServiceStub {
|
||||
readonly start = jasmine.createSpy('start');
|
||||
readonly stop = jasmine.createSpy('stop');
|
||||
}
|
||||
|
||||
class DoctorNotificationServiceStub {
|
||||
readonly start = jasmine.createSpy('start');
|
||||
readonly stop = jasmine.createSpy('stop');
|
||||
}
|
||||
|
||||
class PlatformContextUrlSyncServiceStub {
|
||||
readonly initialize = jasmine.createSpy('initialize');
|
||||
}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let doctorTrend: DoctorTrendServiceStub;
|
||||
let doctorNotification: DoctorNotificationServiceStub;
|
||||
let contextUrlSync: PlatformContextUrlSyncServiceStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
@@ -54,10 +76,62 @@ describe('AppComponent', () => {
|
||||
},
|
||||
{ provide: OfflineModeService, useClass: OfflineModeServiceStub },
|
||||
{ provide: DOCTOR_API, useClass: MockDoctorClient },
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useValue: {
|
||||
listTenants: () => of({
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-1',
|
||||
displayName: 'Tenant One',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
selectedTenant: 'tenant-1',
|
||||
}),
|
||||
getProfile: () => of({
|
||||
subjectId: 'user-1',
|
||||
username: 'user.one',
|
||||
displayName: 'User One',
|
||||
tenant: 'tenant-1',
|
||||
sessionId: 'session-1',
|
||||
roles: ['operator'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['stellaops-web'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
}),
|
||||
introspectToken: () => of({
|
||||
active: true,
|
||||
tenant: 'tenant-1',
|
||||
subject: 'user-1',
|
||||
clientId: 'stellaops-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['stellaops-web'],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ provide: DoctorTrendService, useClass: DoctorTrendServiceStub },
|
||||
{ provide: DoctorNotificationService, useClass: DoctorNotificationServiceStub },
|
||||
{ provide: PlatformContextUrlSyncService, useClass: PlatformContextUrlSyncServiceStub },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
doctorTrend = TestBed.inject(DoctorTrendService) as unknown as DoctorTrendServiceStub;
|
||||
doctorNotification = TestBed.inject(DoctorNotificationService) as unknown as DoctorNotificationServiceStub;
|
||||
contextUrlSync = TestBed.inject(PlatformContextUrlSyncService) as unknown as PlatformContextUrlSyncServiceStub;
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
@@ -66,6 +140,17 @@ describe('AppComponent', () => {
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not start shell background services while signed out', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(contextUrlSync.initialize).not.toHaveBeenCalled();
|
||||
expect(doctorTrend.start).not.toHaveBeenCalled();
|
||||
expect(doctorNotification.start).not.toHaveBeenCalled();
|
||||
expect(doctorTrend.stop).not.toHaveBeenCalled();
|
||||
expect(doctorNotification.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a router outlet for child routes', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
@@ -95,6 +180,36 @@ describe('AppComponent', () => {
|
||||
expect(compiled.querySelector('app-sidebar')).not.toBeNull();
|
||||
expect(compiled.querySelector('app-context-chips')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('starts shell background services once the shell is authenticated', () => {
|
||||
const sessionStore = TestBed.inject(AuthSessionStore);
|
||||
sessionStore.setSession(buildSession());
|
||||
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(contextUrlSync.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(doctorTrend.start).toHaveBeenCalledTimes(1);
|
||||
expect(doctorNotification.start).toHaveBeenCalledTimes(1);
|
||||
expect(doctorTrend.stop).not.toHaveBeenCalled();
|
||||
expect(doctorNotification.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops doctor background services when auth is cleared after startup', () => {
|
||||
const sessionStore = TestBed.inject(AuthSessionStore);
|
||||
sessionStore.setSession(buildSession());
|
||||
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
sessionStore.clear();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(doctorTrend.start).toHaveBeenCalledTimes(1);
|
||||
expect(doctorNotification.start).toHaveBeenCalledTimes(1);
|
||||
expect(doctorTrend.stop).toHaveBeenCalledTimes(1);
|
||||
expect(doctorNotification.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function buildSession(): AuthSession {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
@@ -68,6 +69,7 @@ export class AppComponent {
|
||||
private readonly contextUrlSync = inject(PlatformContextUrlSyncService);
|
||||
private readonly doctorTrend = inject(DoctorTrendService);
|
||||
private readonly doctorNotification = inject(DoctorNotificationService);
|
||||
private shellBackgroundServicesActive = false;
|
||||
|
||||
@ViewChild(CommandPaletteComponent) private commandPalette!: CommandPaletteComponent;
|
||||
|
||||
@@ -109,13 +111,35 @@ export class AppComponent {
|
||||
// Initialize legacy route telemetry tracking (ROUTE-002)
|
||||
this.legacyRouteTelemetry.initialize();
|
||||
|
||||
// Keep global scope in sync with route query parameters.
|
||||
this.contextUrlSync.initialize();
|
||||
effect(() => {
|
||||
const shouldRunShellBackgroundServices =
|
||||
this.isAuthenticated() && !this.isShellExcludedRoute(this.currentUrl());
|
||||
|
||||
// Start Doctor background services (deferred from APP_INITIALIZER
|
||||
// to avoid NG0200 circular DI with Router during bootstrap).
|
||||
this.doctorTrend.start();
|
||||
this.doctorNotification.start();
|
||||
if (shouldRunShellBackgroundServices) {
|
||||
if (this.shellBackgroundServicesActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep global scope in sync with route query parameters only once the
|
||||
// authenticated shell is active.
|
||||
this.contextUrlSync.initialize();
|
||||
|
||||
// Start Doctor background services (deferred from APP_INITIALIZER
|
||||
// to avoid NG0200 circular DI with Router during bootstrap).
|
||||
this.doctorTrend.start();
|
||||
this.doctorNotification.start();
|
||||
this.shellBackgroundServicesActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.shellBackgroundServicesActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doctorTrend.stop();
|
||||
this.doctorNotification.stop();
|
||||
this.shellBackgroundServicesActive = false;
|
||||
});
|
||||
}
|
||||
|
||||
readonly isAuthenticated = this.sessionStore.isAuthenticated;
|
||||
|
||||
@@ -2,16 +2,26 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { PlatformContextStore } from './platform-context.store';
|
||||
|
||||
describe('PlatformContextStore', () => {
|
||||
let store: PlatformContextStore;
|
||||
let httpMock: HttpTestingController;
|
||||
let authenticated = true;
|
||||
|
||||
beforeEach(() => {
|
||||
authenticated = true;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlatformContextStore,
|
||||
{
|
||||
provide: AuthSessionStore,
|
||||
useValue: {
|
||||
isAuthenticated: () => authenticated,
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
@@ -97,4 +107,15 @@ describe('PlatformContextStore', () => {
|
||||
expect(store.selectedEnvironments()).toEqual(['dev']);
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not request protected context endpoints before auth is available', () => {
|
||||
authenticated = false;
|
||||
|
||||
store.initialize();
|
||||
|
||||
httpMock.expectNone('/api/v2/context/regions');
|
||||
expect(store.initialized()).toBe(false);
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
|
||||
export interface PlatformContextRegion {
|
||||
regionId: string;
|
||||
displayName: string;
|
||||
@@ -54,6 +56,7 @@ interface PlatformContextPreferencesRequestPayload {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformContextStore {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private persistPaused = false;
|
||||
private readonly apiDisabled = this.shouldDisableApiCalls();
|
||||
private readonly initialQueryOverride = this.readScopeQueryFromLocation();
|
||||
@@ -106,6 +109,12 @@ export class PlatformContextStore {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authSession.isAuthenticated()) {
|
||||
this.loading.set(false);
|
||||
this.error.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.apiDisabled) {
|
||||
this.tenantId.set(this.initialQueryOverride?.tenantId ?? null);
|
||||
this.loading.set(false);
|
||||
@@ -380,7 +389,7 @@ export class PlatformContextStore {
|
||||
}
|
||||
|
||||
private persistPreferences(): void {
|
||||
if (this.persistPaused || this.apiDisabled) {
|
||||
if (this.persistPaused || this.apiDisabled || !this.authSession.isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
@@ -15,23 +16,43 @@ const MUTED_KEY = 'stellaops_doctor_notifications_muted';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DoctorNotificationService {
|
||||
private readonly api = inject<DoctorApi>(DOCTOR_API);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private startTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Whether notifications are muted. Persisted in localStorage. */
|
||||
readonly muted = signal(this.loadMutedState());
|
||||
|
||||
/** Start polling with 10s initial delay, then every 60s. */
|
||||
start(): void {
|
||||
setTimeout(() => {
|
||||
if (this.intervalId !== null || this.startTimeoutId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startTimeoutId = setTimeout(() => {
|
||||
this.startTimeoutId = null;
|
||||
this.checkForNewReports();
|
||||
this.intervalId = setInterval(() => this.checkForNewReports(), 60000);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/** Stop polling when the authenticated shell is no longer active. */
|
||||
stop(): void {
|
||||
if (this.startTimeoutId !== null) {
|
||||
clearTimeout(this.startTimeoutId);
|
||||
this.startTimeoutId = null;
|
||||
}
|
||||
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle mute state. */
|
||||
toggleMute(): void {
|
||||
const newState = !this.muted();
|
||||
@@ -44,7 +65,7 @@ export class DoctorNotificationService {
|
||||
}
|
||||
|
||||
private checkForNewReports(): void {
|
||||
if (this.muted()) return;
|
||||
if (this.muted() || !this.authSession.isAuthenticated()) return;
|
||||
|
||||
this.api.listReports(1, 0)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client';
|
||||
import { DoctorTrendResponse } from './doctor-trend.models';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { DoctorTrendResponse } from './doctor-trend.models';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DoctorTrendService {
|
||||
private readonly api = inject<DoctorApi | null>(DOCTOR_API, { optional: true });
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -23,17 +25,32 @@ export class DoctorTrendService {
|
||||
|
||||
/** Start periodic trend fetching (60s interval). */
|
||||
start(): void {
|
||||
if (this.intervalId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchTrends();
|
||||
this.intervalId = setInterval(() => this.fetchTrends(), 60000);
|
||||
}
|
||||
|
||||
/** Stop periodic trend fetching and clear shell sparkline state. */
|
||||
stop(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
this.securityTrend.set([]);
|
||||
this.platformTrend.set([]);
|
||||
}
|
||||
|
||||
/** Force immediate re-fetch. */
|
||||
refresh(): void {
|
||||
this.fetchTrends();
|
||||
}
|
||||
|
||||
private fetchTrends(): void {
|
||||
if (!this.api) {
|
||||
if (!this.api || !this.authSession.isAuthenticated()) {
|
||||
this.securityTrend.set([]);
|
||||
this.platformTrend.set([]);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user