docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin (#89)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m22s
CI / check (push) Successful in 2m36s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 3m25s

## Summary

Pure documentation PR. Two ADRs that answer the two strategic questions raised after the footer chantier:

- **[ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md)** — how the portal handles multiple languages.
- **[ADR-0020](docs/decisions/0020-portal-admin-app.md)** — where portal administration lives.

Implementation will land across follow-up feature PRs, each consumable on its own.

## ADR-0019 — Internationalisation

**Decision:** `@angular/localize` in **build-time** mode, two locales (`fr` default served at `/`, `en` source). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. The locale switcher in the footer writes a `__Host-portal_locale` cookie and hard-refreshes to the matching bundle.

**Considered and rejected:**

- `@angular/localize` runtime mode (single bundle, higher LCP / payload cost).
- `@ngx-translate` / `transloco` (community libraries; tech-bar prefers Angular first-party for foundational primitives).
- Query-param URL strategy (fragile, weaker SEO, `<html lang>` becomes harder).
- Subdomain URL strategy (breaks `__Host-` cookie scoping from ADR-0010).

**Scope boundary:** UI strings owned by developers (templates + `$localize` in code). Editorial content (CMS-managed pages, news, etc.) is BFF-served already localised — that's the admin-app pipeline (ADR-0020), not `@angular/localize`.

**First sweep consequence:** the duplicate `/accessibility` + `/accessibilite` routes collapse to one Angular route with locale-translated paths.

## ADR-0020 — `portal-admin`

**Decision:** new Angular SPA `portal-admin` alongside `portal-shell`, sharing the existing `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role plus `@RequireMfa({ freshness: 600 })` at the entry route. Distinct origin + cookie + session (`__Host-portal_admin_session`).

**v1 modules** (all four selected):

1. Editorial pages CMS (multilingual content, fed back to `portal-shell` via the BFF).
2. Sidebar menu management (activates the `requiredPermissions` field already on `MenuItem`).
3. User list (read-only).
4. Audit log viewer (consumes the `audit.events` table per ADR-0013, via the `audit_reader` role).

**Out of v1:** B2B invitations (stay in Entra Admin Center), feature flags (no substrate yet), CMS workflow / approval flows, theme customisation, live preview.

**Considered and rejected:**

- `/admin/*` lazy-loaded inside `portal-shell` (admin code in the same origin → weaker defense in depth, admin URL not IP-restrictable independently).
- Two SPAs **and** two BFFs (doubles infra at our scale — bricolage).
- Off-the-shelf admin tooling (Retool, etc. — escapes our security baseline).

**Performance budget for admin:** ≤ 500 KB gzip initial (vs 300 KB for `portal-shell`, per ADR-0017). Lighthouse Performance ≥ 85 on critical admin routes (vs ≥ 90 on `portal-shell`). Same a11y baseline (ADR-0016), same dark-mode support.

**Shared-libs graduation:** `Icon`, `LayoutStateService`, brand tokens, dark-mode SCSS helpers move from `portal-shell` to `libs/shared/{ui,state}` when both apps need them. Mechanical refactor; tracked as the first implementation PR.

## Implementation roadmap (out of scope of this PR)

ADR-0019:

1. Install `@angular/localize`, wire build target.
2. Mark every existing UI string in `portal-shell` with `i18n` + `@@id`; produce `messages.fr.xlf`.
3. Locale switcher in footer + `/api/preferences/locale` BFF route + smart redirect at `/`.
4. Collapse the duplicate accessibility routes into a localised single route, with 301s.
5. CI gate: `nx build portal-shell --localize` is added to `ci:check` and fails on missing translation.

ADR-0020:

1. `nx g @nx/angular:app portal-admin` skeleton.
2. Shared-libs extraction (`libs/shared/ui`, `libs/shared/state`).
3. BFF `AdminModule` + `AdminRoleGuard` + smoke `GET /api/admin/me`.
4. Admin shell (header / sidebar / footer with an "Admin" badge).
5. One PR per v1 module — suggested order: CMS pages → menu management → audit viewer → user list.

## Test plan

- [x] Both ADRs follow MADR 4.0.0 (frontmatter, sections, tags from the canonical vocabulary).
- [x] `docs/decisions/README.md` index updated in the same commit.
- [x] `CLAUDE.md` architecture summary picks up entries for both decisions and bumps the ADR coverage line to 0020.
- [ ] Read-through review — invite the project lead to push back on any decision before locking implementation.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #89
This commit was merged in pull request #89.
This commit is contained in:
2026-05-11 12:29:54 +02:00
parent 8b986f3dc3
commit c5e91f240b
4 changed files with 439 additions and 21 deletions
@@ -0,0 +1,197 @@
---
status: accepted
date: 2026-05-11
decision-makers: R&D Lead
tags: [frontend, accessibility, performance, process]
---
# Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing
## Context and Problem Statement
The portal addresses a primarily French audience (APF France handicap, fédération française) but must also serve English content for international stakeholders, internal staff who prefer English, and the broader accessibility audit ecosystem (WCAG / EN 301 549). The current state ships UI strings hard-coded in English (project rule: "All code, identifiers, comments, ... written in English"), duplicates the accessibility page across two routes (`/accessibility` and `/accessibilite`), and exposes both language labels in the footer. That works as a placeholder; it does not scale.
Two questions need a recorded answer before any new feature wires a string into a template:
1. **Which i18n library / strategy?**`@angular/localize` (Angular-canonical, build-time), the runtime variant of the same, or a community alternative (`@ngx-translate`, `transloco`).
2. **How is the locale carried in URLs and across navigations?** — path prefix (`/fr/dashboard`), query parameter (`?lang=fr`), subdomain (`fr.portal.apf.fr`), or no URL signal (locale held in a cookie only).
This ADR settles both, plus the related questions of source locale, default locale, locale resolution order, and how the locale switcher in the footer interacts with the build-time bundles.
A related concern — **editorial content** localisation (CMS-managed pages, news, etc.) — is **out of scope** for this ADR. Editorial copy is fetched from the BFF already localised per the active locale; that pipeline belongs to [ADR-0020](0020-portal-admin-app.md) (the admin application). This ADR is about **UI strings owned by developers**: button labels, menu titles, error messages, ARIA labels, format strings.
## Decision Drivers
- **First-party, recognised, stable** — per the project tech bar, default to Angular's own i18n module unless an exception is justified. `@angular/localize` is shipped by the Angular team, tracks Angular versions, will not be orphaned.
- **Performance** — ADR-0017 sets Core Web Vitals + Lighthouse ≥ 90 + initial bundle ≤ 300 KB gzip. A build-time strategy (one bundle per locale) avoids shipping translations to the wrong audience and avoids the runtime cost of resolving every `$localize` token on first paint.
- **Accessibility** — `<html lang>` must match the served content (WCAG 3.1.1 "Language of Page"); screen readers and translation tooling rely on it. A URL prefix per locale makes this trivial; a query-param strategy makes the server forget which locale to declare.
- **SEO & shareability** — path-based locale URLs are the documented best practice (Google Search Central, W3C i18n WG). `/fr/...` and `/en/...` are crawled, indexed, and shared without ambiguity.
- **No bricolage** — a runtime locale switcher that hot-swaps strings without a hard reload is appealing but introduces complexity (every text node observes the locale signal); we accept a hard refresh on switch for v1 because it costs no implementation surface and produces the smaller artefact.
## Considered Options
### i18n library
- **`@angular/localize` — build-time mode** _(chosen)_. Strings marked in templates (`i18n` attribute, `<ng-container i18n>...`) and in code (`$localize`). Translation files in XLIFF 1.2 (`.xlf`). One application bundle per locale at build time. Locale-aware tooling (`extract-i18n`, `nx build --localize`) shipped with the framework.
- **`@angular/localize` — runtime mode (`$localize` only, no build-time embed)**. Single bundle ships all locales; the locale is selected at runtime via `loadTranslations()`. Smaller deploy artefact count, higher runtime cost and bigger initial JS payload.
- **`@ngx-translate/core`**. Community library (organisation-maintained since 2024). Runtime translations from JSON files. Mature but smaller team than Angular Core, occasional Angular-version lag.
- **`transloco` (`@jsverse/transloco`)**. Active community library with a modern, Signals-friendly API and lazy translation file loading. Less mainstream than `@angular/localize`.
- **Roll our own** (Signals + a `tr()` function over a JSON map). _Rejected on the tech bar — bricolage._
### URL strategy
- **Path-based with default locale at root: `/dashboard` _(serves `fr`)_, `/en/dashboard`**. Idiomatic for sites with one strongly dominant locale. Asymmetric: removing the prefix means "default locale".
- **Path-based, always-prefixed: `/fr/dashboard`, `/en/dashboard`; `/` redirects to `/fr/`** _(chosen)_. Symmetric. Every URL carries an explicit locale signal. The redirect at `/` uses a small smart-resolver (cookie → `Accept-Language``fr` fallback).
- **Query parameter: `/dashboard?lang=fr`**. Single canonical path. Fragile (a user trimming the query lands on whatever the default is), worse for SEO, and the locale signal is invisible in the address bar at a glance.
- **Subdomain: `fr.portal.apf.fr`, `en.portal.apf.fr`**. Highest isolation. Overkill for two locales, complicates `__Host-` cookie scoping (ADR-0009 / ADR-0010), requires more TLS certificates.
### Source locale
- **English** _(chosen)_. The source code holds English text in `i18n` attributes; the FR translation is a target. Matches the project English-only rule (CLAUDE.md), matches what translators expect.
- **French**. Source is French; English is a target. Would conflict with the project English-only rule for code artefacts.
### Default locale (served at `/`)
- **French** _(chosen)_. APF audience is overwhelmingly French; the redirect at `/` lands users in the locale most of them want first.
- **English**. Lingua franca but mismatched with the audience.
### Locale switcher mechanism
- **Footer link / button that posts the chosen locale to the BFF; the BFF sets `__Host-portal_locale` cookie; client hard-refreshes to the same path under the new prefix** _(chosen)_. Honest about the cost (full reload to swap bundles), the cookie persists the choice across visits, and the resolver at `/` uses the cookie next time.
- **Pure client-side path swap (`router.navigateByUrl('/en' + currentPath)`)**. Equivalent for the user but loses the cookie persistence — next visit the resolver does not know the preference.
- **Hot-swap translations at runtime via `loadTranslations()`**. Only feasible with the runtime mode of `@angular/localize` (rejected above) or `transloco` / `@ngx-translate`. Smoother UX, much higher complexity.
## Decision Outcome
### Library — `@angular/localize`, build-time per-locale bundles
- Strings are marked with the Angular `i18n` template attribute (`<button i18n="@@dashboard.title">Dashboard</button>`) for templates, and `$localize` tagged template strings for code (`throw new Error($localize\`:@@auth.expired:Session has expired\`)`). Every key gets an explicit `@@id` — auto-generated IDs are brittle (they change when the surrounding text changes).
- Translation files live at `apps/portal-shell/src/locale/messages.fr.xlf`. Source language is `en`, target language is `fr`. The English bundle is the source — no translation file, the source strings ship as-is. The extraction target (`nx extract-i18n portal-shell`) produces `messages.xlf` (source-only); we maintain `messages.fr.xlf` by hand-merging extractor output into the existing translations.
- Build target gains a `localize` configuration: `nx build portal-shell --localize` produces `dist/apps/portal-shell/{fr,en}/...` in one pass. The dev server (`nx serve portal-shell`) defaults to French; `--configuration=en` flips to English.
- Production deploy serves both locale folders behind one origin; the reverse proxy (or the BFF for SPA pass-through) routes `/fr/*` and `/en/*` to the matching folder.
### URL strategy — path-based, always prefixed, `/` smart-redirects
- Every route in the app sits under `/fr/...` or `/en/...`. The Angular routes themselves are locale-agnostic (`/dashboard`, `/accessibility-statement`, ...); the locale prefix is injected by the build-time `baseHref` plus the SPA's `provideRouter({ baseHref: '/fr/' })` (set per the active build locale).
- The bare path `/` redirects via a small smart resolver, executed at the reverse-proxy / BFF level:
1. If the `__Host-portal_locale` cookie is set and matches a supported locale, redirect to that prefix.
2. Else, parse `Accept-Language` and pick the highest-q match among `{fr, en}`.
3. Else, redirect to `/fr/`.
- Direct paths missing a locale prefix (e.g. someone shares `/dashboard`) hit the same resolver and get redirected to the prefixed equivalent under the resolved locale.
- `<html lang="fr">` (or `en`) is set at build time from the active locale; no JavaScript fiddling at runtime.
### Source = `en`, default served = `fr`
- The source locale is English. All `i18n` attribute texts in templates, all `$localize` template literal payloads, are English. This matches the project English-only rule.
- The locale served at the root URL is French. The redirect lands the user there unless their cookie or `Accept-Language` says otherwise.
### Locale switcher — footer dropdown, BFF cookie, hard refresh
- The footer (per the previous chantier) gets a small locale switcher next to the accessibility links. UI: a `[cdkMenuTriggerFor]` button labelled with the active locale's name, opening a menu with the two options ("Français", "English"). The same accessible CDK menu pattern as the theme switcher (ADR-0016 derivative).
- Clicking an option `POST`s `{ locale: 'fr' | 'en' }` to `/api/preferences/locale`. The BFF sets `__Host-portal_locale` (`Secure`, `HttpOnly`, `SameSite=Lax`, scoped to `/`) and returns 204. The client then `window.location.assign('/en' + currentPathWithoutLocalePrefix)` (or the FR equivalent) — a hard refresh that boots the right bundle.
- We accept the hard refresh as the v1 cost. Runtime hot-swap is a v2 ADR if user research surfaces friction; for now the gain (no runtime locale state, no per-text observer) is worth the per-switch reload.
### Routing migration
- The current `/accessibility` and `/accessibilite` duplicate routes collapse to a **single localised route**. The Angular route is `'/accessibility-statement'`; the displayed URL is `/fr/declaration-d-accessibilite` (translated via Angular's i18n route paths feature) or `/en/accessibility-statement`. Both old routes 301-redirect to the localised version.
- All future routes use a single Angular path; the build pipeline emits the locale-specific URL per the i18n route-path translation file.
### Confirmation
**Wired across a sequence of PRs:**
1. Install `@angular/localize`, add the `localize` polyfill to `polyfills.ts`, configure the build target (this ADR's accompanying PR or the next one).
2. First sweep: mark every existing UI string in `portal-shell` with an `i18n` attribute + explicit `@@id`; run extraction; produce `messages.fr.xlf` with translations of the current copy (≤ 30 strings today).
3. Locale switcher in the footer + BFF route `/api/preferences/locale` + cookie + smart redirect at `/`.
4. Collapse `/accessibility` + `/accessibilite` into the single localised route, with 301s.
5. Lint rule (`@angular-eslint/template/no-positive-tabindex` analogue, custom) to flag template strings without an `i18n` attribute — wired only after sweep #2 to avoid mass lint debt.
**CI gate:** the build script `pnpm exec nx build portal-shell --localize` is added to `ci:check`. If a string is missing a translation in `messages.fr.xlf`, the build fails with the missing-translation list. This catches "I added a label and forgot to translate it" at the PR stage rather than at deploy.
**Lighthouse bench:** the localised production builds are exercised by the existing `ci:perf` Lighthouse CI (ADR-0017) on `/fr/` (default) and `/en/`. Both must stay ≥ 90 Performance.
### Consequences
- Good, because the i18n primitive is first-party, recognised, and aligned with the project tech bar. Future Angular upgrades carry it.
- Good, because build-time bundles ship only the strings the user actually sees — smallest possible payload per visit, best Core Web Vitals.
- Good, because the URL strategy makes the locale signal explicit (SEO, sharing, accessibility — `<html lang>` is correct by construction).
- Good, because the source locale is English — matches the project rule and the global i18n convention (translators translate _from_ English).
- Good, because the accessibility statement collapses to one route — a single source of truth instead of two manually-kept-in-sync templates.
- Bad, because every build now produces N bundles (N = locale count). Bounded — we ship two for the foreseeable future. The CI build wall-time grows linearly with locale count; acceptable while N ≤ 4.
- Bad, because adding a new locale requires a code-level change (new translation file, build target, deploy route) rather than a configuration toggle. Acceptable trade-off for the runtime perf gain. Editorial content (CMS-driven, ADR-0020) does not have this constraint — adding a CMS locale is a backend operation.
- Bad, because the locale switch costs a hard refresh. Mitigated by the rarity of the action (users typically pick a locale on first visit and stay there) and by the SPA's fast cold-start budget (initial bundle ≤ 300 KB gzip, LCP ≤ 2.5 s).
- Neutral, because translators see XLIFF 1.2 — the industry standard for CAT tools (memoQ, Trados, Crowdin, Lokalise). Good for handoff to a professional translator if needed; less ergonomic than JSON for a developer-managed file. Acceptable: the file is small.
### Confirmation (continued)
A future ADR may revisit this decision if:
- Runtime locale hot-swap becomes a usability requirement that user research confirms is worth the implementation cost.
- A third locale (Spanish, German, Arabic) is requested — at that point the build-time-per-locale cost grows enough to reconsider the runtime alternatives, AND RTL support (Arabic) needs first-class treatment.
- The number of UI strings grows to the point where hand-maintained XLF becomes a maintenance burden — at which point we plug in a translation management platform (Crowdin / Lokalise) over the same XLF artefacts; no ADR change.
## Pros and Cons of the Options
### Library
#### `@angular/localize` build-time (chosen)
- Good, because first-party, supported as long as Angular itself is.
- Good, because zero runtime cost — strings are literals in the produced bundle.
- Good, because tooling (`extract-i18n`) is integrated into the Angular CLI.
- Bad, because one bundle per locale (N artefacts). Bounded.
- Bad, because adding a locale requires a rebuild rather than a config change. Bounded for our locale count.
#### `@angular/localize` runtime
- Good, because single artefact across locales — single deploy.
- Good, because runtime locale switch is possible without a page reload.
- Bad, because all translations ship to every user — bigger initial payload, worse mobile-3G LCP.
- Bad, because the `$localize` runtime resolver runs on every translated node — measurable on first render of a complex view.
#### `@ngx-translate/core`
- Good, because lazy-loaded JSON files (load on demand per feature module).
- Good, because runtime switching with no reload.
- Bad, because community-maintained — Angular version compatibility has occasionally lagged by weeks.
- Bad, because non-canonical for Angular — every contributor must learn its API on top of Angular's.
#### `transloco`
- Good, because modern, Signals-friendly, actively maintained.
- Good, because lazy-loadable, scope-based.
- Bad, because community library — tech-bar threshold is "recognised + battle-tested"; transloco is recognised but not at the Angular-team level.
- Bad, because we'd carry a non-canonical i18n abstraction across the codebase forever.
### URL strategy
#### Path-based, always prefixed (chosen)
- Good, because explicit, SEO-canonical, easy to reason about.
- Good, because `<html lang>` is correct by construction.
- Good, because cacheable per locale at the proxy / CDN level.
- Bad, because every URL is slightly longer.
#### Path-based with default at root
- Good, because shorter URLs for the dominant locale.
- Bad, because asymmetric — the absence of a prefix is itself a signal, which is harder to teach contributors than "every URL has a locale prefix".
- Bad, because URL surgery on locale switch is more complex (sometimes strip, sometimes add a prefix).
#### Query parameter
- Bad, because trivial to lose; fragile.
- Bad, because SEO crawlers index multiple URLs as one with parameter variants.
#### Subdomain
- Good, because hard isolation per locale.
- Bad, because `__Host-` cookie scoping breaks (the cookie is host-bound). Sessions ADR-0010 explicitly uses `__Host-` for the cookie security guarantees.
- Bad, because every locale needs a TLS certificate.
## More Information
- Angular i18n docs: https://angular.dev/guide/i18n
- Google Search Central — Multi-regional and multilingual sites: https://developers.google.com/search/docs/specialty/international
- W3C i18n — Choosing a language strategy: https://www.w3.org/International/questions/qa-when-xmllang.en
- Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (frontend stack), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (WCAG 3.1.1 Language of Page), [ADR-0017](0017-performance-budgets-lighthouse-ci.md) (Lighthouse gate), [ADR-0020](0020-portal-admin-app.md) (admin app — editorial content localisation).
+217
View File
@@ -0,0 +1,217 @@
---
status: accepted
date: 2026-05-11
decision-makers: R&D Lead
tags: [frontend, backend, security, infrastructure, process]
---
# `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF
## Context and Problem Statement
The portal needs a place for administrative tasks that are not part of the end-user product surface:
- **Static / editorial page management** — create, edit, publish localised pages (per ADR-0019, FR and EN content) outside the developer's deploy loop.
- **Menu management** — toggle which sidebar items are visible, control which roles each item is gated on (the `requiredPermissions` field already reserved in the `MenuItem` shape).
- **User list** (read-only) — see who has authenticated, when they last connected, which audience they came from.
- **Audit log viewer** — filter the `audit.events` table per [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) (actor, action, date range), without giving auditors direct SQL access.
These features share three traits that distinguish them from end-user features:
1. **Audience is disjoint** — APF internal staff with an admin role, not the general user population. Bundle-size optimisations targeted at end users do not apply.
2. **Security context is elevated** — every admin action mutates content / configuration / observes audit data. Defense in depth pays back here.
3. **UX style is data-dense** — tables, filters, bulk actions, formularies. The end-user portal is task-oriented and visually lighter.
This ADR records **where the admin lives**, **what ships in v1**, and **what stays explicitly out of scope** (so the v1 effort is bounded).
## Decision Drivers
- **Defense in depth** — admin code, admin endpoints, and admin auth context should be isolatable. A compromise of the end-user SPA should not give an attacker the admin surface, and vice versa.
- **Operational restrictability** — the admin app should be deployable behind an IP allow-list, on a VPN, or behind a stricter Entra Conditional Access policy (ADR-0011), without affecting the end-user portal.
- **No infrastructure duplication** — the project is small. Two databases, two audit logs, two observability stacks would be bricolage.
- **Compatibility with the existing security stack** — Entra ID identity (ADR-0008), OIDC + PKCE auth flow (ADR-0009), Redis sessions (ADR-0010), audit append-only schema (ADR-0013). The admin should reuse these, not parallel them.
- **Performance bar** — the end-user portal has a 300 KB initial bundle budget (ADR-0017). Admin code lazy-loaded inside the same SPA risks regressions; a separate SPA removes the risk entirely.
- **Discoverability for auditors** — the admin URL is distinct from the user portal URL. An auditor inspecting deploy artefacts, DNS, or access logs sees the admin surface as a first-class concern.
## Considered Options
### Where does the admin app live
- **New Angular SPA `apps/portal-admin`, same BFF (`portal-bff`)** _(chosen)_. Adds one frontend app to the Nx workspace; the BFF gains an `/api/admin/*` route family guarded by RBAC. Distinct deploy URL.
- **`/admin/*` route prefix inside `portal-shell`**, lazy-loaded. Single SPA, shared libs are trivial. Bundle bloat is mitigated by lazy loading. Auth gating is via a route guard on the `/admin` parent.
- **Two SPAs AND two BFFs (`portal-admin-bff`)**. Hard isolation at every layer. Doubles the infrastructure: two Postgres connections to manage, two OBO token caches, two OpenTelemetry pipelines, two deploy targets.
- **Off-the-shelf admin tooling** (Retool / Forest Admin / SQLpad-style). Bypasses our security baseline (sessions, audit log, observability). Hard to keep within the project quality bar.
### How is admin access enforced
- **Entra ID role claim + BFF guard** _(chosen)_. The user's app role in Entra carries `admin` (assignment managed in Entra Admin Center). The BFF reads the role claim on every request to `/api/admin/*`; missing role → 403. The SPA does not own authorisation; it just decides what to render based on the claim it sees through the session payload.
- **Static IP / VPN gate only**. Network-layer isolation but no identity-bound check. Not sufficient; if an internal user without admin role lands on the admin URL, they should not be able to act.
- **Distinct OIDC client / scope per app**. Two app registrations in Entra, one per audience. Symmetric but operationally heavier without clear payoff at our scale.
### Session sharing between `portal-shell` and `portal-admin`
- **Distinct sessions per app** _(chosen)_. Each app authenticates independently against the BFF; the cookie domain / path scoping (`__Host-` prefix, ADR-0010) keeps the sessions isolated. An admin signed in to `portal-admin` is not silently authenticated to `portal-shell` and vice versa. The signing in to admin requires a fresh auth event — which Entra Conditional Access can then enforce as MFA-fresh (ADR-0011's `@RequireMfa` decorator becomes the natural gate for the admin entry route).
- **Shared session across apps**. Would require domain-wide cookies and breaks the `__Host-` security guarantee.
### v1 scope — which features ship now
The user has selected all four candidate features. They are independent enough to land iteratively in their own PRs, all within `portal-admin`:
1. **Editorial pages CMS** — CRUD on a `cms_page` table, fields: `slug`, `locale`, `title`, `body` (markdown), `published_at`, `updated_by`, `version`. Localised per ADR-0019 conventions (one row per `(slug, locale)`).
2. **Menu management** — CRUD on a `menu_item` table that the BFF reads to compose the response for `GET /api/me/menu`. The shape mirrors the existing static `MenuItem` interface in `portal-shell` (label, icon, route, `requiredPermissions[]`, `displayOrder`, `enabled`).
3. **User list (read-only)**`GET /api/admin/users` returns a paginated, filterable view derived from `audit.events` (login events) joined with whatever the BFF caches per user (per-tenant Entra `oid` ↔ display name mapping). No write actions in v1; invitations stay in Entra Admin Center.
4. **Audit log viewer**`GET /api/admin/audit` with filters on `actor_id_hash`, `action`, time range, `target` resource. The query talks to the `audit_reader` Postgres role per ADR-0013. UI: filter form + virtualised table.
### v1 explicitly out of scope
- **B2B user invitations from the admin UI**. Stays in Entra Admin Center. Double-source-of-truth would be a recurring divergence risk; defer until APF Ops actually asks for a unified flow.
- **Feature flag toggles**. We do not yet have a feature-flag substrate; revisit once one is chosen via its own ADR.
- **Theme / branding customisation**. Locked in code via the brand palette in `styles.css`; no per-tenant theming in v1.
- **CMS preview UI** in the user-facing portal. The admin's "preview" is an iframe rendering the user-portal route with a `?previewToken=…` query param; rich live-preview features defer to v2.
- **Workflow / approval flows on CMS edits**. Single-stage save in v1 (the editor publishes directly). Multi-stage workflow (draft → reviewed → published) is its own ADR if APF Comms requests it.
## Decision Outcome
### Architecture — separate SPA, shared BFF
```
apps/
portal-shell/ ← end-user SPA (existing)
portal-bff/ ← shared NestJS BFF (existing)
↳ /api/... ← end-user routes
↳ /api/admin/... ← admin routes, RBAC-gated
portal-admin/ ← admin SPA (new)
```
- `portal-admin` is a separate Angular workspace app per the Nx `apps` preset. Same Angular major (latest LTS), same standalone + zoneless + Signals + CSR-only configuration as `portal-shell` (ADR-0004).
- Shared UI primitives that turn out to need promoting (the `Icon` façade, `LayoutStateService`, brand tokens, Tailwind dark-mode variant, button / form primitives) graduate to `libs/shared/ui` and `libs/shared/state` when the second consumer materialises — that is _now_. They stay app-local in `portal-shell` until then; the move is mechanical.
- The BFF gains `apps/portal-bff/src/admin/admin.module.ts`. Every admin controller is guarded by a Nest `@UseGuards(AdminRoleGuard)` that asserts the session's role claim contains `admin`. The guard returns 403 (not 401) on missing role — the user _is_ authenticated, just not authorised. Audit log captures every 403 with the actor and the attempted route.
- `portal-admin` ships under a distinct origin / hostname (e.g. `admin.portal.apf.fr` or `portal.apf.fr/admin/` depending on the production hosting decision in the future infrastructure ADR). The exact URL is **not** locked here — it depends on TLS / reverse-proxy choices. Lock-in level: medium; either option lets us IP-restrict or VPN-gate the admin URL without affecting `portal-shell`.
### Auth — same Entra ID, same MSAL Node, `admin` role claim, fresh-MFA at entry
- Identity & auth flow per ADR-0008 / ADR-0009 are reused as-is. `portal-admin` is **not** a new Entra app registration in v1 — it uses the same registration with an additional `admin` app role assignable in Entra Admin Center.
- On every request to `/api/admin/*`, the BFF guard reads `session.user.roles` and requires `admin`. Missing role → 403 with an audit log entry (`action: 'admin.access_denied'`).
- The admin SPA's entry route is decorated with `@RequireMfa({ freshness: 600 })` (ADR-0011) — even though the standard portal does not require fresh MFA in v1, the admin app always does. Concrete consequence: signing in to admin re-prompts MFA if the user's last MFA event is older than 10 minutes.
### Sessions — distinct from `portal-shell`
- The admin SPA uses its own `__Host-portal_admin_session` cookie scoped to the admin origin. The Redis payload (ADR-0010) is structured identically but lives under a separate key namespace (`session:admin:<id>`).
- Logging in to `portal-shell` does **not** sign the user in to `portal-admin` and vice versa. SSO is preserved (Entra Single Sign-On at the IdP level still kicks in, no second password prompt) but session establishment is per-app.
### Database — same Postgres, dedicated schemas for admin-managed data
- New schemas added to the existing Postgres instance (per ADR-0006):
- `cms.pages` — editorial content per locale.
- `cms.menu_items` — admin-managed menu structure.
- Migrations land in `apps/portal-bff/prisma/migrations/`, owned by the same Prisma schema. Two-step rollout per ADR-0006 for any change to existing tables; pure-additive for the new schemas.
- The admin module's Prisma client uses the **shared** `DATABASE_URL` pool (full CRUD). The audit log connection stays on `AUDIT_DATABASE_URL` (or the dev fallback per ADR-0018) — admin _writes_ to `audit.events` via the audit module, admin _reads_ via the `audit_reader` role, never directly via the admin schema.
### Observability — same OTel pipeline, distinct service name
- `portal-admin` registers as `service.name=portal-admin` in OTel resource attributes (ADR-0012). Logs, traces, and metrics are routed to the same collector, distinguishable by service name in dashboards. The admin SPA's user-interaction instrumentation is enabled by default (no end-user privacy concern; admin users are a small known set).
- Audit log captures every admin write action with `actor_id_hash`, `action` (e.g. `admin.cms_page.update`), `target` (e.g. `cms_page:<slug>:<locale>`), and `before` / `after` snapshots. Read actions (audit log viewing, user list browsing) are also captured at coarser granularity (`admin.audit.query`) to deter fishing expeditions.
### Performance budgets
- `portal-admin` inherits the ADR-0017 budgets with a relaxation acknowledged here: the admin audience tolerates a heavier initial bundle (table virtualisation, formularies, rich text editor). Concrete numbers:
- **Initial bundle**: ≤ **500 KB gzip** (vs 300 KB for `portal-shell`). Justified by audience + by tables / charts dependencies.
- **LCP / INP / CLS / TBT**: same Core Web Vitals targets as `portal-shell` ("Good" thresholds). Admin users still deserve a responsive UI.
- **Lighthouse**: Performance ≥ 85 on critical admin routes (vs ≥ 90 on `portal-shell`). Five-point allowance for richer interactions.
- Bundle budgets enforced in `apps/portal-admin/project.json` as `type: "error"` thresholds (same mechanism as `portal-shell`).
### Accessibility & visual identity
- `portal-admin` inherits the ADR-0016 baseline: WCAG 2.2 AA + targeted AAA, RGAA 4.1, same brand palette. The denser UI (tables, formularies) makes some AAA criteria harder (1.4.6 contrast on disabled rows, 2.4.9 link purpose in dense tables) — those are best-effort with documented exceptions, not blanket waivers.
- Dark mode (per the recent theme switcher chantier) is supported from day one; the admin shares `LayoutStateService` once it graduates to `libs/shared/state`.
### Confirmation
**Wired across a sequence of PRs**, each independent and reviewable on its own:
1. **App skeleton**: `nx g @nx/angular:app portal-admin` per the conventions of ADR-0003. Smoke route `/` + a stub `<app-root>` rendering a placeholder shell. CI gates apply.
2. **Shared libs graduation**: extract `Icon`, `LayoutStateService`, brand tokens, dark-mode SCSS helpers into `libs/shared/ui` + `libs/shared/state`. `portal-shell` consumes from there; `portal-admin` consumes the same.
3. **BFF admin module**: `AdminModule` with `AdminRoleGuard` + first endpoint (`GET /api/admin/me` returning the session for self-test). Audit log captures access decisions.
4. **Entry route + admin shell** in `portal-admin`: header + sidebar + footer pattern, branded slightly differently (admin badge / banner) so users instantly see they are in the admin UI.
5. **First functional module**: pick one of the four v1 features (suggested order: CMS pages → menu management → audit viewer → user list, by increasing dependency on other systems). One PR per module.
**CI**:
- `portal-admin` is added to the affected-projects matrix in CI (no change needed — `nx affected -t lint test build` picks it up automatically).
- `ci:perf` (ADR-0017) runs Lighthouse against `portal-admin`'s critical routes with the relaxed thresholds above.
### Consequences
- Good, because admin code is isolated from the end-user bundle — zero risk of accidentally shipping admin features to the public surface (e.g. an export button leaking through a feature flag misconfiguration).
- Good, because the admin URL is a first-class concern operationally — VPN, IP allow-list, stricter Conditional Access policy are all local changes.
- Good, because the existing security baseline (sessions, audit, OBO, observability) is reused — no duplicate code paths, no risk of "the admin has its own auth that drifted".
- Good, because the `admin` Entra app role becomes the single source of authority. Granting / revoking admin is one Entra Admin Center operation, takes effect on next sign-in.
- Good, because the menu-management feature delivers the `requiredPermissions` story the `<app-sidebar>` already anticipates.
- Bad, because Nx now manages two SPAs — wider CI matrix, larger workspace. Bounded; Nx is designed for this.
- Bad, because shared UI primitives must graduate to `libs/shared/*` _now_ (a chunk of refactor) where they could have stayed app-local indefinitely otherwise. Acceptable; the move is mechanical and the libs are the right home anyway.
- Bad, because admin user roles are managed in Entra Admin Center (not in our UI) — operationally split. Acceptable for v1; the alternative (writing an in-app role manager) duplicates Entra functionality.
- Neutral, because the same Postgres instance hosts user data, CMS data, and audit data. Mitigated by schema-level isolation (`cms.*`, `audit.*`) and the audit-pool split (ADR-0018).
## Pros and Cons of the Options
### Where the admin app lives
#### Separate SPA, shared BFF (chosen)
- Good, because hard isolation of frontend surfaces — public bundle stays lean.
- Good, because admin URL can be restricted at the network layer independently.
- Good, because no doubled infrastructure on the BFF / DB / observability side.
- Bad, because Nx workspace grows; shared libs must graduate sooner.
#### `/admin/*` route in `portal-shell`, lazy-loaded
- Good, because single workspace app — simpler structure.
- Good, because shared design system is trivial — same SPA.
- Bad, because admin code ships in the same bundle origin; an XSS on the public app reaches admin code (defense-in-depth weaker).
- Bad, because admin URL cannot be IP-restricted without breaking the public app.
- Bad, because admin features end up subject to the public app's bundle budget (300 KB) — and admin tables / editors blow it.
#### Two SPAs + two BFFs
- Good, because maximal isolation at every layer.
- Bad, because doubled infra. The team is small; this is bricolage at our scale.
#### Off-the-shelf admin tooling
- Good, because zero implementation cost on day one.
- Bad, because escapes our security / observability / audit baseline. Re-onboarding it later would be expensive.
### Auth gating
#### Entra role claim + BFF guard (chosen)
- Good, because reuses the existing identity model.
- Good, because role grants / revocations are auditable in Entra.
- Bad, because role propagation latency — Entra role assignment is honoured on next sign-in. Acceptable.
#### Static IP / VPN only
- Bad, because no identity-bound check — an authenticated non-admin internal user on VPN gets in. Inadequate alone (acceptable as a defence-in-depth layer on top of the role check).
#### Distinct OIDC client per app
- Good, because cleanest separation of audiences in Entra.
- Bad, because two app registrations to manage, two sets of consent screens, two redirect URIs to keep aligned. Operational overhead without a clear payoff at our scale.
### Session sharing
#### Distinct sessions per app (chosen)
- Good, because compromise of one session does not leak into the other.
- Good, because `__Host-` cookie scoping (ADR-0010) is preserved.
- Good, because the admin entry point can demand fresh MFA without inconveniencing the end-user portal.
- Bad, because admin users sign in twice if they switch apps in the same browser session. Mitigated by Entra SSO — no password re-prompt, just a click-through.
#### Shared session across apps
- Good, because seamless cross-app navigation.
- Bad, because requires broader cookie domain — breaks `__Host-` guarantees.
## More Information
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx apps preset), [ADR-0003](0003-workspace-and-app-naming-convention.md) (naming conventions), [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (frontend stack — inherited), [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (Entra workforce identity), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (sessions), [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) (MFA freshness), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) (audit), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y baseline — inherited), [ADR-0017](0017-performance-budgets-lighthouse-ci.md) (perf budgets — relaxed for admin), [ADR-0018](0018-environment-configuration-strategy.md) (env config — shared), [ADR-0019](0019-internationalisation-angular-localize.md) (i18n — CMS content uses the same locales but is BFF-served, not bundle-baked).
+22 -20
View File
@@ -42,23 +42,25 @@ The vocabulary below is the source of truth. It is intentionally coarse — prop
ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column.
| # | Title | Status | Tags | Date |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------- | ---------- |
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 |
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 |
| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 |
| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 |
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
| [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 |
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
| [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 |
| # | Title | Status | Tags | Date |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- |
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 |
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 |
| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 |
| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 |
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
| [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 |
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
| [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 |
| [0019](0019-internationalisation-angular-localize.md) | Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing | accepted | `frontend`, `accessibility`, `performance`, `process` | 2026-05-11 |
| [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 |