2ffbfc4034c59420933778df78f0a142f3b7514b
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
db7e479dde |
feat(spa): opt-in 'https' nx serve config for dev-server TLS (#263)
## Summary
Add an opt-in `https` configuration to the SPA dev-servers so the ADR-0030 dockerised dev mode can be reached over a hostname registered in Entra. Entra refuses `http:` redirect URIs for anything other than `localhost`, which made the hostname-based access pattern (`apf-portal.dev-jg.local`, `apf-portal.dev.local`, …) — the only stable way to share a VM-based dev stack with another developer — impossible to wire to OIDC. This PR closes that gap without touching the WSL-native + localhost flow.
## What lands
| File | Change |
| --- | --- |
| `apps/portal-shell/project.json`, `apps/portal-admin/project.json` | New `https` Nx serve configuration: inherits the `development` build, sets `ssl: true` + `sslKey: .secrets/dev-tls.key` + `sslCert: .secrets/dev-tls.pem`. `defaultConfiguration` stays `development`; the `https` config is purely opt-in. |
| `infra/local/dev.compose.yml` | The `portal-shell` and `portal-admin` commands now end with `--configuration=${NX_SERVE_CONFIGURATION:-development}`. Compose interpolates the value at YAML parse time from `infra/local/.env`. Default is `development` (no SSL), so behaviour is unchanged for anyone who doesn't opt in. |
| `infra/local/.env.example` | New commented-out `NX_SERVE_CONFIGURATION=https` block with the rationale + pointer to the mkcert setup. |
| `apps/portal-bff/.env.example` | Comment block above the `ENTRA_*_REDIRECT_URI` defaults shows the HTTPS hostname-based override pattern (the four URIs that go with the `apps` profile when accessing via a hostname) and reminds that each override must be registered Entra-side. |
| `infra/README.md` | New "HTTPS dev-server setup — remote-browser access via a hostname" subsection: mkcert install / `mkcert -install`, cert generation, `.secrets/dev-tls.{key,pem}` convention, Entra registration reminder, `NX_SERVE_CONFIGURATION=https` opt-in. Notes that WSL-native is unaffected and that the cert path stays the same when the corp CA eventually replaces mkcert. |
## Design notes
- **Convention `.secrets/dev-tls.{key,pem}` at repo root.** Matches the existing `apps/portal-bff/.secrets/jwks.pem` pattern (gitignored via `*.pem` + `*.key`). Workspace-relative path means project.json can hardcode it and each dev drops their per-host cert there.
- **Hardcoded path, per-dev cert content.** Each developer generates a cert for **their own** hostname; the cert sits at the same fixed path on every machine. Nothing dev-specific in `project.json`.
- **`https` is opt-in, not default.** Native `nx serve` keeps booting on HTTP (`localhost:4200`) without SSL key files, exactly as before. Compose default is also `development` — only setting `NX_SERVE_CONFIGURATION=https` in `infra/local/.env` switches it on, gated by the dev having actually run the mkcert step.
- **BFF stays plain HTTP.** Only the SPA dev-server terminates TLS — the proxy then hits `http://portal-bff:3000` on the internal Compose network. Entra still gets HTTPS at the browser-facing origin, which is what its policy enforces.
## What this PR deliberately does NOT do
- It does **not** force-enable HTTPS. Devs who don't care about hostname access continue working as before.
- It does **not** touch the BFF code, the Entra config helpers, or the auth flow itself. The whole change is config (project.json + compose + env-examples) + docs.
- It does **not** ship the shared VM cert story. That needs a corp-CA-signed cert (or a shared mkcert CA distributed across workstations); flagged in the README section as a follow-up.
## Test plan
- [x] Both `project.json` files parse as JSON; `nx show project` exposes the new `https` configuration with the expected SSL options.
- [x] `docker compose -f dev.compose.yml --profile apps config` validates with `NX_SERVE_CONFIGURATION=https` (resolves to `--configuration=https`) and without it (resolves to `--configuration=development`).
- [ ] **On vm-jg**: `mkcert -install` on the workstation, `mkcert` against `apf-portal.dev-jg.local`, copy `.secrets/dev-tls.{key,pem}` to the VM, set `NX_SERVE_CONFIGURATION=https` in `infra/local/.env`, register the four `https://apf-portal.dev-jg.local:*` URIs in Entra, restart `dev.sh up apps`, then open `https://apf-portal.dev-jg.local:4200/` from the workstation → SPA loads, no cert warning, sign-in completes and returns to the SPA via the OIDC callback.
- [ ] Native WSL flow unchanged: `nx serve portal-shell` still boots on `http://localhost:4200/`, OIDC against the existing `http://localhost:3000/...` Entra URIs still works.
## Related
- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this completes for the hostname-access case.
- [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md) — per-environment SPA config strategy; `https` is a new Nx serve _configuration_, not a new `environment.ts` sibling, so 0018's build-time replacement story is unchanged.
- [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — OIDC flow; no behaviour change, only the redirect-URI strings.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #263
|
||
|
|
a84ea2d116 |
feat(spa): proxy /api in dev-server, relative bffApiBaseUrl (#259)
## Summary Make the SPAs reach the BFF as a **same-origin** call via an Angular dev-server `/api` proxy. Solves the "Backend unreachable" error surfaced during the ADR-0030 dockerised dev mode VM validation when the SPA is accessed from a remote browser (e.g. `http://<vm-ip>:4200/`), and bypasses CORS in dev altogether. Follow-up to the just-merged ADR-0030 implementation. ## Root cause it fixes Before this PR, both SPAs hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (per ADR-0018) and the BFF's `CORS_ALLOWED_ORIGINS` only allowed `http://localhost:4200,http://localhost:4300`. Both assumptions hold for native `nx serve` (developer on the same machine as the BFF) but break the moment the browser sits on a different host than the BFF — exactly the case for the ADR-0030 `apps` Compose profile: open `http://<vm-ip>:4200/` from your workstation and the SPA's `localhost:3000` call hits your **workstation's** loopback (nothing there), not the VM's BFF. Even if the URL were right, the origin `http://<vm-ip>:4200` is not in the CORS allowlist. ## Fix Switch to a same-origin dev pattern: the Angular dev-server proxies `/api/*` to the BFF, and `environment.ts` uses a relative `'/api'` URL. | File | Change | | --- | --- | | `apps/portal-shell/proxy.conf.js` | **New.** Maps `/api → ${BFF_TARGET:-http://localhost:3000}` (JS form so the env var can swap the target at startup). | | `apps/portal-admin/proxy.conf.js` | **New.** Same shape. | | `apps/portal-shell/project.json`, `apps/portal-admin/project.json` | `serve.options.proxyConfig` points at the new file. | | `apps/portal-shell/src/environments/environment.ts`, `apps/portal-admin/src/environments/environment.ts` | `bffApiBaseUrl: 'http://localhost:3000/api'` → `'/api'`. The comment block explains the rationale + how production siblings can still use an absolute origin if SPA + BFF live on different hosts. | | `apps/portal-shell/src/observability/tracing.ts` | `new URL(environment.bffApiBaseUrl)` would throw on a relative URL — resolved against `window.location.origin` so both relative (dev) and absolute (prod cross-origin) bases work. | | `infra/local/dev.compose.yml` | `BFF_TARGET=http://portal-bff:3000` added to `portal-shell` and `portal-admin` so the proxy hits the BFF **container** by name (Compose DNS) when the `apps` profile is up. Native `nx serve` leaves the var unset and falls back to `localhost:3000`. | ## Why a relative URL is safe - `${bffApiBaseUrl}/health` etc. compose to `/api/health` — relative paths work in `fetch` / `HttpClient`. - `tracing.ts` propagates `traceparent` on requests whose origin matches the BFF origin. Resolving the relative base against `window.location.origin` gives the current page's origin, which is exactly the origin the dev-server proxy serves from — so the regex still matches the right requests in dev. In a future cross-origin production deployment, `environment.prod.ts` can set an absolute `bffApiBaseUrl`; the URL constructor's second arg is ignored when the first is absolute, so the same code path keeps working. - Auth flow (`feature-auth` / `auth.config.ts`): consumes `bffApiBaseUrl` via DI as a string prefix — agnostic to absolute vs relative. ## Scope notes - The OTel HTTP exporter (`environment.otlpEndpoint = 'http://localhost:4318/v1/traces'`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) **remain absolute**. They hit the same remote-browser problem on `apps`-profile access, but neither is blocking the user-visible "Backend unreachable" path this PR targets. Out of scope here; a follow-up could either proxy them too or surface them via runtime config. - This pattern is dev-server only — production builds do not use the proxy. Per-environment `bffApiBaseUrl` overrides remain the supported lever (ADR-0018), unchanged. ## Test plan - [x] `docker compose -f dev.compose.yml --profile apps config` still validates. - [ ] **On the VM**, `./infra/local/dev.sh up apps`, then in the workstation browser open `http://<vm-ip>:4200/` → SPA loads, the "Backend unreachable" message is gone, network tab shows `/api/...` calls succeeding (same-origin, no CORS preflight). - [ ] Native `nx serve portal-shell` still works (the proxy falls back to `localhost:3000`). - [ ] Trace headers (`traceparent`) appear on `/api/*` fetches in the browser network tab. - [ ] `pnpm exec nx affected -t test build` green on the two SPAs. ## Related - [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this enables to actually work from a remote browser. - [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md) — the `environment.ts` per-env strategy this complements (does not supersede — production behaviour unchanged). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #259 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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). |