docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin
ADR-0019 picks `@angular/localize` in build-time mode with two locales (`fr` default, `en` source). URLs are always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → Accept-Language → fr. UI strings live in XLIFF; editorial content (CMS-served) is locked to the admin-app pipeline. The locale switcher in the footer writes a `__Host-portal_locale` cookie and hard-refreshes to the matching bundle. The `@angular/localize` runtime mode, ngx-translate, and transloco alternatives are recorded as considered-and-rejected. ADR-0020 splits portal administration into a dedicated Angular SPA (`portal-admin`) sharing the existing `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role plus fresh-MFA at entry. Same identity / sessions / audit / observability primitives reused, no infrastructure duplication. v1 ships four modules: CMS for static multilingual pages, sidebar menu management (activating the `requiredPermissions` field already on `MenuItem`), read-only user list, and an audit log viewer. Bundle budget relaxed to 500 KB gzip (vs 300 KB on `portal-shell`); same a11y + dark-mode baseline. Together they answer the two questions raised after the footer chantier: how the multilingual story works, and where the admin surface lives. Implementation lands across follow-up feature PRs; this commit is documentation only. CLAUDE.md picks up summary entries for both decisions and bumps the ADR coverage line to 0020.
This commit is contained in:
@@ -47,12 +47,14 @@ The structural, security, observability, and quality choices are recorded as ADR
|
|||||||
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
||||||
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
||||||
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
|
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
|
||||||
|
- **Internationalisation:** `@angular/localize` in build-time mode, two locales (`fr` default served at `/`, `en`), source locale = English (project English-only rule). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. UI strings live in XLIFF (`messages.fr.xlf`); editorial / CMS content is BFF-served already localised (see admin app). Footer hosts the locale switcher; switching writes a `__Host-portal_locale` cookie and hard-refreshes — see [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md).
|
||||||
|
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
|
||||||
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
||||||
- **Runtime:** Node.js latest LTS major.
|
- **Runtime:** Node.js latest LTS major.
|
||||||
|
|
||||||
## Repository status
|
## Repository status
|
||||||
|
|
||||||
The Nx workspace is **not yet bootstrapped** — there is no `package.json`, no source code, no tests. ADRs 0001 → 0017 cover the structural, security, observability, and quality choices for phase-1, phase-2, and phase-3a. The security baseline ADR is **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly PCI DSS / NIS 2). The next step is to scaffold the workspace per [docs/setup/03-angular-nx-monorepo.md](docs/setup/03-angular-nx-monorepo.md), which has already been rewritten to align with the phase-1 ADRs.
|
The Nx workspace is **not yet bootstrapped** — there is no `package.json`, no source code, no tests. ADRs 0001 → 0020 cover the structural, security, observability, quality, i18n, and admin-app choices for phase-1, phase-2, and phase-3a. The security baseline ADR is **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly PCI DSS / NIS 2). The next step is to scaffold the workspace per [docs/setup/03-angular-nx-monorepo.md](docs/setup/03-angular-nx-monorepo.md), which has already been rewritten to align with the phase-1 ADRs.
|
||||||
|
|
||||||
If asked to "build", "test", or "run" anything, first verify whether the workspace exists; if not, the right response is to scaffold it, not to invent commands.
|
If asked to "build", "test", or "run" anything, first verify whether the workspace exists; if not, the right response is to scaffold it, not to invent commands.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -43,7 +43,7 @@ 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.
|
ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column.
|
||||||
|
|
||||||
| # | Title | Status | Tags | Date |
|
| # | Title | Status | Tags | Date |
|
||||||
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------- | ---------- |
|
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- |
|
||||||
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
|
| [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 |
|
| [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 |
|
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
|
||||||
@@ -62,3 +62,5 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
|
|||||||
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user