b84b58068abd1afc0c539cf5eba77b8f39d2f480
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2772a918c2 |
feat(portal-shell): chatbot widget — SSE-bridged AI assistant with full a11y uplift (#197)
## Summary Final piece of the AI relay chantier opened with [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md): a chatbot widget on `portal-shell` that consumes the BFF's `POST /api/ai/chat` SSE endpoint (shipped in #196). Built fresh against the WCAG 2.2 AA + targeted AAA bar set by [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) rather than transcribing the stargate POC's React widget — the POC's drag UX, absent `aria-live`, and minimal screen-reader contract did not meet that bar. The widget renders as a floating launcher bottom-right; clicking opens a dialog panel that can be toggled into fullscreen; sending a message streams the assistant's reply token-by-token; citations render as inline footnote chips with an extracted side panel for the snippet/source/score detail. ## What lands ### Files ``` apps/portal-shell/src/app/features/chatbot/ ├── chatbot-host.ts Standalone component, mounted in app.html via @defer ├── chatbot-host.html Launcher + panel + suggestions + messages + form ├── chatbot-host.scss 7.6 KB compiled — under the new 8 KB budget ├── chatbot-host.spec.ts 12 cases: launcher / panel / log / input / citations ├── chatbot-citation-panel.ts Extracted sibling component so the host stays under budget ├── chatbot-citation-panel.html dialog with metadata + snippet, role=dialog aria-modal=false ├── chatbot-citation-panel.scss 2.6 KB ├── chatbot.service.ts Signals-based state (view / messages / streaming / citation) ├── chatbot.service.spec.ts 10 cases: view, send/stop, citations, errors ├── chatbot-api.service.ts fetch + ReadableStream + CSRF cookie + AbortSignal ├── chatbot-api.service.spec.ts 5 cases: request shape, parsing, errors ├── sse-parser.ts Pure ReadableStream<Uint8Array> → AsyncIterable<{event,data}> ├── sse-parser.spec.ts 8 cases: LF / CRLF / split chunks / trailing / JSON / passthrough └── chatbot.types.ts UI types decoupled from the proto-derived BFF types ``` Plus the shell-side glue: - `apps/portal-shell/src/app/app.html` — `<app-chatbot-host />` mounted as a sibling of `<app-footer />`, wrapped in `@defer (on idle)` so the widget bundle (~27 KB lazy chunk) does not weigh on the initial paint. - `apps/portal-shell/src/app/app.ts` — `ChatbotHost` added to `imports`. - `apps/portal-shell/src/app/app.spec.ts` — `AUTH_CSRF_COOKIE_NAME` provider added to keep the shell smoke test compiling now that the SPA renders the chatbot. - `apps/portal-shell/src/locale/messages.fr.xlf` — 19 new `@@chatbot.*` trans-units covering the trigger / title / fullscreen toggle / suggestions / input / streaming / errors / citation panel. - `apps/portal-shell/project.json` — `anyComponentStyle` budget raised from `5/6 KB` to `6/8 KB` to match `portal-admin`'s posture (the audit page hit the same wall). - `libs/shared/ui/src/lib/icon/icon.ts` — 6 new icons in the registry: `maximize-2`, `message-circle`, `minimize-2`, `send`, `square`, `x`. ### Accessibility decisions (per ADR-0016) | Decision | Why | |---|---| | **No drag**. Fixed bottom-right launcher + fullscreen toggle. | Stargate's mouse-only drag had no keyboard equivalent. Keyboard parity is the project's bar; drag is the wrong primitive to inherit. | | **`role="dialog"` + `aria-modal="false"`** (non-modal). | The page chrome stays operable behind the panel; no focus trap, no `inert` toggle on `<main>`. Closing returns focus to the launcher via a `viewChild` + `effect`. | | **`role="log"` + `aria-live="polite"` + `aria-relevant="additions"`** on the message container. | Screen readers announce the assistant's incoming tokens without re-reading the whole history. Used a `<div>` + `<article>` children — `role="log"` is not allowed on `<ol>` / `<ul>` per ARIA. | | **Typing dots `aria-hidden="true"`**, paired with a `<span class="sr-only">{{ streamingLabel }}</span>`. | The visual signal is decorative; the SR signal is textual. Animation gated by `@media (prefers-reduced-motion: reduce)`. | | **Stop button visible during streaming**. | Wired to `AbortController` → `ChatClient` → upstream LLM cancel (the cancel chain shipped in #195). | | **Inline `role="alert"`** on per-message error banners. | Errors are part of the conversation log, not page-level interruptions; assertive announcement keeps them perceivable without yanking focus. | | **44 × 44 px touch targets everywhere**. | Launcher (3.5 rem), header chrome buttons, send / stop, citation chips, suggestion buttons. ADR-0016 baseline. | | **Citations as inline footnotes** (`[1]`, `[2]`, …) + side panel with `source` / `score` / `snippet`. | Validated in the design check before implementation. Side panel extracted into its own component so the host stays under the SCSS budget. | | **`prefers-reduced-motion` gating** on launcher transitions, suggestion hover, action button transitions, typing-dots animation. | Standard motion-preferences contract. | | **i18n via `$localize`** with the `@@key` catalogue convention per [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md). | FR strings tagged at source; translations added to `messages.fr.xlf` (build fails otherwise — `i18nMissingTranslation: error`). | ### Streaming + cancellation The browser-side flow: 1. SPA submits a prompt → `ChatbotService.send(prompt)`. 2. Service appends a user message + empty assistant placeholder, sets `isStreaming = true`. 3. `ChatbotApiService.openChatStream(...)` opens a `POST` with `Accept: text/event-stream`, `credentials: 'include'`, `X-CSRF-Token` from the `__Host-portal_csrf` cookie, and an `AbortSignal`. 4. The response body's `ReadableStream<Uint8Array>` is parsed frame-by-frame by `sse-parser.ts` and yielded as `AsyncIterable<{event, data}>`. 5. The state service routes each frame: `token` appends to the assistant message, `citation` accumulates with a 1-based index, `error` marks the message as failed, `done` terminates the stream. 6. Cancellation: clicking Stop, navigating away, or any unhandled error fires `AbortController.abort()` → fetch terminates → upstream gRPC call is cancelled → AI service stops the LLM. Persistence: **session-ephemeral** by design. A SPA reload starts a fresh conversation. Matches the AI service's v1 posture (no per-user conversation table) and avoids the GDPR question of storing free-form transcripts before a consent flow lands. ### Native `fetch` + manual CSRF `HttpClient` buffers responses; native `fetch().body` is the only way to consume the stream incrementally. As a consequence, the project's `bffCredentialsInterceptor` + `csrfInterceptor` do not run on this call. The service handles both concerns manually: - `credentials: 'include'` is set explicitly so `__Host-portal_session` travels. - The `__Host-portal_csrf` cookie is read via `document.cookie` (it is intentionally not `HttpOnly` per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) §"CSRF defense") and echoed in the `X-CSRF-Token` header. The cookie name + BFF base URL come from the same `AUTH_*` injection tokens the interceptors use, so the wire contract stays single-sourced. ## Notes for the reviewer - **Why ChatbotCitationPanel is its own component.** Initial draft put the side panel inside `chatbot-host.scss`, which crossed the `anyComponentStyle` 6 KB error budget. Two paths to fix: raise the budget, or extract. Did both — the panel is genuinely its own dialog with its own ARIA contract, and the host stays under budget. The budget bump (5/6 → 6/8 KB) brings portal-shell in line with portal-admin where the same wall was hit on the audit page. - **Why `@defer (on idle)` rather than `@defer (on viewport)` or eager.** Eager pushed the initial main bundle from ~282 KB to ~315 KB — past the 300 KB budget. The widget is not critical path on first paint, so deferring is correct on its own terms. `on idle` ensures it loads as soon as the browser is idle, so the launcher is interactive within a second on a fresh load. `on viewport` would have required the widget to be in the initial viewport, which the floating launcher is not consistently. - **Why `<article>` children rather than `<li>` inside `role="log"`.** axe-linter (and the underlying ARIA spec) reject `role="log"` on `<ol>` / `<ul>`. Switched to `<div role="log">` with `<article>` children; semantically each chat turn is a discrete piece of content, which is exactly what `<article>` is for. - **Why ChatbotService doesn't extend / re-use the existing `AuthService` patterns more directly.** Conversation state is ephemeral and not security-sensitive; the auth service's discriminated-union state machine is overkill for the toggle + buffer pattern the chatbot needs. The two services share the same `signal` / `computed` / `effect` idiom; that's enough consistency. - **Why hard-coded suggestions instead of pulling from a server.** v1 ships with four French suggestions tagged for translation; server-driven suggestions are a v2 step that requires a new endpoint and a personalisation question that v1 doesn't need to answer. The current shape moves to server-driven by replacing the array literal with an effect that fetches — single point of change. - **Tool-call event handling.** The SSE writer in #196 emits `event: tool-call` frames; the SPA parses them but does not render anything. v1 ships with an empty tool registry on the BFF side, so the AI service never emits them. When the first tool lands, the rendering UI is a follow-up PR. - **stargate-a11y-uplift memory.** The memory note `feedback_stargate_a11y_uplift.md` codifies the rule used here for future migrations from stargate: adapt, don't transcribe. The PR text under "Accessibility decisions" is the case study. ## Test plan - [x] `pnpm nx test portal-shell` — **85 specs pass** (was 58, +27 new across SSE parser / API service / state service / host component). - [x] `pnpm nx test shared-ui` — 10 specs pass (icon registry exhaustive-key spec picks up the 6 new icons). - [x] `pnpm nx lint portal-shell` — 0 errors. 5 pre-existing non-null assertion warnings in the new spec files are documented limits of vitest's mock typing. - [x] `pnpm nx build portal-shell` — clean. Main bundle 297.49 KB raw (under 300 KB budget). Chatbot lazy chunk: 27.61 KB raw / 6.49 KB transfer. SCSS: host 7.6 KB / citation panel 2.6 KB, both under the new 8 KB error budget. - [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-ui,shared-charts` — five projects all green. - [ ] **Manual smoke** (requires the BFF wired to `apf-ai-service` per #196's plan): 1. `cd ../apf-ai-service && docker compose -f infra/docker-compose.yml up` to bring up the AI service. 2. `AI_SERVICE_GRPC_ENDPOINT=localhost:8080` in `apps/portal-bff/.env`, then `pnpm nx serve portal-bff` + `pnpm nx serve portal-shell`. 3. Sign in to the SPA, click the floating launcher bottom-right → panel opens, focus lands on the close button. 4. Pick a suggestion → user message appears right-aligned, assistant message streams in left-aligned with the typing dots animating (unless `prefers-reduced-motion` is on). 5. Click Stop mid-stream → the AI service log shows the gRPC call cancelled. 6. Press Escape with focus inside the panel → panel closes, focus returns to the launcher. 7. Toggle fullscreen → panel expands to `inset: 1rem`, ARIA contract unchanged. 8. Toggle dark mode → all themed surfaces switch via the CSS-variable swap in `chatbot-host.scss`; AA contrast still holds against the brand tokens. 9. Hit `/fr` and `/en` builds independently; suggestion labels swap between locales. ## What's next The AI relay chantier closes here. Pending follow-ups stay as written in #196: 1. **PR (post-v1)** — proto-drift CI gate diffing the BFF's vendored `proto/apf-ai/` against an upstream tag of `apf-ai-service`. 2. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed `Principal` envelope or mTLS) on the same date. 3. **Tool-call rendering** — UI surface for the `tool-call` SSE frame, once the BFF gains its first tool descriptor. 4. **Server-driven suggestions** — replace the four hard-coded prompts with an effect that fetches per-user suggestions from a future endpoint. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #197 |
||
|
|
6f26bcdd65 |
feat(shared-ui): move the role label from the portal-shell sidebar into the user menu (#165)
## Summary
Moves the "Role: …" widget from the bottom of the `portal-shell` sidebar into the user-menu panel header. Result: the role chip appears only when the reader is authenticated (the user menu only renders in that state), removing the noise of "Role: Anonymous" on signed-out traffic and de-duplicating the surface that already carries displayName / username / capability-gated actions.
```
Before: sidebar bottom → "Role: Anonymous" (always rendered, even signed-out)
After: user-menu panel → • <role chip> (only when authenticated)
```
## What lands
### Shared component — [`UserMenu`](libs/shared/ui/src/lib/user-menu/) gets an optional `role` input
When set, a small brand-tinted pill (`data-testid="user-menu-role"`) renders inside the panel header below the username. When undefined, the entire chip is omitted — keeps backwards compat with any future caller that doesn't carry a role concept.
```ts
readonly role = input<string | undefined>(undefined);
```
Visually echoes the `.role-chip` already used on the admin `/profile` page (rounded brand-tinted pill), tightened for the menu header density and `align-self: flex-start` so the pill doesn't stretch.
### Portal-shell
- **Header** ([header.ts](apps/portal-shell/src/app/components/header/header.ts)) gains a `roleLabel` computed that derives `Administrator` / `User` from `CapabilitiesService.canAccessAdmin` — the same logic that previously lived in the sidebar. The template binds it as `[role]="roleLabel()"` on `<lib-user-menu>`.
- **Sidebar** ([sidebar.ts](apps/portal-shell/src/app/components/sidebar/sidebar.ts), [sidebar.html](apps/portal-shell/src/app/components/sidebar/sidebar.html)) loses the role widget entirely: HTML block, `roleLabel` + `roleAriaLabel` computeds, and the `AuthService` + `CapabilitiesService` injections (the sidebar no longer needs them).
- **Sidebar spec** drops its three "role label" tests, grows a guard test asserting `data-testid="sidebar-role"` no longer exists, and reverts to the pre-#151 setup (no Http testing infrastructure — the sidebar no longer fires `/auth/me` or `/me/capabilities`).
- **Header spec** gains two assertions for the role chip inside the open menu panel (`Administrator` when `canAccessAdmin: true`, `User` otherwise).
### Portal-admin
- **Header** ([header.ts](apps/portal-admin/src/app/components/header/header.ts)) passes a **hardcoded `'Administrator'`** through `[role]`. Every reader who reaches the admin app already carries `Portal.Admin` (it's the `AdminRoleGuard` precondition for `/api/admin/*`); no need to re-derive. No i18n marks per ADR-0020's source-locale-only chrome.
### i18n
Five translation units gone from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf):
```
sidebar.role.anonymous
sidebar.role.administrator
sidebar.role.user
sidebar.role.aria
sidebar.role.label
```
Replaced by two new units under a generic prefix (the new owners are the user menu in either app, not the sidebar):
```
common.role.administrator
common.role.user
```
The `Anonymous` string is gone too — the chip is hidden on anonymous traffic, the label served no purpose without the user menu rendering it.
## Notes for the reviewer
- **Why a generic `common.role.*` prefix rather than `userMenu.role.*`?** The strings are reusable in any context that needs a curated role display (profile page header in a future PR, an audit log "actor role" column, …). Generic prefix avoids the next rename when a second consumer lands.
- **Why hardcode "Administrator" for portal-admin rather than reading `roles`?** The admin SPA's `CurrentUser.roles` carries the raw Entra role string (`Portal.Admin`). Displaying that in the menu header would leak the internal nomenclature into the UI — fine for the `/profile` page (it's explicit context there), wrong for the casual menu glance. The hardcoded label keeps the menu consistent with portal-shell ("Administrator" in both surfaces). If a non-admin role ever reaches portal-admin (the guard rejects it today, but the surface could evolve), this is the one place to revisit.
- **No CapabilitiesService dependency on portal-admin.** The admin app doesn't import the service — its session already exposes `roles` on `/api/admin/auth/me`, and the chip is unconditional. Keeps the admin bundle untouched by the user-portal capabilities plumbing.
- **A11y check.** The role chip is plain text inside the menu panel; the menu panel itself carries `role="menu"` + `aria-label`. The chip doesn't need an extra label — it reads "Administrator" / "User" in context after the displayName and username, which conveys the meaning. No focus state needed (not interactive).
## Test plan
- [x] `pnpm nx run-many -t lint test build --projects=shared-ui,portal-shell,portal-admin` — green.
- `shared-ui` — **10 specs pass** (was 8; +2 for the role chip).
- `portal-shell` — **43 specs pass** (header +2, sidebar -3 + 1 guard = -2 net, balanced to +0 on the total → 43 unchanged).
- `portal-admin` — **50 specs pass** (no spec edits; the `[role]` binding is exercised by the existing user-menu spec via the shared component).
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes; confirms the xlf cleanup + new `common.role.*` keys are consistent with usage.
- [ ] **Manual smoke**:
- **portal-shell anonymous**: sidebar bottom shows only the collapse toggle (no role line). Click "Sign in" → land back signed in, sidebar bottom unchanged.
- **portal-shell signed in (non-admin)**: avatar → open menu → header shows `Signed in as / displayName / username / [User]` chip.
- **portal-shell signed in (admin)**: same, chip reads `Administrator`, and the menu carries the `Open Portal Admin` row above Sign out.
- **portal-admin signed in**: avatar → open menu → `[Administrator]` chip regardless of which admin user signed in.
- Tabbing across the sidebar bottom no longer pauses on a non-interactive `<p>` element — directly hits the collapse toggle button.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #165
|
||
|
|
076cfb67a6 |
feat(portal-shell): move theme switcher to footer, drop redundant settings icon (#162)
## Summary Three coupled chrome adjustments in `portal-shell`'s shell layout, all motivated by the same realisation that the UserMenu introduced in #149 made the header's standalone Settings icon redundant and surfaced an inconsistency in the theme switcher's placement (header for everyone, soon to be split between header and user-menu depending on auth state). ## What lands ### 1. Drop the standalone Settings button from the header The UserMenu already carries a "Settings (Soon)" row for authenticated users, and Settings has no meaning for anonymous traffic. The header icon pointed nowhere; keeping it doubled the surface and forced readers to guess which control to use. `header.action.settings` is removed from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) in the same step — the i18n-strict prod build is now back in sync with the source. ### 2. Move `<app-theme-switcher>` from the header to the footer The switcher now lives beside `<app-locale-switcher>` in a single **device-prefs cluster** on the right side of the footer. Two reasons: - **Consistency across auth states.** The alternative — keep it in the header for anonymous, move it into the user-menu once authenticated — was the original temptation. Rejected: changing the location of an identical control depending on whether the user is signed in violates Jakob's law and breaks muscle memory. Especially bad for an a11y-first platform per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md): a reader who relies on dark mode shouldn't have to expand a menu they don't yet recognise on first visit to escape a flashing white background. - **Precedent.** [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md) already established the footer as the home for ambient device preferences (locale switcher). Theme is the obvious sibling — same family (per-device, non-identity). ### 3. Reshape the footer into two semantic clusters ``` [© APF France handicap · Accessibility statement] [locale • theme] ←——— info / legal ———→ ←—— device prefs ——→ ``` The Accessibility statement link moves from the right cluster (where it sat alongside the locale switcher) to the left, joining the copyright with a typographic separator (`·`). Left = static info / legal anchor; right = interactive prefs the reader controls. Keyboard order naturally follows: a Tab-traversal from the main content hits the informational anchor before the interactive controls — a small a11y win. ## What I left alone - **`portal-admin`'s header / footer.** No theme switcher there in v1 (the admin chrome is brand-primary-600 hardcoded); no settings icon either. ADR-0020 explicitly trims that admin chrome relative to the user shell — nothing to align right now. - **The user-menu's "Settings (Soon)" row.** That stays. PR 2 of the auth chantier ([#150](#150)) wired the row at the request of the chantier's staging; the Settings page itself lands in a future chantier and the row's pre-existing "Soon" badge keeps the affordance honest. ## Notes for the reviewer - **Why not also a "Theme: <current>" hint inside the user-menu?** Considered, deferred. The current Hint would be ornament without a corresponding *target*; once the Settings page exists and the theme has a settings sub-section, a "Theme: System" line in the user-menu makes sense as a deep-link affordance. Not before. - **Why a `·` text separator rather than a CSS border?** Border would force vertical alignment math (height, dark-mode color) for one line of footer text; a typographic mid-dot inherits text color, scales with the line, and is `aria-hidden` so screen readers don't read it as "middle dot". Smaller surface for one less moving piece. - **A11y check**: the left cluster's `<nav aria-label="Legal">` is preserved verbatim (just moved into the left `<div>` rather than the right). The accessibility statement keeps the same focus styles, keyboard behavior, and routerLink target. ## Test plan - [x] `pnpm nx test portal-shell` — **43 specs pass** (was 40; +3 net: dropped a Settings-button assertion + the "embeds theme switcher" header assertion swapped to its inverse, added two footer assertions for the theme switcher's new home + the cluster grouping). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell` — green. - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes, confirming the `header.action.settings` xlf removal didn't leave a dangling source-locale reference. - [ ] **Manual visual smoke**: open `pnpm nx serve portal-shell` in a browser, confirm: - The Settings cog is gone from the header. - The theme-switcher (sun/moon chip) now lives at the bottom-right of the footer, beside the locale chip. - Bottom-left of the footer reads `© 2026 APF France handicap · Accessibility statement`, the dot being decorative (no link). - Tabbing from the main content lands first on the accessibility link, then on the locale switcher, then on the theme switcher. - Toggling the theme still works as before, and toggling it persists across navigations and reloads (the underlying ThemeService is untouched). ## Follow-ups (optional) - The header still carries the global search form + notifications + help buttons. None of those are wired to live data yet — when they get real consumers, the equivalent "double-surface" check applies (is there a duplicate path in the user-menu? a redundant trigger?). For now the placeholder shapes are useful to anchor the layout. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #162 |
||
|
|
ee51efb688 |
feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
## Summary PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar. | PR | Périmètre | | --- | --- | | PR 1 ✅ | Shared `UserMenu` dropdown + integration. | | PR 2 ✅ | `/profile` pages on both apps. | | **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. | ## What lands ### BFF — `GET /api/me/capabilities` New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint: ```ts GET /api/me/capabilities → { canAccessAdmin: boolean } ``` Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array. ### ADR-0009 amendment [`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging: > The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface. The routes table grows a row for `/me/capabilities`. ### Portal-shell — capabilities-driven UI - **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway. - **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink. - **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed: - `Anonymous` when no session. - `Administrator` when `canAccessAdmin()` is true. - `User` otherwise (signed-in, no admin). The aria-label gains a `role` placeholder so screen readers hear the live value. ### Portal-admin — symmetric cross-app entry - **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface. ### Environments `adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018. ### i18n | Key | EN source | FR target | | --- | --- | --- | | `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal | | `sidebar.role.administrator` | Administrator | Administrateur | | `sidebar.role.user` | User | Utilisateur | | `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise | Admin-side strings stay in English source per ADR-0020. ## Notes for the reviewer - **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit. - **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`. - **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request. - **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`. ## Test plan - [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`). - [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`. - [ ] Manual smoke (with a `Portal.Admin`-assigned account): - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`. - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`. - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent. - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button. ## What's next Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #151 |
||
|
|
a8ead65ea8 |
feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin (#149)
## Summary
PR 1 of 3 from the user-menu / profile / cross-app-link chantier per the agreed staging:
| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | Shared `UserMenu` dropdown component + integration on `portal-shell` and `portal-admin`; anonymous Sign-in button moves to `rounded-md`. |
| PR 2 | `/profile` pages on both apps. |
| PR 3 | `/api/me/capabilities` endpoint + real role surfacing on the sidebar widget + cross-app links in the menu (both directions). |
Lands the gitea / github-shaped avatar dropdown so admins and end users get the familiar "Signed in as / Profile / Settings / Sign out" pattern. Future entries (cross-app link, role-based visibility) plug into the same `items` input without touching the shared component.
## What lands
### Shared component — [`libs/shared/ui/src/lib/user-menu/`](libs/shared/ui/src/lib/user-menu/)
```html
<lib-user-menu
[displayName]="state.user.displayName"
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[signedInAsLabel]="…"
[signOutLabel]="…"
[triggerAriaLabel]="…"
(signOut)="signOut()"
/>
```
- Avatar trigger (initials in a rounded square + chevron-down hint), CDK `cdkMenuTriggerFor` opening the panel in an overlay portal. Same primitives `ThemeSwitcher` and `LocaleSwitcher` already use — keyboard nav, focus management and Escape-to-close come for free.
- Panel layout: "Signed in as" small-caps header → `displayName` → `username` → separator → caller-supplied `items` → separator → dedicated Sign out row at the bottom.
- `UserMenuItem` shape supports `routerLink` (intra-app) **and** `href` (cross-app / external) so PR 3's "Open Portal Admin" entry can land without re-shaping the API.
- Disabled items render as `aria-disabled` rows with a right-aligned badge — same Soon-chip pattern the admin sidebar already uses for not-yet-shipped entries.
- Sign out is **not** an item — it's a hardcoded row that emits a `signOut` output. Avoids special-casing item types and keeps the destructive action visually + structurally distinct.
- Avatar background is driven by `--user-menu-avatar-bg` / `--user-menu-avatar-fg` CSS custom properties so each host header can re-skin without forking the component (portal-admin uses translucent white over the brand-primary-600 header; portal-shell keeps the default brand-primary-500).
### Portal-shell integration — [`apps/portal-shell/src/app/components/header/`](apps/portal-shell/src/app/components/header/)
- Authenticated state in [header.html](apps/portal-shell/src/app/components/header/header.html) swaps the inline avatar + display name + Sign-out button for `<lib-user-menu>`.
- Anonymous Sign-in button moves from `rounded-full` → `rounded-md` per the reference image. Loading + error chips follow for visual consistency with the new square-ish avatar.
- 6 new strings in [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf): `header.userMenu.{profile,settings,signedInAs,signOut,trigger.aria}` + `common.badge.soon`.
### Portal-admin integration — [`apps/portal-admin/src/app/components/header/`](apps/portal-admin/src/app/components/header/)
- Same swap, leaner: the admin header keeps its inline `.btn--primary` / `.btn--secondary` for anonymous + error states (no search bar / notification cluster, per ADR-0020), only the authenticated state goes through the menu.
- Overrides `--user-menu-avatar-bg` / `--user-menu-avatar-fg` in [`header.scss`](apps/portal-admin/src/app/components/header/header.scss) under `.auth-widget lib-user-menu` so the avatar reads on the brand-primary-600 background. No FR labels (no admin locale in v1 per ADR-0020).
## Notes for the reviewer
- **Why `Sign out` outside the `items` array?** It's the only destructive action in the panel + always present + emits an event rather than navigates. Keeping it special-cased lets the `items` API stay tight (`{ label, icon?, routerLink? | href?, disabled?, badge? }`) and gives each app a single typed entry point (`signOut` output) rather than a brittle `items[].action === 'sign-out'` discriminator.
- **Why `data-testid="user-menu"` on the component host?** The shared spec already covers panel internals; each app's spec needs a top-level handle to reach the trigger without coupling to the avatar CSS class. Same pattern as the existing `data-testid="sign-in-button"`.
- **Profile entry points at `/profile` on both apps in this PR.** Portal-shell already has a demo `/profile` route, so the link works; portal-admin will 404 until PR 2 lands the actual page. Interim cost is acceptable given PR 2 lands directly behind this.
- **No ADR for the component.** It's a UI primitive in `libs/shared/ui` — same tier as `Icon`. Promotion criteria, dark-mode, a11y, and i18n all follow the existing ADRs already in force (ADR-0004 + 0016 + 0019).
## Test plan
- [x] `pnpm nx test shared-ui` — **8 specs pass** (was 3, +5 for `UserMenu`).
- [x] `pnpm nx test portal-shell` — **35 specs pass** (was 34, +1 for the new "opens menu" assertion).
- [x] `pnpm nx test portal-admin` — **46 specs pass** (was 45, +1 for the new "opens menu" assertion).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,feature-auth` — 15/15 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke — sign in on portal-shell, confirm the avatar lives at the top right with the dropdown opening on click / Enter / ArrowDown, Profile navigates to `/profile`, Settings appears greyed with a "Soon" badge, Sign out triggers the BFF logout. Repeat on portal-admin (the avatar background should pick the translucent-white override over the brand-primary-600 header).
## What's next
PR 2 picks up directly: builds a real `/profile` page on each app, both reading from `feature-auth`'s `currentUser()` signal.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #149
|
||
|
|
177f2f20c0 |
feat(portal-shell): authGuard + BFF http interceptors + /profile demo route (#117)
## Summary Brings the SPA auth track to the level of polish the BFF surface deserves. After #113/#114, the header reflects sign-in state — but the SPA had no protected routes and no global handling of session-state drift. This PR adds three building blocks (one guard, two interceptors) plus one demo consumer. - **`authGuard`** (`CanActivateFn`) — gates routes on `AuthService.state`. Waits out the bootstrap `loading` state, allows when `authenticated`, redirects through `auth.login()` (full-page navigation to the BFF's `/auth/login` → Entra round-trip) when `anonymous` or `error`. - **`bffCredentialsInterceptor`** — flips `withCredentials: true` on every request whose URL starts with `AUTH_BFF_BASE_URL`. Replaces the per-call flag we had on `/me` (#114) with a single point of truth. Future BFF calls inherit it automatically — no chance of forgetting it. - **`bffUnauthorizedInterceptor`** — calls `AuthService.refresh()` when a BFF route (other than `/auth/me` itself) answers 401. Keeps the SPA's auth state in sync after server-side session destruction (absolute-timeout, manual revoke, idle-TTL expiry). - **`/profile`** demo route — first real consumer of the guard. Lazy-loaded component that renders the curated `CurrentUser` payload (display name, username, oid, tid). Exercises the full loop end-to-end: guard waits on /me → BFF answers → SPA renders. ## Notable choices **Lazy `AuthService` resolution in the 401 interceptor.** A naive `inject(AuthService)` at the top of the interceptor caused a circular-construction error: `AuthService`'s own constructor fires the bootstrap `/me`, which goes through the interceptor chain, which tries to inject `AuthService` while it's still being constructed. The fix is to inject the parent `Injector` and resolve `AuthService` lazily inside `catchError` — by the time a 401 actually fires, construction is done. Standard Angular pattern for "interceptor depends on a service that uses HttpClient". **`/auth/me` is excluded from the 401 refresh trigger.** The interceptor's whole job is to catch session-state drift; `/me` is the probe `AuthService.refresh()` itself uses. Without the exclusion, a 401 from /me would call `refresh()` → another /me → another 401 → infinite loop. **On `error` state, the guard still redirects to `/auth/login`.** Could have shown a "can't reach the server" page on the protected route, but the BFF-side login screen surfaces diagnostics more usefully (Entra's own error path) than a generic SPA outage page would. **Per-call `withCredentials: true` removed from `AuthService.refresh()`.** The interceptor now applies it uniformly. The spec that pinned the per-call flag is also gone — that contract moved to `bff-credentials.interceptor.spec.ts` where it belongs. **`profileTitle` + 6 new i18n message ids.** `route.profile.title`, `profile.heading`, `profile.intro`, `profile.field.{displayName,username,oid,tid}` shipped in `messages.fr.xlf` with FR translations. ## Out of scope (next PRs) - A real user-profile feature (settings, preferences, etc.) — `/profile` is just an auth-loop fixture today. - Showing the auth-loading state on protected routes (currently the guard blocks navigation; the user sees the previous route until /me resolves). Acceptable for v1. ## Test plan - [x] `pnpm nx test feature-auth` → **19/19 pass** (was 8; +11 across `auth.guard.spec.ts`, `bff-credentials.interceptor.spec.ts`, `bff-unauthorized.interceptor.spec.ts`). - [x] `pnpm nx test portal-shell` → **34/34 pass** (was 32; +2 for the Profile component). - [x] `pnpm nx lint feature-auth portal-shell` → clean. - [x] `pnpm nx build portal-shell` → clean. Bundle: main 492 kB raw / 131 kB transfer (well under the 300 KB gzip budget per ADR-0017). - [x] **CI clean-env repro** (lesson from #115/#116): `env -u REDIS_URL -u SESSION_* ... pnpm exec nx run-many -t test` → 123 + 19 + 34 = **176/176 pass**. - [ ] Manual smoke against running BFF: - [ ] Anonymous → visit `/profile` → redirect to `/auth/login` → Entra → callback → SPA lands at `/profile` with identity card filled in. - [ ] Trigger an absolute-timeout (set `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in BFF `.env`, wait) → next BFF call returns 401 → header flips to "Sign in". --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #117 |
||
|
|
9a9faf9a31 |
feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget (#113)
## Summary First user-visible piece of the auth track. The portal-shell SPA now consumes the BFF auth surface (`/api/auth/me`, `/api/auth/login`, `/api/auth/logout`) and the header reflects sign-in state. - `libs/feature/auth` ships an `AuthService` that fetches `/auth/me` on first injection, holds a signal-backed `AuthState` (`loading` / `anonymous` / `authenticated` / `error`) and exposes `currentUser` + `isLoading` computed signals plus `login()` / `logout()` / `refresh()` methods. - The header's right-side widget renders four states: a sign-in button when anonymous, the user avatar (initials, `JD` for "Jane Doe") + display name + sign-out button when authenticated, a loading dot before `/me` resolves, and a "Can't reach the server" chip on non-401 failures. - `login()` / `logout()` go through an injected `AUTH_NAVIGATOR` token whose default calls `window.location.assign(url)`. Specs override it with `vi.fn()` — no `window.location` mocking required. ## Notable choices **Auto-bootstrap on construction, not via `provideAppInitializer`.** The service fires `/me` from its constructor (unawaited) so consuming components transition through the explicit `loading` state. Blocking app boot on the round-trip would push the first paint behind the network call — bad for TTFB, especially on slow links. The header handles `loading` as a first-class state. **Discriminated `AuthState` over flat fields.** A single source of truth (`state()`) with four `kind`s lets templates `switch` and narrow automatically. `currentUser` and `isLoading` are computed conveniences but never out of sync with `state`. **`AUTH_BFF_BASE_URL` + `AUTH_NAVIGATOR` injection tokens.** Decouples the lib from the host's `environment.ts` shape and keeps tests free of `window.location` redefinition (which jsdom resists across multiple specs in the same file — first redefine works, second throws "Cannot redefine property"). The host wires both in `app.config.ts`. **Curated public user type.** `CurrentUser` mirrors the BFF's `/me` response (`oid`, `tid`, `username`, `displayName`) — no `amr`, no internal claims. The shape lives in `auth.types.ts` so feature code can import it without depending on Angular HTTP details. **Distinct `error` state.** A network failure / 5xx surfaces a different UI than "not signed in" — "Can't reach the server" chip vs. sign-in button. Avoids the trap of treating any `/me` failure as "anonymous". ## Out of scope (next PRs) - Route guards (protecting routes from anonymous users). For now the header is the only consumer. - Auto-refresh of the session before idle timeout. - HTTP interceptor that redirects to `/auth/login` on a 401 from any other BFF call. - Per-locale styling polish on the new header strings. ## Test plan - [x] `pnpm nx test feature-auth` → **8/8 pass**. - [x] `pnpm nx test portal-shell` → **32/32 pass** (was 27 before). - [x] `pnpm nx lint portal-shell feature-auth` → clean. - [x] `pnpm nx build portal-shell` → main bundle 488 kB raw / 129.94 kB transfer; well under the 300 kB gzip budget from ADR-0017. - [ ] Manual smoke once the BFF is up: - [ ] Anonymous landing → header shows "Sign in". - [ ] Click "Sign in" → BFF /login → Entra → callback → SPA lands with avatar + display name in the header. - [ ] Click "Sign out" → BFF /logout → Entra logout → back at SPA, header back to "Sign in". --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #113 |
||
|
|
192cc483b6 |
feat(portal-shell): locale switcher in the footer (#95)
## Summary
Add a locale switcher (FR / EN) to the footer's right cluster, next to the accessibility link. Closes the user-facing i18n loop — the FR bundle has existed since the sweep PR but had no in-app entry point until now.
The switcher reads the active locale from `<html lang>` (set per locale by the build), shows the native name + a globe + chevron-down chip, and on selection rewrites the URL prefix (`/en/...` ↔ `/fr/...`) and hard-refreshes so the right bundle boots — per ADR-0019.
## Architecture
Same pattern as the theme switcher:
- **`@angular/cdk/menu`** for the trigger + roving-focus menu + escape / click-outside dismissal.
- **`ViewEncapsulation.None`** because the menu opens in an overlay portal outside the component host — BEM-style class names (`.locale-switcher__*`) keep the global emissions contained.
- Each menu item carries `[attr.lang]="locale.code"` so screen readers pronounce the native names correctly.
## Decisions worth flagging
- **Locale display names ("Français", "English") are NOT i18n-marked.** Universal switcher convention: each language is always shown in its own language. Translating them would defeat the purpose for someone trying to switch *away* from the active locale they can't read.
- **No backend, no cookie, no smart `/` redirect — yet.** The URL prefix is the source of truth in v1: the next visit lands on the same locale because the URL says so. The `__Host-portal_locale` cookie + the BFF route at `/api/preferences/locale` + the smart `/` redirect described in ADR-0019 wait for the auth flow to bring the BFF online.
- **Dev-mode limitation, accepted.** Under `nx serve`, the dev server has no locale prefix in the URL — clicking the trigger lands on a non-existent path. The switcher works against the production build (`nx run portal-shell:serve-static` or any real deploy). This matches ADR-0019: dev = source locale, locale switching is a built-bundle concern.
- **Touch target.** Visible height stays at ~28 px to fit the 40 px footer; vertical padding extends the tap area to **44 px**, meeting the ADR-0016 AAA minimum without inflating the footer.
## Translation choices
Two new i18n keys:
| Key | Source (EN) | Target (FR) |
|---|---|---|
| `@@locale.trigger.aria` | `Language: <name> (change language)` | `Langue : <name> (changer de langue)` |
| `@@locale.menu.aria` | `Language` | `Langue` |
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. **40 / 40 specs** (+5: four new for `LocaleSwitcher`, one for the footer embedding).
- [x] Production build emits both locales; spot-checked the FR bundle for "Langue", "changer de langue", and the absence of "Language:" leakage.
- [ ] Manual: build prod + serve-static → on `/en/`, click switcher → lands on `/fr/`; widget shows "Français"; reload stays in FR.
- [ ] Manual: keyboard the trigger → ENTER opens, arrows navigate, ENTER selects, ESC closes; focus returns to trigger on close.
- [ ] Manual: screen reader announces both languages with the right pronunciation (`<button lang="fr">Français</button>` is announced with the FR voice).
- [ ] Manual: query/hash preserved across switch (`/en/accessibility?foo=bar` → `/fr/accessibility?foo=bar`).
## What this PR explicitly does NOT do
- BFF route `/api/preferences/locale` + `__Host-portal_locale` cookie.
- Smart `/` redirect (cookie → Accept-Language → fr) — that's reverse-proxy / BFF work.
- CI gate on missing translations.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #95
|
||
|
|
8f84cc6389 |
feat(portal-shell): collapse accessibility routes into one i18n-marked route (#94)
## Summary
Continue ADR-0019: replace the `/accessibility` + `/accessibilite` twin routes with a single canonical route whose content is i18n-marked in the template. The per-locale build (en/fr) already inlines the right copy — the route-data + `copy()` service indirection is no longer carrying its weight.
## What changes
- **`AccessibilityStatement`** loses its `ActivatedRoute` injection, the `Lang` discriminator, and the `COPY` lookup table. The component is now a plain shell over the template.
- **`accessibility.html`** carries the title + intro + status panel as `i18n="@@page.accessibility.*"` markers. Six new trans-units in [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) provide the French copy — verbatim from the old `COPY.fr` block, so the page reads the same in FR as before.
- **`app.routes.ts`** declares the single canonical route at `path: 'accessibility'` and keeps `/accessibilite` alive as a `redirectTo: 'accessibility'`. Drops `data: { lang: ... }` — no longer consumed.
- **`footer.html`** collapses the dual link into one i18n-marked link (`@@footer.accessibilityLink`). EN bundle reads "Accessibility statement"; FR bundle reads "Déclaration d'accessibilité".
## Decision worth flagging
The path stays in English across both locales for now: `/en/accessibility` and `/fr/accessibility`. Translating route *segments* (`/fr/declaration-d-accessibilite`) needs either a custom URL serializer or per-locale route trees — not worth the complexity at this scale. The page title and the link label already differ per locale via i18n, which is what's actually visible to users.
The historical `/accessibilite` path keeps working via the route-level redirect. Drops out of the codebase once analytics confirm no traffic reaches it.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. **35 / 35 specs** (was 36; the obsolete `lang`-fallback test is removed).
- [x] Production build emits both locales. FR bundle contains `Statut`, `Déclaration`, the FR intro / panel bodies. No leftover English on swept strings.
- [x] `extract-i18n` clean (49 unique units now: +5 for `page.accessibility.*` + `footer.accessibilityLink`, −0; the old route-data `lang` markers were not i18n).
- [ ] Manual: serve-static then `/en/accessibility` and `/fr/accessibility` render their respective content; `/fr/accessibilite` 301-redirects to `/fr/accessibility`.
- [ ] Manual: footer shows one link, locale-aware ("Accessibility statement" / "Déclaration d'accessibilité").
- [ ] Manual: browser tab title flips between bundles ("Accessibility statement · APF Portal" / "Déclaration d'accessibilité · Portail APF").
## What this PR explicitly does NOT do
- Translate the URL path segment (next ADR-only refinement if needed).
- Add the locale switcher in the footer — that's the next PR on the i18n track.
- Wire a CI gate on missing translations.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #94
|
||
|
|
65fae7f963 |
feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
## Summary
Continue ADR-0019 implementation. Mark every UI string surfaced by the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript), and populate [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) with French translations. The production build now ships **two genuinely different bundles** under `dist/.../{en,fr}/`.
**43 trans-units** marked, grouped by feature:
| Surface | Strings |
|---|---|
| `app.html` | skip-link |
| `header.html` | wordmark, search label + placeholder, action button aria-labels, user-menu placeholder |
| `sidebar.ts` + `.html` | menu groups + items, aside / nav aria-labels, role badge, toggle button (Expand / Collapse aria + Collapse text) |
| `theme-switcher.ts` + `.html` | mode labels, menu aria, trigger aria (`Theme: <mode> (open menu)` with named placeholder) |
| `footer.html` | aria-labels, copyright (interpolation preserved) |
| `home.html` | welcome heading + intro + status widget labels |
| `app.routes.ts` | browser tab titles |
## Tooling
- Add `"@angular/localize"` to the `types` array in both [`tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`tsconfig.spec.json`](apps/portal-shell/tsconfig.spec.json) so TypeScript resolves the `$localize` global at compile time. Specs need it too — they evaluate the same component code paths.
- Extraction target (`nx run portal-shell:extract-i18n`) reports **44 messages** (43 unique IDs).
## Translation choices worth flagging
- **Wordmark**: "APF Portal" → "Portail APF". Same key used for the `/` browser tab title. The PWA manifest (`site.webmanifest`) stays "APF Portal" — manifest values are not bundled, they sit in the static assets and are language-neutral in v1.
- **System theme mode** → "Système".
- **"Anonymous"** role → "Anonyme"; **"Role:"** → **"Rôle :"** (French uses a non-breaking space before the colon — typographic convention, preserved in the XLIFF target).
- **Accessibility links in the footer stay bilingual.** Each carries its own `lang` attribute (`lang="en"` and `lang="fr"`). The dual-link pattern goes away in the upcoming route-fusion PR; until then it's the most honest stopgap.
- **Both accessibility routes share one title key** (`@@route.accessibility.title`). In the EN bundle, both display "Accessibility statement · APF Portal"; in the FR bundle, both display "Déclaration d'accessibilité · Portail APF". After route fusion only one route remains.
## Verification
- Production build: **129 kB gzip initial per locale** (vs 122 kB before). +7 kB absorbs the i18n marker metadata and the embedded translation data in the FR bundle. Well under the 300 KB budget.
- Spot-checked the FR bundle: no leftover English source text on any swept string, route title, or home page intro. The `Welcome to APF Portal` in the lazy `home` chunk shows "Bienvenue sur Portail APF" in FR.
- **36 / 36 specs unchanged.** They run in the source locale (`en`), so the English assertions still match. No spec edits needed.
- Lint clean.
## Out of scope (each its own follow-up PR)
- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect.
- **Collapse `/accessibility` + `/accessibilite`** into one localised route with locale-translated path segments.
- **CI gate** that fails the build on a missing translation. Once the sweep is reviewed, we add `nx build --localize` to `ci:check` and verify it rejects unsealed strings.
- **Accessibility page content localisation.** Its content is driven by a `copy()` service rather than i18n-marked templates — restructured during the route fusion.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs).
- [x] `pnpm exec nx run portal-shell:extract-i18n` — clean, 43 unique unit IDs.
- [x] Production build emits both locales; FR bundle contains "Tableau de bord", "Aller au contenu principal", etc.
- [ ] Manual: `pnpm exec nx serve portal-shell` shows the EN source. `pnpm exec nx build portal-shell --configuration=production && pnpm exec nx run portal-shell:serve-static` → open `http://localhost:4200/fr/` for FR, `/en/` for EN.
- [ ] Manual: every aria-label announced by a screen reader in FR build matches the French translation.
- [ ] Manual: browser tab title flips between bundles ("APF Portal" vs "Portail APF").
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #93
|
||
|
|
29d16c7527 |
feat(portal-shell): wire @angular/localize plumbing per ADR-0019 (#91)
## Summary
First implementation step of ADR-0019. Wire the `@angular/localize` plumbing into `portal-shell` so the next sweep PR can start marking UI strings without any infrastructure work.
## What changes
- **Promote `@angular/localize` to a direct dependency** (it was already a transitive via the Angular metapackage; promoting it makes the `init` polyfill explicitly resolvable from the project).
- **Configure the `i18n` block** in [`apps/portal-shell/project.json`](apps/portal-shell/project.json):
- `sourceLocale: { code: "en", baseHref: "/en/" }` — matches the project English-only rule.
- `locales.fr: { translation: "...messages.fr.xlf", baseHref: "/fr/" }` — single target locale for now.
- **Add the `init` polyfill** to the build target (`"polyfills": ["@angular/localize/init"]`).
- **Add an `extract-i18n` Nx target** that wraps Angular's `@angular/build:extract-i18n` executor and drops the source XLF next to the translation files.
- **Enable `--localize` on the production build** — `nx build portal-shell --configuration=production` now emits two folders side by side: `dist/apps/portal-shell/browser/en/` and `.../fr/`. Each carries its own `<html lang>` and `<base href>` per the ADR.
- **Seed an empty `messages.fr.xlf`** (XLIFF 1.2 skeleton with sourceLanguage="en" / targetLanguage="fr" and an inline editor convention note). The sweep PR drops `<trans-unit>` entries directly into the body block.
## Verification
```
dist/apps/portal-shell/browser/
├── en/
│ └── index.html ← <html lang="en">, <base href="/en/">
└── fr/
└── index.html ← <html lang="fr">, <base href="/fr/">
```
Until the sweep PR marks strings, both bundles ship the same English source text — that's expected and matches what the ADR calls out ("the FR bundle falls back to source text for every untranslated key").
## What this PR explicitly does NOT do
- **Mark UI strings.** Every `i18n` attribute / `$localize` call lands in the next PR. Pure infra commit here.
- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect. Lands once switching shows a meaningful difference.
- **Collapse `/accessibility` + `/accessibilite` into a single localised route.** Depends on marked text + localized route paths — sweep PR territory.
- **CI gate** that fails the build on missing translations. Lands when there are translations to be missing.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs unchanged).
- [x] `pnpm exec nx build portal-shell --configuration=production` — produces both locale folders with correct `<html lang>` and `<base href>`.
- [x] `pnpm exec nx run portal-shell:extract-i18n` — runs cleanly, reports `(Messages: 0)` as expected.
- [x] Production initial bundle: **123 kB gzip per locale** (vs 121 kB on `main`; +1.5 kB for the `@angular/localize` runtime polyfill). Both stay well under the 300 KB budget.
- [ ] Manual: `pnpm exec nx serve portal-shell` runs unchanged (source locale `en`, no `--localize` in dev for now).
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #91
|