From 4a707cdb0fb96b2006df00290aa784f18de5c608 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 16:54:10 +0200 Subject: [PATCH] feat(portal-admin): spa auth wiring + admin shell skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-3a step per ADR-0020 §"Confirmation" item 4 (entry route + admin shell). Wires the existing `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. Lib changes (libs/feature/auth) - New AUTH_PATH_PREFIX injection token. Default factory returns `/auth` so portal-shell-shaped hosts keep working without an explicit provider. Admin hosts override with `/admin/auth`. - AuthService composes URLs as `${bffBaseUrl}${pathPrefix}/{me,login, logout}`. The interceptors are unchanged — they only care about the BFF base URL. portal-admin wiring - 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: 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 components - AdminHeader: APF wordmark + persistent "Admin" badge + auth widget. No global search / notifications / help cluster — admins land on tabular workloads, not discovery. Per ADR-0020 §"UX style is data-dense". - AdminSidebar: static menu listing the four ADR-0020 v1 modules. Audit log is a live router link (target of PR 8); the others are aria-disabled placeholders with a "Soon" badge so the navigation shape is visible even before they ship. - AdminFooter: copyright + persistent "Admin surface" tag — the badge stays in view for long workloads where the header may have scrolled off. Home page - Auth self-test panel: signed-in payload (display name, tenant, oid) when authenticated, Sign in button when anonymous, error state when the BFF is unreachable. - Roadmap list quoting ADR-0020's v1 catalogue so a returning contributor sees what's pending without grepping. Notes - LayoutStateService isn't consumed yet (no collapse toggle in v1), but the theme preference still threads through because both apps read the same localStorage key — toggle in portal-shell, see it honoured in portal-admin. - Strings are plain English in v1. The admin app's $localize plumbing is wired but adding markers to the shell chrome here would require regenerating messages.fr.xlf and i18nMissingTranslation=error fails the prod build on every gap. Full i18n is its own follow-up. - v1 reuses the shared `portal_csrf` cookie name. A user with both portals open could see CSRF cookie overwrites on session creation; splitting to `portal_admin_csrf` is a follow-up if the pattern becomes common. - Build verified locally: 86.65 KB gzip initial / 300 KB budget, 4.46 KB CSS / 150 KB budget. Tests: +15 specs (AdminHeader 5, AdminSidebar 3, AdminFooter 2, Home 4, App 1 expanded). 1 new test in feature-auth AuthService for the AUTH_PATH_PREFIX override path. --- apps/portal-admin/src/app/app.config.ts | 46 +++++- apps/portal-admin/src/app/app.html | 11 +- apps/portal-admin/src/app/app.scss | 32 ++++ apps/portal-admin/src/app/app.spec.ts | 17 +- apps/portal-admin/src/app/app.ts | 5 +- .../src/app/components/footer/footer.html | 4 + .../src/app/components/footer/footer.scss | 29 ++++ .../src/app/components/footer/footer.spec.ts | 26 +++ .../src/app/components/footer/footer.ts | 22 +++ .../src/app/components/header/header.html | 27 ++++ .../src/app/components/header/header.scss | 110 +++++++++++++ .../src/app/components/header/header.spec.ts | 93 +++++++++++ .../src/app/components/header/header.ts | 61 +++++++ .../src/app/components/sidebar/sidebar.html | 25 +++ .../src/app/components/sidebar/sidebar.scss | 102 ++++++++++++ .../app/components/sidebar/sidebar.spec.ts | 43 +++++ .../src/app/components/sidebar/sidebar.ts | 42 +++++ .../portal-admin/src/app/pages/home/home.html | 59 ++++--- .../portal-admin/src/app/pages/home/home.scss | 153 ++++++++++++++++++ .../src/app/pages/home/home.spec.ts | 86 ++++++++++ apps/portal-admin/src/app/pages/home/home.ts | 29 +++- .../src/environments/environment.ts | 37 +++++ apps/portal-admin/tsconfig.app.json | 10 +- libs/feature/auth/src/index.ts | 7 +- libs/feature/auth/src/lib/auth.config.ts | 17 ++ .../feature/auth/src/lib/auth.service.spec.ts | 27 +++- libs/feature/auth/src/lib/auth.service.ts | 9 +- 27 files changed, 1085 insertions(+), 44 deletions(-) create mode 100644 apps/portal-admin/src/app/components/footer/footer.html create mode 100644 apps/portal-admin/src/app/components/footer/footer.scss create mode 100644 apps/portal-admin/src/app/components/footer/footer.spec.ts create mode 100644 apps/portal-admin/src/app/components/footer/footer.ts create mode 100644 apps/portal-admin/src/app/components/header/header.html create mode 100644 apps/portal-admin/src/app/components/header/header.scss create mode 100644 apps/portal-admin/src/app/components/header/header.spec.ts create mode 100644 apps/portal-admin/src/app/components/header/header.ts create mode 100644 apps/portal-admin/src/app/components/sidebar/sidebar.html create mode 100644 apps/portal-admin/src/app/components/sidebar/sidebar.scss create mode 100644 apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts create mode 100644 apps/portal-admin/src/app/components/sidebar/sidebar.ts create mode 100644 apps/portal-admin/src/app/pages/home/home.scss create mode 100644 apps/portal-admin/src/app/pages/home/home.spec.ts create mode 100644 apps/portal-admin/src/environments/environment.ts diff --git a/apps/portal-admin/src/app/app.config.ts b/apps/portal-admin/src/app/app.config.ts index bef26c2..9172782 100644 --- a/apps/portal-admin/src/app/app.config.ts +++ b/apps/portal-admin/src/app/app.config.ts @@ -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 { + 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'; 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 }, + ], }; diff --git a/apps/portal-admin/src/app/app.html b/apps/portal-admin/src/app/app.html index b138539..65ed4ef 100644 --- a/apps/portal-admin/src/app/app.html +++ b/apps/portal-admin/src/app/app.html @@ -1,4 +1,9 @@ -
- -
+ +
+ +
+ +
+
+ diff --git a/apps/portal-admin/src/app/app.scss b/apps/portal-admin/src/app/app.scss index 2e6c7fe..789cc30 100644 --- a/apps/portal-admin/src/app/app.scss +++ b/apps/portal-admin/src/app/app.scss @@ -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 // visible and focusable when reached via Tab from the address bar. // Plain CSS rather than the `sr-only` Tailwind utilities so the diff --git a/apps/portal-admin/src/app/app.spec.ts b/apps/portal-admin/src/app/app.spec.ts index f11a31e..85b4129 100644 --- a/apps/portal-admin/src/app/app.spec.ts +++ b/apps/portal-admin/src/app/app.spec.ts @@ -1,20 +1,33 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from 'feature-auth'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ 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(); }); - 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); await fixture.whenStable(); const root = fixture.nativeElement as HTMLElement; expect(root.querySelector('a.skip-link')).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(); }); }); diff --git a/apps/portal-admin/src/app/app.ts b/apps/portal-admin/src/app/app.ts index d620f53..dddb8f1 100644 --- a/apps/portal-admin/src/app/app.ts +++ b/apps/portal-admin/src/app/app.ts @@ -1,9 +1,12 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { AdminFooter } from './components/footer/footer'; +import { AdminHeader } from './components/header/header'; +import { AdminSidebar } from './components/sidebar/sidebar'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterOutlet, AdminHeader, AdminSidebar, AdminFooter], templateUrl: './app.html', styleUrl: './app.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/apps/portal-admin/src/app/components/footer/footer.html b/apps/portal-admin/src/app/components/footer/footer.html new file mode 100644 index 0000000..ab3214f --- /dev/null +++ b/apps/portal-admin/src/app/components/footer/footer.html @@ -0,0 +1,4 @@ +
+ © {{ year }} APF France handicap + Admin surface +
diff --git a/apps/portal-admin/src/app/components/footer/footer.scss b/apps/portal-admin/src/app/components/footer/footer.scss new file mode 100644 index 0000000..356c0b6 --- /dev/null +++ b/apps/portal-admin/src/app/components/footer/footer.scss @@ -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); +} diff --git a/apps/portal-admin/src/app/components/footer/footer.spec.ts b/apps/portal-admin/src/app/components/footer/footer.spec.ts new file mode 100644 index 0000000..190a8e2 --- /dev/null +++ b/apps/portal-admin/src/app/components/footer/footer.spec.ts @@ -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'); + }); +}); diff --git a/apps/portal-admin/src/app/components/footer/footer.ts b/apps/portal-admin/src/app/components/footer/footer.ts new file mode 100644 index 0000000..8780874 --- /dev/null +++ b/apps/portal-admin/src/app/components/footer/footer.ts @@ -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(); +} diff --git a/apps/portal-admin/src/app/components/header/header.html b/apps/portal-admin/src/app/components/header/header.html new file mode 100644 index 0000000..8b2c7dc --- /dev/null +++ b/apps/portal-admin/src/app/components/header/header.html @@ -0,0 +1,27 @@ +
+ + APF Portal + Admin + + +
+ @switch (authState().kind) { @case ('loading') { + + } @case ('anonymous') { + + } @case ('error') { + + } @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') { + {{ initials() }} + {{ state.user.displayName }} + + } } } } +
+
diff --git a/apps/portal-admin/src/app/components/header/header.scss b/apps/portal-admin/src/app/components/header/header.scss new file mode 100644 index 0000000..fb5c9ec --- /dev/null +++ b/apps/portal-admin/src/app/components/header/header.scss @@ -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); + } +} diff --git a/apps/portal-admin/src/app/components/header/header.spec.ts b/apps/portal-admin/src/app/components/header/header.spec.ts new file mode 100644 index 0000000..d18f83f --- /dev/null +++ b/apps/portal-admin/src/app/components/header/header.spec.ts @@ -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('.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('.btn--secondary') + ?.click(); + expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`); + }); +}); diff --git a/apps/portal-admin/src/app/components/header/header.ts b/apps/portal-admin/src/app/components/header/header.ts new file mode 100644 index 0000000..aa6f4a1 --- /dev/null +++ b/apps/portal-admin/src/app/components/header/header.ts @@ -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(); +} diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.html b/apps/portal-admin/src/app/components/sidebar/sidebar.html new file mode 100644 index 0000000..003c266 --- /dev/null +++ b/apps/portal-admin/src/app/components/sidebar/sidebar.html @@ -0,0 +1,25 @@ + diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.scss b/apps/portal-admin/src/app/components/sidebar/sidebar.scss new file mode 100644 index 0000000..b56b153 --- /dev/null +++ b/apps/portal-admin/src/app/components/sidebar/sidebar.scss @@ -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); +} diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts b/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts new file mode 100644 index 0000000..c5ba493 --- /dev/null +++ b/apps/portal-admin/src/app/components/sidebar/sidebar.spec.ts @@ -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('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'); + } + }); +}); diff --git a/apps/portal-admin/src/app/components/sidebar/sidebar.ts b/apps/portal-admin/src/app/components/sidebar/sidebar.ts new file mode 100644 index 0000000..4456efb --- /dev/null +++ b/apps/portal-admin/src/app/components/sidebar/sidebar.ts @@ -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; +} diff --git a/apps/portal-admin/src/app/pages/home/home.html b/apps/portal-admin/src/app/pages/home/home.html index b446b12..5fa1ec0 100644 --- a/apps/portal-admin/src/app/pages/home/home.html +++ b/apps/portal-admin/src/app/pages/home/home.html @@ -1,24 +1,41 @@ -
-

