feat(portal-admin): spa auth wiring + admin shell skeleton (#134)
CI / check (push) Successful in 3m27s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 2m29s
CI / perf (push) Successful in 4m24s

## 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:
2026-05-14 17:00:38 +02:00
parent aea395ae65
commit 40741ce326
27 changed files with 1085 additions and 44 deletions
+6 -1
View File
@@ -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';
+17
View File
@@ -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<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',
});
+25 -2
View File
@@ -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()', () => {
+5 -4
View File
@@ -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<AuthState>({ 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`;
}
}