feat(portal-admin): spa auth wiring + admin shell skeleton (#134)
## Summary Phase-3a step per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Confirmation" item 4 (entry route + admin shell). Wires the existing [`feature-auth`](libs/feature/auth) library against the distinct admin OIDC routes (`/api/admin/auth/*`) the BFF exposes since PR #129, ships a lean header/sidebar/footer chrome with an "Admin" badge so an internal user can never mistake the surface for portal-shell, and gives the landing page a self-test panel that confirms the auth chain end-to-end as soon as an `admin` Entra role gets assigned. ## What lands ### Lib change — `AUTH_PATH_PREFIX` injection token [`libs/feature/auth`](libs/feature/auth/src/lib/auth.config.ts) gains one injection token. AuthService composes URLs as `${bffBaseUrl}${pathPrefix}/{me,login,logout}` instead of hard-coding `/auth/...`. Default factory returns `/auth`, so portal-shell-shaped consumers keep working without an explicit provider — no churn on existing call sites. Admin hosts override with `/admin/auth`. The interceptors (`bffCredentialsInterceptor`, `csrfInterceptor`, `bffUnauthorizedInterceptor`) are **unchanged** — they only care about the BFF base URL, not the path prefix. ### portal-admin wiring - [environment.ts](apps/portal-admin/src/environments/environment.ts) — same shape as portal-shell, same BFF base URL (both SPAs talk to one BFF per ADR-0020 §"Where does the admin app live"). Same CSRF cookie name in v1. - [app.config.ts](apps/portal-admin/src/app/app.config.ts) — `HttpClient` with the standard interceptor chain (credentials → csrf → unauthorized), `AUTH_BFF_BASE_URL` from env, `AUTH_PATH_PREFIX = '/admin/auth'`, `AUTH_CSRF_COOKIE_NAME` from env. ### Admin shell - **[AdminHeader](apps/portal-admin/src/app/components/header/header.ts)** — APF wordmark + persistent "Admin" badge + auth widget. No global search / notifications / help cluster: admins land on tabular workloads, not a discovery dashboard (ADR-0020 §"UX style is data-dense"). - **[AdminSidebar](apps/portal-admin/src/app/components/sidebar/sidebar.ts)** — static menu listing the four ADR-0020 v1 modules. Audit log is a live router link (target of the next PR); the others are `aria-disabled` placeholders with a "Soon" badge so the navigation shape is visible even before they ship. - **[AdminFooter](apps/portal-admin/src/app/components/footer/footer.ts)** — copyright + persistent "Admin surface" tag. Stays in view for long workloads where the header has scrolled off. ### Home — auth self-test panel [apps/portal-admin/src/app/pages/home/](apps/portal-admin/src/app/pages/home/): - Signed-in: shows `displayName`, `username`, `tenant`, `oid` in a monospaced detail list. - Anonymous: "No admin session detected" + a "Sign in via Entra" button that delegates to `AuthService.login()` → 302 through `/api/admin/auth/login`. - Error: "Could not reach the BFF" + retry button. - Roadmap list quoting ADR-0020's v1 catalogue. ## Known limitations (v1, documented) - **CSRF cookie shared between surfaces.** Both portals issue `portal_csrf` on session creation. A user with both portals open will see overwrites on the second sign-in; mutating actions on the first surface will then 403 until refresh. Splitting to `portal_admin_csrf` is a follow-up if the pattern becomes common. - **Shell chrome strings are plain English.** The admin app's `$localize` plumbing is wired (matches portal-shell), but adding markers to the shell here would require regenerating `messages.fr.xlf` and `i18nMissingTranslation=error` fails the prod build on every gap. Full admin i18n is its own follow-up. - **`LayoutStateService` not yet consumed.** No collapse toggle in v1. Theme preference still threads through because both apps read the same `localStorage` key — toggle in portal-shell, see it honoured in portal-admin. - **`ci:perf` only runs against portal-shell.** Admin perf budgets are enforced at build time by `apps/portal-admin/project.json` (`maximumError == maximumWarning` per ADR-0020's relaxed thresholds: 500 KB initial JS / 1.5 MB initial total). Adding admin to `pnpm ci:perf`'s gzip + Lighthouse chain is a follow-up. ## Test plan - [x] `pnpm nx test feature-auth` — **29 specs pass** (was 28; +1 for the `AUTH_PATH_PREFIX` override). - [x] `pnpm nx test portal-admin` — **15 specs pass** (was 1; +14: AdminHeader 5, AdminSidebar 3, AdminFooter 2, Home 4, App 1 expanded). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Gzip-budget script run manually against `dist/apps/portal-admin/browser`: 86.65 KB initial / 300 KB budget, 4.46 KB CSS / 150 KB budget, 2.15 KB largest lazy / 100 KB per-chunk budget. Both `en` + `fr` bundles checked thanks to PR #133's multi-locale detection. - [ ] e2e — pending an Entra `admin` role assignment. Once available: `pnpm nx serve portal-admin`, visit `http://localhost:4300`, see "No admin session detected", click "Sign in via Entra", complete the round-trip, land back on `/` with the signed-in payload + `portal_admin_session` cookie set. ## Notes for the reviewer - `AdminHeader` is intentionally narrower than `Header` from portal-shell — no shared base class. The two are likely to evolve in different directions (admin-specific banner with system-status badges, audit-trail link, etc.) and a premature abstraction would be expensive to undo. - `AdminSidebar`'s "Soon" badge is a deliberate signal — without it, an admin who clicked a placeholder link and got a route-not-found would assume the app is broken. The badge sets expectations. - All shell components use `:host-context(.dark)` for dark-mode SCSS instead of `:host(.dark)` — same pattern as portal-shell, since the `.dark` class lives on `<html>` outside the component's view encapsulation. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #134
This commit was merged in pull request #134.
This commit is contained in:
@@ -1,7 +1,49 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
provideZonelessChangeDetection,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import {
|
||||||
|
AUTH_BFF_BASE_URL,
|
||||||
|
AUTH_CSRF_COOKIE_NAME,
|
||||||
|
AUTH_PATH_PREFIX,
|
||||||
|
bffCredentialsInterceptor,
|
||||||
|
bffUnauthorizedInterceptor,
|
||||||
|
csrfInterceptor,
|
||||||
|
} from 'feature-auth';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
// `withFetch()` makes Angular's HttpClient delegate to the
|
||||||
|
// browser `fetch` API — what
|
||||||
|
// `@opentelemetry/instrumentation-fetch` patches, so every
|
||||||
|
// HttpClient request gets its own span and W3C `traceparent`
|
||||||
|
// header automatically.
|
||||||
|
//
|
||||||
|
// Same interceptor chain as portal-shell (per ADR-0009 / ADR-0021):
|
||||||
|
// - bffCredentialsInterceptor: withCredentials on BFF URLs.
|
||||||
|
// - bffUnauthorizedInterceptor: refresh AuthService on 401.
|
||||||
|
// - csrfInterceptor: echo CSRF cookie on mutating requests.
|
||||||
|
provideHttpClient(
|
||||||
|
withFetch(),
|
||||||
|
withInterceptors([bffCredentialsInterceptor, csrfInterceptor, bffUnauthorizedInterceptor]),
|
||||||
|
),
|
||||||
|
// `feature-auth` is environment-agnostic. portal-admin diverges
|
||||||
|
// from portal-shell on a single token: AUTH_PATH_PREFIX. The
|
||||||
|
// BFF mounts the admin OIDC routes under `/api/admin/auth/*`
|
||||||
|
// (per ADR-0020 §"Sessions — distinct from `portal-shell`"), so
|
||||||
|
// the AuthService composes `${bffApiBaseUrl}/admin/auth/{me,login,logout}`.
|
||||||
|
// The session middleware on the BFF resolves `/api/admin/*` to
|
||||||
|
// the distinct `__Host-portal_admin_session` cookie.
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: environment.bffApiBaseUrl },
|
||||||
|
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
||||||
|
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: environment.bffCsrfCookieName },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
|
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
|
||||||
<main id="main-content" tabindex="-1" class="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<app-admin-header />
|
||||||
<router-outlet />
|
<div class="shell-body">
|
||||||
</main>
|
<app-admin-sidebar />
|
||||||
|
<main id="main-content" tabindex="-1" class="shell-main">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<app-admin-footer />
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
|
// Admin shell: full-viewport flex column — header on top, sidebar +
|
||||||
|
// main side-by-side filling the middle, footer pinned at the bottom.
|
||||||
|
// The sidebar owns its own scrolling so a long nav doesn't push the
|
||||||
|
// header off-screen; main scrolls independently so audit-log tables
|
||||||
|
// don't drag the chrome with them.
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #111827;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .shell-main {
|
||||||
|
background-color: #0b1220;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
|
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
|
||||||
// visible and focusable when reached via Tab from the address bar.
|
// visible and focusable when reached via Tab from the address bar.
|
||||||
// Plain CSS rather than the `sr-only` Tailwind utilities so the
|
// Plain CSS rather than the `sr-only` Tailwind utilities so the
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from 'feature-auth';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [provideRouter([])],
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
|
||||||
|
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: 'portal_csrf' },
|
||||||
|
{ provide: AUTH_NAVIGATOR, useValue: vi.fn() },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the layout shell — skip link, main landmark', async () => {
|
it('renders the admin shell — skip link, header, sidebar, main landmark, footer', async () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('a.skip-link')).not.toBeNull();
|
expect(root.querySelector('a.skip-link')).not.toBeNull();
|
||||||
expect(root.querySelector('main#main-content')).not.toBeNull();
|
expect(root.querySelector('main#main-content')).not.toBeNull();
|
||||||
|
expect(root.querySelector('app-admin-header')).not.toBeNull();
|
||||||
|
expect(root.querySelector('app-admin-sidebar')).not.toBeNull();
|
||||||
|
expect(root.querySelector('app-admin-footer')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { AdminFooter } from './components/footer/footer';
|
||||||
|
import { AdminHeader } from './components/header/header';
|
||||||
|
import { AdminSidebar } from './components/sidebar/sidebar';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, AdminHeader, AdminSidebar, AdminFooter],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<footer class="admin-footer">
|
||||||
|
<span class="copyright">© {{ year }} APF France handicap</span>
|
||||||
|
<span class="surface-tag">Admin surface</span>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.admin-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .admin-footer {
|
||||||
|
background-color: #0f172a;
|
||||||
|
border-top-color: #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-brand-accent-700, #b45309);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .surface-tag {
|
||||||
|
color: var(--color-brand-accent-300, #fbbf24);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { AdminFooter } from './footer';
|
||||||
|
|
||||||
|
describe('AdminFooter', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [AdminFooter] }).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(): HTMLElement {
|
||||||
|
const fixture = TestBed.createComponent(AdminFooter);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the APF copyright with the current year', () => {
|
||||||
|
const root = render();
|
||||||
|
const copy = root.querySelector('.copyright')?.textContent ?? '';
|
||||||
|
expect(copy).toContain('APF France handicap');
|
||||||
|
expect(copy).toContain(String(new Date().getFullYear()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the "Admin surface" tag as a persistent visual reminder', () => {
|
||||||
|
const root = render();
|
||||||
|
expect(root.querySelector('.surface-tag')?.textContent?.trim()).toBe('Admin surface');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal admin-portal footer. Sits flush at the bottom of the
|
||||||
|
* shell with the copyright + a small "Admin" surface tag — same
|
||||||
|
* visual reminder as the header badge, since the footer is in
|
||||||
|
* view for any sufficiently long workload (audit-log paging,
|
||||||
|
* CMS editing) where the header may have scrolled out.
|
||||||
|
*
|
||||||
|
* No locale switcher, no accessibility-statement link in v1 — both
|
||||||
|
* land when the admin app gets full i18n / a11y plumbing of its
|
||||||
|
* own (currently inherited from the portal-shell baseline only).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-footer',
|
||||||
|
templateUrl: './footer.html',
|
||||||
|
styleUrl: './footer.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AdminFooter {
|
||||||
|
protected readonly year = new Date().getFullYear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<header class="admin-header">
|
||||||
|
<a class="brand" routerLink="/">
|
||||||
|
<span class="brand-wordmark">APF Portal</span>
|
||||||
|
<span class="brand-badge">Admin</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="auth-widget">
|
||||||
|
@switch (authState().kind) { @case ('loading') {
|
||||||
|
<span class="auth-status auth-status--loading" aria-live="polite">…</span>
|
||||||
|
} @case ('anonymous') {
|
||||||
|
<button type="button" class="btn btn--primary" (click)="signIn()">Sign in</button>
|
||||||
|
} @case ('error') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--secondary"
|
||||||
|
(click)="signIn()"
|
||||||
|
aria-label="Session unavailable — sign in again"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
|
||||||
|
<span class="avatar" [attr.aria-label]="state.user.displayName">{{ initials() }}</span>
|
||||||
|
<span class="auth-name">{{ state.user.displayName }}</span>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="signOut()">Sign out</button>
|
||||||
|
} } } }
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Lean admin header — single flex row, no collapse animation, no
|
||||||
|
// nested grids. Matches the `LayoutStateService`-driven sidebar's
|
||||||
|
// rail metrics so the brand sits flush above the navigation column.
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background-color: var(--color-brand-primary-600, #1d4ed8);
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Admin" pill — distinct color from the wordmark so it reads as a
|
||||||
|
// status badge even in dense headers. Keeps the surface
|
||||||
|
// immediately recognisable per ADR-0020 §"Discoverability for
|
||||||
|
// auditors".
|
||||||
|
.brand-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background-color: var(--color-brand-accent-300, #f59e0b);
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-widget {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status--loading {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background-color: var(--color-brand-accent-300, #f59e0b);
|
||||||
|
color: #1f2937;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import {
|
||||||
|
AUTH_BFF_BASE_URL,
|
||||||
|
AUTH_NAVIGATOR,
|
||||||
|
AUTH_PATH_PREFIX,
|
||||||
|
type CurrentUser,
|
||||||
|
} from 'feature-auth';
|
||||||
|
import { AdminHeader } from './header';
|
||||||
|
|
||||||
|
const BFF_BASE = 'http://bff.test/api';
|
||||||
|
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
|
||||||
|
|
||||||
|
const USER: CurrentUser = {
|
||||||
|
oid: 'admin-oid',
|
||||||
|
tid: 'tenant-1',
|
||||||
|
username: 'admin@apf.example',
|
||||||
|
displayName: 'Admin Smith',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(opts: { onBootstrap: 'authenticated' | 'anonymous' | 'leave' }) {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [AdminHeader],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||||
|
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
||||||
|
{ provide: AUTH_NAVIGATOR, useValue: navigate },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(AdminHeader);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
// AuthService fires the bootstrap /me from its constructor. Flush
|
||||||
|
// it before the first whenStable so the signal update has settled
|
||||||
|
// by the time we assert on the rendered DOM. `leave` keeps the
|
||||||
|
// loading state visible for tests that need it.
|
||||||
|
if (opts.onBootstrap !== 'leave') {
|
||||||
|
const req = http.expectOne(ADMIN_ME_URL);
|
||||||
|
if (opts.onBootstrap === 'authenticated') {
|
||||||
|
req.flush(USER);
|
||||||
|
} else {
|
||||||
|
req.flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
return { fixture, http, navigate };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminHeader', () => {
|
||||||
|
it('renders the wordmark + admin badge', async () => {
|
||||||
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelector('.brand-wordmark')?.textContent).toContain('APF Portal');
|
||||||
|
expect(root.querySelector('.brand-badge')?.textContent?.trim()).toBe('Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a Sign in button when anonymous', async () => {
|
||||||
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector('.btn--primary');
|
||||||
|
expect(button?.textContent?.trim()).toBe('Sign in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the user widget with initials when authenticated', async () => {
|
||||||
|
const { fixture } = await setup({ onBootstrap: 'authenticated' });
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelector('.avatar')?.textContent?.trim()).toBe('AS');
|
||||||
|
expect(root.querySelector('.auth-name')?.textContent).toContain('Admin Smith');
|
||||||
|
expect(root.querySelector('.btn--secondary')?.textContent?.trim()).toBe('Sign out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the admin login URL on Sign in click', async () => {
|
||||||
|
const { fixture, navigate } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
(fixture.nativeElement as HTMLElement)
|
||||||
|
.querySelector<HTMLButtonElement>('.btn--primary')
|
||||||
|
?.click();
|
||||||
|
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the admin logout URL on Sign out click', async () => {
|
||||||
|
const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' });
|
||||||
|
(fixture.nativeElement as HTMLElement)
|
||||||
|
.querySelector<HTMLButtonElement>('.btn--secondary')
|
||||||
|
?.click();
|
||||||
|
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AuthService, type CurrentUser } from 'feature-auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-portal header. Deliberately leaner than the user-portal
|
||||||
|
* counterpart per ADR-0020 §"UX style is data-dense" — admins land
|
||||||
|
* on tabular workloads, not a discovery dashboard, so we drop the
|
||||||
|
* global search box, notification cluster, and help widget for v1.
|
||||||
|
*
|
||||||
|
* Two anchor points:
|
||||||
|
* - The "Admin" badge next to the wordmark, so an internal user
|
||||||
|
* instantly sees they're on the elevated surface (defense
|
||||||
|
* against "thought I was on portal-shell, just deleted a CMS
|
||||||
|
* page" muscle-memory misfires).
|
||||||
|
* - The auth widget: signed in → avatar with initials + Sign out,
|
||||||
|
* signed out → a Sign in button that 302s through the admin
|
||||||
|
* OIDC flow (`/api/admin/auth/login`). State driven by the
|
||||||
|
* shared `feature-auth` `AuthService` with `AUTH_PATH_PREFIX`
|
||||||
|
* overridden to `/admin/auth` (see `app.config.ts`).
|
||||||
|
*
|
||||||
|
* No locale switcher in v1 — admin users are a small known set
|
||||||
|
* and the source-locale-only chrome keeps the bundle leaner.
|
||||||
|
* Promotion when the editorial team needs FR/EN parity in the
|
||||||
|
* admin UI itself.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-header',
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './header.html',
|
||||||
|
styleUrl: './header.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AdminHeader {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
|
||||||
|
protected readonly authState = this.auth.state;
|
||||||
|
|
||||||
|
protected readonly initials = computed(() => {
|
||||||
|
const user = this.auth.currentUser();
|
||||||
|
return user ? initialsFor(user) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected signIn(): void {
|
||||||
|
this.auth.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected signOut(): void {
|
||||||
|
this.auth.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsFor(user: CurrentUser): string {
|
||||||
|
const name = (user.displayName || user.username).trim();
|
||||||
|
if (!name) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
const parts = name.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||||
|
const letters = parts.map((p) => p.charAt(0).toUpperCase()).join('');
|
||||||
|
return letters || name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<nav class="admin-sidebar" aria-label="Admin navigation">
|
||||||
|
<ul class="nav-list">
|
||||||
|
@for (item of menu; track item.label) {
|
||||||
|
<li class="nav-item">
|
||||||
|
@if (item.routerLink !== null) {
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
[routerLink]="item.routerLink"
|
||||||
|
routerLinkActive="nav-link--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: false }"
|
||||||
|
>
|
||||||
|
<span class="nav-icon"><lib-icon [name]="item.icon" [size]="18" /></span>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<span class="nav-link nav-link--disabled" aria-disabled="true">
|
||||||
|
<span class="nav-icon"><lib-icon [name]="item.icon" [size]="18" /></span>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
<span class="nav-badge">Soon</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.admin-sidebar {
|
||||||
|
width: 16rem;
|
||||||
|
flex: 0 0 16rem;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .admin-sidebar {
|
||||||
|
background-color: #0f172a;
|
||||||
|
border-right-color: #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .nav-link {
|
||||||
|
color: #e5e7eb;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link--active {
|
||||||
|
background-color: rgba(29, 78, 216, 0.12);
|
||||||
|
color: var(--color-brand-primary-700, #1e40af);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .nav-link--active {
|
||||||
|
background-color: rgba(96, 165, 250, 0.18);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link--disabled {
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .nav-link--disabled {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 1.25rem;
|
||||||
|
flex: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .nav-badge {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AdminSidebar } from './sidebar';
|
||||||
|
|
||||||
|
describe('AdminSidebar', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AdminSidebar],
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(): HTMLElement {
|
||||||
|
const fixture = TestBed.createComponent(AdminSidebar);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders a navigation landmark labelled "Admin navigation"', () => {
|
||||||
|
const root = render();
|
||||||
|
const nav = root.querySelector('nav');
|
||||||
|
expect(nav?.getAttribute('aria-label')).toBe('Admin navigation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the Audit log entry as a live router link', () => {
|
||||||
|
const root = render();
|
||||||
|
const links = Array.from(root.querySelectorAll<HTMLAnchorElement>('a.nav-link'));
|
||||||
|
const auditLink = links.find((a) => a.textContent?.includes('Audit log'));
|
||||||
|
expect(auditLink).toBeTruthy();
|
||||||
|
// RouterLink renders the resolved href on the anchor.
|
||||||
|
expect(auditLink?.getAttribute('href')).toBe('/audit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the not-yet-implemented entries as aria-disabled placeholders with a "Soon" badge', () => {
|
||||||
|
const root = render();
|
||||||
|
const disabled = Array.from(root.querySelectorAll('.nav-link--disabled'));
|
||||||
|
expect(disabled.length).toBeGreaterThan(0);
|
||||||
|
for (const node of disabled) {
|
||||||
|
expect(node.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(node.querySelector('.nav-badge')?.textContent?.trim()).toBe('Soon');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { Icon, type IconName } from 'shared-ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-side navigation. v1 lists the four modules from ADR-0020's
|
||||||
|
* v1 admin catalogue (CMS, menu management, user list, audit log
|
||||||
|
* viewer). Only the audit-log link points at a live route in this
|
||||||
|
* PR; the rest land as routerLink placeholders disabled with
|
||||||
|
* `aria-disabled`, so the navigation shape is established even
|
||||||
|
* before the modules ship.
|
||||||
|
*
|
||||||
|
* No collapse mode (the portal-admin v1 layout doesn't need to
|
||||||
|
* compete with content for screen real estate the way the user
|
||||||
|
* portal does). When that changes we promote the
|
||||||
|
* `LayoutStateService.sidebarCollapsed` signal here too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AdminMenuItem {
|
||||||
|
readonly label: string;
|
||||||
|
readonly icon: IconName;
|
||||||
|
/** Live route once the module ships; placeholder when null. */
|
||||||
|
readonly routerLink: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU: readonly AdminMenuItem[] = [
|
||||||
|
{ label: 'Audit log', icon: 'file-text', routerLink: '/audit' },
|
||||||
|
{ label: 'CMS pages', icon: 'folder', routerLink: null },
|
||||||
|
{ label: 'Menu management', icon: 'layout-dashboard', routerLink: null },
|
||||||
|
{ label: 'User list', icon: 'building-2', routerLink: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-sidebar',
|
||||||
|
imports: [RouterLink, RouterLinkActive, Icon],
|
||||||
|
templateUrl: './sidebar.html',
|
||||||
|
styleUrl: './sidebar.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AdminSidebar {
|
||||||
|
protected readonly menu = MENU;
|
||||||
|
}
|
||||||
@@ -1,24 +1,41 @@
|
|||||||
<section class="mx-auto max-w-3xl px-6 py-12">
|
<section class="home">
|
||||||
<h1
|
<h1 class="title" i18n="@@page.home.title">APF Portal Admin</h1>
|
||||||
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
|
<p class="intro">
|
||||||
i18n="@@page.home.title"
|
Administrative back-office for the APF Portal. Sign in with an Entra account that carries the
|
||||||
>
|
<code>admin</code> app role to reach the functional modules.
|
||||||
APF Portal Admin
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
|
|
||||||
i18n="@@page.home.intro"
|
|
||||||
>
|
|
||||||
Administrative back-office for the APF Portal. The first functional modules — content
|
|
||||||
management, menu administration, user list, audit log viewer — land in upcoming PRs per
|
|
||||||
ADR-0020.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<article class="auth-card" aria-live="polite">
|
||||||
class="mt-6 inline-flex items-center rounded-full border border-brand-accent-300 bg-brand-accent-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand-accent-700 dark:border-brand-accent-700 dark:bg-brand-accent-950/40 dark:text-brand-accent-200"
|
@switch (state().kind) { @case ('loading') {
|
||||||
role="status"
|
<p class="status-line">Checking your admin session…</p>
|
||||||
i18n="@@page.home.status"
|
} @case ('anonymous') {
|
||||||
>
|
<p class="status-line">No admin session detected.</p>
|
||||||
Skeleton — coming soon
|
<button type="button" class="btn btn--primary" (click)="signIn()">Sign in via Entra</button>
|
||||||
</p>
|
} @case ('error') {
|
||||||
|
<p class="status-line status-line--error">Could not reach the BFF to resolve the session.</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="signIn()">Try sign in</button>
|
||||||
|
} @case ('authenticated') { @if (currentUser(); as user) {
|
||||||
|
<p class="status-line status-line--ok">Signed in as <strong>{{ user.displayName }}</strong>.</p>
|
||||||
|
<dl class="session-detail">
|
||||||
|
<dt>Username</dt>
|
||||||
|
<dd>{{ user.username }}</dd>
|
||||||
|
<dt>Tenant</dt>
|
||||||
|
<dd>{{ user.tid }}</dd>
|
||||||
|
<dt>OID</dt>
|
||||||
|
<dd>{{ user.oid }}</dd>
|
||||||
|
</dl>
|
||||||
|
} } }
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="roadmap" aria-labelledby="roadmap-heading">
|
||||||
|
<h2 id="roadmap-heading" class="roadmap-heading">Coming next</h2>
|
||||||
|
<ul class="roadmap-list">
|
||||||
|
<li>
|
||||||
|
<strong>Audit log viewer</strong> — paginated, filterable view of <code>audit.events</code>.
|
||||||
|
</li>
|
||||||
|
<li><strong>CMS pages</strong> — CRUD on editorial content per locale (ADR-0019).</li>
|
||||||
|
<li><strong>Menu management</strong> — toggle items + role-gate them.</li>
|
||||||
|
<li><strong>User list</strong> — read-only directory derived from sign-in events.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
.home {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
margin: 0.75rem 0 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0.0625rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .intro {
|
||||||
|
color: #d1d5db;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .auth-card {
|
||||||
|
background-color: #111827;
|
||||||
|
border-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line--ok {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-line--error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .status-line--ok {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .status-line--error {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8rem 1fr;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .session-detail dt {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background-color: var(--color-brand-primary-600, #2563eb);
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-brand-primary-700, #1d4ed8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmap {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .roadmap {
|
||||||
|
border-top-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmap-heading {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmap-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
AUTH_BFF_BASE_URL,
|
||||||
|
AUTH_NAVIGATOR,
|
||||||
|
AUTH_PATH_PREFIX,
|
||||||
|
type CurrentUser,
|
||||||
|
} from 'feature-auth';
|
||||||
|
import { Home } from './home';
|
||||||
|
|
||||||
|
const BFF_BASE = 'http://bff.test/api';
|
||||||
|
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
|
||||||
|
|
||||||
|
const USER: CurrentUser = {
|
||||||
|
oid: 'admin-oid',
|
||||||
|
tid: 'tenant-1',
|
||||||
|
username: 'admin@apf.example',
|
||||||
|
displayName: 'Admin Smith',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(opts: { onBootstrap: 'authenticated' | 'anonymous' }) {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [Home],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||||
|
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
||||||
|
{ provide: AUTH_NAVIGATOR, useValue: navigate },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(Home);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
const req = http.expectOne(ADMIN_ME_URL);
|
||||||
|
if (opts.onBootstrap === 'authenticated') {
|
||||||
|
req.flush(USER);
|
||||||
|
} else {
|
||||||
|
req.flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
return { fixture, http, navigate };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Home', () => {
|
||||||
|
it('renders the "no session" state with a Sign in button when anonymous', async () => {
|
||||||
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelector('.status-line')?.textContent).toContain('No admin session');
|
||||||
|
expect(root.querySelector('.btn--primary')?.textContent?.trim()).toBe('Sign in via Entra');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the signed-in payload (display name + tenant + oid) when authenticated', async () => {
|
||||||
|
const { fixture } = await setup({ onBootstrap: 'authenticated' });
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelector('.status-line--ok')?.textContent).toContain('Admin Smith');
|
||||||
|
const dds = Array.from(root.querySelectorAll<HTMLElement>('.session-detail dd')).map((el) =>
|
||||||
|
el.textContent?.trim(),
|
||||||
|
);
|
||||||
|
expect(dds).toContain('admin@apf.example');
|
||||||
|
expect(dds).toContain('tenant-1');
|
||||||
|
expect(dds).toContain('admin-oid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the admin login URL on Sign in click', async () => {
|
||||||
|
const { fixture, navigate } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
(fixture.nativeElement as HTMLElement)
|
||||||
|
.querySelector<HTMLButtonElement>('.btn--primary')
|
||||||
|
?.click();
|
||||||
|
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the roadmap items (audit log, CMS, menu, user list)', async () => {
|
||||||
|
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
|
const items = Array.from(
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('.roadmap-list li'),
|
||||||
|
).map((li) => li.textContent ?? '');
|
||||||
|
expect(items.some((t) => t.includes('Audit log viewer'))).toBe(true);
|
||||||
|
expect(items.some((t) => t.includes('CMS pages'))).toBe(true);
|
||||||
|
expect(items.some((t) => t.includes('Menu management'))).toBe(true);
|
||||||
|
expect(items.some((t) => t.includes('User list'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { AuthService } from 'feature-auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smoke-test landing page for portal-admin.
|
* Admin landing page. v1 renders a self-test panel for the
|
||||||
|
* admin-portal auth chain: status from `AuthService` (signed-in
|
||||||
|
* payload + roles, or anonymous), a "Sign in" button when no admin
|
||||||
|
* session is present, and the next-up roadmap from ADR-0020 §"v1
|
||||||
|
* scope".
|
||||||
*
|
*
|
||||||
* v1 ships a placeholder. The real admin shell (header + sidebar +
|
* Functional modules (CMS, menu management, user list, audit log
|
||||||
* footer with the "Admin" badge, per ADR-0020) lands in a follow-up
|
* viewer) replace this page progressively. The audit-log viewer is
|
||||||
* PR together with the first functional module (CMS pages, menu
|
* next per the chantier sequence.
|
||||||
* management, user list, or audit log viewer).
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
|
styleUrl: './home.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class Home {}
|
export class Home {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
|
||||||
|
protected readonly state = this.auth.state;
|
||||||
|
protected readonly currentUser = this.auth.currentUser;
|
||||||
|
protected readonly isLoading = this.auth.isLoading;
|
||||||
|
|
||||||
|
protected signIn(): void {
|
||||||
|
this.auth.login();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Per-environment configuration for `portal-admin`, per ADR-0018.
|
||||||
|
*
|
||||||
|
* Holds the dev defaults; per-environment siblings
|
||||||
|
* (`environment.staging.ts`, `environment.prod.ts`) land alongside
|
||||||
|
* once those targets exist, and `project.json` declares a
|
||||||
|
* `fileReplacements` entry to swap at build time.
|
||||||
|
*
|
||||||
|
* Shape mirrors `apps/portal-shell/src/environments/environment.ts`
|
||||||
|
* intentionally: any change here that affects the consumer contract
|
||||||
|
* with `feature-auth` must be made in both files (same env vars on
|
||||||
|
* the lib side, same BFF base URL pattern).
|
||||||
|
*/
|
||||||
|
export const environment = {
|
||||||
|
/**
|
||||||
|
* Origin + prefix of the BFF HTTP API. Same value as portal-shell
|
||||||
|
* — both SPAs talk to the same BFF (per ADR-0020 §"Where does
|
||||||
|
* the admin app live"). The admin-specific routing happens via
|
||||||
|
* the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
|
||||||
|
* `app.config.ts`, not by talking to a different host.
|
||||||
|
*/
|
||||||
|
bffApiBaseUrl: 'http://localhost:3000/api',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
|
||||||
|
* (shared between surfaces; see PR #129's notes on
|
||||||
|
* `__Host-portal_csrf` / `portal_csrf`). A future hardening
|
||||||
|
* could split this into `portal_admin_csrf` when both portals
|
||||||
|
* are open simultaneously becomes a regular pattern.
|
||||||
|
*/
|
||||||
|
bffCsrfCookieName: 'portal_csrf',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OTLP/HTTP traces endpoint — same OTel collector as portal-shell.
|
||||||
|
*/
|
||||||
|
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
||||||
|
};
|
||||||
@@ -5,5 +5,13 @@
|
|||||||
"types": ["@angular/localize"]
|
"types": ["@angular/localize"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../libs/shared/ui"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../libs/feature/auth"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from './lib/auth.config';
|
export {
|
||||||
|
AUTH_BFF_BASE_URL,
|
||||||
|
AUTH_CSRF_COOKIE_NAME,
|
||||||
|
AUTH_NAVIGATOR,
|
||||||
|
AUTH_PATH_PREFIX,
|
||||||
|
} from './lib/auth.config';
|
||||||
export { authGuard } from './lib/auth.guard';
|
export { authGuard } from './lib/auth.guard';
|
||||||
export { AuthService } from './lib/auth.service';
|
export { AuthService } from './lib/auth.service';
|
||||||
export type { AuthState, CurrentUser } from './lib/auth.types';
|
export type { AuthState, CurrentUser } from './lib/auth.types';
|
||||||
|
|||||||
@@ -35,3 +35,20 @@ export const AUTH_NAVIGATOR = new InjectionToken<(url: string) => void>('AUTH_NA
|
|||||||
* (`portal_csrf` dev / `__Host-portal_csrf` prod) the BFF picks.
|
* (`portal_csrf` dev / `__Host-portal_csrf` prod) the BFF picks.
|
||||||
*/
|
*/
|
||||||
export const AUTH_CSRF_COOKIE_NAME = new InjectionToken<string>('AUTH_CSRF_COOKIE_NAME');
|
export const AUTH_CSRF_COOKIE_NAME = new InjectionToken<string>('AUTH_CSRF_COOKIE_NAME');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path prefix the BFF mounts the OIDC routes under. Joined to
|
||||||
|
* `AUTH_BFF_BASE_URL` to build the full URLs for `/me`, `/login`,
|
||||||
|
* `/logout`. `portal-shell` provides `/auth` (per ADR-0009);
|
||||||
|
* `portal-admin` provides `/admin/auth` (per ADR-0020 §"Sessions —
|
||||||
|
* distinct from `portal-shell`"). The lib stays surface-agnostic;
|
||||||
|
* the host picks which BFF auth surface its session belongs to.
|
||||||
|
*
|
||||||
|
* Default is `/auth` so portal-shell-shaped consumers can omit the
|
||||||
|
* provider and still work — matches the original (pre-admin) lib
|
||||||
|
* shape. Hosts on the admin surface MUST override.
|
||||||
|
*/
|
||||||
|
export const AUTH_PATH_PREFIX = new InjectionToken<string>('AUTH_PATH_PREFIX', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => '/auth',
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
|
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import type { CurrentUser } from './auth.types';
|
import type { CurrentUser } from './auth.types';
|
||||||
|
|
||||||
@@ -98,13 +98,36 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('URL accessors', () => {
|
describe('URL accessors', () => {
|
||||||
it('derives me / login / logout URLs from the injected base', () => {
|
it('derives me / login / logout URLs from the injected base + default /auth prefix', () => {
|
||||||
const { service, http } = setup();
|
const { service, http } = setup();
|
||||||
expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`);
|
expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`);
|
||||||
expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`);
|
expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`);
|
||||||
expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`);
|
expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`);
|
||||||
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
|
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('honours AUTH_PATH_PREFIX when the host provides a non-default value (admin surface)', () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
||||||
|
{ provide: AUTH_NAVIGATOR, useValue: navigate },
|
||||||
|
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
||||||
|
AuthService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
expect(service.meUrl).toBe(`${BFF_BASE}/admin/auth/me`);
|
||||||
|
expect(service.loginUrl).toBe(`${BFF_BASE}/admin/auth/login`);
|
||||||
|
expect(service.logoutUrl).toBe(`${BFF_BASE}/admin/auth/logout`);
|
||||||
|
// The auto-refresh on first inject fires against the override path.
|
||||||
|
http
|
||||||
|
.expectOne(`${BFF_BASE}/admin/auth/me`)
|
||||||
|
.flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('login() / logout()', () => {
|
describe('login() / logout()', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
|
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config';
|
||||||
import type { AuthState, CurrentUser } from './auth.types';
|
import type { AuthState, CurrentUser } from './auth.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +23,7 @@ import type { AuthState, CurrentUser } from './auth.types';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
||||||
|
private readonly pathPrefix = inject(AUTH_PATH_PREFIX);
|
||||||
private readonly navigate = inject(AUTH_NAVIGATOR);
|
private readonly navigate = inject(AUTH_NAVIGATOR);
|
||||||
|
|
||||||
private readonly _state = signal<AuthState>({ kind: 'loading' });
|
private readonly _state = signal<AuthState>({ kind: 'loading' });
|
||||||
@@ -95,15 +96,15 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get meUrl(): string {
|
get meUrl(): string {
|
||||||
return `${this.bffBaseUrl}/auth/me`;
|
return `${this.bffBaseUrl}${this.pathPrefix}/me`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get loginUrl(): string {
|
get loginUrl(): string {
|
||||||
return `${this.bffBaseUrl}/auth/login`;
|
return `${this.bffBaseUrl}${this.pathPrefix}/login`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get logoutUrl(): string {
|
get logoutUrl(): string {
|
||||||
return `${this.bffBaseUrl}/auth/logout`;
|
return `${this.bffBaseUrl}${this.pathPrefix}/logout`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user