- APF Portal Admin -

-

- 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. +

+

APF Portal Admin

+

+ Administrative back-office for the APF Portal. Sign in with an Entra account that carries the + admin app role to reach the functional modules.

-

- Skeleton — coming soon -

+
+ @switch (state().kind) { @case ('loading') { +

Checking your admin session…

+ } @case ('anonymous') { +

No admin session detected.

+ + } @case ('error') { +

Could not reach the BFF to resolve the session.

+ + } @case ('authenticated') { @if (currentUser(); as user) { +

Signed in as {{ user.displayName }}.

+
+
Username
+
{{ user.username }}
+
Tenant
+
{{ user.tid }}
+
OID
+
{{ user.oid }}
+
+ } } } +
+ +
+

Coming next

+
    +
  • + Audit log viewer — paginated, filterable view of audit.events. +
  • +
  • CMS pages — CRUD on editorial content per locale (ADR-0019).
  • +
  • Menu management — toggle items + role-gate them.
  • +
  • User list — read-only directory derived from sign-in events.
  • +
+
diff --git a/apps/portal-admin/src/app/pages/home/home.scss b/apps/portal-admin/src/app/pages/home/home.scss new file mode 100644 index 0000000..303e693 --- /dev/null +++ b/apps/portal-admin/src/app/pages/home/home.scss @@ -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; + } +} diff --git a/apps/portal-admin/src/app/pages/home/home.spec.ts b/apps/portal-admin/src/app/pages/home/home.spec.ts new file mode 100644 index 0000000..c55230f --- /dev/null +++ b/apps/portal-admin/src/app/pages/home/home.spec.ts @@ -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('.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('.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('.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); + }); +}); diff --git a/apps/portal-admin/src/app/pages/home/home.ts b/apps/portal-admin/src/app/pages/home/home.ts index e4eafc4..f6b1217 100644 --- a/apps/portal-admin/src/app/pages/home/home.ts +++ b/apps/portal-admin/src/app/pages/home/home.ts @@ -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 + - * footer with the "Admin" badge, per ADR-0020) lands in a follow-up - * PR together with the first functional module (CMS pages, menu - * management, user list, or audit log viewer). + * Functional modules (CMS, menu management, user list, audit log + * viewer) replace this page progressively. The audit-log viewer is + * next per the chantier sequence. */ @Component({ selector: 'app-home', templateUrl: './home.html', + styleUrl: './home.scss', 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(); + } +} diff --git a/apps/portal-admin/src/environments/environment.ts b/apps/portal-admin/src/environments/environment.ts new file mode 100644 index 0000000..f7687c6 --- /dev/null +++ b/apps/portal-admin/src/environments/environment.ts @@ -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', +}; diff --git a/apps/portal-admin/tsconfig.app.json b/apps/portal-admin/tsconfig.app.json index 2ee35fa..66d8e42 100644 --- a/apps/portal-admin/tsconfig.app.json +++ b/apps/portal-admin/tsconfig.app.json @@ -5,5 +5,13 @@ "types": ["@angular/localize"] }, "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" + } + ] } diff --git a/libs/feature/auth/src/index.ts b/libs/feature/auth/src/index.ts index 4d52ea0..8a24e9a 100644 --- a/libs/feature/auth/src/index.ts +++ b/libs/feature/auth/src/index.ts @@ -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 { AuthService } from './lib/auth.service'; export type { AuthState, CurrentUser } from './lib/auth.types'; diff --git a/libs/feature/auth/src/lib/auth.config.ts b/libs/feature/auth/src/lib/auth.config.ts index 16c3920..2c6eefa 100644 --- a/libs/feature/auth/src/lib/auth.config.ts +++ b/libs/feature/auth/src/lib/auth.config.ts @@ -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. */ export const AUTH_CSRF_COOKIE_NAME = new InjectionToken('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('AUTH_PATH_PREFIX', { + providedIn: 'root', + factory: () => '/auth', +}); diff --git a/libs/feature/auth/src/lib/auth.service.spec.ts b/libs/feature/auth/src/lib/auth.service.spec.ts index 7aac605..6a7e7d0 100644 --- a/libs/feature/auth/src/lib/auth.service.spec.ts +++ b/libs/feature/auth/src/lib/auth.service.spec.ts @@ -1,7 +1,7 @@ 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 } from './auth.config'; +import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config'; import { AuthService } from './auth.service'; import type { CurrentUser } from './auth.types'; @@ -98,13 +98,36 @@ describe('AuthService', () => { }); 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(); expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`); expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`); expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`); 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()', () => { diff --git a/libs/feature/auth/src/lib/auth.service.ts b/libs/feature/auth/src/lib/auth.service.ts index 66ce5fc..f1aef0e 100644 --- a/libs/feature/auth/src/lib/auth.service.ts +++ b/libs/feature/auth/src/lib/auth.service.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; 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'; /** @@ -23,6 +23,7 @@ import type { AuthState, CurrentUser } from './auth.types'; export class AuthService { private readonly http = inject(HttpClient); private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL); + private readonly pathPrefix = inject(AUTH_PATH_PREFIX); private readonly navigate = inject(AUTH_NAVIGATOR); private readonly _state = signal({ kind: 'loading' }); @@ -95,15 +96,15 @@ export class AuthService { } get meUrl(): string { - return `${this.bffBaseUrl}/auth/me`; + return `${this.bffBaseUrl}${this.pathPrefix}/me`; } get loginUrl(): string { - return `${this.bffBaseUrl}/auth/login`; + return `${this.bffBaseUrl}${this.pathPrefix}/login`; } get logoutUrl(): string { - return `${this.bffBaseUrl}/auth/logout`; + return `${this.bffBaseUrl}${this.pathPrefix}/logout`; } }