7358e53de9d41ece3f1776d6d5f8ab095f0db299
34 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 |
||
|
|
aa61ea0e02 |
feat(portal-shell): ghost-style Sign-in button with log-in icon (#189)
## Summary The anonymous "Sign in" CTA in `portal-shell`'s header was a filled brand-primary block sitting next to two round icon-only buttons (Notifications, Help). The contrast was off — the filled rectangle visually dominated the row even though it's the *third* action in the strip. This PR turns it into a ghost-style button (no fill, no border) with a `log-in` icon ahead of the label, matching the quiet posture of its neighbours while still reading as the primary CTA for unauthenticated users. ## What lands `apps/portal-shell/src/app/components/header/header.html` — the anonymous-state button: | Aspect | Before | After | |---|---|---| | Fill | `bg-brand-primary-500` (filled) | `bg-transparent` (ghost) | | Text colour | `text-white` | `text-brand-primary-500` (`-300` in dark) | | Border | none | none — pure text-only style | | Hover | darker fill | light `bg-brand-primary-50` tint + `text-brand-primary-600` | | Icon | (none) | `<lib-icon name="log-in" [size]="16" aria-hidden="true" />` ahead of the label | | Padding / gap | `px-4 gap-2` | `px-3 gap-1.5` (slightly tighter, makes room for the icon without growing the chrome) | | Height | `h-11` | `h-11` (unchanged — 44 × 44 touch target per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) holds) | `libs/shared/ui/src/lib/icon/icon.ts` — adds the missing pair of the existing `log-out` icon: - Imports `LogIn` from `lucide-angular`. - Registers `'log-in': LogIn,` in the alphabetical registry between `'layout-dashboard'` and `'log-out'`. ## Notes for the reviewer - **Ghost vs. outline vs. filled — why ghost.** Tried two intermediate iterations during the design pass (outline with brand border, then a more compact outline). The user preferred the ghost rendering once we removed the border — the header strip is the right surface for an "always-quiet, surfaces on hover" CTA, since the user typically scans for the search bar first, not the auth state. Filled buttons are the right call inside content where the CTA *is* the focal point (forms, modals). - **Touch-target stays at 44 × 44.** `h-11` is kept on purpose. The CI a11y gate from ADR-0016 (`touch-target check (44×44 min)`) is non-negotiable for interactive controls; visually shrinking horizontal padding + reducing visual weight is the right way to "compact" a button without breaking the target rule. - **`aria-hidden="true"` on the icon.** The adjacent `<span>` carries the localised label, and the screen-reader contract is "the button announces 'Sign in', not 'log-in icon Sign in'". The icon is decorative reinforcement. - **Label still wrapped in `<span i18n="@@header.signIn">` rather than directly on the button.** Required because the button now contains both an icon child and the text — Angular's `i18n` on the button itself would extract the icon's rendered SVG into the translation unit, which is not what translators want to see. Wrapping the text isolates the translation unit cleanly. - **`log-in` belongs in `shared-ui`, not portal-shell.** Even though portal-shell is the only consumer today, the icon registry is by contract the single point of truth for both apps — `portal-admin`'s eventual sign-in surface will use the same icon, so registering it once in the shared lib is the right boundary. ## Test plan - [x] `pnpm nx test portal-shell` — green. The existing spec asserts `btn.textContent.trim() === 'Sign in'`; the `<lib-icon>` renders to an SVG (no text content) and the label is now inside a `<span>`, so the text-trim check still holds. - [x] `pnpm nx test shared-ui` — green. Icon registry's exhaustive-key spec picks up the new entry automatically (it iterates `Object.keys(ICON_REGISTRY)`). - [x] `pnpm nx build portal-shell` — clean, no bundle-size deltas worth flagging (`log-in` is tree-shaken alongside the rest of lucide-angular). - [x] `pnpm nx lint portal-shell shared-ui` — clean. - [ ] **Manual smoke** — `pnpm nx serve portal-shell`, signed-out, header visible: - Anonymous state: ghost "Sign in" button with the log-in icon. Hover surfaces a faint fill; focus shows the brand outline ring. - Switch to authenticated: button is replaced by `<lib-user-menu>` (unchanged). - `error` state: amber "Can't reach the server" badge (unchanged). - Toggle dark mode: text shifts to `brand-primary-300`, hover surfaces a `gray-800` fill; still readable. - Tab from the address bar into the header — focus order: search input → bell → help → Sign in. Focus ring on the ghost button matches the other icon buttons (`outline-brand-primary-500 outline-offset-2`). ## What's next - `portal-admin` will get the same `log-in` icon for its own (still skeleton) sign-in surface once that wiring lands — single shared registry means no further change here. - If the marketing folks ever ask the sign-in CTA to come back forward visually (festival days, post-incident push), the ghost class block can flip to a filled variant locally without touching the icon or i18n contract. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #189 |
||
|
|
b9daaa5f58 |
fix(portal-shell): same html/body overflow-y hidden shield as admin (#177)
## Summary Mirror of #176 onto `portal-shell`. Same one-line shield, same rationale: the two apps share an identical `:host { height: 100vh }` + `<main> overflow-y: auto` layout, so the same defensive `html, body { overflow-y: hidden }` rule belongs on both surfaces. Brings the public-facing shell to the same posture as the admin one — any future layout escape stops at the shell boundary rather than producing a phantom body scrollbar plus an empty band below the footer. ## What lands `apps/portal-shell/src/styles.css`: ```css html, body { overflow-y: hidden; } ``` Same block as #176 with a comment that explicitly cross-references the admin shield so future contributors don't accidentally diverge the two apps. ## Notes for the reviewer - **Why now, rather than waiting for portal-shell to demonstrate the symptom?** #176's reviewer note said this would land "if/when a layout escape shows up there." The user asked for parity immediately, on the reasoning that the shell contract is the *same* on both apps — the shield is defensive and one-line, so coupling the two posture changes is cheaper than tracking a "TODO once we see it in shell". - **No new tests.** Same justification as #176 — the change is at the global stylesheet level, has no behavioural surface, and the manual repro path already exists (force a chart or wide element past the viewport; pre-shield → body scrollbar + footer gap; post-shield → clipped at the shell root, `<main>` still scrolls). - **Element-level scrolling on `<main>` is unaffected.** The skip-link, sidebar, and footer all keep their pinned positions; long routes (the user list, future content pages) scroll inside `<main>` as designed. ## Test plan - [x] `pnpm nx build portal-shell` — clean. - [x] `pnpm nx test portal-shell` — green. - [x] `pnpm nx lint portal-shell` — clean. - [x] `pnpm nx run-many -t build test lint -p portal-shell,portal-admin` — green (admin and shell share the build cache; touching only one file invalidates only the shell target). - [ ] **Manual smoke** — `pnpm nx serve portal-shell`: - Open `/fr` and `/en`, scroll long pages — `<main>` scrolls, body doesn't. - Resize across the breakpoint where the sidebar collapses — body still doesn't scroll; sidebar/footer pin correctly. - Toggle dark mode — no visual regression. ## What's next Nothing pending on this shell-shield front. The two apps are now symmetric; if a third app appears (it won't in v1) the same pattern is documented in both `styles.css` headers. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #177 |
||
|
|
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
|
||
|
|
10c80f189d |
feat(portal-shell): align theme switcher trigger with locale switcher shape (#164)
## Summary Aligns the theme-switcher trigger on the same chip shape as the locale-switcher. Both controls live side-by-side in the footer's device-prefs cluster (#162); the visual mismatch (round icon button vs. chip) made them read as two unrelated widgets rather than one family. ``` Before: [☀] (round icon button, 44×44) After: [☀ System ▾] (chip — icon + label + chevron) ``` The leading icon stays driven by `currentIcon()` so a **sun / moon / monitor** glyph still flips with the selected mode — that's the visual feedback the original icon-only design existed for, and it's preserved. ## What lands ### [`theme-switcher.html`](apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html) The round Tailwind-utility icon button becomes a `.theme-switcher__trigger` chip with three children — current-mode icon (size 14), localised mode label, `chevron-down` (size 12). Same children layout as `.locale-switcher__trigger`. ### [`theme-switcher.scss`](apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss) Adds `.theme-switcher__trigger` mirroring `.locale-switcher__trigger` rule-for-rule: same chip metrics (min-height 2.75rem for the AAA 44×44 tap target via vertical-padding overflow inside the thin footer), same hover/focus tokens, same dark-mode swap. Two copies rather than a shared `_chip-trigger.scss` partial — two switchers in one app is below the "three similar things" threshold CLAUDE.md sets for extraction. Promotes when a third switcher lands. ## Notes for the reviewer - **No spec changes**. The existing assertions check `button[aria-haspopup="menu"]` + the aria-label content (`"Theme: <current> (open menu)"`). Both preserved — the trigger button still has `aria-haspopup="menu"` from `cdkMenuTriggerFor`, and `triggerAriaLabel()` is unchanged. - **No new i18n strings**. The chip text reuses `currentLabel()`, which already returns `Light` / `Dark` / `System` from the existing `theme.mode.{light,dark,system}` translation units (they were used in the dropdown menu items before this PR; now they also drive the trigger label). Prod i18n-strict build passes. - **Why mirror, not refactor into a shared partial?** Two switchers, identical chip shape — extracting now would be premature abstraction per [CLAUDE.md](CLAUDE.md). When a third switcher lands (an accessibility-panel toggle is the most likely candidate per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)'s preferences-panel plan), `_chip-trigger.scss` or a `<lib-chip-trigger>` component starts to pay off. The two copies today are mechanical and live next door — easy to keep in sync. - **No `portal-admin` impact.** No theme switcher there (admin chrome is brand-primary-600 hardcoded for now); no change needed. ## Test plan - [x] `pnpm nx run-many -t lint test build --projects=portal-shell` — green; **43 specs pass** (unchanged from main). - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes. - [ ] **Manual visual smoke** — `pnpm nx serve portal-shell`, open the home page in a browser, confirm: - The theme switcher in the bottom-right of the footer is now a chip with the current mode icon + label + chevron, matching the locale chip beside it. - Hovering both switchers shows the same brand-primary hover color (light + dark mode). - Clicking the theme chip still opens the menu with three options (Light / Dark / System), the current option still carries a `✓` check. - Selecting Dark → trigger icon flips to moon + label flips to "Dark" + page goes dark. Switching to System: trigger icon flips to monitor + label flips to "System". - Tabbing across the footer hits accessibility link → locale → theme in that order, focus rings are identical between the two switchers. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #164 |
||
|
|
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
|
||
|
|
6120471b66 |
chore(workspace): tsconfig composite refs + axe-linter false-positive config (#147)
## Summary
Three editor-noise sources flagged by the VS Code TypeScript service + the Deque axe Linter extension, each tamed at the right layer. No runtime behaviour change.
| Source | Fix |
| --- | --- |
| **TS6306** — Referenced project `libs/{shared/ui,shared/state,feature/auth}` must have `composite: true`. Nx 22's lib generator doesn't emit `composite`; modern VS Code TS service flags it. | Add `composite: true` to each lib's `tsconfig.lib.json`, let `nx sync` redirect consumer references in `apps/portal-{shell,admin}/tsconfig.app.json` to point at the `.lib.json` directly. |
| **TS6504** — `moduleResolution: "node"` / `"node10"` deprecated, removed in TS 7.0. Two hits on the BFF tsconfigs. | Add `ignoreDeprecations: "5.0"` on `apps/portal-bff/tsconfig.{app,spec}.json` — the opt-out knob the diagnostic itself suggests. A proper migration to `nodenext`/`node16` is a separate chantier. |
| **axe-core/list (WCAG 1.3.1)** — `<ul>` "must only directly contain `<li>`, `<script>`, or `<template>`" — fires on Angular 17+ `@for` blocks inside lists. Pure static-linter limitation; rendered DOM is fine. | New `.axe-linter.yml` at repo root: `global-disable: [list]`. |
## What lands
### `composite: true` on lib `.lib.json`
[`libs/shared/ui/tsconfig.lib.json`](libs/shared/ui/tsconfig.lib.json), [`libs/shared/state/tsconfig.lib.json`](libs/shared/state/tsconfig.lib.json), [`libs/feature/auth/tsconfig.lib.json`](libs/feature/auth/tsconfig.lib.json) get `composite: true` added. `nx sync` then automatically rewrites consumer references:
```diff
- "path": "../../libs/shared/ui"
+ "path": "../../libs/shared/ui/tsconfig.lib.json"
```
in [`apps/portal-shell/tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`apps/portal-admin/tsconfig.app.json`](apps/portal-admin/tsconfig.app.json). Semantically cleaner — the app references the lib's actual compile config (which produces the `.d.ts` it consumes), not the lib's solution-style root tsconfig.
**Earlier attempt — composite on the solution `tsconfig.json` — silently broke `vitest`**: the Angular Vite plugin chokes on a composite project with `files: []` / `include: []` and falls through, leaving spec files loaded but tests not registered (`"No test suite found in file"`). Moving `composite` to `.lib.json` (the project that actually has inputs) fixes the contract without poking the plugin.
### `ignoreDeprecations: "5.0"`
[`apps/portal-bff/tsconfig.app.json`](apps/portal-bff/tsconfig.app.json) and [`apps/portal-bff/tsconfig.spec.json`](apps/portal-bff/tsconfig.spec.json) — silences `Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0`. The diagnostic suggests `"6.0"` as the value, but TS 5.9 (our pinned version) only accepts `"5.0"`; using `"6.0"` results in `TS5103: Invalid value for '--ignoreDeprecations'` and breaks every spec. `"5.0"` is the current-gen accepted value.
The deprecation is real — TS 7.0 will drop both `"node"` and `"node10"` `moduleResolution` modes. The migration target is `moduleResolution: "nodenext"` paired with matching `module: "nodenext"`, but that interacts non-trivially with Nest's CommonJS pipeline and the BFF's import semantics. Out of scope for a drive-by fix; we'll handle it as a dedicated chantier when TS 7.0 lands on the roadmap.
### `.axe-linter.yml`
New file at repo root:
```yaml
global-disable:
- list
```
The Deque axe Linter VS Code extension reads `.axe-linter.yml` at workspace root. The `list` rule (WCAG 1.3.1) fires false positives on Angular 17+ control-flow syntax — `@for (item of list; ...) { <li>… }` looks like a non-`<li>` child of `<ul>` to a static HTML scanner. The Angular compiler erases those tokens at build time; the rendered DOM is compliant. CI accessibility coverage is provided by `axe-playwright` per [ADR-0016 §"Tooling"](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) — it runs against the rendered DOM and is unaffected by this disable.
## Notes for the reviewer
- **Why not `composite: true` on every lib?** Per CLAUDE.md "no premature abstractions" — `libs/shared/{tokens,util}` are not currently referenced by any `tsconfig.app.json`, so they don't trigger TS6306. Adding `composite` to them would be future-proofing without a current consumer. When a consumer reference is added, the same one-line fix lands then.
- **Why not migrate `moduleResolution` properly?** The BFF runs on Nest's CommonJS pipeline; `nodenext` brings stricter ESM resolution (`.js` extensions in imports, package `exports` map enforcement) that ripples through. Not a 5-minute change. The `ignoreDeprecations` knob is the textbook defer mechanism for exactly this case.
- **Why disable `list` globally rather than per-file?** The rule's false-positive pattern (`<ul><@for>` / `<ol><@for>`) applies workspace-wide; we use `@for` consistently across `portal-shell` + `portal-admin`. Per-file disables would multiply as new templates land. axe-playwright remains the authoritative check on the rule.
## Test plan
- [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 pass. **517 specs green** across the affected projects.
- [x] `pnpm nx sync:check` — workspace in sync after the changes; running `sync` again is a no-op.
- [ ] Editor smoke — reopen the workspace in VS Code: the TS6306 errors on lib `tsconfig.json` files should be gone, the two `moduleResolution=node10` deprecation lines on BFF tsconfigs should be silenced, and the `list` rule under `sidebar.html` (`portal-admin`) should no longer surface.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #147
|
||
|
|
5bbe2304ff |
feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
## Summary Phase-2 security baseline that the `main.ts` placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together. ### Helmet on the BFF `helmet()` with three overrides matching our specific shape: - **HSTS only in production** — dev runs on plain HTTP, HSTS is just noise. - **`crossOriginResourcePolicy: 'cross-origin'`** — the SPA on its own origin reads JSON from the BFF; the default `same-origin` would block it. - **CSP disabled in non-production** — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy `connect-src` violations in browser devtools that we don't need. Everything else is Helmet defaults: `X-Frame-Options=SAMEORIGIN`, `X-Content-Type-Options=nosniff`, `Referrer-Policy=no-referrer`, `X-Powered-By` removed, etc. ### CORS allowlist, env-driven `CORS_ALLOWED_ORIGINS` env (comma-separated) is now **mandatory** at boot. The BFF refuses to start without it via `readCorsAllowlist()` — same boot-time validator family as `assertSessionSecret` etc. The previous hardcoded `http://localhost:4200` fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. `X-CSRF-Token` is now in the allowed headers. ### Double-submit CSRF - BFF mints a 256-bit `csrfToken` at session creation (`/auth/callback`), stored on `req.session.csrfToken` and mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). The cookie is the SPA's read-only view; the server-side session is the source of truth. - `createCsrfMiddleware` (mounted after the session middleware in `main.ts`) compares the `X-CSRF-Token` header with `req.session.csrfToken` using `crypto.timingSafeEqual`. Skips: - safe methods (`GET / HEAD / OPTIONS`), - anonymous requests (no `req.session.user`), - `/api/auth/login` and `/api/auth/callback` (those mint the token themselves). - Mismatch → `403 {"error":"csrf"}` with a structured Pino warn. - SPA's `csrfInterceptor` reads the cookie via `document.cookie` and copies its value into `X-CSRF-Token` on every mutating BFF request. The header is omitted on `GET / HEAD / OPTIONS` (BFF skips them anyway) and on non-BFF origins. - Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie. ## Notable choices **Session-bound double-submit, not pure cookie-vs-header.** A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place. **No CSRF for anonymous mutating routes (v1).** None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with `saveUninitialized: false` on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship. **`SameSite=Lax`, not `Strict`, on the CSRF cookie.** Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; `SameSite=Lax` is a belt-and-braces layer. **`csrfInterceptor` runs after `bffCredentialsInterceptor` and before `bffUnauthorizedInterceptor` in the chain.** Order: credentials first (set `withCredentials`), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises. **`CORS_ALLOWED_ORIGINS` has no localhost fallback.** I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit. ## Out of scope (next PRs) - Rate limiting + structured error filter (still in the phase-2 to-do). - CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving). - CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations). ## Test plan - [x] `pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell` clean env → **177 + 28 + 34 = 239/239 pass** (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage). - [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean. - [x] **CI clean-env repro** (lesson from prior PRs): every env var unset (including new `CORS_ALLOWED_ORIGINS`) → tests still pass. The BFF refuses to boot without `CORS_ALLOWED_ORIGINS`, which is the intended behaviour. - [x] Prettier-clean. - [ ] Manual smoke against running BFF: - [ ] Sign in → `__Host-portal_csrf` (prod) / `portal_csrf` (dev) cookie set, value matches `audit.events.payload->>actorIdHash`-style traceability via `req.session.csrfToken` in Redis. - [ ] Hit a future POST route from the SPA → request carries `X-CSRF-Token`, BFF accepts. - [ ] Forge a POST without the header (curl) → 403 `{"error":"csrf"}`. - [ ] Sign out → both cookies cleared. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #122 |
||
|
|
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 |
||
|
|
9443a52bb7 |
chore(brand): swap header logo to a real svg + ship favicons for portal-admin (#106)
## Summary Two related brand-asset cleanups, bundled per request: ### portal-shell — header logo - Replace [`apf-small.png`](apps/portal-shell/public/logos/) (a 7.6 KB raster we'd extracted from the original PNG-in-SVG and re-encoded through sharp) with [`apf-logo.svg`](apps/portal-shell/public/logos/apf-logo.svg) — an actual 1024×1024 vector export (2.1 KB). Sharper at any density, smaller payload, no rasterisation artefacts when zoomed. - [`header.html`](apps/portal-shell/src/app/components/header/header.html) swaps `<img src=…>` accordingly. - The wide-format `apf-portal.svg` stays in place for future surfaces (login splash, etc.). ### portal-admin — favicons + PWA manifest Mirrors what PR #84 did for `portal-shell`: - Copy the six favicon images (`favicon.ico`, `favicon.svg`, `favicon-96x96.png`, `apple-touch-icon.png`, `web-app-manifest-{192,512}.png`) into [`apps/portal-admin/public/favicons/`](apps/portal-admin/public/favicons/). Single visual identity across the two apps. - Customise [`site.webmanifest`](apps/portal-admin/public/favicons/site.webmanifest) for admin: `name: "APF Portal Admin"`, `short_name: "Admin"`. Everything else (icons, theme-color, display) stays identical to portal-shell's manifest. - Wire the `<link>` block + `<meta name="theme-color">` in [`apps/portal-admin/src/index.html`](apps/portal-admin/src/index.html). - Remove the obsolete top-level `apps/portal-admin/public/favicon.ico` — now under `favicons/`, served via `<link rel="shortcut icon">`. ## Decision worth flagging **Assets duplicated rather than shared via a lib.** Both apps ship their own copy of the 7 favicon files (~120 KB binary each). The alternative — a `shared-assets` (or extended `shared-tokens`) lib with the assets glob copied into each app's `dist/` at build time — is the architecturally tidier path, but introduces a build-config change with no real payoff at our scale. Revisit if a third surface (e.g., a future static landing page) ends up needing the same set. ## Verification - `nx run-many -t lint test build --projects=portal-shell,portal-admin` — green. - Both `dist/apps/{portal-shell,portal-admin}/browser/{en,fr}/favicons/` ship the seven expected files. - Admin's emitted `site.webmanifest` carries `"name": "APF Portal Admin"`. - Admin's emitted `index.html` carries the full `<link>` block + `theme-color` meta. - Portal-shell ships `apf-logo.svg` in its `logos/` folder per locale; `apf-small.png` is gone. ## Test plan - [x] Lint + test + build green. - [x] Built outputs spot-checked (assets ship, manifest text correct, index.html wired). - [ ] Manual: `nx serve portal-shell` → header shows the new SVG logo crisp at 1x / 2x / 3x DPR. - [ ] Manual: `nx serve portal-admin` → tab favicon visible, dev tools → Application → Manifest shows "APF Portal Admin" with no errors. - [ ] Manual: install portal-admin as a PWA from a Chromium browser → the install dialog reads "Install APF Portal Admin", home-screen icon uses the same family as portal-shell. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #106 |
||
|
|
8329fa133d |
refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/* (#99)
## Summary
Per ADR-0020 §"Shared-libs graduation": the three primitives that `portal-shell` (today) and `portal-admin` (next) will both share move out of `apps/portal-shell/src/` and into the workspace libs. Mechanical move — no behaviour change, prepares the ground for the admin-app PR.
## Graduated
| Primitive | Old location | New location |
|---|---|---|
| `Icon` component (+ `IconName` type) | `apps/portal-shell/src/app/components/icon/` | [`libs/shared/ui/src/lib/icon/`](libs/shared/ui/src/lib/icon/) |
| `LayoutStateService` (+ `ThemeMode` type) | `apps/portal-shell/src/app/state/` | [`libs/shared/state/src/lib/`](libs/shared/state/src/lib/) |
| Brand-palette `@theme` block | `apps/portal-shell/src/styles.css` | [`libs/shared/tokens/src/brand-tokens.css`](libs/shared/tokens/src/brand-tokens.css) |
## Notable changes
- **Icon selector renamed `app-icon` → `lib-icon`.** Required by the lib's ESLint `@angular-eslint/component-selector` rule (prefix `lib` for shared libs). All ~20 usages in `portal-shell` templates updated in one sweep.
- **New `shared-state` library** generated via `nx g @nx/angular:library libs/shared/state`. Mirrors the existing `feature-auth` / `shared-ui` shape (vite + Angular + spec setup). `tsconfig.base.json` path alias `shared-state` added by the generator.
- **Lib tags rebalanced** to satisfy the module-boundary lint rule:
- `shared-state`: new lib → `scope:shared, type:shared`.
- `shared-ui`: was `scope:portal-shell` (scaffold default) → `scope:shared, type:shared`.
- **`shared-tokens` is now CSS-only.** The placeholder TS function is gone; `index.ts` is an empty `export {}` with a TODO note. Vitest config flips `passWithNoTests: true` so the test target stops failing on the empty lib.
- **`portal-shell`'s `styles.css`** drops the inline `@theme {}` block and instead `@import`s `../../../libs/shared/tokens/src/brand-tokens.css`. Both apps will read the same source.
- Placeholder scaffolded files in `shared-ui/src/lib/shared-ui/` removed.
## Verification
- `nx run-many -t lint test build` across `portal-shell, shared-ui, shared-state, shared-tokens` — green.
- Tests redistributed: **portal-shell 28**, **shared-state 9**, **shared-ui 3**, **shared-tokens 0** = 40 total (same as before the move).
- Production build: **128.7 KB gzip** initial per locale (was 128.5 KB on `main` — noise-level delta).
- Brand-color CSS variables (`--color-brand-primary-500: #12546c`, …) still inlined in the produced `styles-*.css`.
- `dist/.../browser/{en,fr}/` emitted as expected.
## What this PR explicitly does NOT do
- Move other components (Header, Sidebar, Footer, ThemeSwitcher, LocaleSwitcher). Those are app-specific shell concerns; if `portal-admin` ends up needing them, they graduate in their own PR.
- Set up `portal-admin`. That's the next PR on the admin track — and now imports from `shared-ui` / `shared-state` / `shared-tokens` will Just Work.
- Refactor `feature-auth`'s tagging. It still says `scope:portal-shell`; revisit when the auth implementation actually lands and the dual-audience design (per ADR-0008) makes the right scope obvious.
## Test plan
- [x] Per-project run-many — green.
- [x] Production build emits both locales with brand tokens applied.
- [ ] Manual: `pnpm exec nx serve portal-shell` — the dev experience is unchanged.
- [ ] Manual: serve-static + locale switcher / theme switcher / sidebar collapse / navigation — all the behaviours the moved primitives drive still work in production.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #99
|
||
|
|
99522540a5 |
feat(portal-shell): ci gate — fail prod build on missing translations (#98)
## Summary
Set `"i18nMissingTranslation": "error"` on the production build configuration in [`apps/portal-shell/project.json`](apps/portal-shell/project.json). The Angular CLI checks every `<source>` extracted from `i18n` markers and `$localize` calls against the loaded translation file (`messages.fr.xlf`); with the flag on `error`, the build now **fails** when a target is missing instead of silently falling back to the source text in the FR bundle.
Closes the loop on ADR-0019 §"Confirmation" — the final piece of the i18n track that does not depend on the BFF route + cookie work (deferred to the auth-flow chantier).
## Gate mechanics
- `pnpm ci:check` runs `nx affected -t ... build`, which uses the production configuration by default. A PR that adds an `i18n` marker without updating `messages.fr.xlf` fails its build job → merge blocked.
- The default build configuration is unchanged (`production`), so no other CI plumbing is needed.
## Verification
Locally, temporarily inserting an unmarked-in-fr string:
```html
<span i18n="@@sanity.test.missingTranslation">A string with no FR translation</span>
```
then running `pnpm exec nx build portal-shell --configuration=production` produces:
```
ERROR: No translation found for "sanity.test.missingTranslation"
("A string with no FR translation").
Application bundle generation failed.
NX Running target build for project portal-shell failed
```
…with a non-zero exit code. The patch was reverted before commit.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (40 / 40 specs, build still passes on the current state).
- [x] Sanity-check that the gate actually fires when a translation is missing (above).
- [ ] CI: the next PR that adds an `i18n` marker without updating `messages.fr.xlf` should fail at the `build` step with the explicit error.
## What this PR explicitly does NOT do
- Catch **unmarked** source strings (e.g. a developer who forgets to add `i18n="@@..."` to a new template line). That would need a custom ESLint rule against the Angular template AST — a separate, smaller scope mentioned in ADR-0019 as a future refinement.
- Localise editorial / CMS content. Editorial copy comes from the BFF already localised; this gate covers only the developer-owned UI strings baked at build time.
- Add a similar gate to the i18n extraction (we could fail the build if `nx extract-i18n` produces diffs against the committed `messages.xlf`, but we don't commit `messages.xlf` today).
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #98
|
||
|
|
d118d09aba |
fix(portal-shell): catch-all route prevents NG04002 on /fr in dev mode (#96)
## Reproducer 1. `pnpm exec nx serve portal-shell` (source-locale dev server, no `--localize`). 2. Open `http://localhost:4200/`, the EN home page renders. 3. Click the locale switcher's **Français** entry in the footer. Browser navigates to `/fr/`. The Angular CLI dev server applies its SPA fallback and serves the same source `index.html`. The source bundle boots with `<base href="/">`, the router tries to match the URL `/fr/`, finds no route, and rejects the bootstrap promise: ``` ERROR RuntimeError: NG04002: Cannot match any routes. URL Segment: 'fr' ``` ## Fix Add a `{ path: '**', redirectTo: '' }` catch-all to [`app.routes.ts`](apps/portal-shell/src/app/app.routes.ts). Unknown paths now bounce to home gracefully. ## Why this doesn't break production Production builds with `--localize` emit one bundle per locale, each with its own `<base href="/{locale}/">`. The router never sees the locale segment — `/fr/foo` is normalised to `foo` before route matching, hitting the bundle's `foo` route. The wildcard only fires when **no** declared route matches, which is exactly what we want for genuine 404 paths in either mode. ## What this does NOT fix The locale switcher's underlying dev-mode limitation stands: under `nx serve` there is no per-locale bundle, so switching to FR from dev mode can only land back on the source-locale bundle. The fix turns the ugly bootstrap error into a silent bounce to home, so the dev iteration is no longer interrupted — but full locale switching still requires the production build (`nx run portal-shell:serve-static` or a real deploy). This matches the limitation already documented in PR #95. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (40 / 40 specs unchanged). - [ ] Manual `nx serve` + click Français → URL becomes `/fr/`, page bounces back to `/` with no console error. - [ ] Manual prod build + serve-static, `/fr/` → loads FR bundle as before, no regression. - [ ] Manual prod build + serve-static, `/fr/does-not-exist` → router-level 404 catch redirects to `/fr/` (home), no NG04002. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #96 |
||
|
|
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
|
||
|
|
04675b1b59 |
fix(ci): perf job — point Lighthouse at /fr/ and /en/, drop spa fallback (#92)
## Summary The previous PR (#91) enabled `--localize` on the production build, so the output layout became `dist/apps/portal-shell/browser/{en,fr}/` with **no top-level `index.html`**. The `perf` CI job broke in two places downstream: 1. **`nx run portal-shell:serve-static`** had `spa: true`. The `@nx/web:file-server` executor reads that as "copy `<staticFilePath>/index.html` to `404.html` for SPA fallback". The source file no longer exists, so the executor crashed with `ENOENT … copyfile … index.html` before opening the port. lhci then failed its healthcheck and exited 1. 2. **`lighthouserc.js`** was hitting `http://localhost:4200/`, which now lands on `http-server`'s directory listing (no index.html at that path). Even if the server had started, the audit would have measured the wrong page. ## What changes - **Drop `spa: true`** from the `serve-static` target in [`apps/portal-shell/project.json`](apps/portal-shell/project.json). Deep-link fallback in production is the reverse proxy's job (it routes `/{en,fr}/anything` to the matching `index.html`); `nx serve-static` is only used here for the perf gate and for local prod-build inspection of entry points. For deep-link testing in dev, `nx serve` is the right tool. - **Update [`lighthouserc.js`](lighthouserc.js)** `url` list to `['http://localhost:4200/fr/', 'http://localhost:4200/en/']`, matching the directive in ADR-0019 that both locales clear the same performance bar. ## Verification Local repro (against the merged plumbing PR's build): ``` $ pnpm exec nx run portal-shell:serve-static $ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4200/en/ # 200 $ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4200/fr/ # 200 ``` Served files have the right metadata per locale: ``` /tmp/probe-en.html: lang="en" <base href="/en/"> /tmp/probe-fr.html: lang="fr" <base href="/fr/"> ``` ## Side-effect to call out - `/en/deep/route` and `/fr/deep/route` now return 404 from `nx serve-static`. That's by design — Lighthouse only audits the root locale URLs, and the reverse proxy owns deep-link routing in production. - `http://localhost:4200/` returns http-server's directory listing under the new layout. Lighthouse doesn't hit it, so the perf gate is unaffected. We could disable the listing if it becomes a footgun. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. - [x] Local `nx serve-static` + curl against `/en/` and `/fr/` returns the expected per-locale `index.html`. - [ ] CI: `pnpm ci:perf` runs through `serve-static` start → Lighthouse autorun (×3 per locale, ×2 locales = 6 audits) → assertions hold ≥ 90 on Performance for both. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #92 |
||
|
|
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
|
||
|
|
8f125d2a90 |
feat(portal-shell): wire environment.ts per ADR-0018 (#90)
## Summary First implementation step of ADR-0018. Create [`src/environments/environment.ts`](apps/portal-shell/src/environments/environment.ts) holding the two SPA per-environment values the ADR calls out — `bffApiBaseUrl` and `otlpEndpoint` — and replace the hard-coded URLs at the two SPA call sites that needed them. ## What changes - **New `environment.ts`** with dev defaults (`http://localhost:3000/api` and `http://localhost:4318/v1/traces`). Header comment links to ADR-0018, documents the constraint that per-environment siblings must share the same shape, and notes that nothing here is a secret (the SPA bundle is public). - **`observability/tracing.ts`** reads `environment.otlpEndpoint` for the exporter, and **derives** the `propagateTraceHeaderCorsUrls` regex from `environment.bffApiBaseUrl` — a future change to the BFF origin propagates `traceparent` to the right host automatically, no second edit needed. - **`home-status.service.ts`** builds `/health` as `${environment.bffApiBaseUrl}/health`. ## What this PR explicitly does NOT do - **No `environment.staging.ts` / `environment.prod.ts`** yet. The ADR says "ship later" for those, and the real prod / staging URLs are unknown until the infrastructure ADR lands. Dropping plausible-but-wrong URLs into the repo would be worse than waiting. - **No `fileReplacements` configuration in `project.json`** — it depends on the per-environment files existing. Wired in the same PR that introduces them. - **No BFF-side audit pool split** (`AUDIT_DATABASE_URL` validator, second Prisma client, boot-time UPDATE-rejection self-test). Also in the ADR's Confirmation list, but it touches `AuditModule` and deserves its own review. Separate PR. - **No `SERVICE_VERSION` wiring** in `tracing.ts`. Still hard-coded to `'dev'`; the build-time version source (same one that will feed the footer's dev-only version badge) is its own small chantier. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs unchanged, no new tests needed). - [x] Production build size unchanged (121 kB gzip initial — `environment.ts` is one literal object inlined by the bundler). - [ ] Manual: `pnpm exec nx serve portal-shell` → home page loads, the health widget hits the BFF, Jaeger shows the SPA `document_load` + `fetch` + BFF child span trace. - [ ] Manual: temporarily change `bffApiBaseUrl` to `http://localhost:9999/api` → the fetch fails (expected), and the `traceparent` propagation regex no longer matches `:3000` (verifiable in Network panel — header is absent on cross-origin requests). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #90 |
||
|
|
8b986f3dc3 |
feat(portal-shell): thin full-width footer with copyright + a11y links (#88)
## Summary
- Re-add a **40 px** (h-10) footer pinned to the bottom of the shell, spanning the full viewport width below header + sidebar + main.
- Hosts the **`© <year> APF France handicap`** copyright on the left and the FR + EN accessibility statement links on the right, separated by a thin divider.
- **Move the accessibility links out of the sidebar bottom.** Putting legal / compliance links in the footer matches universal web convention and keeps the sidebar focused on product navigation.
## Why footer rather than the help menu
The header's `circle-help` icon will eventually open a help menu (FAQ, keyboard shortcuts, contact support) — that's **product help**, not **legal / compliance**. Auditors (RGAA 4.1, EN 301 549) look for the accessibility statement in the footer; hiding it inside a help dropdown would hurt discoverability. The footer is the canonical home.
## Why both FR + EN stay visible
There is no language toggle yet — `@angular/localize` and its ADR are a separate chantier. Until then, exposing both languages prevents a francophone user from landing on the EN page (or vice versa) via a stale favourite. Once the locale switcher lands, the footer drops the link that doesn't match the active locale.
## Layout
```
:host flex column, h-100vh
├── app-header shrink-0, h-16
├── div.shell-body flex-1, flex row
│ ├── app-sidebar w-{64|16}, h-full (== shell-body)
│ └── main.shell-main flex-1, overflow-y-auto
└── app-footer shrink-0, h-10
```
The sidebar now sits *between* header and footer, so the collapse toggle stays flush above the footer at every viewport size. shell-main still owns its own vertical scroll so long content does not push the footer off-screen.
## What this PR reserves for later (placeholder)
- **Language toggle (FR / EN)** — lands once `@angular/localize` is in.
- **Dev-only version badge** — lands once `environment.ts` per ADR-0018 is wired up.
Both belong in the footer; the layout already has slots for them (center / right groupings).
## Accessibility
- `<footer aria-label="Page footer">` landmark.
- Inline text links inside `<nav aria-label="Legal">` with hover underline + visible focus ring (brand primary, 4 px offset). Inline-link exception applies to the 44×44 touch target (ADR-0016).
- Dark mode: white → `dark:bg-gray-900`, gray-500 text → `dark:text-gray-400`, brand-primary-500 hover → `dark:hover:text-brand-primary-300`.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (**36 / 36 specs**, +3 for the footer).
- [x] Production build: **121 kB gzip initial** (unchanged from main).
- [ ] Manual: footer pinned at the bottom in every viewport size; sidebar height tracks shell-body so the collapse button sits just above it.
- [ ] Manual: both `/accessibility` and `/accessibilite` links navigate correctly and get `aria-current="page"` when active.
- [ ] Manual: dark mode → footer surface flips with the rest of the shell.
- [ ] Manual: keyboard — Tab into the footer reaches each link, focus ring is visible, Shift+Tab walks back out.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #88
|
||
|
|
3a0a9c700d |
fix(portal-shell): dark mode actually applies to component-scoped surfaces (#87)
## Bug
Three component stylesheets (`app.scss`, `sidebar.scss`, `theme-switcher.scss`) carried `:where(.dark) &` rules to react to the `<html>.dark` class — the same pattern Tailwind uses internally. **Angular's emulated CSS encapsulation descends into `:where()` and rewrites its contents with the `_ngcontent-XXX` scoping attribute**, so the produced selector looked like:
```css
:where(.dark[_ngcontent-c0]) .shell-main[_ngcontent-c0] { ... }
```
`<html>` carries `.dark` but no `_ngcontent` attribute — the rule never matched.
Visible symptom: the main page background stayed light even when `.dark` was on `<html>` and every Tailwind `dark:` utility in the templates was working correctly. Sidebar hover/focus/active states and the theme-switcher menu surface had the same bug.
## Fix
- **`app.scss` and `sidebar.scss`** switch to `:host-context(.dark)`. The Angular compiler recognises this directive and expands it without forcing the ancestor (`<html>`) to carry the scoping attribute. Compiled output:
```css
.dark[_nghost-c0] .shell-main[_ngcontent-c0],
.dark [_nghost-c0] .shell-main[_ngcontent-c0] { ... }
```
The second selector matches `<html>.dark` as an ancestor of `<app-root>` (the host) — exactly what we want.
- **`theme-switcher` switches to `ViewEncapsulation.None`**. Its CDK menu opens in an overlay portal appended to `<body>` — outside the component's host subtree — so even `:host-context()` would miss it. With encapsulation disabled, the styles emit globally; the BEM-style class names (`.theme-switcher__menu`, `.theme-switcher__item`, ...) keep the rules contained without leaking.
## Drive-by
- Remove the right border on the `.header__logo-zone`. The visible hairline above the sidebar was extra noise — the alignment of widths already carries the visual relationship to the rail below.
## Why not also rip out the `dark:` Tailwind utilities used in the templates?
They work. They're emitted into the global Tailwind sheet, sit outside view encapsulation, and already handle the `.dark` ancestor lookup via their own selector (`:where(.dark, .dark *)`). The bug was specifically about component-authored CSS *inside* an SCSS file under emulated encapsulation.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (33 / 33 specs).
- [x] Inspect the produced CSS: `:host-context(.dark)` expands to both `.dark[_nghost-XXX]` and `.dark [_nghost-XXX]` selectors, no `_ngcontent` attribute leaked into the `.dark` portion.
- [ ] Manual: pick dark mode → `/` shows the dark page background; sidebar hover / active / focus visibly switches; theme-switcher menu opens with the dark surface.
- [ ] Manual: header no longer shows a vertical hairline between the logo zone and the search/actions cluster.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #87
|
||
|
|
0ae7e0e23d |
feat(portal-shell): light / dark / auto theme switcher (#86)
## Summary
- Add a header dropdown letting users pick **light**, **dark**, or **auto** (follow the OS) color schemes. Choice persists in `localStorage`, applies on next reload, and reacts live to OS theme changes when in `auto`.
- Built on `@angular/cdk/menu` for accessible roving focus, escape-to-close, and proper `menuitemradio` semantics on the three options.
- Apply `dark:` variants across the shell (header, sidebar, main bg) and the existing two pages. No semantic-token refactor yet — that belongs in a future ADR (`--color-surface-1`, `--color-text-1`, …).
## Architecture
- [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) grows a `themeMode: signal<'light'|'dark'|'auto'>` alongside the existing `sidebarCollapsed`, plus an `effectiveTheme` computed that resolves `auto` against the system preference. A side-effect toggles the `.dark` class on `<html>`, so every `dark:` Tailwind utility flips at once.
- Tailwind v4 dark mode is rewired to **class-based** via `@custom-variant dark (&:where(.dark, .dark *));` in `styles.css`. This overrides the v4 default (`prefers-color-scheme` only) so the user's explicit override wins over the OS preference.
- The switcher trigger reflects the **selected** mode (sun / moon / monitor), not the effective theme — so users can tell which mode they're in even when `auto` happens to resolve to the same scheme as a manual pick.
## Side-edits in the same PR (already validated in the chat)
- **Logo asset.** Replace `apf-small.svg` (94 kB — a base64-PNG wrapped in SVG markup, not actually vector) with `apf-small.png` (144×144, 7.6 kB after `sharp --kernel lanczos3 --compressionLevel 9`). Header swaps to the PNG. The wide vector `apf-portal.svg` stays around for future surfaces that want the horizontal lockup.
- **Revert FR strings** that crept into the header template — project rule (CLAUDE.md) is English-only for source artefacts; FR localisation will happen properly via `@angular/localize` (separate ADR).
## Decisions worth flagging
- **Dropdown over segmented control.** The CDK Menu pays its weight: accessible by default (proper `aria-haspopup`, focus management, ESC handling, click-outside dismissal), reusable for future header menus (user, language, notifications), and one tidy primitive rather than three competing buttons.
- **`auto` is the default,** not `light`. Most users have an OS-level preference already; respecting it is the least surprising baseline.
- **`<html>.dark` class lives at the root,** not on `<app-root>`. That's the Tailwind convention and it means CDK overlay popups (the menu itself, future dialogs) inherit the right theme without extra wiring.
- **Bundle delta +21 kB gzip** (100 → 121 kB initial). All of it is `@angular/cdk/{menu,overlay,a11y}` and the dark CSS rules. We stay well under the 300 kB budget. The CDK is already on the architecture menu (ADR-0016 — *UI stack: Angular CDK + TailwindCSS*) so this is on-strategy spend.
- **No semantic tokens yet.** The dark variants use raw Tailwind gray ramps (`dark:bg-gray-900`, etc.) instead of a `--color-surface-1` / `--color-text-1` token layer. That keeps the change tractable for now; promotion to semantic tokens deserves its own ADR with the design team in the loop.
## Accessibility (ADR-0016)
- Menu trigger has `aria-haspopup="menu"`, `aria-label` announcing the current mode + "(open menu)".
- Menu uses `role="menu"`, items use `role="menuitemradio"` with `aria-checked` — assistive tech announces the selection state correctly.
- All interactive controls keep the 44×44 px touch target.
- `prefers-reduced-motion: reduce` already covered by the sidebar transitions; theme switcher has no animations of its own.
- Contrast: dark surfaces are gray-900 + gray-800 border / gray-100 text — passes WCAG AA. Brand primary shifted to the `300` step in dark mode so the active states keep contrast against gray-900.
## What this PR explicitly does NOT do
- Tokenise the palette into semantic surface / text / border roles (next iteration, ADR-led).
- Localise UI strings (separate `@angular/localize` ADR + PR).
- Animate the theme transition (FOIT-style flicker on toggle is acceptable in v1; we can soften later with a `color-scheme` CSS transition if it bothers users).
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (**33 / 33 specs**, +8 for the theme work).
- [x] Production build: **121 kB gzip initial** (was 100 kB). Under the 300 kB budget.
- [ ] Manual: toggle each of the three modes → header / sidebar / main / cards switch surface colors instantly; trigger glyph updates.
- [ ] Manual: pick `auto`, change OS theme → UI follows live (Chrome DevTools → Rendering → "Emulate CSS media feature prefers-color-scheme").
- [ ] Manual: reload after each pick → the chosen mode is restored.
- [ ] Manual: keyboard the trigger → ENTER opens menu, arrow keys navigate, ENTER selects, ESC closes; focus returns to the trigger on close.
- [ ] Manual: Lighthouse accessibility on `/` in dark mode — score unchanged from light mode.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #86
|
||
|
|
b6aa17f6a0 |
feat(portal-shell): align header logo zone with the sidebar rail (#85)
## Summary - Split the header into two zones: a **logo zone** whose width tracks the sidebar (16 rem expanded / 4 rem collapsed), and the existing search + actions cluster on the right. - The logo glyph stays at both widths; the "APF Portal" wordmark hides when the rail collapses, mirroring the sidebar's icon-only mode. - Promote the sidebar's `collapsed` state to a new [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) (Signals + `localStorage`) so header and sidebar share a single source of truth. ## Why a service rather than prop-drilling Two reasons: 1. The state is **shell-level**, not sidebar-internal — the header now reads it, and future surfaces (right rail, breadcrumbs, command palette) will too. Passing it down via inputs would force `<app-root>` to act as the owner and turn every intermediate component into a relay. 2. As we add other cross-cutting shell state (density, theme, panel pinning, RTL), they all belong in the same place. `LayoutStateService` is the natural collector and stays trivial as long as we don't over-broaden it. v1 ships with one signal — keeping it narrow. The service is `providedIn: 'root'` (singleton), Signals-only, and owns localStorage persistence — same UX as before, just relocated. ## Decisions worth flagging - **Widths stay duplicated between `header.scss` and `sidebar.scss`** (16 rem / 4 rem). Extracting a shared SCSS variable would be premature — only two callers, and the coupling is loud (cross-file comment in `header.scss`). If a third surface needs the same widths, we promote to a shared token. - **Border continuity.** The logo zone's right border and the sidebar's right border share the same x coordinate, so they read as one continuous vertical separator running the full shell height. Same `#e5e7eb` so the seam is invisible. - **Width transition** matches the sidebar's (`0.18s ease-out`) and is skipped under `prefers-reduced-motion: reduce`. - **Sidebar component lost its private state.** Its own signal + effect + storage glue is gone; it delegates reads to the service and the toggle click to `layout.toggleSidebar()`. Net `sidebar.ts` shrunk by ~15 lines. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (25 / 25 specs, +6 for the new service spec + header zone tests). - [x] Production build under bundle budgets (100 kB gzip initial — +0.2 kB vs main). - [ ] Manual: load `/`, confirm the logo zone's right edge aligns exactly with the sidebar's right edge at both widths. - [ ] Manual: toggle the sidebar → both columns animate in sync; wordmark hides; logo glyph stays centered in the 4 rem zone. - [ ] Manual: reload after toggling → both header and sidebar restore to the persisted state. - [ ] Manual: `prefers-reduced-motion: reduce` → no width animation in either zone. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #85 |
||
|
|
29f79d677e |
feat(portal-shell): full favicon set + PWA manifest (#84)
## Summary - Replace the single root `favicon.ico` with a complete asset bundle under [`apps/portal-shell/public/favicons/`](apps/portal-shell/public/favicons/): SVG primary favicon, PNG fallback (96×96), legacy ICO, Apple touch icon (180×180), and a Web App Manifest with 192 / 512 maskable PNGs. - Wire the assets in [`index.html`](apps/portal-shell/src/index.html) via the standard `<link rel="icon|apple-touch-icon|manifest">` block and add a matching `<meta name="theme-color" content="#ffffff">`. - Set the manifest name to `"APF Portal"` (short_name `"Portal"`) so the PWA install banner and home-screen icon read consistently with the in-app header. ## Why it matters - **Modern favicons.** SVG is now the primary icon — vector-clean at every density, automatic dark-mode adaptation when the SVG carries `prefers-color-scheme` rules. PNG + ICO entries cover legacy and pinned-tab contexts. - **Installability.** With a valid manifest + 192/512 icons + `display: standalone`, the portal is installable on Android home-screens and as a desktop PWA in Chromium-based browsers, without shipping a service worker. - **Mobile chrome.** `<meta name="theme-color">` aligned with the manifest's `theme_color` tints the browser address bar and the PWA chrome to white — matching the app header. ## Decisions worth flagging - **Icons declared `purpose: "maskable"` only.** The PNGs were generated with the Android safe-zone padding. On Android they render correctly (cropped to the device's adaptive shape); on contexts that ask for `"any"` the browser falls back to the SVG / PNG / ICO entries from the `<link rel="icon">` block, so nothing breaks. If we later want a no-padding edge-to-edge variant for desktop, we can add a second icon entry with `purpose: "any"` and a different source. - **`theme_color: #ffffff` rather than brand teal.** The app header is white in the v1 design; tinting the mobile chrome to teal would create a visible seam at the top of the viewport. We can revisit if a darker header lands. - **Cache-buster query strings kept (`?v=20260511`).** Static `public/` assets are not hashed by Angular's build (only bundled JS/CSS are), so the explicit version stamp guards against stale caches on icon updates. The date matches the generation day. ## What this PR explicitly does NOT do - Ship a service worker / offline support (PWA installability does not require it; offline strategy is a separate decision and likely a future ADR). - Replace the SVG icon contents with a brand-tuned design — uses the existing generator output. - Wire a localized manifest (single `name` / `short_name`, no `lang` variant per locale). ## Test plan - [x] `pnpm exec nx build portal-shell --configuration=production` — green, 100 kB gzip initial (unchanged). - [x] All 7 assets ship to `dist/apps/portal-shell/browser/favicons/`. - [x] `index.html` in the production build contains every `<link>` + the `theme-color` meta. - [ ] Manual: tab favicon visible in Chrome / Firefox / Safari. - [ ] Manual: Chrome DevTools → Application → Manifest reports no errors and shows both 192/512 icons. - [ ] Manual: Chrome desktop install prompt offers "Install APF Portal". - [ ] Manual: Add-to-Home-Screen on Android shows the maskable icon clipped to the device's adaptive shape. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #84 |
||
|
|
3371fbd613 |
feat(portal-shell): app-shell layout with collapsable sidebar (#83)
## Summary - Replace the flat header+main+footer layout with a real app-shell: fixed header on top, collapsable sidebar + scrollable main below. Sidebar state (collapsed / expanded) persists across reloads via `localStorage`. - Introduce the APF brand palette as Tailwind v4 `@theme` tokens (primary teal `#12546c`, accent orange `#f7a919`) so every utility (`bg-brand-primary-500`, `text-brand-accent-400`, `ring-brand-primary-200`, …) is available from now on. - Add an `<app-icon>` façade backed by `lucide-angular` for v1. Logical kebab-case names already match the icomoon-sprite convention, so the future migration is a single-file change in `icon.ts` and consumers stay untouched. - Migrate the accessibility-statement links from the (now-deleted) footer to the bottom of the sidebar. ## Decisions worth flagging - **Static menu, permission-shaped data.** Items point to `#` placeholders in v1, except *Dashboard* which is `routerLink="/"` so the active-state styling is visible on the home page. The `MenuItem` shape already carries an optional `requiredPermissions: string[]` so the permission-aware filter (PR 2, alongside ADR-0009 auth) plugs in without restructuring. - **`<app-icon>` over direct lucide imports.** Consumers write `<app-icon name="bell">` rather than importing the lucide pascal-case symbol. When the icomoon sprite lands, only the registry in `icon.ts` changes — templates do not. - **Sidebar persistence via `localStorage`, not backend.** Zero round-trip, survives reloads, falls back gracefully when storage is blocked (private mode). Eventually mirrored server-side if the user-preferences feature lands. - **Footer removed entirely.** With the sidebar carrying the FR + EN accessibility-statement links and the role badge, the bottom rail no longer earned its vertical real estate. The version badge moved out for now; it will return as part of a debug/help menu when there's a real release to surface. ## Accessibility (ADR-0016) - Skip-link preserved (WCAG 2.4.1 *Bypass Blocks*) and restyled in the brand palette. - Sidebar exposes named landmarks (`<nav aria-label="Sections">`, `<nav aria-label="Accessibility">`) and the collapse button uses `aria-expanded` + a descriptive `aria-label`. - Active links carry `ariaCurrentWhenActive="page"`. - All interactive controls (header action buttons, sidebar links/toggle) meet the 44×44 px minimum hit-target. - Sidebar width transition is skipped under `prefers-reduced-motion: reduce`. - Lucide SVGs are marked `aria-hidden` (decorative); accessible names live on the parent control. ## Perf (ADR-0017) - Production build: **100 kB gzip** initial transfer (budget: 300 kB). Lucide imports are tree-shaken — only the ~18 icons actually used ship. ## What this PR explicitly does NOT do - Wire icon-set migration to icomoon (kept as a deferred swap behind `<app-icon>`). - Filter the menu by permission (deferred to PR 2 once the auth flow lands). - Replace the avatar placeholder with a real user menu (waits on ADR-0009). - Implement the search input behavior (placeholder only; needs a search backend). ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (19 / 19 specs). - [x] Production build under bundle budgets (100 kB gzip initial). - [ ] Manual: load `/`, confirm Dashboard appears active in the sidebar, collapse → reload → still collapsed, focus the address bar then Tab → skip-link visible, keyboard-traverse the sidebar. - [ ] Manual: `prefers-reduced-motion: reduce` → no width animation when toggling. - [ ] Manual: zoom to 200 % → no horizontal scroll, header search hides at narrow widths (`md:` breakpoint). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #83 |
||
|
|
e2dd2e4dd8 |
fix(portal-shell): use .postcssrc.json so tailwind utilities are emitted (#75)
## Summary The portal-shell scaffold shipped a `postcss.config.js`, but `@angular/build` (Angular 17+ esbuild pipeline) does **not** load that file format — it only reads `.postcssrc.json`. Result: the `@tailwindcss/postcss` plugin was never registered, Tailwind ran with no source files visible, generated only its `@theme` block, and dropped every utility class on the floor. The page therefore rendered unstyled — black text on white — even though `nx build` reported success and shipped a 23 KB `styles*.css`. Rename / re-encode the config to `.postcssrc.json`. Same plugin, same options, just the file format Angular's esbuild adapter actually reads. ## Verification | | Before | After | | - | - | - | | Class selectors in `dist/apps/portal-shell/browser/styles*.css` | **0** | **75** (production) | | `.text-3xl`, `.bg-amber-50`, `.font-semibold` etc. emitted | ❌ | ✓ | | Pages render with intended Tailwind layout | ❌ | ✓ | ```bash pnpm exec nx build portal-shell --configuration=production grep -oE '\.[a-zA-Z][a-zA-Z0-9_-]*' dist/apps/portal-shell/browser/styles*.css | sort -u | wc -l # 75 ``` ## Doc update `docs/development.md` repo-layout walkthrough now reads `.postcssrc.json` and explicitly calls out that `postcss.config.js` is ignored by `@angular/build` — so a future contributor reaching for the legacy filename gets a hint instead of a silent failure. ## Test plan - [ ] CI green on this PR. - [ ] Local: bring up the SPA dev server, `localhost:4200` shows the styled layout — header bar with brand link, system-status widget with rounded card, footer with the two language links. - [ ] Production build: `nx build portal-shell --configuration=production` succeeds; `dist/.../styles*.css` contains tens of utility-class selectors (not just `@theme` tokens). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #75 |
||
|
|
0d31937aeb |
feat(portal-shell): replace nx-welcome with real shell and home + budgets per ADR-0017 (#74)
## Summary Cuts the Nx scaffold's `NxWelcome` page (938 LOC of placeholder markup) and lays down the actual portal layout — header, main, footer landmarks plus a skip link — with a real home page and the two RGAA-mandated accessibility statement routes. Bundle budgets in `project.json` are tightened to match ADR-0017. ## What lands **Layout shell** (`apps/portal-shell/src/app/`): - `components/header/` — brand link + primary nav landmark stub - `components/footer/` — accessibility statement links (FR + EN) + version badge - `app.html` / `app.scss` — flex column with sticky footer + skip link to `#main-content` (WCAG 2.4.1) **Pages**: - `pages/home/` — welcome heading + "system status" widget that fetches `/api/health` from the BFF. Doubles as a smoke test of the full SPA → BFF stack: CORS, OTel trace propagation, Pino log correlation. After page load, http://localhost:16686 should show one trace whose root span is the SPA `document_load`, with a child `fetch` span that has its own child `HTTP GET /api/health` BFF span. - `pages/accessibility/` — single component, content selected by route data (`lang: 'fr' | 'en'`). Mounted at `/accessibility` (en) and `/accessibilite` (fr). v1 carries placeholder copy explicitly framed as "awaiting APF user panel review" — RGAA + ADR-0016 require the routes to exist; the substantive statement (audit grid, gaps list, remediation plan) lands separately. **Routing & wiring**: - `app.routes.ts` adds the three routes, all lazy-loaded. - `app.config.ts` adds `provideHttpClient(withFetch())` so HttpClient delegates to the patched browser `fetch` and the W3C `traceparent` header propagates automatically (per ADR-0012 phase 2). - `nx-welcome.ts` removed; `app.ts` no longer imports it. **Bundle budgets** (`apps/portal-shell/project.json`): | Type | Limit (raw) | ADR-0017 (gzip) | | ------------------- | ------------ | --------------- | | `initial` | 1 MB | ≤ 300 KB | | `anyScript` | 300 KB | ≤ 100 KB / chunk | | `anyComponentStyle` | 6 KB | ≤ 6 KB | | `bundle "styles"` | 150 KB | ≤ 150 KB | `maximumWarning == maximumError` so an overshoot fails the build (matching `type: "error"` in spirit). Angular CLI compares **raw** sizes; ADR-0017 §Confirmation is updated to record the gzip→raw translation and the deferred follow-up that will add a CI check on the actual gzipped transfer size (Angular has no native gzip-mode budget). ## Verified locally - `pnpm exec nx run-many -t lint test build` → 8 projects green; **12 tests pass** for `portal-shell` across 5 specs (App, Header, Footer, Home, AccessibilityStatement). - `pnpm nx build portal-shell --configuration=production` → initial bundle **326.99 KB raw / 89.82 KB transfer** (gzip) — well under the ADR-0017 300 KB gzip target. Lazy chunks: home 2.66 KB, accessibility 2.90 KB. - `pnpm audit` clean. After `./infra/local/dev.sh up observability` + `pnpm nx serve portal-bff` + `pnpm nx serve portal-shell`, opening http://localhost:4200 shows the new layout, the system-status widget connects to the BFF, and the corresponding trace appears in Jaeger. ## Out of scope (separate PRs) - **Real RGAA audit content** — APF user-panel review. - **Design tokens system** in `libs/shared/tokens`. - **Reusable UI primitives** in `libs/shared/ui`. - **User-preferences panel** (ADR-0016 §"User-preferences panel"). - **i18n machinery** (`@angular/localize`) — for v1 the FR/EN copy is inlined and route-driven; real i18n is a separate ADR. - **Env-config** (Angular `environment.ts`) — the hard-coded `http://localhost:3000/api/health` will move there alongside the OTLP endpoint when the env-config PR lands. - **CI gzip-transfer-size assertion** to complement the raw-size Angular budgets. ## Test plan - [ ] CI green on this PR. - [ ] Local: home page renders, system-status widget transitions from "Checking the backend…" to the success state when the BFF is up; falls back to "Backend unreachable" with the BFF stopped. - [ ] `/accessibility` and `/accessibilite` render the matching language with `<article lang="en|fr">`. - [ ] Skip link reachable via Tab from address bar; clicking it focuses `#main-content`. - [ ] Production build size summary roughly matches the figures above (initial total ≈ 90 KB transfer). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #74 |
||
|
|
8f2cd4e068 |
feat(portal-shell): wire spa-side opentelemetry tracing (#72)
## Summary Phase 2 of ADR-0012 — closes the loop SPA → BFF → DB. After this PR, a single user action (initial page load, click, form submit) produces one trace whose root span is owned by the SPA and whose child spans cover the BFF request, Postgres queries through Prisma, and (eventually) Redis / downstream-API hops. ## What lands **Browser-side OTel libs** (production deps): - `@opentelemetry/sdk-trace-web` — browser tracer + provider - `@opentelemetry/exporter-trace-otlp-http` — OTLP/HTTP+JSON exporter - `@opentelemetry/instrumentation` — auto-instrumentation runtime - `@opentelemetry/instrumentation-fetch` — `fetch` + W3C `traceparent` propagation - `@opentelemetry/instrumentation-document-load` — initial-paint timings - `@opentelemetry/instrumentation-user-interaction` — click / keypress / submit No `@opentelemetry/context-zone`: the workspace is zoneless per ADR-0004; the default `StackContextManager` covers the auto-instrumented paths. Custom spans across `await` will need explicit `context.with(...)` plumbing — fine, encountered as code lands. **Code**: - [`apps/portal-shell/src/observability/tracing.ts`](apps/portal-shell/src/observability/tracing.ts) — `WebTracerProvider` bootstrap. Documents the load-order constraint inline (same pattern as the BFF: must be the very first import of `main.ts`, otherwise auto-instrumentations miss everything imported above). - `apps/portal-shell/src/main.ts` now imports the tracing module as line 1. **CORS plumbing** for end-to-end trace propagation: - BFF (`apps/portal-bff/src/main.ts`) calls `enableCors` with a minimal dev allowlist (`http://localhost:4200`) and explicit permission for the W3C `traceparent` / `tracestate` headers. The full security-grade CORS (per-environment allowlists, helmet, cookie-session, CSRF) belongs to the future phase-2 security ADR — this PR adds the strict minimum for the SPA→BFF trace context to survive cross-origin pre-flight. - OTel Collector (`infra/local/otel-collector.yaml`) gains a `cors` block on its OTLP/HTTP receiver so the browser's own OTLP POST clears its pre-flight. **ADR-0012 §Confirmation** rewritten: a new "Wired in the SPA foundation PR (phase 2)" block enumerates what landed here; the carry-over "Wired as features land" list drops the SPA-side SDK item and adds a follow-up note about the security-grade CORS. ## Verification ```bash pnpm exec nx run-many -t lint test build # 8 projects green pnpm audit # 0 vulns ./infra/local/dev.sh up observability # bring up Collector + Jaeger ./infra/local/dev.sh # (separately, BFF stack — your choice) pnpm nx serve portal-bff # localhost:3000 pnpm nx serve portal-shell # localhost:4200 ``` Open http://localhost:4200 → a `document_load` trace appears in http://localhost:16686 with `service.name=portal-shell`. From DevTools, run `fetch('http://localhost:3000/api/health').then(r => r.json())` → a fetch span appears with a child BFF span on the same trace. ## Test plan - [ ] CI green on this PR. - [ ] After local up, `document_load` span visible in Jaeger UI for the SPA. - [ ] Cross-origin fetch from SPA carries `traceparent` (visible in Network tab) and produces a single end-to-end trace SPA → BFF in Jaeger. - [ ] DevTools console shows no CORS warnings about `traceparent`, `tracestate`, or the `localhost:4318/v1/traces` POST. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #72 |
||
|
|
bd8eefb44a |
chore: configure Tailwind 4 and align lib tsconfigs
Wire Tailwind CSS 4 in the portal-shell app per ADR-0016 (the future host of spartan-ng components in libs/shared/ui will read from the Tailwind tokens via the shared-tokens lib). - pnpm add -D tailwindcss @tailwindcss/postcss postcss - apps/portal-shell/postcss.config.js declares @tailwindcss/postcss - apps/portal-shell/src/styles.scss renamed to styles.css and now contains a single @import 'tailwindcss' directive. Plain CSS for the global file avoids the Sass @import deprecation warning that fires when Tailwind directives sit inside SCSS. Component-level styles can still use SCSS. - apps/portal-shell/project.json styles entry updated accordingly. Side fix: align libs/shared/tokens and libs/shared/util tsconfigs from module: commonjs to module: esnext. The Nx @nx/js:library --bundler= tsc generator emits commonjs by default, but tsconfig.base.json specifies moduleResolution: bundler, which TS only allows alongside esnext or es2015+ modules. Without the alignment, those libs failed to build (TS5095). All apps and libs now build green. spartan-ng wiring is intentionally NOT in this commit. spartan-ng is currently at 0.0.1-alpha.681 - clearly pre-1.0, which trips the project rule against pre-1.0 dependencies. ADR-0016 chose spartan-ng with the copy-paste mitigation, but the alpha state warrants an explicit go/no-go decision before committing the workspace to it. |
||
|
|
cd1d482aa8 |
fix(portal-shell): make 'nx test' run once by default, add watch configuration
The Angular 21 unit-test builder (@angular/build:unit-test) defaults to watch mode. Without an explicit option, 'pnpm nx test portal-shell' hangs on 'Waiting for task' indefinitely - unsuitable for CI and surprising for ad-hoc invocations. Pin watch=false as the default in the target options. Add a 'watch' configuration so developers who want continuous test running can opt in with 'pnpm nx test portal-shell --configuration=watch'. portal-bff uses Jest which defaults to no-watch and needs no change. |
||
|
|
bea5e1954f |
chore: generate portal-shell and portal-bff apps per ADR-0004 / ADR-0005
Add the @nx/angular, @nx/nest, @nx/vite, @nx/eslint plugins, then generate the two apps. Adjust the empty-template tsconfig.base.json to be Angular-compatible (drop project references and customConditions that the empty-template defaults to but Angular doesn't support; keep the strict-TS extensions from ADR-0004). apps/portal-shell (Angular 21): - standalone APIs, routing, SCSS, esbuild - vitest-angular as unitTestRunner, playwright for e2e - strict mode - tags scope:portal-shell, type:app - app.config.ts wired with provideZonelessChangeDetection() per ADR-0004 (Angular 21 + Nx 22 generates without zone.js by default) apps/portal-bff (NestJS 11): - Express adapter (default per ADR-0005) - Jest as unitTestRunner - tags scope:portal-bff, type:app - main.ts wired with a global ValidationPipe configured whitelist + forbidNonWhitelisted + transform per ADR-0005 - Phase-2 security additions (helmet, CORS, sessions, CSRF, rate limit, auth guards, error filter) deferred to their respective ADRs - placeholder comment in main.ts Workspace dependencies: class-validator + class-transformer added (required by NestJS ValidationPipe at runtime). Nx-generated .gitignore additions (.angular, __screenshots__) merged into ours. .vscode/extensions.json and launch.json added by Nx are kept (do not override our existing settings.json). |