Commit Graph

213 Commits

Author SHA1 Message Date
APF Portal Bot d519f2d474 fix(deps): update opentelemetry-js-contrib monorepo
CI / commits (pull_request) Has been skipped
CI / perf (pull_request) Has been skipped
CI / scan (pull_request) Successful in 3m16s
CI / a11y (pull_request) Successful in 5m14s
CI / check (pull_request) Successful in 7m53s
Docs site / build (pull_request) Successful in 4m58s
2026-05-17 22:48:27 +00:00
APF Portal Bot ee59fd900c fix(deps): update opentelemetry-js monorepo (#183)
CI / scan (push) Successful in 1m32s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 4m57s
CI / check (push) Successful in 5m52s
Docs site / build (push) Successful in 2m14s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@opentelemetry/exporter-trace-otlp-http](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-trace-otlp-http) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-trace-otlp-http/0.217.0/0.218.0) |
| [@opentelemetry/exporter-trace-otlp-proto](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-trace-otlp-proto) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-trace-otlp-proto/0.217.0/0.218.0) |
| [@opentelemetry/instrumentation](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation/0.217.0/0.218.0) |
| [@opentelemetry/instrumentation-fetch](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-fetch) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-fetch/0.217.0/0.218.0) |
| [@opentelemetry/instrumentation-http](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-http/0.217.0/0.218.0) |
| [@opentelemetry/sdk-node](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-sdk-node) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsdk-node/0.217.0/0.218.0) |
| [@opentelemetry/semantic-conventions](https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`1.40.0` -> `1.41.1`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsemantic-conventions/1.40.0/1.41.1) |

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js (@&#8203;opentelemetry/exporter-trace-otlp-http)</summary>

### [`v0.218.0`](https://github.com/open-telemetry/opentelemetry-js/compare/74cde1b674508ccc0ed2601ac43a80ff2d35114c...06ad0eaaecbd49f5ead871325f852cc2a3454079)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js/compare/74cde1b674508ccc0ed2601ac43a80ff2d35114c...06ad0eaaecbd49f5ead871325f852cc2a3454079)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #183
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 23:47:04 +02:00
APF Portal Bot 868cbb6747 chore(deps): update dependency @playwright/test to v1.60.0 (#182)
CI / scan (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / check (push) Successful in 7m27s
CI / a11y (push) Successful in 2m0s
CI / perf (push) Successful in 5m29s
Docs site / build (push) Successful in 3m15s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@playwright/test](https://playwright.dev) ([source](https://github.com/microsoft/playwright)) | devDependencies | minor | [`1.59.1` -> `1.60.0`](https://renovatebot.com/diffs/npm/@playwright%2ftest/1.59.1/1.60.0) |

---

### Release Notes

<details>
<summary>microsoft/playwright (@&#8203;playwright/test)</summary>

### [`v1.60.0`](https://github.com/microsoft/playwright/releases/tag/v1.60.0)

[Compare Source](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

#### 🌐 HAR recording on Tracing

[tracing.startHar()](https://playwright.dev/docs/api/class-tracing#tracing-start-har) / [tracing.stopHar()](https://playwright.dev/docs/api/class-tracing#tracing-stop-har) expose HAR recording as a first-class tracing API, with the same `content`, `mode` and `urlFilter` options as `recordHar`. The returned [Disposable](https://playwright.dev/docs/api/class-disposable) makes it easy to scope a recording with `await using`:

```js
await using har = await context.tracing.startHar('trace.har');
const page = await context.newPage();
await page.goto('https://playwright.dev');
// HAR is finalized when `har` goes out of scope.
```

#### 🪝 Drop API

New [locator.drop()](https://playwright.dev/docs/api/class-locator#locator-drop) simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches `dragenter`, `dragover`, and `drop` with a synthetic \[DataTransfer] in the page context — works cross-browser and is great for testing upload zones:

```js
await page.locator('#dropzone').drop({
  files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') },
});

await page.locator('#dropzone').drop({
  data: {
    'text/plain': 'hello world',
    'text/uri-list': 'https://example.com',
  },
});
```

#### 🎯 Aria snapshots

- [expect(page).toMatchAriaSnapshot()](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-match-aria-snapshot) now works on a [Page](https://playwright.dev/docs/api/class-page), in addition to a [Locator](https://playwright.dev/docs/api/class-locator) — equivalent to asserting against `page.locator('body')`.
- New `boxes` option on [locator.ariaSnapshot()](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot) / [page.ariaSnapshot()](https://playwright.dev/docs/api/class-page#page-aria-snapshot) appends each element's bounding box as `[box=x,y,width,height]`, useful for AI consumption.

#### 🛑 test.abort()

New [test.abort()](https://playwright.dev/docs/api/class-test#test-abort) aborts the currently running test from a fixture, hook, or route handler with an optional message. Use it when you have detected an unrecoverable misuse and want to fail the test right away:

```js
test('does not publish to the shared page', async ({ page }) => {
  await page.route('**/publish', route => {
    test.abort('Tests must not publish to the shared page. Use the `clone` option.');
    return route.abort();
  });
  // ...
});
```

#### New APIs

##### Browser, Context and Page

- Event [browser.on('context')](https://playwright.dev/docs/api/class-browser#browser-event-context) — fired when a new context is created on the browser.
- [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) now mirrors lifecycle events from its pages: [browserContext.on('download')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-download), [browserContext.on('frameattached')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-frame-attached), [browserContext.on('framedetached')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-frame-detached), [browserContext.on('framenavigated')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-frame-navigated), [browserContext.on('pageclose')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-page-close), [browserContext.on('pageload')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-page-load).

##### Locators and Assertions

- New option `description` in [page.getByRole()](https://playwright.dev/docs/api/class-page#page-get-by-role) / [locator.getByRole()](https://playwright.dev/docs/api/class-locator#locator-get-by-role) / [frame.getByRole()](https://playwright.dev/docs/api/class-frame#frame-get-by-role) / [frameLocator.getByRole()](https://playwright.dev/docs/api/class-framelocator#frame-locator-get-by-role) for matching the [accessible description](https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description).
- New option `pseudo` in [expect(locator).toHaveCSS()](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css) reads computed styles from `::before` or `::after`.
- New option `style` in [locator.highlight()](https://playwright.dev/docs/api/class-locator#locator-highlight) applies extra inline CSS to the highlight overlay, plus new [page.hideHighlight()](https://playwright.dev/docs/api/class-page#page-hide-highlight) to clear all highlights.

##### Network

- [webSocketRoute.protocols()](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-protocols) returns the WebSocket subprotocols requested by the page.
- New option `noDefaults` in [browserType.connectOverCDP()](https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp) disables Playwright's default overrides on the default context (download behavior, focus emulation, media emulation), so attaching to a user's daily-driver browser doesn't disturb its state.

##### Errors and Reporting

- New [webError.location()](https://playwright.dev/docs/api/class-weberror#web-error-location) mirrors [consoleMessage.location()](https://playwright.dev/docs/api/class-consolemessage#console-message-location).
- [consoleMessage.location()](https://playwright.dev/docs/api/class-consolemessage#console-message-location) now exposes `line` / `column` properties (`lineNumber` / `columnNumber` are deprecated).
- New [testInfoError.errorContext](https://playwright.dev/docs/api/class-testinfoerror#test-info-error-error-context) surfaces additional diagnostic context, such as the aria snapshot of the receiver at the time of an `expect(...)` matcher failure.
- [reporter.onError()](https://playwright.dev/docs/api/class-reporter#reporter-on-error) now receives a `workerInfo` argument with details about the worker for fixture teardown errors.

##### Test runner

- New `{testFileBaseName}` token in [testProject.snapshotPathTemplate](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-path-template) — file name without extension.
- Test runner now errors when a config tries to override a non-option fixture, and rejects `workers: 0` or negative values.

#### 🛠️ Other improvements

- HTML reporter:
  - `npx playwright show-report` accepts `.zip` files directly — no need to unzip first.
  - Steps that contain attachments inside nested children show an indicator on the parent step.
  - The `repeatEachIndex` is shown in the test header when non-zero.
- Trace Viewer adds a pretty-print toggle for JSON / form request and response bodies in the network details panel.

#### Breaking Changes ⚠️

- Removed long-deprecated APIs:
  - `Locator.ariaRef()` — use the standard [locator.ariaSnapshot()](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot) pipeline.
  - `handle` option on `BrowserContext.exposeBinding` and `Page.exposeBinding`.
  - `logger` option on `BrowserType.connect` and `BrowserType.connectOverCDP` — use [tracing](https://playwright.dev/docs/trace-viewer) instead.
  - Context options `videosPath` / `videoSize` — use `recordVideo` instead.

#### Browser Versions

- Chromium 148.0.7778.96
- Mozilla Firefox 150.0.2
- WebKit 26.4

This version was also tested against the following stable channels:

- Google Chrome 147
- Microsoft Edge 147

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #182
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 23:46:36 +02:00
APF Portal Bot e376ea1295 chore(deps): update dependency @oxc-project/runtime to ^0.131.0 (#181)
CI / scan (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / check (push) Successful in 6m30s
CI / a11y (push) Successful in 2m53s
CI / perf (push) Successful in 9m49s
Docs site / build (push) Successful in 5m35s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@oxc-project/runtime](https://oxc.rs) ([source](https://github.com/oxc-project/oxc/tree/HEAD/npm/runtime)) | devDependencies | minor | [`^0.129.0` -> `^0.131.0`](https://renovatebot.com/diffs/npm/@oxc-project%2fruntime/0.129.0/0.131.0) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #181
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 23:42:58 +02:00
julien f2360b9db3 chore(shared-charts): soften the curated palette (#185)
CI / commits (push) Has been skipped
CI / check (push) Successful in 4m50s
CI / scan (push) Successful in 5m22s
CI / a11y (push) Successful in 3m56s
CI / perf (push) Successful in 9m20s
## Summary

Tune the curated chart palette to a softer, lower-saturation set. The values shipped in #175 were pulled straight from Tailwind's `-600 / -700` ramp; on real audit-log data the donut's three slices and the bar-chart's blue read as too punchy when they share a tile, especially in dark mode. Same five intents, same a11y posture — just less visual fight.

## What lands

`libs/shared/charts/src/lib/_internal/palette.ts`:

| Constant                          | Before   | After    |
| --------------------------------- | -------- | -------- |
| `DEFAULT_BAR_FILL`                | `#1d4ed8` | `#4075e7` |
| `semanticStatusColors.info`       | `#2563eb` | `#4075e7` |
| `semanticStatusColors.success`    | `#16a34a` | `#46ac6b` |
| `semanticStatusColors.warning`    | `#ea580c` | `#f38043` |
| `semanticStatusColors.error`      | `#dc2626` | `#eb5252` |
| `semanticStatusColors.neutral`    | `#6b7280` | `#6b7280` (unchanged) |

`info` and `DEFAULT_BAR_FILL` collapse to the same hex — bars and "informational" donut slices are *meant* to read as the same semantic class (no special status), so unifying them at the constant level removes a future drift hazard.

Docstrings updated alongside — the previous comments name-checked Tailwind shades (`green-600`, `tailwind blue-700`) that no longer correspond to the values; the new comments describe the palette by intent (`muted green`, `muted orange`, ...) and call out that the softening is deliberate.

## Notes for the reviewer

- **A11y posture unchanged.** The lib's contract is "AA contrast on white surfaces, deuteranopia/protanopia distinguishability via lightness deltas, not just hue". All four chromatic entries clear the same bar: each lightness sits in a distinct band (≈ 67 % for warning, ≈ 60 % for success, ≈ 60 % for error, ≈ 56 % for info), so colour-blind viewers still distinguish them by brightness even if the hue collapses.
- **Why not derive these from `libs/shared/tokens/brand-tokens.css`?** Brand-primary is the dark teal `#12546c` and brand-accent is `#f7a919`. Neither reads correctly as "success" or "neutral chart fill"; the charts need a categorical palette tuned for *legibility on dense surfaces*, not for chrome and CTAs. Keeping the chart palette in its own lib stays consistent with ADR-0023's "lib owns the palette" stance.
- **Bar fill default + `info` semantic alias to the same value on purpose.** A bar with no per-bar encoding is semantically "informational quantity over time" — the same intent as a donut slice tagged `info`. Future consumer that wants to flag a single "info" bar inside a stacked chart will read the colour as consistent.
- **No code changes outside this file.** Consumers (`<lib-bar-chart>`, `<lib-donut-chart>`, the audit page) import these constants by name; the swap is purely a value change.

## Test plan

- [x] `pnpm nx test shared-charts` — 15 specs pass (the donut `colorMap` spec asserts the *consumer-provided* hexes, not the lib defaults, so the change is transparent there; the bar single-fill spec checks uniqueness, not the specific value).
- [x] `pnpm nx test portal-admin` — 62 specs pass.
- [x] `pnpm nx run-many -t test build lint -p shared-charts,portal-admin` — clean (same three pre-existing lint warnings unrelated to this PR).
- [ ] **Manual smoke** — `pnpm nx serve portal-admin`, sign in with `Portal.Admin`, navigate to `/admin/audit`, switch to Charts:
  - Daily-volume bars render in the new muted blue.
  - Outcome donut slices: green (success), red (failure), orange (denied) — softer than before, semantic mapping intact.
  - Dark-mode toggle — palette still legible against the dark surface.
  - Side-by-side comparison vs `main` — the new shades feel calmer, especially when multiple charts share the viewport.

## What's next

Nothing pending on the palette front. If a future chart needs a sixth intent (e.g. `pending` for in-flight states), add it here with a contrast / colour-blind check and update the typed `SemanticStatus` union in the same PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #185
2026-05-17 23:42:07 +02:00
APF Portal Bot 8a02ca86a2 fix(deps): update vitest to v4.1.6 (#180)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m37s
CI / check (push) Successful in 6m47s
CI / a11y (push) Successful in 4m12s
CI / perf (push) Successful in 6m36s
Docs site / build (push) Successful in 4m40s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | devDependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.5/4.1.6) |
| [@vitest/ui](https://vitest.dev/guide/ui) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui)) | devDependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/@vitest%2fui/4.1.5/4.1.6) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | devDependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/vitest/4.1.5/4.1.6) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | dependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/vitest/4.1.5/4.1.6) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.6`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.6)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.5...v4.1.6)

#####    🐞 Bug Fixes

- **browser**: Provide project reference in `ToMatchScreenshotResolvePath`  -  by [@&#8203;macarie](https://github.com/macarie) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in https://github.com/vitest-dev/vitest/issues/10138 [<samp>(31882)</samp>](https://github.com/vitest-dev/vitest/commit/31882607c)
- Global `sequence.concurrent: true` with top-level `test(..., { concurrent: false })` + depreacte `sequential` test API and options  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Codex** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in https://github.com/vitest-dev/vitest/issues/10196 [<samp>(2847d)</samp>](https://github.com/vitest-dev/vitest/commit/2847dfa2a)
- **browser**: Simplify orchestrator otel carrier  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in https://github.com/vitest-dev/vitest/issues/10285 [<samp>(18af9)</samp>](https://github.com/vitest-dev/vitest/commit/18af98cee)

#####    🏎 Performance

- Stringify diff objects only once  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in https://github.com/vitest-dev/vitest/issues/10276 [<samp>(9f7b1)</samp>](https://github.com/vitest-dev/vitest/commit/9f7b1528c)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.5...v4.1.6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/180
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 22:29:35 +02:00
APF Portal Bot bd94bb4ffe fix(deps): update nx to v22.7.2 (#179)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m26s
CI / check (push) Successful in 7m6s
CI / a11y (push) Successful in 2m46s
CI / perf (push) Successful in 9m15s
Docs site / build (push) Successful in 3m56s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nx/devkit](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/devkit)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fdevkit/22.7.1/22.7.2) |
| [@nx/eslint-plugin](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint-plugin)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2feslint-plugin/22.7.1/22.7.2) |
| [@nx/jest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/jest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjest/22.7.1/22.7.2) |
| [@nx/js](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/js)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjs/22.7.1/22.7.2) |
| [@nx/node](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/node)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fnode/22.7.1/22.7.2) |
| [@nx/playwright](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/playwright)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fplaywright/22.7.1/22.7.2) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | dependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.1/22.7.2) |
| [@nx/vitest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vitest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvitest/22.7.1/22.7.2) |
| [@nx/web](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/web)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fweb/22.7.1/22.7.2) |
| [@nx/webpack](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/webpack)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fwebpack/22.7.1/22.7.2) |
| [nx](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nx)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/nx/22.7.1/22.7.2) |

---

### Release Notes

<details>
<summary>nrwl/nx (@&#8203;nx/devkit)</summary>

### [`v22.7.2`](https://github.com/nrwl/nx/releases/tag/22.7.2)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.1...22.7.2)

##### 22.7.2 (2026-05-14)

##### 🚀 Features

- **gradle:** stream batch task results to nx as they finish ([#&#8203;35487](https://github.com/nrwl/nx/pull/35487))
- **nx-dev:** track docs analytics for code copy, LLM prompt, YouTube ([#&#8203;35526](https://github.com/nrwl/nx/pull/35526))
- **testing:** add migration for Jest 30 snapshot guide link ([#&#8203;35629](https://github.com/nrwl/nx/pull/35629))

##### 🩹 Fixes

- **angular:** disable vitest watch by default ([#&#8203;35493](https://github.com/nrwl/nx/pull/35493))
- **angular-rspack:** keep root-scoped assets out of per-locale i18n emit ([#&#8203;35621](https://github.com/nrwl/nx/pull/35621))
- **bundling:** include tsconfig solution input for rollup ([#&#8203;35476](https://github.com/nrwl/nx/pull/35476))
- **bundling:** include tsconfig solution input for webpack ([#&#8203;35477](https://github.com/nrwl/nx/pull/35477), [#&#8203;35476](https://github.com/nrwl/nx/issues/35476))
- **core:** bump axios to 1.16.0 for all packages ([#&#8203;35568](https://github.com/nrwl/nx/pull/35568))
- **core:** add provenance check in nx console status path ([#&#8203;35485](https://github.com/nrwl/nx/pull/35485))
- **core:** remove access control header from graph app ([#&#8203;35494](https://github.com/nrwl/nx/pull/35494))
- **core:** ensure verbose logs go to stderr and daemon logs are properly decorated ([#&#8203;34358](https://github.com/nrwl/nx/pull/34358))
- **core:** show flaky-task count in run summary ([#&#8203;35491](https://github.com/nrwl/nx/pull/35491))
- **core:** unique telemetry user\_id; expose workspace\_id dimension ([#&#8203;35553](https://github.com/nrwl/nx/pull/35553))
- **core:** update minimatch to 10.2.5 ([#&#8203;35569](https://github.com/nrwl/nx/pull/35569), [#&#8203;34660](https://github.com/nrwl/nx/issues/34660))
- **core:** restore use-legacy-versioning shim for [@&#8203;nx/js](https://github.com/nx/js)[@&#8203;21](https://github.com/21) ensurePackage path ([#&#8203;35574](https://github.com/nrwl/nx/pull/35574))
- **core:** isolate NX\_PARALLEL env var in parallel-related specs ([#&#8203;35579](https://github.com/nrwl/nx/pull/35579))
- **core:** skip handleimport miss path when nx key packages are absent ([#&#8203;35596](https://github.com/nrwl/nx/pull/35596))
- **core:** use gethostuuid(3) instead of ioreg on macOS ([#&#8203;35599](https://github.com/nrwl/nx/pull/35599))
- **core:** isolate cache env vars in splitArgs spec ([#&#8203;35584](https://github.com/nrwl/nx/pull/35584))
- **core:** enable node's native v8 compile cache support ([#&#8203;35415](https://github.com/nrwl/nx/pull/35415), [#&#8203;20454](https://github.com/nrwl/nx/issues/20454))
- **core:** support skipped batch tasks end-to-end and fix TUI double logs ([#&#8203;35617](https://github.com/nrwl/nx/pull/35617))
- **core:** keep TUI task selection on the in-progress section ([#&#8203;35640](https://github.com/nrwl/nx/pull/35640))
- **core:** allow `nx mcp` to run outside of an Nx workspace ([#&#8203;35655](https://github.com/nrwl/nx/pull/35655))
- **core:** cast perf entries to PerformanceMeasure for detail access ([43c0c821ba](https://github.com/nrwl/nx/commit/43c0c821ba))
- **devkit:** exclude dist from jest module path scan ([#&#8203;35615](https://github.com/nrwl/nx/pull/35615))
- **devkit:** expand @&#8203;nx/devkit/internal re-exports for cherry-picked v23 deep-import migration ([#&#8203;35541](https://github.com/nrwl/nx/issues/35541))
- **dotnet:** correct output paths for Web SDK and centralized dist setups ([#&#8203;35398](https://github.com/nrwl/nx/pull/35398))
- **gradle:** exclude batch-runner from jest haste-map crawl ([#&#8203;35501](https://github.com/nrwl/nx/pull/35501))
- **gradle:** exclude project-graph from jest module path scan ([#&#8203;35609](https://github.com/nrwl/nx/pull/35609))
- **gradle:** support Windows file paths ([#&#8203;35184](https://github.com/nrwl/nx/pull/35184), [#&#8203;34987](https://github.com/nrwl/nx/issues/34987))
- **js:** strip glob from inferred outputs before resolving as path ([#&#8203;35463](https://github.com/nrwl/nx/pull/35463), [#&#8203;35452](https://github.com/nrwl/nx/issues/35452))
- **js:** reference vitest.config in eslint dep-checks for vitest libs ([#&#8203;35460](https://github.com/nrwl/nx/pull/35460), [#&#8203;33670](https://github.com/nrwl/nx/issues/33670), [#&#8203;35450](https://github.com/nrwl/nx/issues/35450))
- **js:** include transitive workspace deps in pruned pnpm lockfile ([#&#8203;35532](https://github.com/nrwl/nx/pull/35532), [#&#8203;35347](https://github.com/nrwl/nx/issues/35347), [#&#8203;34655](https://github.com/nrwl/nx/issues/34655))
- **linter:** prevent ENOENT crash in getRelativeImportPath for unresolvable paths ([#&#8203;35007](https://github.com/nrwl/nx/pull/35007), [#&#8203;13872](https://github.com/nrwl/nx/issues/13872), [#&#8203;34066](https://github.com/nrwl/nx/issues/34066), [#&#8203;30491](https://github.com/nrwl/nx/issues/30491), [#&#8203;16716](https://github.com/nrwl/nx/issues/16716), [#&#8203;35006](https://github.com/nrwl/nx/issues/35006), [#&#8203;21889](https://github.com/nrwl/nx/issues/21889), [#&#8203;32190](https://github.com/nrwl/nx/issues/32190))
- **maven:** skip attached artifacts that fail to materialize in batch record ([#&#8203;35473](https://github.com/nrwl/nx/pull/35473))
- **maven:** serialize Maven 4 build state recording ([#&#8203;35555](https://github.com/nrwl/nx/pull/35555))
- **maven:** widen runCLI timeout for --no-batch maven.test.ts cases ([#&#8203;35589](https://github.com/nrwl/nx/pull/35589))
- **nx-dev:** document nested CLI subcommands beyond two levels ([#&#8203;35519](https://github.com/nrwl/nx/pull/35519))
- **nx-dev:** short-circuit bot probes in framer rewrite edge function ([#&#8203;35527](https://github.com/nrwl/nx/pull/35527))
- **react:** withSvgr migration preserves other properties ([#&#8203;35484](https://github.com/nrwl/nx/pull/35484))
- **repo:** clear NX\_INVOCATION\_ROOT\_PID in run-native-target to avoid recursion false-positive ([443dee0b22](https://github.com/nrwl/nx/commit/443dee0b22))
- **repo:** revert deep-import rewrites that targeted v23-only @&#8203;nx/devkit/internal entry ([ac8187963d](https://github.com/nrwl/nx/commit/ac8187963d))
- **repo:** unblock 22.7.x cargo tests and nx-build e2e ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285))
- **repo:** expand "..." spread token in graph typecheck inputs ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285), [#&#8203;35458](https://github.com/nrwl/nx/issues/35458))
- **testing:** pin jest to ~30.3.0 to avoid jest-runtime 30.4 RN incompat ([#&#8203;35618](https://github.com/nrwl/nx/pull/35618))
- **testing:** handle absolute cypress screenshotsFolder/videosFolder paths ([#&#8203;35624](https://github.com/nrwl/nx/pull/35624))
- **testing:** exclude dist and out-tsc from default jest module path scan ([#&#8203;35619](https://github.com/nrwl/nx/pull/35619))
- **testing:** update remaining snapshot guide links missed by migration ([cd350c1140](https://github.com/nrwl/nx/commit/cd350c1140))

##### ❤️ Thank You

- Adam Keenan [@&#8203;adamk33n3r](https://github.com/adamk33n3r)
- AgentEnder [@&#8203;AgentEnder](https://github.com/AgentEnder)
- beeman
- Claude
- Craigory Coppola [@&#8203;AgentEnder](https://github.com/AgentEnder)
- FrozenPandaz [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Jack Hsu [@&#8203;jaysoo](https://github.com/jaysoo)
- Jason Jean [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Leosvel Pérez Espinosa [@&#8203;leosvelperez](https://github.com/leosvelperez)
- Max Kless
- MaxKless [@&#8203;MaxKless](https://github.com/MaxKless)
- Optischa [@&#8203;Optischa](https://github.com/Optischa)
- Sharon Lougheed

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled because a matching PR was automerged previously.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/179
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 22:15:42 +02:00
APF Portal Bot 6b20c3413b fix(deps): update nx to v22.7.2 (#178)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m24s
CI / a11y (push) Successful in 2m6s
CI / check (push) Successful in 6m3s
CI / perf (push) Successful in 6m14s
Docs site / build (push) Successful in 2m49s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nx/angular](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/angular)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fangular/22.7.1/22.7.2) |
| [@nx/devkit](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/devkit)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fdevkit/22.7.1/22.7.2) |
| [@nx/eslint](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2feslint/22.7.1/22.7.2) |
| [@nx/eslint-plugin](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint-plugin)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2feslint-plugin/22.7.1/22.7.2) |
| [@nx/jest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/jest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjest/22.7.1/22.7.2) |
| [@nx/js](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/js)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjs/22.7.1/22.7.2) |
| [@nx/nest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fnest/22.7.1/22.7.2) |
| [@nx/node](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/node)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fnode/22.7.1/22.7.2) |
| [@nx/playwright](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/playwright)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fplaywright/22.7.1/22.7.2) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.1/22.7.2) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | dependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.1/22.7.2) |
| [@nx/vitest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vitest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvitest/22.7.1/22.7.2) |
| [@nx/web](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/web)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fweb/22.7.1/22.7.2) |
| [@nx/webpack](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/webpack)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fwebpack/22.7.1/22.7.2) |
| [nx](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nx)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/nx/22.7.1/22.7.2) |

---

### Release Notes

<details>
<summary>nrwl/nx (@&#8203;nx/angular)</summary>

### [`v22.7.2`](https://github.com/nrwl/nx/releases/tag/22.7.2)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.1...22.7.2)

##### 22.7.2 (2026-05-14)

##### 🚀 Features

- **gradle:** stream batch task results to nx as they finish ([#&#8203;35487](https://github.com/nrwl/nx/pull/35487))
- **nx-dev:** track docs analytics for code copy, LLM prompt, YouTube ([#&#8203;35526](https://github.com/nrwl/nx/pull/35526))
- **testing:** add migration for Jest 30 snapshot guide link ([#&#8203;35629](https://github.com/nrwl/nx/pull/35629))

##### 🩹 Fixes

- **angular:** disable vitest watch by default ([#&#8203;35493](https://github.com/nrwl/nx/pull/35493))
- **angular-rspack:** keep root-scoped assets out of per-locale i18n emit ([#&#8203;35621](https://github.com/nrwl/nx/pull/35621))
- **bundling:** include tsconfig solution input for rollup ([#&#8203;35476](https://github.com/nrwl/nx/pull/35476))
- **bundling:** include tsconfig solution input for webpack ([#&#8203;35477](https://github.com/nrwl/nx/pull/35477), [#&#8203;35476](https://github.com/nrwl/nx/issues/35476))
- **core:** bump axios to 1.16.0 for all packages ([#&#8203;35568](https://github.com/nrwl/nx/pull/35568))
- **core:** add provenance check in nx console status path ([#&#8203;35485](https://github.com/nrwl/nx/pull/35485))
- **core:** remove access control header from graph app ([#&#8203;35494](https://github.com/nrwl/nx/pull/35494))
- **core:** ensure verbose logs go to stderr and daemon logs are properly decorated ([#&#8203;34358](https://github.com/nrwl/nx/pull/34358))
- **core:** show flaky-task count in run summary ([#&#8203;35491](https://github.com/nrwl/nx/pull/35491))
- **core:** unique telemetry user\_id; expose workspace\_id dimension ([#&#8203;35553](https://github.com/nrwl/nx/pull/35553))
- **core:** update minimatch to 10.2.5 ([#&#8203;35569](https://github.com/nrwl/nx/pull/35569), [#&#8203;34660](https://github.com/nrwl/nx/issues/34660))
- **core:** restore use-legacy-versioning shim for [@&#8203;nx/js](https://github.com/nx/js)[@&#8203;21](https://github.com/21) ensurePackage path ([#&#8203;35574](https://github.com/nrwl/nx/pull/35574))
- **core:** isolate NX\_PARALLEL env var in parallel-related specs ([#&#8203;35579](https://github.com/nrwl/nx/pull/35579))
- **core:** skip handleimport miss path when nx key packages are absent ([#&#8203;35596](https://github.com/nrwl/nx/pull/35596))
- **core:** use gethostuuid(3) instead of ioreg on macOS ([#&#8203;35599](https://github.com/nrwl/nx/pull/35599))
- **core:** isolate cache env vars in splitArgs spec ([#&#8203;35584](https://github.com/nrwl/nx/pull/35584))
- **core:** enable node's native v8 compile cache support ([#&#8203;35415](https://github.com/nrwl/nx/pull/35415), [#&#8203;20454](https://github.com/nrwl/nx/issues/20454))
- **core:** support skipped batch tasks end-to-end and fix TUI double logs ([#&#8203;35617](https://github.com/nrwl/nx/pull/35617))
- **core:** keep TUI task selection on the in-progress section ([#&#8203;35640](https://github.com/nrwl/nx/pull/35640))
- **core:** allow `nx mcp` to run outside of an Nx workspace ([#&#8203;35655](https://github.com/nrwl/nx/pull/35655))
- **core:** cast perf entries to PerformanceMeasure for detail access ([43c0c821ba](https://github.com/nrwl/nx/commit/43c0c821ba))
- **devkit:** exclude dist from jest module path scan ([#&#8203;35615](https://github.com/nrwl/nx/pull/35615))
- **devkit:** expand @&#8203;nx/devkit/internal re-exports for cherry-picked v23 deep-import migration ([#&#8203;35541](https://github.com/nrwl/nx/issues/35541))
- **dotnet:** correct output paths for Web SDK and centralized dist setups ([#&#8203;35398](https://github.com/nrwl/nx/pull/35398))
- **gradle:** exclude batch-runner from jest haste-map crawl ([#&#8203;35501](https://github.com/nrwl/nx/pull/35501))
- **gradle:** exclude project-graph from jest module path scan ([#&#8203;35609](https://github.com/nrwl/nx/pull/35609))
- **gradle:** support Windows file paths ([#&#8203;35184](https://github.com/nrwl/nx/pull/35184), [#&#8203;34987](https://github.com/nrwl/nx/issues/34987))
- **js:** strip glob from inferred outputs before resolving as path ([#&#8203;35463](https://github.com/nrwl/nx/pull/35463), [#&#8203;35452](https://github.com/nrwl/nx/issues/35452))
- **js:** reference vitest.config in eslint dep-checks for vitest libs ([#&#8203;35460](https://github.com/nrwl/nx/pull/35460), [#&#8203;33670](https://github.com/nrwl/nx/issues/33670), [#&#8203;35450](https://github.com/nrwl/nx/issues/35450))
- **js:** include transitive workspace deps in pruned pnpm lockfile ([#&#8203;35532](https://github.com/nrwl/nx/pull/35532), [#&#8203;35347](https://github.com/nrwl/nx/issues/35347), [#&#8203;34655](https://github.com/nrwl/nx/issues/34655))
- **linter:** prevent ENOENT crash in getRelativeImportPath for unresolvable paths ([#&#8203;35007](https://github.com/nrwl/nx/pull/35007), [#&#8203;13872](https://github.com/nrwl/nx/issues/13872), [#&#8203;34066](https://github.com/nrwl/nx/issues/34066), [#&#8203;30491](https://github.com/nrwl/nx/issues/30491), [#&#8203;16716](https://github.com/nrwl/nx/issues/16716), [#&#8203;35006](https://github.com/nrwl/nx/issues/35006), [#&#8203;21889](https://github.com/nrwl/nx/issues/21889), [#&#8203;32190](https://github.com/nrwl/nx/issues/32190))
- **maven:** skip attached artifacts that fail to materialize in batch record ([#&#8203;35473](https://github.com/nrwl/nx/pull/35473))
- **maven:** serialize Maven 4 build state recording ([#&#8203;35555](https://github.com/nrwl/nx/pull/35555))
- **maven:** widen runCLI timeout for --no-batch maven.test.ts cases ([#&#8203;35589](https://github.com/nrwl/nx/pull/35589))
- **nx-dev:** document nested CLI subcommands beyond two levels ([#&#8203;35519](https://github.com/nrwl/nx/pull/35519))
- **nx-dev:** short-circuit bot probes in framer rewrite edge function ([#&#8203;35527](https://github.com/nrwl/nx/pull/35527))
- **react:** withSvgr migration preserves other properties ([#&#8203;35484](https://github.com/nrwl/nx/pull/35484))
- **repo:** clear NX\_INVOCATION\_ROOT\_PID in run-native-target to avoid recursion false-positive ([443dee0b22](https://github.com/nrwl/nx/commit/443dee0b22))
- **repo:** revert deep-import rewrites that targeted v23-only @&#8203;nx/devkit/internal entry ([ac8187963d](https://github.com/nrwl/nx/commit/ac8187963d))
- **repo:** unblock 22.7.x cargo tests and nx-build e2e ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285))
- **repo:** expand "..." spread token in graph typecheck inputs ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285), [#&#8203;35458](https://github.com/nrwl/nx/issues/35458))
- **testing:** pin jest to ~30.3.0 to avoid jest-runtime 30.4 RN incompat ([#&#8203;35618](https://github.com/nrwl/nx/pull/35618))
- **testing:** handle absolute cypress screenshotsFolder/videosFolder paths ([#&#8203;35624](https://github.com/nrwl/nx/pull/35624))
- **testing:** exclude dist and out-tsc from default jest module path scan ([#&#8203;35619](https://github.com/nrwl/nx/pull/35619))
- **testing:** update remaining snapshot guide links missed by migration ([cd350c1140](https://github.com/nrwl/nx/commit/cd350c1140))

##### ❤️ Thank You

- Adam Keenan [@&#8203;adamk33n3r](https://github.com/adamk33n3r)
- AgentEnder [@&#8203;AgentEnder](https://github.com/AgentEnder)
- beeman
- Claude
- Craigory Coppola [@&#8203;AgentEnder](https://github.com/AgentEnder)
- FrozenPandaz [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Jack Hsu [@&#8203;jaysoo](https://github.com/jaysoo)
- Jason Jean [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Leosvel Pérez Espinosa [@&#8203;leosvelperez](https://github.com/leosvelperez)
- Max Kless
- MaxKless [@&#8203;MaxKless](https://github.com/MaxKless)
- Optischa [@&#8203;Optischa](https://github.com/Optischa)
- Sharon Lougheed

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/178
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 15:51:56 +02:00
julien b9daaa5f58 fix(portal-shell): same html/body overflow-y hidden shield as admin (#177)
CI / check (push) Successful in 3m55s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m15s
CI / a11y (push) Successful in 1m36s
CI / perf (push) Successful in 5m11s
## 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
2026-05-17 02:31:15 +02:00
julien 67e50be1dc fix(portal-admin): html/body overflow-y hidden as a shell shield (#176)
CI / check (push) Successful in 4m43s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m2s
CI / a11y (push) Successful in 2m10s
CI / perf (push) Successful in 3m43s
## Summary

Tiny follow-up to #175 — the bar / donut / stacked-bar charts on the audit-log Charts tab still surfaced a *phantom* body scrollbar plus an empty band below the footer in some viewport widths. The lib-side overflow constraints from #175 hold for the cases tested, but the symptom can re-appear from any future layout escape (a wider downstream component, a third-party iframe, an unforeseen flex bug).

This PR adds a `html, body { overflow-y: hidden }` shield at the global stylesheet so any vertical overflow at the document level — wherever it comes from — stops at the shell boundary instead of producing a phantom scrollbar. Element-level scrolling on `<main>` (the only surface that *should* scroll) is unaffected.

## What lands

`apps/portal-admin/src/styles.css`:

```css
html,
body {
  overflow-y: hidden;
}
```

That's the whole change. The admin shell already commits to the "fills the viewport, never scrolls the body" layout — `<app-root>` is locked at `height: 100vh` and `<main>` owns its own `overflow-y: auto`. Anything that escapes that contract is, by design, a bug to fix at the source. The shield is a safety net, not a load-bearing layout rule.

## Notes for the reviewer

- **Why only portal-admin?** `portal-shell`'s app.scss carries the exact same `height: 100vh` + `<main> overflow-y: auto` shape, so the same shield would make sense there too. Holding it back to a separate PR because portal-shell hasn't actually demonstrated the symptom and the audit-log chantier is the immediate motivation — a one-line shield to the public-facing app deserves its own minute of consideration. Trivial to extend if/when we want symmetry.
- **Why not just delete `height: 100vh` and let the document scroll naturally?** The admin shell deliberately keeps the header, sidebar, and footer pinned while only the content area scrolls — that's a deliberate UX choice for a dense admin surface (long audit-log tables, future CMS editors), not an accident. Keeping the 100vh contract and adding the shield preserves the intent.
- **Manual reproduction of the original symptom** (now fixed): pre-shield, switching to the Charts tab on a 1280×720 viewport produced a body scrollbar with ~12 px of empty space below the footer, even though every visible element was inside `<main>`. Post-shield, the body scrollbar is gone; `<main>`'s internal scrollbar still works for the table page below the fold.

## Test plan

- [x] `pnpm nx build portal-admin` — clean.
- [x] `pnpm nx test portal-admin` — 62 specs pass (no behavioural change).
- [x] `pnpm nx lint portal-admin` — same three pre-existing warnings, no new ones.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin`, sign in with `Portal.Admin`:
  - Navigate to `/admin/audit`, switch to Charts — no body scrollbar, no gap under the footer, charts still render at the column width.
  - Resize the viewport across the `(max-width: 800px)` breakpoint — body still doesn't scroll; `<main>` still does where it should.
  - Open the user-list page (long table) — `<main>` scrolls internally as expected; the shield does *not* prevent legitimate content scrolling.
  - Toggle dark mode — no visual regression.

## What's next

- Mirror the same shield into `apps/portal-shell/src/styles.css` if/when a layout escape shows up there. Tracked in `docs/decisions/0020-portal-admin-app.md` follow-ups.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #176
2026-05-17 01:56:26 +02:00
julien 9e7eb4af15 fix(audit): chart colours + Charts-tab layout regressions (#175)
CI / check (push) Successful in 6m42s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m14s
CI / a11y (push) Successful in 2m38s
CI / perf (push) Successful in 4m33s
## Summary

Follow-up polish on the audit-log Charts tab shipped in #174. Three small but visible regressions surfaced once real data was loaded:

1. **"Events per day" bar chart cycled a categorical palette across the X axis** — every day got a different colour, which read as "categorical meaning per day" when the X axis is just a time bucket. Switched to a single fixed fill (curated blue from the lib palette).
2. **"Outcome breakdown" donut painted slices in Set2 order** — `success` came out teal, `denied` came out orange. With orange = "danger" in most readers' mental model, this is the wrong way around. Added a `colorMap` input on `<lib-donut-chart>` and a curated `semanticStatusColors` export so the audit page can map `success → green`, `denied → orange`, `failure → red`.
3. **Charts tab caused a body scrollbar + empty space below the footer.** Plot renders into a hidden `[hidden]` panel on first switch, so `canvas.clientWidth` is 0 and Plot falls back to its 600/720 default — the resulting fixed-width SVG was wider than the grid column, dragged horizontal overflow up the tree, and (because the grid item defaults to `min-width: auto`) wouldn't shrink. The shell layout broke. Fixed by constraining the chart envelope and the grid items so the SVG can scale down to the actual column width.

## What lands

### Bar chart — single fill, lib-owned default

- `Plot.barY` mark no longer encodes colour from `xKey`. The `fill` parameter is now a literal-string colour, applied uniformly to every bar.
- New `fillColor` input on `<lib-bar-chart>` for the rare case a consumer needs a different shade; defaults to `DEFAULT_BAR_FILL = '#1d4ed8'` (≈ Tailwind blue-700, picked for AA contrast against both light and dark surfaces, colour-blind-safe).
- Removed the now-unused `color: { type: 'ordinal', range: palette }` scale and the `colorScheme` input on the bar chart — the categorical palette only made sense when `fill: xKey` was the default behaviour.

### Donut chart — optional semantic mapping

- New `colorMap?: Readonly<Record<string, string>>` input on `<lib-donut-chart>`. When provided, slice fill resolves to `colorMap[category]` first, falling back to the categorical palette for any unmapped category. Lib still owns the palette per ADR-0023; the map only constrains *which colour the consumer picks for which category*, not what colours exist.
- New `semanticStatusColors` export from `shared-charts` — a curated 5-entry map (`success / warning / error / info / neutral`) tuned for AA contrast and deuteranopia/protanopia distinguishability. Consumers stay inside the lib's palette without authoring their own hex codes.
- The audit page now ships `outcomeColorMap = { success: green, failure: red, denied: orange }` and passes it to the donut. The donut's `description` (screen-reader fallback) and the legend chip semantics now line up.

### Chart envelope — overflow containment

`libs/shared/charts/src/lib/_internal/chart-envelope.scss`:

- Force `display: block` + `min-width: 0` on every chart host element (`lib-bar-chart`, `lib-donut-chart`, `lib-stacked-bar-chart`). Angular custom-element hosts default to `display: inline`, which lets the inner `<figure>` escape parent sizing constraints — the root cause of the body-scrollbar symptom.
- `.chart-canvas` gets `min-width: 0` and constrains every inner `figure { max-width: 100% }` + `svg { max-width: 100%; height: auto }`. The SVG keeps its viewBox aspect ratio while scaling down to the actual column width.

`apps/portal-admin/src/app/pages/audit/audit.scss`:

- `.chart-tile { min-width: 0; }` — grid items default to `min-width: auto`, which prevents shrinking below the intrinsic content width. Without it, Plot's fixed-width SVG could still push the grid column past `1fr` even with the lib-side fixes.

## Notes for the reviewer

- **Why a hardcoded hex (`#1d4ed8`) for `DEFAULT_BAR_FILL` rather than a brand token?** The chart lib is intentionally decoupled from `libs/shared/tokens` — it has no SCSS/CSS-vars dependency and Plot writes the fill as an SVG attribute, not a CSS value, so a CSS variable wouldn't apply anyway. The hex stays inside the lib, marked as the canonical default, with the docstring promising AA contrast + colour-blind safety. If the brand wants to take it over later, swap the constant in one place.
- **`semanticStatusColors` is a curated map, not an open extension point.** Five entries (`success / warning / error / info / neutral`) covers the audit module and any future status-bearing donut (user list, integrations health, etc.). Adding a sixth entry needs a small PR + an a11y-contrast check, which is the right friction.
- **The `aria-label="bar"` selector in the new bar-chart spec.** Plot tags its bar-mark `<g>` with `aria-label="bar"` (and similarly `"rule"` for `Plot.ruleY`). Selecting on it scopes the fill-uniqueness check to actual bars, not axes / ticks / labels. If Plot changes that label upstream the spec breaks loudly — preferable to a fragile geometric selector.
- **Lazy fetch policy and the empty-state edge case** from #174 are unchanged. The donut still receives `colorMap` even when `data` is empty; the lib's `arcs.forEach` short-circuits on zero arcs so no colour lookup happens.
- **`audit.scss` is now 7.5 KB**, still over the 6 KB component-style warning (untouched from #174) and well under the 8 KB error. The new `.chart-tile { min-width: 0 }` rule is two lines.

## Test plan

- [x] `pnpm nx test shared-charts` — **15 specs pass** (was 14: +1 donut `colorMap` test, +1 bar single-fill test, -1 obsolete bar palette assumption).
- [x] `pnpm nx test portal-admin` — **62 specs pass** (unchanged from #174 — semantic mapping is a template-only change, behavioural tests still hold).
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean. Same three pre-existing lint warnings, no new ones; bundle sizes unchanged within ±0.1 KB.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`:
  - Switch to **Charts** tab — daily-volume bars all render the same blue.
  - Donut centre matches `s.total`, the green slice is `success`, the orange slice is `denied`, the red slice is `failure`. Hover each slice — `<title>` shows `<category>: <count>` (untouched a11y contract from the lib).
  - Body scrollbar stays absent; footer stays anchored at the viewport bottom with no white-space gap.
  - Resize the window through the `(max-width: 800px)` breakpoint — grid collapses to one column, charts re-flow without horizontal overflow.
  - Dark mode toggle — colours stay readable, no contrast regressions.

## What's next

Nothing in this chantier. The audit dashboard is now visually coherent and layout-stable. Two background items to track separately when the CMS / user-list pages land their own dashboards:

- Tab-state URL persistence (carried forward from #174's "what's next").
- Promote the WAI-ARIA tab pattern out of `audit.html` into `libs/shared/ui/tabs/` once a second consumer appears.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #175
2026-05-17 01:17:33 +02:00
julien 9f5106b805 feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption (#174)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m26s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 3m57s
CI / check (push) Successful in 1m24s
## Summary

PR 2 of the tabs + full-result-charts chantier — closes the loop opened in #173. The audit log page now splits into a **Table** tab (existing behaviour) and a **Charts** tab fed by the server-side stats endpoint shipped in PR 1.

```
PR 1 (#173, merged) — BFF GET /api/admin/audit/stats + Redis 5min cache
                      + admin.audit.stats.query audit event + ADR-0013 amendment.
PR 2 (this one)     — SPA Tabs UX (Table / Charts) + consume the stats endpoint,
                      replacing the per-page client-side aggregations from #172.
```

## What lands

### Tabs UX — WAI-ARIA tab pattern

The two tabs sit between the filter card and the content area. Visual treatment is intentionally minimal: thin brand-coloured underline on the active tab, focus rings on `:focus-visible`, no surrounding chrome. The panel below inherits the page surface so each tab swap reads as a content-only change.

ARIA wiring:

- `<div role="tablist">` with two `<button role="tab">` children.
- `aria-selected` mirrors the active tab; `aria-controls` points each tab at its panel id; roving `tabindex` (active = `0`, inactive = `-1`) keeps Tab linear.
- Arrow-key navigation between tabs is bound on the individual buttons (not the tablist div) — focusable elements only, satisfies the `template/click-events-have-key-events` lint without an artificial `tabindex="-1"` on the container.
- Two `<section role="tabpanel">` with `[hidden]` binding, `aria-labelledby` pointing back at the tab id.

### Stats consumption — replaces the per-page computeds

`AuditEventsService` grows a `stats(filters)` method that calls `GET /api/admin/audit/stats` and returns `AdminAuditStats` (mirror of the BFF DTO). The audit page replaces the four per-page `computed()`s — `totalOnPage`, `dailyVolume`, `outcomeBreakdown`, `dailyByEventType` — with four signals:

```ts
readonly stats        = signal<AdminAuditStats | null>(null);
readonly statsLoading = signal(false);
readonly statsError   = signal<string | null>(null);
readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
```

The chart-tile components on the Charts panel consume `stats()?.dailyVolume`, `stats()?.outcomeBreakdown`, `stats()?.eventTypeByDay`, and `stats()?.total` unchanged — same shape as the old per-page projections, just sourced server-side.

### Lazy fetch policy

| Interaction              | Action on `stats`                              |
| ------------------------ | ---------------------------------------------- |
| Page load (Table active) | No call — default tab is Table, stats untouched |
| Click Charts (first time) | Fetch                                          |
| Click Table → Charts     | Fetch only if cleared by a filter change       |
| Apply / clear filters    | Clear `stats`, re-fetch **only if Charts active** |
| Filter by row's actor    | Clear `stats`, re-fetch if Charts active       |
| Next / previous page     | Do **not** invalidate stats (pagination is presentation, the aggregated set is unchanged) |

The Redis cache in PR 1 absorbs repeated identical fetches at ~5 ms; the policy here just makes sure we don't fire a stats call when nobody's looking at it.

### Honest panel copy

The Charts panel's note now reads:

> Aggregations are computed across the full filtered set (server-side), not just the events on the current page. Results are cached for 5 minutes per filter combination.

Replaces the previous "this only reflects the current page" disclaimer that #172 carried as a temporary truth.

## Notes for the reviewer

- **Why a hand-coded tablist instead of a shared `libs/shared/ui/tabs` primitive?** Two tabs, one consumer, ~30 LOC of template + ~25 LOC of SCSS. The two-occurrences-is-duplication rule says wait until a second consumer appears (cf. CMS module or user-list module slated for the next chantier), then extract with the shape both consumers have actually demanded.
- **Why is `setTab()` `async`?** It triggers `fetchStats()` on the first switch to Charts, and the spec exercises that flow with `await`. Keeping it `async` lets the test sequence assertions deterministically without `tick()`/fakeAsync.
- **Why does `search()` clear `stats` *before* the fetch even though `fetchStats()` clears it again at the top?** So the empty-state never flickers through a "stale 1000-event" total while the new fetch is in flight on a slow link. The double-clear is intentional.
- **Bundle impact.** The audit chunk grew from ~82 KB to ~84.5 KB gzip (two new signals, a stats-mapping HTTP call, the tablist template + SCSS). Well under the lazy-chunk 100 KB ceiling from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
- **`audit.scss` is 7.5 KB** — over the 6 KB component-style warning, under the 8 KB error. The new `.tablist` / `.tab` rules account for the bump. If a third consumer of the tab pattern lands, the SCSS extraction comes with it.

## Test plan

- [x] `pnpm nx test portal-admin` — **62 specs pass** (was 58: removed 3 obsolete "per-page chart" cases, added 7 new tabs/stats cases).
- [x] `pnpm nx run portal-admin:lint` — clean (the two pre-existing `use-lifecycle-interface` warnings on `audit.ts` / `users.ts` and the spec non-null assertion are untouched, not regressions).
- [x] `pnpm nx build portal-admin` — clean, audit chunk 84.5 KB gzip.
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`:
  - Page loads on the Table tab — DevTools Network shows `GET /api/admin/audit` only, **no** `/audit/stats` call.
  - Click **Charts** → spinner state, then three chart tiles render with server-side totals.
  - Apply a filter (e.g. `eventType=auth.sign_in`) while on Charts → tiles re-render with the filtered aggregates; donut centre matches the server `total`.
  - Switch back to Table, page through results → no new `/audit/stats` calls (pagination doesn't invalidate).
  - Arrow-Right / Arrow-Left between the two tabs with keyboard — focus moves, ARIA selection follows, `Tab` skips past the inactive tab to the panel content.
  - Toggle dark mode → tablist + active underline + tab focus rings all read correctly in both modes.

## What's next

- Persist the active tab in the URL (`?tab=charts`) so deep-linking and Back/Forward survive the swap. Out of scope for this chantier — once the user-list module lands and we have a second tab consumer, persistence becomes a shared concern.
- Extract `libs/shared/ui/tabs` when a second consumer materialises (CMS or user-list module).
- Surface cache observability — the stats endpoint Redis cache is silent in v1. A future ADR-0024 follow-up could add a `X-Audit-Stats-Cache: hit|miss` header for ops or an OTel attribute on the BFF span. Out of scope here, but worth flagging while the design is fresh.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #174
2026-05-17 00:16:47 +02:00
julien 2cdeb74341 feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache (#173)
CI / check (push) Successful in 3m26s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m47s
CI / a11y (push) Successful in 4m1s
CI / perf (push) Successful in 7m30s
Docs site / build (push) Successful in 6m2s
## Summary

PR 1 of the tabs + full-result-charts chantier. New BFF endpoint `GET /api/admin/audit/stats` that computes the three chart aggregations server-side over the **full filtered set** (not the paginated slice the SPA currently feeds the charts with).

```
PR 1 (this one) — BFF endpoint + Redis cache + audit event + ADR-0013 amendment.
PR 2            — SPA: Tabs UX (Table / Charts) + replace the per-page computeds
                  with calls to this endpoint.
```

## What lands

### New route — `GET /api/admin/audit/stats`

```ts
GET /api/admin/audit/stats?eventType=...&audience=...&outcome=...
                          &subjectPrefix=...&createdAtFrom=...&createdAtTo=...
                          &actorIdHash=...
→ {
    dailyVolume:       [{ day: 'YYYY-MM-DD', count }],
    outcomeBreakdown:  [{ outcome, count }],
    eventTypeByDay:    [{ day, eventType, count }],
    total              // sum of dailyVolume.count, drives the donut centre
  }
```

Same filter shape as the existing `GET /api/admin/audit` minus pagination — the stats endpoint always aggregates the whole filtered set. `@RequireAdmin` gated (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)). Time bound respects the filters strictly per the chantier brief: no filter → aggregates across the full audit retention (365 days per [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)). The Redis cache below absorbs repeated heavy queries.

### New service — [`AuditStatsReader`](apps/portal-bff/src/admin/audit-stats.service.ts)

Mirrors `AuditReader`'s posture:

- Every query inside a transaction that opens with `SET LOCAL ROLE audit_reader`. SELECT-only on `audit.events` even if the BFF's connection is otherwise privileged.
- Parameterised SQL only. Filter values flow through positional parameters, never concatenated.
- Three `GROUP BY` queries scoped by the same `WHERE` clause:
  - `date_trunc('day', created_at)::date AS day, COUNT(*) GROUP BY day`
  - `outcome::text, COUNT(*) GROUP BY outcome`
  - `date_trunc('day', created_at)::date AS day, event_type, COUNT(*) GROUP BY day, event_type`

### Redis cache — 5-minute TTL per filter-hash

- Cache key: `audit:stats:<sha256(canonical-JSON of filters), 16 hex chars>`. Sorted-keys canonicalisation so the same filters in different argument orders map to the same key.
- TTL: 300 s. Audit rows are append-only so past aggregations are stable; new events are continuously inserted, so admins see at most 5-minute-stale aggregations — acceptable for "approximate dashboard" usage, not for "did the last event just land" debugging (use the list endpoint for that).
- Cache writes are best-effort — a Redis-write failure does not fail the response. The DB read already happened; the next call rebuilds the cache.
- The cache *write* path is covered by spec; the cache-hit shortcut path is covered too (skips the DB transaction entirely).

### New audit event — `admin.audit.stats.query`

Mirrors `admin.audit.query` in posture (every admin read is auditable per ADR-0020 §"Read actions ... to deter fishing expeditions") with two differences:

- Distinct `event_type` so an auditor can spot "scanned aggregations" vs "paged through rows" — different observation signals (the stats endpoint can sweep millions of rows in one call; the list endpoint is bounded by `MAX_LIMIT=200`).
- Payload carries `total` (size of the aggregated set) instead of `resultCount` — stats responses don't paginate, the value carries more "size of scan" signal.

### Light amendment — [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)

Two additions:

- New **"Reader endpoints"** subsection that enumerates the two-endpoint reader surface (list + stats), documents the Redis-cache caveat, and points at the new `admin.audit.stats.query` event family.
- The "events emitted in v1" table grows four rows it was previously missing on `main`: `admin.access_denied`, `admin.audit.query`, `admin.audit.stats.query`, `admin.users.query`.

No supersession, no new ADR. The decision shape (server-side aggregation + Redis cache + new audit event family) was settled in chat via `AskUserQuestion` before the implementation started; recording it here keeps the ADR honest without spawning a full ADR-0024 for what's essentially an extension of ADR-0013's reader surface.

## Notes for the reviewer

- **Why not factor `buildWhere` into a shared helper between `AuditReader` and `AuditStatsReader`?** Considered. The two readers' shapes diverge in non-trivial ways: `AuditReader` adds `LIMIT/OFFSET` parameters appended to the same parameter array, `AuditStatsReader` runs three queries that all share the same `WHERE` (no further params). A shared helper would have to either expose both shapes or hand back the raw clauses + params for callers to assemble — at which point the abstraction earns its weight back. Two ~50 LOC copies today, extraction when a third reader lands or when the shape diverges further.
- **Why not cap the time window when no filter is provided?** Honest disclosure beats clever defaults. The list endpoint also returns "everything matching the filters" with no protective cap; the stats endpoint follows the same posture. The Redis cache absorbs the cost when the same heavy query lands repeatedly; an admin running unfiltered queries at high rate will see flat latency after the first call. If we later observe a real perf issue, a `windowDays` parameter is a smaller change than retrofitting one across the API.
- **Why a `text` cast on `outcome` in the SQL?** Prisma's Postgres enum types come back as JS strings already, but the `outcome` column carries a Postgres enum (`audit.AuditOutcome`). The explicit `::text` is defensive — `$queryRawUnsafe`'s typing isn't enum-aware, and the cast keeps the projection unambiguous regardless of the driver's row-shape inference.
- **Why does the date round-trip through `Date.toISOString().slice(0, 10)`?** `date_trunc('day', ...)::date` returns a Postgres `date` that node-postgres surfaces as a JS `Date` at UTC midnight. The default `toJSON` serialises the full ISO timestamp with the timezone offset — which is not what the chart x-axis wants. Slicing to `YYYY-MM-DD` matches the SPA's chart bucket convention exactly.
- **No mention of the `actorIdHash` audit row for the stats endpoint?** It's the same hash flow as `adminAuditQuery` — the `actor.oid` from the session goes through `HashUserIdService` per ADR-0012's salt-based pseudonymisation. The same flow is exercised by the existing `adminAuditQuery` tests; the new `adminAuditStatsQuery` method just routes to `recordEvent` with a different `eventType`.

## Test plan

- [x] `pnpm nx test portal-bff` — **414 specs pass** (was 401; +13 new: 8 `AuditStatsReader` service + 5 controller `stats` endpoint).
- [x] `pnpm nx run portal-bff:lint` — clean.
- [x] `pnpm nx build portal-bff` — clean (webpack).
- [ ] **Manual smoke** — `pnpm nx serve portal-bff`, sign in to portal-admin with `Portal.Admin`:
  - `curl http://localhost:3000/api/admin/audit/stats --cookie-jar /tmp/admin` returns the three projections.
  - Verify the `admin.audit.stats.query` row in `audit.events` after the call (`SELECT * FROM audit.events WHERE event_type = 'admin.audit.stats.query' ORDER BY occurred_at DESC LIMIT 1`).
  - Hit the endpoint twice in quick succession with the same filters → second call shows < 5 ms latency (cache hit, no DB transaction).
  - Hit it with different filters → first call hits DB, second cache, third with same filters → cache hit.
  - Stop Redis (`./infra/local/dev.sh stop redis`), hit the endpoint → still succeeds (cache miss + write swallowed), comes back live from DB.

## What's next

PR 2 — SPA Tabs UX (Table / Charts) + replace `dailyVolume() / outcomeBreakdown() / dailyByEventType()` (currently computed from `page().items`) with calls to this endpoint. The three computeds become signals filled by the HTTP call; the chart components on the Charts tab consume them unchanged.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #173
2026-05-16 23:11:52 +02:00
julien 209f44d667 feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table (#172)
CI / check (push) Successful in 4m13s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m26s
CI / a11y (push) Successful in 1m48s
CI / perf (push) Successful in 3m11s
## Summary

PR 3 (final) of the charts chantier — closes the loop on [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) by wiring the three starter components shipped in #171 onto the `/audit` page.

```
PR 1  — ADR-0023 (decision + a11y contract + bundle plan).
PR 2  — libs/shared/charts/ foundations + bar / donut / stacked-bar.
PR 3 (this one) — /audit page integration: three charts above the existing table.
```

## What lands

### Three computed aggregations on `AuditPage`

```ts
dailyVolume()       // (day: 'YYYY-MM-DD', count: number)[]
outcomeBreakdown()  // (outcome: string, count: number)[]
dailyByEventType()  // (day, eventType, count)[]   — flat, the chart pre-pivots
totalOnPage()       // number for the donut centre label
hasChartData()      // gate the section out when the page is empty
```

All four derive from the **current page only** (`page()?.items`). No new BFF endpoint — server-side aggregations across the full filter set would need a `/api/admin/audit/stats` resource that's worth its own ADR + chantier when the use case appears.

### Section markup — [`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html)

A new `<section class="charts">` between the status bar and the existing table, gated on `hasChartData()`:

```
[At a glance — current page]
[charts-note: "Aggregations are computed from the events currently loaded on this page only…"]

┌─ Events per day (bar) ─┬─ Outcome breakdown (donut) ─┐
├─ Events per day, by event type (stacked bar) — wide ┤
└──────────────────────────────────────────────────────┘
```

The stacked-bar tile gets `.chart-tile--wide` (spans both grid columns) since its legend benefits from the horizontal space. The two-column grid collapses to one column under 800 px viewports.

### SCSS — [`audit.scss`](apps/portal-admin/src/app/pages/audit/audit.scss)

`.charts` shares the same surface tokens as `.filters` + `.table-wrap` (white / gray-800 background, 1 px border, 0.5 rem radius) so the page reads as one stack of related blocks. `.charts-grid` is `display: grid; grid-template-columns: repeat(2, 1fr)` with a `@media (max-width: 800px)` fallback to single-column.

### Project budget — [`apps/portal-admin/project.json`](apps/portal-admin/project.json)

`anyComponentStyle` budget bumped from 5/6 KB warn/error to **6/8 KB**. `audit.scss` lands at ~6.5 KB after the charts grid additions, comfortably under the new ceiling. The old 5/6 KB threshold predated the charts row; this is a one-time accommodation, not a global relaxation.

### Tsconfig wiring — [`apps/portal-admin/tsconfig.app.json`](apps/portal-admin/tsconfig.app.json)

`nx sync` added the new project reference to `libs/shared/charts/tsconfig.lib.json` after the `import { BarChart, … } from 'shared-charts'` in `audit.ts`. Standard plumbing.

### Spec — [`audit.spec.ts`](apps/portal-admin/src/app/pages/audit/audit.spec.ts)

Three new assertions under a `charts` describe block:

1. The three `<lib-*-chart>` elements render when the page has data.
2. `<section class="charts">` is **absent** when the page is empty (no half-rendered donut on `{ total: 0, items: [] }`).
3. The donut's `.donut-center-label` reads the page's item count — verifies the `[centerLabel]` binding wired correctly.

## Notes for the reviewer

- **Why charts only on the current page, not on the full result set?** Per the "Don't add features beyond what the task requires" rule from CLAUDE.md. The user's brief was "tester les composants sur la page Audit log", not "design a full analytics dashboard". The `.charts-note` paragraph explicitly says "computed from the events currently loaded on this page only" so the limitation is **disclosed**, not hidden. A future server-side `/api/admin/audit/stats` (with `audit_reader`-scoped queries + caching) is the natural follow-up if real investigative use exposes the per-page scope as a friction point.
- **Why `<section>` with its own `<h2>` rather than just inline tiles?** A11y. Per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md), document structure is a first-class concern. The charts are a meaningful sub-region of the page; landmark + heading help screen-reader users navigate.
- **Why no i18n marks on the captions?** Portal-admin chrome stays in source locale per ADR-0020. The audit page has zero i18n markers today (filter labels, table headers, error messages are all plain English); the chart captions land in the same posture for consistency. If portal-admin grows i18n later, the captions get marked in the same sweep as the rest of the page.
- **Bundle impact reality-check**: the chart-bearing lazy `audit` chunk grew from ~4.4 KB gzip to **84 KB gzip** with the chart deps loaded. ADR-0023 estimated ~65 KB; the actual is higher because Plot pulls in more `d3-scale-chromatic` interpolators than I projected. Still well under the 100 KB cap from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md), but worth noting if a fourth chart type with its own d3 submodule lands (line / scatter / heatmap will push it further).
- **No anonymous-state regression on the table**: the existing trace-link + actor-pivot behaviours from #163 are unaffected — the charts section sits between the status bar and the table, doesn't share any markup.

## Test plan

- [x] `pnpm nx test portal-admin` — **57 specs pass** (was 54; +3 for the new charts assertions).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-charts` — 9/9 tasks green.
- [x] Audit lazy chunk: **84 KB gzip** (was 4.4 KB before the chart deps loaded); under the 100 KB lazy-chunk budget.
- [ ] **Manual visual smoke** — sign in to portal-admin with `Portal.Admin` → navigate `/audit`:
  - The three charts render above the filter form, in a 2-column grid (stacked-bar spanning both).
  - The donut centre label reads the current page's event count.
  - Apply a filter that narrows the page to ~3 events → charts re-render with the narrowed dataset.
  - Click the `<details>` "Data table" disclosure under each chart → the tabular fallback expands with the exact rows.
  - Toggle dark mode in the footer → axis text + chart envelope swap; the donut's Cividis-equivalent palette kicks in on the categorical chart too (palette already swapped by `resolveTheme()` in the lib).
  - Filter to an empty result set → the `.charts` section disappears, the "No audit events match the current filters" empty-state stays.

## What's next

Chantier closed. Three light follow-ups stay open but optional:

- **Server-side aggregations** if the per-page scope becomes a real friction point. New BFF endpoint + likely a fourth `time-range` selector on the SPA.
- **More chart types** (line / scatter / heatmap) when business modules ask for them. The lib's `_internal/` carries the a11y plumbing already; each new component is the ~50 LOC Plot wrapper + spec.
- **Promote shared chart styling** to a fourth shared concern in `libs/shared/ui/` if a third app ever joins. Not urgent.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #172
2026-05-16 22:18:22 +02:00
julien eb8b65c7bc feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m30s
CI / scan (push) Successful in 2m44s
CI / a11y (push) Successful in 2m37s
CI / perf (push) Successful in 4m21s
Docs site / build (push) Successful in 3m1s
## Summary

Implementation of [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) — the foundations of the workspace's chart library. PR 2 of the chantier:

| PR | Périmètre |
| --- | --- |
| PR 1  | ADR-0023 — decision + a11y contract + bundle plan. |
| **PR 2 (this one)** | `libs/shared/charts/` foundations + `<lib-bar-chart>`, `<lib-donut-chart>`, `<lib-stacked-bar-chart>`. |
| PR 3 | Integration on the `/audit` page — daily-volume bar + outcome-breakdown donut + event-type-over-time stacked bar. |

## What lands

### Workspace deps

```
d3                       — top-level toolkit, types via @types/d3
d3-shape                 — used directly by <lib-donut-chart>
d3-scale-chromatic       — colour-blind-safe palette source
@observablehq/plot       — declarative layer over D3, used by bar + stacked-bar
```

All four (+ matching `@types/*`) land in the workspace root `devDependencies`. Tree-shaken at build time per ADR-0023's bundle plan.

### New lib `libs/shared/charts/`

```
libs/shared/charts/src/lib/
├── _internal/                   ← single source of truth for the a11y contract
│   ├── a11y.ts                  ← chartId, findChartSvg, injectSvgTitleDesc, prefersReducedMotion, resolveTheme
│   ├── chart-envelope.scss      ← shared figure / caption / fallback / dark-mode rules
│   ├── chart-types.ts           ← `ChartBaseInputs<T>` extended by each component
│   └── palette.ts               ← Viridis / Cividis (sequential) + ColorBrewer Set2 (categorical)
├── bar-chart/                   ← Plot.barY
├── donut-chart/                 ← raw d3-shape (pie + arc); Plot has no donut mark
└── stacked-bar-chart/           ← Plot.barY with `fill: <seriesKey>` (auto-stacked, legend on)
```

### A11y contract baked in for v1

Per ADR-0023's six commitments, every chart component produces (and unit-tests for):

1. `<figure role="img" aria-labelledby aria-describedby>` wrapping the SVG.
2. SVG `<title>` + `<desc>` as the **first two children** — injected post-render via `injectSvgTitleDesc` because Plot doesn't emit them itself. `findChartSvg` handles both Plot output shapes (bare SVG, or `<figure>` wrapping a legend + SVG for `legend: true` configs).
3. A `<details>` disclosure with a `<table>` rendering every data point — the keyboard-navigable / screen-reader-friendly fallback for non-visual users.
4. Palette from `_internal/palette.ts` only — Viridis / Cividis for sequential, ColorBrewer Set2 for categorical. Both colour-blind-safe.
5. AA-contrast axis text via `:where(.dark)` flips in `_internal/chart-envelope.scss`.
6. `prefers-reduced-motion` → `data-no-transitions` marker on the SVG, CSS strips animations + transitions.

A custom ESLint rule in [`libs/shared/charts/eslint.config.mjs`](libs/shared/charts/eslint.config.mjs) bans direct imports of `d3-scale-chromatic` outside `_internal/palette.ts` so a future contributor can't bypass the colour-blind-safe contract.

### Component contract

Every `<lib-*-chart>` exposes the same Signal-based shape per ADR-0023:

```ts
[data]: readonly T[];
[caption]: string;
[description]: string;
[ariaLabel]: string;
[colorScheme]?: 'sequential' | 'categorical';
// + chart-specific keys (xKey, yKey, categoryKey, valueKey, seriesKey, …)
```

Re-renders triggered by Angular's `effect()` on input changes; the previous SVG is `replaceChildren`-d out so there's no DOM accumulation across data updates.

## Notes for the reviewer

- **Why three SCSS files importing one shared envelope?** Extracted at the third consumer per CLAUDE.md's "three similar lines is better than a premature abstraction" rule — bar + donut + stacked-bar share ~70 LOC of figure / caption / fallback / dark-mode chrome. `_internal/chart-envelope.scss` is the consolidation; each chart's `.scss` is now 4-20 LOC of chart-specific tweaks.
- **Why is `<lib-donut-chart>` raw D3 rather than Plot?** Plot's design philosophy explicitly excludes pie/donut marks ("a bar chart is almost always more legible"). The audit-log outcome breakdown reads naturally as a donut (the centre carries the total). Raw `d3-shape` is the lower-level fallback ADR-0023 reserves precisely for this kind of case; the component's API is identical to the Plot-backed siblings.
- **Why does the donut also stamp per-slice `<title>`?** Belt-and-suspenders. The top-level SVG `<title>` reads the caption; per-slice `<title>` reads "category: value" on hover (the SVG-native tooltip convention) for keyboard / screen-reader users who land on a specific slice.
- **Why no `pnpm.overrides` adjustment for `d3-*` transitives?** None of the new deps brought a vulnerability in this install. The `pnpm audit --audit-level=moderate` gate from #161 stays green.
- **What's deliberately deferred to PR 3?** The `/audit`-page integration — data aggregation from the current page (`AdminAuditPage` rows already loaded), the actual `<lib-*-chart>` placements above the existing table, the i18n strings for the captions / descriptions / aria-labels. No mock SAMPLE data shipped in this PR — every test uses local fixtures so the lib stays decoupled from any specific consumer.

## Test plan

- [x] `pnpm nx test shared-charts` — **13 specs pass** across the three components.
- [x] `pnpm nx lint shared-charts` — clean, including the custom `no-restricted-imports` guard on `d3-scale-chromatic`.
- [x] `pnpm nx build shared-charts` — clean (TS strict + ng-packagr).
- [x] `pnpm nx run-many -t lint test build --projects=shared-charts,portal-shell,portal-admin` — 12/12 tasks green.
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build clean. The lib ships no i18n marks (axis labels are caller-supplied per ADR-0023's i18n posture), so no xlf entry change here.
- [ ] **Visual smoke (deferred to PR 3 when the components are placed on `/audit`)** — `<figure>` + caption visible, SVG `<title>` exposed by VoiceOver / NVDA, `<details>` fallback expands to a table, dark-mode toggle flips axis text + Cividis palette, `prefers-reduced-motion` strips Plot's fade-in.

## What's next

PR 3 wires the three components onto the `/audit` page: aggregates `AdminAuditPage.items` client-side into the three required shapes (daily totals, outcome counts, daily-by-event-type pivots), places them above the existing filter form, ships the i18n strings for the captions and descriptions in `messages.fr.xlf`. Bundle impact on the lazy `audit` chunk gets verified there (the ~65 KB gzip plan from the ADR).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #171
2026-05-16 21:56:28 +02:00
julien 7ee7b2dadf docs(adr-0023): charts and dashboards — d3 + observable plot (#170)
CI / check (push) Successful in 1m17s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 2m33s
CI / perf (push) Successful in 6m7s
Docs site / build (push) Successful in 3m37s
## Summary

Records the decision to use **D3 + Observable Plot**, wrapped in a new `libs/shared/charts/`, as the chart toolkit shared by `portal-shell` and `portal-admin`. ADR-only — implementation lands as the next chantier(s).

This is staged as a 3-PR chantier per the agreed plan:

| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | ADR-0023 — decision + a11y contract + bundle plan. |
| PR 2 | `libs/shared/charts/` foundations + 3 starter components (`<lib-bar-chart>`, `<lib-donut-chart>`, `<lib-stacked-bar-chart>`). |
| PR 3 | Integration on the `/audit` page — daily-volume bar + outcome-breakdown donut + event-type-over-time stacked bar. |

## What lands

### [`docs/decisions/0023-charts-d3-observable-plot.md`](docs/decisions/0023-charts-d3-observable-plot.md)

Full MADR 4.0.0 record. Highlights:

- **Choice**: D3 + Observable Plot, both from Mike Bostock / Observable Inc., both MIT, both past 1.0. Plot covers ~80 % of standard charts in declarative one-liners; D3 stays the escape hatch for bespoke viz (heatmap, sankey, …) inside the same lib.
- **Why not D3 alone**: ~250 LOC per chart × 4-5 types × a11y discipline = sustained code investment before the first dashboard ships.
- **Why not ECharts / Chart.js**: 600 KB minified + canvas-rendered + an `aria` plugin afterthought (ECharts), or narrower vocabulary + brittle dark-mode (Chart.js). Both furthest from the Angular-Signals-zoneless idiom the rest of the workspace runs on.
- **A11y contract** is baked into `_internal/` (palette, tabular fallback, SVG `<title>` / `<desc>` builders) so every chart inherits WCAG 2.2 AA + AAA-targeted compliance from the lib, not from contributor discipline. Six commitments, each unit-tested per chart component.
- **Bundle plan**: ~65 KB gzip added to a chart-bearing lazy chunk (d3 modules tree-shaken + Plot + thin wrapper) — well under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s 100 KB cap.
- **Component contract**: every `<lib-*-chart>` exposes the same Signal-based input shape (`[data]`, `[caption]`, `[description]`, `[ariaLabel]`, `[colorScheme]`) regardless of whether Plot or raw D3 powers the rendering.

### [`docs/decisions/README.md`](docs/decisions/README.md)

ADR-0023 added to the index table.

### [`CLAUDE.md`](CLAUDE.md)

- "Architecture (recorded in ADRs)" gains a "Charts + dashboards" bullet describing the lib + a11y baseline + bundle posture.
- "Repository status" bumps the ADR range to `0001 → 0023`.
- "Still on the roadmap" gains the charts implementation entry pointing at this ADR.

## Notes for the reviewer

- **Why honour the user's D3 preference rather than recommend pure ECharts?** D3 (and by extension Plot) is the closest match to the project's tech bar ("stable, recognized, battle-tested") for data-viz on the web; it's also the user's stated preference, and Plot's higher-level layer eliminates the "250 LOC per chart" cost that would otherwise push us toward an alternative. The ADR explicitly walks through ECharts + Chart.js as runners-up so future challengers see the trade-offs we chose against.
- **Why a single shared lib rather than per-app charts?** Both SPAs (portal-shell + portal-admin) will host dashboards. The chart vocabulary, a11y contract, palette, and theme integration are identical between the two — duplicating into app-local code would invite drift. The lib stays at `libs/shared/charts/` next to `libs/shared/ui/`.
- **Why the `_internal/` folder for cross-cutting code?** Single source of truth for the colour palette and the a11y plumbing. A lint rule (added in PR 2) will ban consumers from importing `d3-scale-chromatic` directly so the colour-blind-safe palette stays the only path.
- **Why no ADR amendment to ADR-0016 / ADR-0017?** Both are binding constraints, not superseded. The new ADR operationalises both for the chart surface; cross-references in the "Related ADRs" section make that explicit.

## Test plan

- [x] ADR validates as MADR 4.0.0 (frontmatter, section order, tag vocabulary).
- [x] No code touched — lint / test / build matrix unaffected.
- [x] `docs/decisions/README.md` index updated in the same change per the [ADR conventions](docs/decisions/README.md#conventions).
- [ ] Review for trade-off accuracy: are the bundle estimates fair? Is the "Plot covers ~80 % of standard charts" framing defensible against the user's mental model of D3?
- [ ] Implementation chantier (PR 2) lands directly behind this if accepted: `pnpm add -w d3 @observablehq/plot @types/d3`, `libs/shared/charts/` scaffold via `pnpm nx g @nx/angular:library --name=shared-charts --directory=libs/shared/charts --standalone=true --unitTestRunner=vitest-analog --tags="scope:shared,type:shared" --no-interactive`, then the 3 starter components.

## What's next

If accepted as-is, PR 2 (lib foundations + 3 starter components) follows. If a reviewer wants to push back on D3-vs-ECharts or on the a11y contract's strictness, this is the right PR to surface that — no implementation has started.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #170
2026-05-16 21:28:03 +02:00
APF Portal Bot 6c42d4c232 fix(deps): update nestjs to v11.1.21 (#169)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m32s
CI / a11y (push) Successful in 1m52s
CI / check (push) Successful in 4m27s
CI / perf (push) Successful in 5m25s
Docs site / build (push) Successful in 2m29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nestjs/common](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/common)) | dependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.19/11.1.21) |
| [@nestjs/core](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/core)) | dependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.19/11.1.21) |
| [@nestjs/platform-express](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express)) | dependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.19/11.1.21) |
| [@nestjs/testing](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/testing)) | devDependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2ftesting/11.1.19/11.1.21) |

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

### [`v11.1.21`](https://github.com/nestjs/nest/releases/tag/v11.1.21)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

##### v11.1.21 (2026-05-14)

##### Bug fixes

- `core`
  - [#&#8203;16948](https://github.com/nestjs/nest/pull/16948) fix(core): settle skipped provider initialization ([@&#8203;yudin-s](https://github.com/yudin-s))

##### Committers: 1

- Serge Yudin ([@&#8203;yudin-s](https://github.com/yudin-s))

### [`v11.1.20`](https://github.com/nestjs/nest/releases/tag/v11.1.20)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

##### v11.1.20 (2026-05-13)

##### Bug fixes

- `core`, `testing`
  - [#&#8203;16939](https://github.com/nestjs/nest/pull/16939) fix(core): fix deeply nested transient providers resolution ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))
- `core`
  - [#&#8203;16861](https://github.com/nestjs/nest/pull/16861) fix(core): fix [@&#8203;Sse](https://github.com/Sse) losing events on complete ([@&#8203;MatthiasBrehmer](https://github.com/MatthiasBrehmer))
  - [#&#8203;16753](https://github.com/nestjs/nest/pull/16753) fix(core): defer sse writehead until after lifecycle completes ([@&#8203;jkalberer](https://github.com/jkalberer))
  - [#&#8203;16782](https://github.com/nestjs/nest/pull/16782) fix(core): use strict null check for SSE message id ([@&#8203;burhanharoon](https://github.com/burhanharoon))
- `microservices`
  - [#&#8203;16850](https://github.com/nestjs/nest/pull/16850) fix(microservices): ServerRMQ crashes at boot when [@&#8203;MessagePattern](https://github.com/MessagePattern)(undefined) is combined with wildcards: true ([@&#8203;lavieennoir](https://github.com/lavieennoir))
- `common`
  - [#&#8203;16845](https://github.com/nestjs/nest/pull/16845) fix(common): accept zero timestamp in parse date pipe ([@&#8203;Mysh3ll](https://github.com/Mysh3ll))
- `platform-socket.io`
  - [#&#8203;16742](https://github.com/nestjs/nest/pull/16742) fix(socket.io): Deduplicate disconnect listener in bindMessageHandlers ([@&#8203;fru1tworld](https://github.com/fru1tworld))

##### Enhancements

- `microservices`
  - [#&#8203;16676](https://github.com/nestjs/nest/pull/16676) feat(microservices): add return buffers option for binary data ([@&#8203;Forceres](https://github.com/Forceres))
  - [#&#8203;16826](https://github.com/nestjs/nest/pull/16826) feat(microservices): handle rmq blocked/unblocked connection events ([@&#8203;thisalihassan](https://github.com/thisalihassan))
- `common`
  - [#&#8203;16902](https://github.com/nestjs/nest/pull/16902) fix(common): filetype validator buffer message ([@&#8203;QusaiAlbonni](https://github.com/QusaiAlbonni))
- `platform-express`
  - [#&#8203;16844](https://github.com/nestjs/nest/pull/16844) feat(platform-express): add defParamCharset to MulterOptions ([@&#8203;starnayuta](https://github.com/starnayuta))

##### Dependencies

- `platform-ws`
  - [#&#8203;16941](https://github.com/nestjs/nest/pull/16941) chore(deps): bump ws from 8.20.0 to 8.20.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))

##### Committers: 13

- Ali Hassan ([@&#8203;thisalihassan](https://github.com/thisalihassan))
- Burhan Haroon ([@&#8203;burhanharoon](https://github.com/burhanharoon))
- Dmytro Khyzhniak ([@&#8203;lavieennoir](https://github.com/lavieennoir))
- Harsh Rathod ([@&#8203;harshrathod50](https://github.com/harshrathod50))
- IlyaCredo ([@&#8203;Forceres](https://github.com/Forceres))
- Kamil Mysliwiec ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))
- Mysh3ll ([@&#8203;Mysh3ll](https://github.com/Mysh3ll))
- [@&#8203;MatthiasBrehmer](https://github.com/MatthiasBrehmer)
- [@&#8203;QusaiAlbonni](https://github.com/QusaiAlbonni)
- [@&#8203;jkalberer](https://github.com/jkalberer)
- [@&#8203;pazaderey](https://github.com/pazaderey)
- fru1tworld ([@&#8203;fru1tworld](https://github.com/fru1tworld))
- starnayuta ([@&#8203;starnayuta](https://github.com/starnayuta))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #169
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-16 20:36:45 +02:00
APF Portal Bot 1edf154b67 fix(deps): update dependency express-rate-limit to v8.5.2 (#168)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m15s
CI / a11y (push) Successful in 1m11s
CI / check (push) Successful in 3m46s
CI / perf (push) Successful in 4m40s
Docs site / build (push) Successful in 2m15s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | dependencies | patch | [`8.5.1` -> `8.5.2`](https://renovatebot.com/diffs/npm/express-rate-limit/8.5.1/8.5.2) |

---

### Release Notes

<details>
<summary>express-rate-limit/express-rate-limit (express-rate-limit)</summary>

### [`v8.5.2`](https://github.com/express-rate-limit/express-rate-limit/releases/tag/v8.5.2)

[Compare Source](https://github.com/express-rate-limit/express-rate-limit/compare/v8.5.1...v8.5.2)

You can view the changelog [here](https://express-rate-limit.mintlify.app/reference/changelog).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #168
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-16 17:32:44 +02:00
APF Portal Bot b61e550d59 chore(deps): update dependency lint-staged to v17.0.5 (#167)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m55s
CI / a11y (push) Successful in 1m42s
CI / check (push) Successful in 4m38s
CI / perf (push) Successful in 5m29s
Docs site / build (push) Successful in 2m32s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [lint-staged](https://github.com/lint-staged/lint-staged) | devDependencies | patch | [`17.0.4` -> `17.0.5`](https://renovatebot.com/diffs/npm/lint-staged/17.0.4/17.0.5) |

---

### Release Notes

<details>
<summary>lint-staged/lint-staged (lint-staged)</summary>

### [`v17.0.5`](https://github.com/lint-staged/lint-staged/blob/HEAD/CHANGELOG.md#1705)

[Compare Source](https://github.com/lint-staged/lint-staged/compare/v17.0.4...v17.0.5)

##### Patch Changes

- [#&#8203;1792](https://github.com/lint-staged/lint-staged/pull/1792) [`1f67271`](https://github.com/lint-staged/lint-staged/commit/1f672718b6fa67e0f00aafe107cb9f084f4d9102) - Correctly set the `--max-arg-length` default value based on the running platform. This controls how very long lists of staged files are split into multiple chunks.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #167
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-16 16:18:44 +02:00
julien 0435fec10a feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash (#166)
CI / check (push) Successful in 2m35s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m26s
CI / a11y (push) Successful in 1m49s
CI / perf (push) Successful in 3m48s
## Summary

Turns the audit-log table's `trace_id` and `actor_id_hash` columns from inert text into the two pivots an investigator actually needs:

- **trace_id** → Jaeger deep link (opens in a new tab). Closes the "join audit + traces by trace_id" loop from [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md) / [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) without any new BFF surface.
- **actor_id_hash** → click to refilter the table on that single actor. "Show me everything else this user did" stays in the page; no copy-paste loop.

## What lands

### Trace-id deep link to Jaeger

[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L162-L173) — the cell becomes an `<a target="_blank" rel="noopener noreferrer">` pointing at `${environment.jaegerBaseUrl}/trace/<traceId>`. Anonymous events (`traceId === null`) keep the dash placeholder.

[`environment.ts`](apps/portal-admin/src/environments/environment.ts) gains `jaegerBaseUrl`. Dev defaults to `http://localhost:16686` (matches the compose `observability` profile from `infra/local/dev.compose.yml`). Per-env replacement picks up whatever trace backend the future infrastructure ADR settles on — Tempo, Grafana Cloud, on-prem Jaeger; the SPA-side wiring doesn't care.

### Actor-pivot click

[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L146-L160) — non-null `actorIdHash` becomes a `<button>` styled to read inline like the hash text (`.actor-hash--clickable`: button reset + dotted-underline hover + brand-colored focus ring). Click → `filterByActor(hash)` sets the existing `actorIdHash` filter signal, resets offset to 0, and re-runs the query. Each pivot still emits its own `admin.audit.query` audit row server-side (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)) so the drill is itself auditable.

Anonymous rows keep the `(anonymous)` plain-text rendering — there's no useful filter value to pivot on.

## Why not inline-expand Pino log lines under the row

Considered, deferred. The BFF's Pino output goes to **stdout only** today; standing up a queryable log aggregator (Loki, OpenSearch, …) is a separate infrastructure chantier with its own ADR. The Jaeger jump-off carries ~99 % of the investigator's needs anyway — the trace already contains span attributes (`db.statement`, `http.status_code`, exception events) for the same request scope; Pino lines on top of that would be redundant for most investigations.

When the log aggregator does land, the inline-expand model can come back as a follow-up: `GET /api/admin/logs?traceId=<id>` + an expand affordance on the same row. The current Jaeger anchor and the future inline-logs would naturally coexist (different drills, both surfaced on the same `trace_id`).

## Notes for the reviewer

- **Why a `<button>` for the actor cell rather than an `<a>`?** The action is an in-page filter change, not a navigation. Buttons keep keyboard activation (Enter / Space), don't pollute browser history, and screen readers announce "Filter the table on hash(jane), button" rather than a misleading link role.
- **Why the dotted-underline hover for the actor, but solid-underline for trace?** Different affordances. The trace anchor is a permanent link to an external resource (Jaeger UI), so the solid underline matches the universal "link" convention. The actor button is an inline pivot that *mutates state* — the dotted underline + hover-fill conveys "this does something subtle within the page" without screaming "link".
- **CSS guardrails preserved**: focus rings on both elements, brand-color tokens (light + dark), tap targets meet the AAA 44×44 minimum (the button reset preserves the line-height + the `cell-actor` padding ≥ 12 px on each side).
- **No new i18n strings.** `title` attributes are hover hints, not screen-reader-essential — the underlying hash + traceId are the actual semantic content. The "(anonymous)" string and the dash placeholder were already in the template.
- **No BFF change.** This whole PR is SPA-side only. The audit endpoint already returns `traceId` and `actorIdHash` in every row.

## Test plan

- [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — green.
- [x] **54 portal-admin specs pass** (was 50; +4 for the four new behaviours).
- [x] Lazy `audit` chunk: 18.26 → ~18.5 KB raw / 4.44 KB gzip — comfortably under the per-chunk budget.
- [ ] **Manual smoke**:
  - Sign in to portal-admin with `Portal.Admin` → open `/audit`.
  - Click any trace_id → new tab opens at `http://localhost:16686/trace/<id>` (assuming `./infra/local/dev.sh up observability` is running so Jaeger is up).
  - Anonymous rows show `—` for trace, `(anonymous)` (plain text, not clickable) for actor.
  - Click any non-anonymous actor hash → the table refreshes filtered on that hash, the "Actor id hash" filter input above shows the same value, page jumps to offset 0.
  - Tab through a row: timestamp / event are plain text; outcome badge skipped (not interactive); actor button gets focus ring; trace link gets focus ring; payload `<details>` summary gets focus ring.

## Follow-ups (optional)

- When the log aggregator ADR lands, extend the trace cell to also offer an inline-expand of Pino lines for that trace. Jaeger anchor stays as the primary affordance.
- A similar treatment on the `/users` page (clicking a row's `oid` to "show me this user's audit trail") is the natural sibling. Defer until there's an investigator workflow that asks for it — premature otherwise.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #166
2026-05-16 03:36:40 +02:00
julien 6f26bcdd65 feat(shared-ui): move the role label from the portal-shell sidebar into the user menu (#165)
CI / check (push) Successful in 3m41s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m44s
CI / a11y (push) Successful in 1m38s
CI / perf (push) Successful in 3m8s
## 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
2026-05-16 03:09:56 +02:00
julien 10c80f189d feat(portal-shell): align theme switcher trigger with locale switcher shape (#164)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m50s
CI / check (push) Successful in 3m24s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 4m59s
## 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
2026-05-16 02:52:53 +02:00
julien bba1703622 fix(deps): scope the vite < 7 override to vitepress so @angular/build keeps vite 8 (#163)
CI / check (push) Successful in 3m15s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m25s
CI / a11y (push) Successful in 2m31s
Docs site / build (push) Successful in 4m2s
CI / perf (push) Successful in 5m0s
## Summary

Hotfix on the override added in [#161](#161). The unconditional `"vite": ">=6.4.2 <7"` was too broad — it downgraded **every** vite consumer in the workspace to vite 6.4.2, including `@angular/build@21.2.11`. Angular's dev-server plugin (`angular-memory-plugin.js > loadViteClientCode`) monkey-patches Vite's client error-overlay code; the patch targets vite 8.x's internal layout, fails against vite 6, and the home page blank-screens with:

```
[vite] Internal server error: Failed to update Vite client error overlay text.
  at loadViteClientCode (angular-memory-plugin.js:140:31)
```

…on the very first request to `pnpm nx serve portal-shell`.

## Fix

One-line change in [`package.json`](package.json) → `pnpm.overrides`:

```diff
- "vite":          ">=6.4.2 <7",
+ "vitepress>vite": ">=6.4.2 <7",
```

pnpm's `parent>child` selector syntax scopes the override to vitepress's transitive resolution only. Other vite consumers (Angular, Nx, Vitest, the analog plugin) follow their own peer constraints and resolve to vite 8.0.13 again.

## Resolution after the fix

| Consumer | vite version | Why |
| --- | --- | --- |
| `vitepress 1.6.4` | **6.4.2** | Override target — keeps VitePress 1.x off rolldown-vite |
| `@angular/build 21.2.11` | **8.0.13** | Restored; its dev-server plugin needs vite 8's client layout |
| `@nx/vite`, `@analogjs/vite-plugin-angular`, Vitest | **8.0.13** | Restored |
| `@vitejs/plugin-basic-ssl` (analog transitive) | 7.3.2 | Its own peer range; HTTPS-cert helper only, not the dev-server host. Doesn't affect us. |

## Why this regression didn't surface in #161's CI

The `docs-site.yml` workflow and `pnpm exec nx run-many -t lint test build` exercise the **production build** paths. Vite 6 ↔ Angular-build 21.2's monkey-patch incompatibility is a **dev-server-only** failure (the prod Rollup pipeline doesn't go through `loadViteClientCode`). CI couldn't have caught it; manual `nx serve portal-shell` is the only repro path. Lesson logged.

## Test plan

- [x] `pnpm install` — clean. Lockfile diff confirms: vite 6.4.2 only under vitepress, vite 8.0.13 restored everywhere else.
- [x] `pnpm audit --audit-level=moderate` — no known vulnerabilities.
- [x] `pnpm docs:build` — ~9.5 s, Mermaid still renders in ADR-0009's HTML (regression fence passes).
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — green.
- [x] **`pnpm nx serve portal-shell`** — boots cleanly, `curl http://localhost:4200/` returns HTTP 200 with the SPA shell, no `loadViteClientCode` error in the dev log. (This is the path that broke.)
- [ ] **Manual smoke (the user's repro)** — `pnpm nx serve portal-shell`, open the home page in a browser, confirm:
  - No "Failed to update Vite client error overlay text" in the browser DevTools console.
  - Page renders; navigating to `/accessibility`, `/profile` works.
  - Theme switcher in the footer toggles light / dark / system; locale switcher likewise.

## Notes for the reviewer

- **Lesson on override scoping**: the version-selector form (`"package@<vuln-range>": "patched"`) is the safest default — it only kicks in for the vulnerable range. The unconditional form (`"package": "<range>"`) is a sledgehammer and should be reserved for cases where the version-selector form genuinely can't express the intent (as was the case in #161 where the resolved version was already past the vulnerable range, so the selector was a no-op). When the unconditional form is needed, **always scope to a parent** (`"parent>package": …`) to avoid the workspace-wide blast radius.
- **No documentation change in this PR**: development.md's "Transitive vulnerabilities — `pnpm.overrides`" subsection (from #160) is still accurate. A follow-up edit could add a "scope to a parent when possible" sentence; not blocking.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #163
2026-05-16 02:07:43 +02:00
julien 076cfb67a6 feat(portal-shell): move theme switcher to footer, drop redundant settings icon (#162)
CI / check (push) Successful in 2m6s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m32s
CI / a11y (push) Successful in 1m14s
CI / perf (push) Successful in 3m9s
## 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
2026-05-16 01:14:36 +02:00
julien 2dbf2d8ce4 fix(docs): cap vite below 7 (rolldown-vite) and pin mermaid transitives (#161)
CI / check (push) Successful in 3m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m54s
CI / a11y (push) Successful in 3m7s
CI / perf (push) Successful in 4m54s
Docs site / build (push) Successful in 4m45s
## Summary

`pnpm docs:dev` failed after #159's transitive-vuln fix landed. Two distinct symptoms in the same log :

1. **VitePress refused to boot** — `VitePress v1 is not compatible with rolldown-vite. Use VitePress v2 instead.` The override added in #159 (`vite@<6.4.2 → >=6.4.2`) had no upper bound; pnpm resolved vitepress's `vite ^5.0.0` constraint up to **vite 7.3.2**, which uses the new rolldown bundler. VitePress 1.x explicitly rejects rolldown-vite.

2. **Resolution-warning flood** — Even on a fallback port, the console showed `Failed to resolve dependency: dayjs / debug / @braintree/sanitize-url / cytoscape / cytoscape-cose-bilkent` from `optimizeDeps.include`. The vitepress-plugin-mermaid wrapper injects those into the optimizer's include list, but under pnpm's strict isolation they aren't reachable from the workspace root (transitives of mermaid, never hoisted).

## What lands

### 1. Vite override is now an **unconditional** `>=6.4.2 <7` range

[`package.json`](package.json):

```diff
- "vite@<6.4.2": ">=6.4.2",
+ "vite": ">=6.4.2 <7",
```

The selector form (`vite@<6.4.2`) was a no-op once vite had resolved into 7.x — the override only activates when the resolved version is _in_ the vulnerable range. Vite 7 is ≥ 6.4.2, so the override stayed dormant and rolldown-vite slipped through.

The unconditional form forces a downgrade across every consumer (vitepress, `@nx/vite`, `@analogjs/vite-plugin-angular`, Vitest, etc.). All six top-level projects still lint, test, and build under vite 6.4.2.

**Trade-off acknowledged**: we're now pinning the whole workspace to vite 6.x to keep VitePress 1.x happy. The day VitePress 2 ships a 1.0 (currently in beta), we revisit and let vite advance again. Tracked as a soft follow-up.

### 2. `optimizeDeps.include` simplified to `['mermaid']`

[`docs/.vitepress/config.mts`](docs/.vitepress/config.mts):

```diff
- include: ['mermaid', 'dayjs', 'debug', '@braintree/sanitize-url'],
+ include: ['mermaid'],
```

Vite 6's dep optimizer walks Mermaid's transitives automatically once Mermaid itself is in `include`. The explicit child list from #157 was carried forward in the rolldown attempt and tripped on vite's stricter resolver — collapsing it now both removes the noise and matches what the plugin's docs recommend for vite 6.

### 3. Mermaid transitives pinned as top-level devDeps

[`package.json`](package.json):

```diff
+ "@braintree/sanitize-url": "^7.1.2",
+ "cytoscape": "^3.33.3",
+ "cytoscape-cose-bilkent": "^4.1.0",
+ "dayjs": "^1.11.20",
+ "debug": "^4.4.3",
```

These are already in `node_modules` (pulled in by mermaid). Declaring them at the workspace root makes them reachable from `optimizeDeps.include` under pnpm's strict isolation, which silences the five "Failed to resolve dependency" warnings the plugin's wrapper produced.

Cost: five extra devDep lines in `package.json` whose only purpose is to make the optimizer happy. Acceptable — they don't influence the resolved tree, just the resolver's reachability rules.

## Notes for the reviewer

- **Why not bump VitePress 1 → 2?** VitePress 2 is still beta. Per [CLAUDE.md](CLAUDE.md) §"Project rules": pre-1.0 dependencies and one-maintainer projects are rejected unless an ADR justifies the exception. ADR-0022 already records VitePress 1.6.4 as the chosen baseline; switching to a beta on the very first follow-up PR would burn the rationale.
- **Why an unconditional vite range, not a tighter selector?** The selector form (`vite@vulnerable-range → patched-range`) is the standard pattern when the parent dep's own range *includes* a patched version — pnpm picks it naturally and the override never fires. Here vite's 5.x branch was never patched (5.4.21 stayed vulnerable; vite team moved on to 6.x), so we need to force the downgrade from 7.x to 6.x regardless of the previous resolution. An unconditional override is the cleanest expression of that intent.
- **Why not extract the mermaid-transitive pins into the ADR-0022 trail?** They're plumbing for the plugin wrapper, not an architectural decision worth recording. If the plugin ships a fix that removes the include list, these can be removed without consequence. Pinning them is reversible.

## Test plan

- [x] `pnpm install` clean; lockfile changes reflect vite 6.4.2 across all consumers.
- [x] `pnpm audit --audit-level=moderate` — **No known vulnerabilities found**.
- [x] `pnpm docs:dev` — server boots cleanly on `:5173`, no warnings, home + ADR-0009 page return 200.
- [x] `pnpm docs:build` — clean build in ~9 s (back to Rollup-based timings; the rolldown 3.87 s we saw briefly was the incompatible path).
- [x] `pnpm exec nx run-many -t lint test --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 12/12 tasks green under the vite 6 downgrade.
- [x] `pnpm exec nx build portal-shell` — clean Angular production build; no Vite 6 incompatibility surfaced.
- [ ] **Manual smoke (visual)** — `pnpm docs:dev`, open `http://localhost:5173`, navigate to `/decisions/0009-…` and `/architecture`, confirm Mermaid diagrams render inline (this is the actual UX the user opened the issue on). Dark mode toggle still flips diagrams.

## Follow-ups (optional)

- When VitePress 2 reaches 1.0 (`vue/vitepress > releases`), revisit this override and let vite resume its mainline cadence.
- If the next Renovate cycle proposes a `cytoscape` / `dayjs` / `debug` major bump that VitePress 1.x can't keep up with, the pins above act as the safety net — Renovate will open a PR rather than silently break the dev server.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #161
2026-05-15 23:57:57 +02:00
julien e3a495ab46 docs(development): refresh after phase-3a + add topology / trace diagrams (#160)
CI / check (push) Successful in 2m5s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m50s
CI / a11y (push) Successful in 2m17s
CI / perf (push) Successful in 4m6s
Docs site / build (push) Successful in 2m23s
## Summary

`docs/development.md` drifted as `portal-admin` shipped, the docs site landed, Prisma stayed pinned at 6.x, and a fair chunk of the phase-2 "to come" roadmap quietly turned into "shipped". Surgical refresh of the affected sections + two Mermaid diagrams where prose alone wasn't carrying the cognitive load.

## What changed

### Section 1 — Repo layout

- `apps/portal-admin/` + `apps/portal-admin-e2e/` added (both exist on `main` since #134, were never reflected in the tree).
- `prisma.config.ts` removed (phantom — Prisma 6.x doesn't ship one). The "Prisma 7" tag corrected to "Prisma 6.x" with the [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md) pin reference.
- `docs/index.md`, `docs/architecture.md`, `docs/.vitepress/` added.
- New workflows listed: `docs-site.yml`, `renovate.yml`.

### Section 3 — Initial setup, new diagram

Mermaid `flowchart` of the local-dev topology. Host-side dev servers (`portal-shell:4200`, `portal-admin:4300`, `portal-bff:3000`, `docs:5173`) ↔ Compose containers (Postgres, Redis, OTel) ↔ viewer profiles (Jaeger, pgweb). Replaces a ports/services context that was scattered across §3 and §5.

### Section 4 — Daily commands

- `portal-admin` added to serve / test / generate examples.
- Single-test-file recipe corrected: Nx's vitest executor rejects `--testFile` (we hit this empirically while debugging the sidebar spec for #151). The new wording recommends positional path for Vitest, `--testPathPattern=…` for Jest.
- **New "Documentation site" subsection** with the three commands (`docs:dev`, `docs:build`, `docs:preview`), plus the recipe for adding an ADR.

### Section 5 — Observability, new diagram

Mermaid `sequenceDiagram` showing how a click becomes a trace: browser `user_interaction` span → `traceparent` header → BFF span → Pino log line with matching `trace_id` → OTLP batch → Jaeger UI. Anchors the prose's "trace_id is the correlation point" rule with a visual.

### Section 6 — Renovate

New subsection **"Transitive vulnerabilities — `pnpm.overrides`"**. Documents the pattern from #159 so the next contributor hitting a transitive vuln (Renovate silent, dashboard empty) has the playbook on hand without re-discovering it.

### Section 9 — Sections to come

Restructured into two groups:

- **Code shipped — doc to write** (Auth dev-loop, session inspection, MFA step-up debugging, admin surface walkthroughs, audit-log workflow, downstream API recipe, OpenAPI/Scalar workflow, capabilities-driven SPA UX). The code is on `main`; only the prose is missing. Each entry now cites the PR(s) that landed the implementation.
- **Not yet** (component patterns library, a11y testing workflow, perf debugging, release workflow, GitLab migration runbook). Both code and doc still pending.

## Notes for the reviewer

- **Why diagrams now, not at the next chantier?** Two pieces of context were genuinely easier to grasp visually than as prose lists: the local-dev topology (which port goes where), and the trace-correlation flow. The other sections (Renovate, conventional commits, CI gates) already read well as tables — adding diagrams there would be ornament, not signal.
- **Why two diagrams rather than ten?** Per the user instruction "use diagrams when useful" — restraint applies. The two added are the ones that *replace* explanatory prose rather than add to it.
- **Why didn't I rewrite the "to come" roadmap from scratch?** The phase mapping was useful intent — kept the same structure, just moved entries between the two columns as code-shipping unlocked them. Future re-shuffles stay cheap.
- **The doc still has phase-1 framing in places** (e.g. the "this doc starts as a phase-1 reference" paragraph in §9). I left it — promoting the doc to "phase-3a reference" is a larger editorial pass than this refresh deserves; the new diagrams + section-9 split already do the practical work.
- **Open questions deferred to a future pass**: the §2 "Prerequisites" table doesn't mention the docs site (`pnpm docs:dev` adds nothing to the prereqs list — just Node + pnpm, both already required). Leaving the table as is.

## Test plan

- [x] `rm -rf docs/.vitepress/{cache,dist} && pnpm docs:build` — clean, 6.3 s.
- [x] Mermaid renders inside `docs/.vitepress/dist/development.html` — both new diagrams produce `class="mermaid"` markers. `grep -c 'class="mermaid"\|<svg' development.html` → **2**.
- [ ] Visual smoke: `pnpm docs:dev`, navigate to `/development`, confirm both diagrams render (topology with port labels readable, sequence diagram with the trace flow). Toggle dark mode, confirm diagrams flip theme.
- [ ] Spot-check the "Code shipped — doc to write" table — for any contributor reading this PR, do the PR-number citations match their memory of when each chantier landed? (`auth ≈ ADR-0009 series`, `admin ≈ #127, #128, #134, #136, #140–142`, `downstream ≈ #137–139`, `OpenAPI ≈ #143`, `capabilities ≈ #151`).

## What's next

The two largest "doc to write" entries (Auth dev-loop, Audit-log inspection workflow) are good candidates for the next docs chantier — both have shipped code, RSSI-relevant content, and would benefit from a guided walkthrough. Not blocking anything; pick when the team has bandwidth.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #160
2026-05-15 23:08:50 +02:00
APF Portal Bot a8f027f546 fix(deps): update dependency axios to v1.16.1 (#158)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m43s
CI / a11y (push) Successful in 1m54s
CI / check (push) Successful in 4m39s
CI / perf (push) Successful in 5m41s
Docs site / build (push) Successful in 1m59s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [axios](https://axios-http.com) ([source](https://github.com/axios/axios)) | dependencies | patch | [`1.16.0` -> `1.16.1`](https://renovatebot.com/diffs/npm/axios/1.16.0/1.16.1) |

---

### Release Notes

<details>
<summary>axios/axios (axios)</summary>

### [`v1.16.1`](https://github.com/axios/axios/releases/tag/v1.16.1)

[Compare Source](https://github.com/axios/axios/compare/v1.16.0...v1.16.1)

#### v1.16.1 — May 13, 2026

This release ships a defence-in-depth fix for prototype pollution in `formDataToJSON`, hardens proxy and CI workflows, restores Webpack 4 compatibility for the fetch adapter, and includes several small bug fixes and maintenance improvements.

#### 🔒 Security Fixes

- **Prototype Pollution Defence-in-Depth:** Hardened `formDataToJSON` against already-polluted `Object.prototype` by walking own properties only, so attacker-controlled keys inherited from a poisoned prototype cannot propagate through deserialization. (**[#&#8203;7413](https://github.com/axios/axios/issues/7413)**)
- **Proxy Cleartext Leak:** Fixed an issue where HTTPS request data could be transmitted in cleartext to an HTTP proxy under certain configurations. (**[#&#8203;10858](https://github.com/axios/axios/issues/10858)**)
- **CI Cache Removal:** Removed all GitHub Actions caches as a defence-in-depth measure against cache poisoning vectors in the build pipeline. (**[#&#8203;10882](https://github.com/axios/axios/issues/10882)**)

#### 🐛 Bug Fixes

- **Data URI Parsing:** Updated the `fromDataURI` regex to match RFC 2397 more strictly, fixing edge cases in `data:` URL handling. (**[#&#8203;10829](https://github.com/axios/axios/issues/10829)**)
- **Unicode Headers:** Preserved Unicode header values when running through request interceptors, so non-ASCII header content is no longer corrupted before dispatch. (**[#&#8203;10850](https://github.com/axios/axios/issues/10850)**)
- **XHR Upload Progress:** Guarded against malformed `ProgressEvent` payloads emitted by some environments during XHR upload, preventing crashes when `loaded` / `total` are missing or invalid. (**[#&#8203;10868](https://github.com/axios/axios/issues/10868)**)
- **Webpack 4 Fetch Adapter:** Fixed an "unexpected token" error caused by syntax in the fetch adapter that Webpack 4 could not parse, restoring compatibility for legacy bundler users. (**[#&#8203;10864](https://github.com/axios/axios/issues/10864)**)
- **Type Definitions:** Made `parseReviver` `context.source` optional in the type definitions to align with the ES2023 specification. (**[#&#8203;10837](https://github.com/axios/axios/issues/10837)**)
- **URL Object Support Reverted:** Reverted the change that allowed passing a `URL` object as `config.url` (originally **[#&#8203;10866](https://github.com/axios/axios/issues/10866)**) due to regressions; this support will be reintroduced in a later release once the underlying issues are addressed. (**[#&#8203;10874](https://github.com/axios/axios/issues/10874)**)

#### 🔧 Maintenance & Chores

- **Cycle Detection Refactor:** Replaced the array-based cycle tracker in `toJSONObject` with a `WeakSet`, improving performance and memory behaviour on large nested structures. (**[#&#8203;10832](https://github.com/axios/axios/issues/10832)**)
- **composeSignals Cleanup:** Refactored `composeSignals` to use a clearer early-return structure, simplifying the cancellation/abort composition path. (**[#&#8203;10844](https://github.com/axios/axios/issues/10844)**)
- **AI Readiness & Repo Docs:** Added `AGENTS.md` and related contributor-guide updates for both human and AI agents, plus post-release documentation improvements. (**[#&#8203;10835](https://github.com/axios/axios/issues/10835)**, **[#&#8203;10841](https://github.com/axios/axios/issues/10841)**)
- **Docs Improvements:** Clarified the GET request example, fixed the interceptor `eject` example to reference the correct instance, and corrected the Buzzoid sponsor description in the README. (**[#&#8203;10836](https://github.com/axios/axios/issues/10836)**, **[#&#8203;10853](https://github.com/axios/axios/issues/10853)**, **[#&#8203;10856](https://github.com/axios/axios/issues/10856)**)
- **Sponsorship Tooling:** Fixed empty sponsor arrays in the sponsor processing script, added the ability to inject additional sponsors, updated the sponsorship link, and added a Twicsy advertisement entry. (**[#&#8203;10843](https://github.com/axios/axios/issues/10843)**, **[#&#8203;10859](https://github.com/axios/axios/issues/10859)**, **[#&#8203;10869](https://github.com/axios/axios/issues/10869)**)
- **Dependencies:** Bumped `@commitlint/cli` from 20.5.0 to 20.5.2. (**[#&#8203;10846](https://github.com/axios/axios/issues/10846)**)

#### 🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

- **[@&#8203;hpinmetaverse](https://github.com/hpinmetaverse)** (**[#&#8203;10836](https://github.com/axios/axios/issues/10836)**)
- **[@&#8203;tommyhgunz14](https://github.com/tommyhgunz14)** (**[#&#8203;7413](https://github.com/axios/axios/issues/7413)**)
- **[@&#8203;abhu85](https://github.com/abhu85)** (**[#&#8203;10829](https://github.com/axios/axios/issues/10829)**)
- **[@&#8203;divyanshuraj1095](https://github.com/divyanshuraj1095)** (**[#&#8203;10853](https://github.com/axios/axios/issues/10853)**)
- **[@&#8203;sagodi97](https://github.com/sagodi97)** (**[#&#8203;10856](https://github.com/axios/axios/issues/10856)**)
- **[@&#8203;rkdfx](https://github.com/rkdfx)** (**[#&#8203;10868](https://github.com/axios/axios/issues/10868)**)
- **[@&#8203;Liuwei1125](https://github.com/Liuwei1125)** (**[#&#8203;10866](https://github.com/axios/axios/issues/10866)**)

[Full Changelog](https://github.com/axios/axios/compare/v1.16.0...v1.16.1)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/158
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 22:38:43 +02:00
julien 779061660b chore(security): override vite + esbuild past vitepress's vulnerable transitives (#159)
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m46s
CI / scan (push) Successful in 2m4s
CI / a11y (push) Successful in 2m3s
Docs site / build (push) Successful in 3m44s
CI / perf (push) Successful in 3m59s
## Summary

Unblocks `pnpm ci:audit`. Two moderate vulnerabilities surfaced after the docs-site chantier (#154):

| Advisory | Package | Vulnerable | Patched | Path |
| --- | --- | --- | --- | --- |
| [GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) | esbuild | ≤ 0.24.2 | ≥ 0.25.0 | `. > vitepress > vite > esbuild` |
| [GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9) | vite | ≤ 6.4.1 | ≥ 6.4.2 | `. > vitepress > vite` |

Both come from VitePress 1.6.4's pinned dep tree (`vite@5.4.21 → esbuild@0.21.5`). The rest of the workspace was already on vite 8.0.13 + esbuild 0.27.3 — only the VitePress branch was stuck on the vulnerable line.

## What lands

Two new entries in `package.json`'s existing `pnpm.overrides` block:

```json
"esbuild@<0.25.0": ">=0.25.0",
"vite@<6.4.2":     ">=6.4.2",
```

Same version-selector pattern as the other overrides already in the file (axios, follow-redirects, ip-address, …). The override only kicks in when the resolved version is in the vulnerable range, so it becomes a no-op the day the underlying dep ships a clean version of its own.

After `pnpm install`, the resolver picks **vite 7.3.2** + **esbuild 0.27.3** for the VitePress branch (the workspace's other vite consumers stay on 8.0.13, deduped on esbuild).

## Why couldn't we pin a patched vite 5.x?

The vite team did **not** backport the security fix to the 5.x line. `vite@5.4.22` is not published — the latest 5.x stays at 5.4.21, which is vulnerable. The only path forward is to let pnpm pick a patched 6.x or 7.x major. Verified that VitePress 1.6.4 still:

- builds cleanly (`pnpm docs:build` succeeds in ~4 s, down from 9 s on the older vite);
- renders Mermaid (regression fence in `.gitea/workflows/docs-site.yml` still grep-matches `class="mermaid"` / `<svg>` in ADR-0009's HTML);
- runs the dev server (`pnpm docs:dev` boots, the dayjs CJS-interop fix from #156 still applies for the same reason — Mermaid's CJS deps need pre-bundling regardless of vite major).

## Why didn't Renovate propose this PR itself?

The user reported that Renovate wasn't creating PRs for these advisories, not even listing them on the Dependency Dashboard.

**Root cause: both vite and esbuild are transitive dependencies** — declared by vitepress, not by us. Renovate's `vulnerabilityAlerts` flow handles **direct** package.json deps. For pnpm transitives, the remediation would have to land in `pnpm.overrides`, which the renovatebot/renovate:40 image doesn't write automatically.

Adjacent points:

- The Renovate workflow runs on a daily 03:00 UTC cron only (plus manual dispatch). If the user wants an immediate dashboard refresh now, the workflow accepts `workflow_dispatch` — fire it once from the Gitea Actions UI.
- After this PR merges, Renovate's dashboard should also stop flagging these advisories (the overrides count as remediation).
- Renovate config itself is unchanged — no `ignorePaths`, no exclude of vite/esbuild/vitepress. The silence was purely about the transitive-remediation gap, not a config bug.

## Test plan

- [x] `pnpm install` — clean, no peer warnings beyond the pre-existing `nestjs-prisma → chokidar` one.
- [x] `pnpm audit --audit-level=moderate` — **"No known vulnerabilities found"**.
- [x] `pnpm docs:build` — clean build in ~4 s.
- [x] Mermaid regression fence — `grep 'class="mermaid"' docs/.vitepress/dist/decisions/0009-…html` matches.
- [ ] `pnpm ci:audit` on CI — should now pass (the goal of this PR).
- [ ] Manual smoke: `pnpm docs:dev`, navigate to `/decisions/0009-…`, confirm the OIDC sequence diagram renders. Dark-mode toggle still flips theme.

## Follow-ups (optional)

- Trigger the Renovate workflow manually (Gitea Actions → Renovate → Run workflow) so the Dependency Dashboard refreshes against this overridden state.
- If we hit this transitive-remediation gap again, consider raising a `renovateConfig.transitiveRemediation` story or switching the workflow to `renovate/renovate:latest` — newer point releases sometimes ship better pnpm-overrides authoring. Not urgent.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #159
2026-05-15 22:18:07 +02:00
APF Portal Bot ed477aeb26 fix(deps): update dependency @scalar/nestjs-api-reference to v1.1.16 (#156)
CI / scan (push) Failing after 1m16s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 2m19s
CI / perf (push) Successful in 3m51s
Docs site / build (push) Successful in 3m41s
CI / check (push) Failing after 13m41s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@scalar/nestjs-api-reference](https://github.com/scalar/scalar) ([source](https://github.com/scalar/scalar/tree/HEAD/integrations/nestjs)) | dependencies | patch | [`1.1.14` -> `1.1.16`](https://renovatebot.com/diffs/npm/@scalar%2fnestjs-api-reference/1.1.14/1.1.16) |

---

### Release Notes

<details>
<summary>scalar/scalar (@&#8203;scalar/nestjs-api-reference)</summary>

### [`v1.1.16`](https://github.com/scalar/scalar/blob/HEAD/integrations/nestjs/CHANGELOG.md#1116)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #156
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 20:04:13 +02:00
julien d94df427a1 fix(docs): pre-bundle mermaid deps so the dev server resolves dayjs/cytoscape (#157)
CI / check (push) Successful in 1m6s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 1m15s
CI / a11y (push) Successful in 2m3s
CI / perf (push) Successful in 4m45s
Docs site / build (push) Successful in 3m19s
## Summary

`pnpm docs:dev` rendered a blank page with this thrown by the browser on the first navigation:

```
Uncaught SyntaxError: The requested module '/@fs/.../node_modules/.pnpm/dayjs@1.11.20/node_modules/dayjs/dayjs.min.js?v=…'
does not provide an export named 'default'
(at chunk-AGHRB4JF.mjs?v=…:9:8)
```

Root cause: Mermaid 11 (transitive dep of `vitepress-plugin-mermaid` shipped in #154) pulls in `dayjs`, `cytoscape`, `debug`, `@braintree/sanitize-url` — each ships a CommonJS `main` field with no `default` ESM export. Vite's dev server resolves these modules eagerly as ESM and the browser blows up before the home page renders.

The **production build was unaffected** — Rollup's plugin pipeline already wraps CJS deps for ESM consumers. `docs:build` shipped clean HTML in #154 and the CI Mermaid-fence (`grep class="mermaid"` in ADR-0009's HTML) passed precisely because it inspects the prod bundle, not the dev-server output.

## What lands

[`docs/.vitepress/config.mts`](docs/.vitepress/config.mts) — adds a single `vite.optimizeDeps.include` block:

```ts
vite: {
  optimizeDeps: {
    include: ['mermaid', 'dayjs', 'debug', '@braintree/sanitize-url'],
  },
},
```

`optimizeDeps.include` tells Vite to pre-bundle those modules through esbuild at dev-server boot, applying the same CJS→ESM interop wrapper Rollup uses in prod. The dev server now serves a working `localhost:5173` and Mermaid diagrams render.

## Notes for the reviewer

- **Why these four names specifically?** They are the Mermaid deps that ship CJS-only entrypoints. Adding `'mermaid'` alone is sometimes enough because Vite walks the dep tree, but the plugin's own README + a handful of upstream issue threads recommend listing the leaf CJS modules explicitly so the optimizer doesn't miss them on a cold cache. Cheap, defensive.
- **Why didn't the CI gate catch this?** The `Assert Mermaid renders` step in `.gitea/workflows/docs-site.yml` greps `docs/.vitepress/dist/` — the production-build output. The bug only manifests on the dev server's runtime-pre-bundling path. Catching it in CI would require booting `docs:dev` headlessly and curling the page; not worth the workflow weight for a once-per-major-upgrade class of issue. Manual `pnpm docs:dev` smoke is the right gate for now.
- **Why not bump dayjs / mermaid?** Dayjs's package shape is a long-standing upstream quirk (the `main` points at the UMD/CJS bundle); fixing it upstream would be a breaking change for non-bundler consumers. Mermaid upstream is aware; their fix has historically been "tell your bundler to pre-bundle us", which is exactly what `optimizeDeps.include` does.

## Test plan

- [x] `rm -rf docs/.vitepress/cache && pnpm docs:dev` — server boots, `curl http://localhost:5173/` returns 200.
- [x] `rm -rf docs/.vitepress/{cache,dist} && pnpm docs:build` — clean prod build in ~10 s, Mermaid SVG still present in ADR-0009's HTML (regression fence passes).
- [ ] Manual smoke: with the fix applied, navigate `localhost:5173` → home → `/decisions/0009-…` → confirm the OIDC sequence diagram renders inline, no console errors. Toggle dark mode, confirm diagrams flip theme.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #157
2026-05-15 20:03:29 +02:00
APF Portal Bot abdfb8e1f4 fix(deps): update angular (#155)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m44s
CI / check (push) Successful in 6m19s
CI / a11y (push) Successful in 3m23s
CI / perf (push) Successful in 7m22s
Docs site / build (push) Successful in 2m28s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@angular-devkit/core](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular-devkit%2fcore/21.2.10/21.2.11) |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular-devkit%2fschematics/21.2.10/21.2.11) |
| [@angular/build](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular%2fbuild/21.2.10/21.2.11) |
| [@angular/cdk](https://github.com/angular/components) | dependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular%2fcdk/21.2.10/21.2.11) |
| [@angular/cli](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular%2fcli/21.2.10/21.2.11) |
| [@angular/common](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/common)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcommon/21.2.12/21.2.13) |
| [@angular/compiler](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcompiler/21.2.12/21.2.13) |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/main/packages/compiler-cli) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli)) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcompiler-cli/21.2.12/21.2.13) |
| [@angular/core](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/core)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcore/21.2.12/21.2.13) |
| [@angular/forms](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/forms)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fforms/21.2.12/21.2.13) |
| [@angular/language-service](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/language-service)) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2flanguage-service/21.2.12/21.2.13) |
| [@angular/localize](https://github.com/angular/angular) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2flocalize/21.2.12/21.2.13) |
| [@angular/platform-browser](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/platform-browser)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fplatform-browser/21.2.12/21.2.13) |
| [@angular/router](https://github.com/angular/angular/tree/main/packages/router) ([source](https://github.com/angular/angular/tree/HEAD/packages/router)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2frouter/21.2.12/21.2.13) |
| [@schematics/angular](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@schematics%2fangular/21.2.10/21.2.11) |
| [angular-eslint](https://github.com/angular-eslint/angular-eslint) ([source](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/angular-eslint)) | devDependencies | minor | [`21.3.1` -> `21.4.0`](https://renovatebot.com/diffs/npm/angular-eslint/21.3.1/21.4.0) |

---

### Release Notes

<details>
<summary>angular/angular-cli (@&#8203;angular-devkit/core)</summary>

### [`v21.2.11`](https://github.com/angular/angular-cli/blob/HEAD/CHANGELOG.md#21211-2026-05-13)

[Compare Source](https://github.com/angular/angular-cli/compare/v21.2.10...v21.2.11)

##### [@&#8203;angular/cli](https://github.com/angular/cli)

| Commit                                                                                              | Type | Description                            |
| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- |
| [bbd63b7a5](https://github.com/angular/angular-cli/commit/bbd63b7a5a1049bc56b9ddf6edf6563a1f2d9ace) | fix  | robustly parse npm manifest from array |

##### [@&#8203;angular/ssr](https://github.com/angular/ssr)

| Commit                                                                                              | Type | Description                                                                     |
| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- |
| [eafe1a719](https://github.com/angular/angular-cli/commit/eafe1a719fd3fecd5263e0a8371200b4b1ff4bb9) | fix  | allow all hosts in common engine rendering options to prevent validation errors |
| [7a116a80d](https://github.com/angular/angular-cli/commit/7a116a80d7e6db341fd003737285d1a9db10ba6c) | fix  | remove stateful flag from URL\_PARAMETER\_REGEXP                                  |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/components (@&#8203;angular/cdk)</summary>

### [`v21.2.11`](https://github.com/angular/components/blob/HEAD/CHANGELOG.md#21211-crystal-ball-2026-05-13)

[Compare Source](https://github.com/angular/components/compare/v21.2.10...v21.2.11)

No user facing changes in this release

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/angular (@&#8203;angular/common)</summary>

### [`v21.2.13`](https://github.com/angular/angular/blob/HEAD/CHANGELOG.md#21213-2026-05-13)

[Compare Source](https://github.com/angular/angular/compare/v21.2.12...v21.2.13)

##### core

| Commit | Type | Description |
| -- | -- | -- |
| [1c6553e97d](https://github.com/angular/angular/commit/1c6553e97d9655d8c48fbf625987fae86f9cd947) | fix | disallow event attribute bindings in host bindings unconditionally |

##### platform-server

| Commit | Type | Description |
| -- | -- | -- |
| [629905d537](https://github.com/angular/angular/commit/629905d537f59dc3c264c49f6347e3599dea0215) | fix | add `allowedHosts` option to `renderModule` and `renderApplication` |
| [0b7192f441](https://github.com/angular/angular/commit/0b7192f4410d055191ac9b15bff57d1d0b9a644f) | fix | forward BEFORE\_APP\_SERIALIZED errors to ErrorHandler |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular-eslint/angular-eslint (angular-eslint)</summary>

### [`v21.4.0`](https://github.com/angular-eslint/angular-eslint/blob/HEAD/packages/angular-eslint/CHANGELOG.md#2140-2026-05-13)

[Compare Source](https://github.com/angular-eslint/angular-eslint/compare/v21.3.1...v21.4.0)

This was a version bump only for angular-eslint to align it with other projects, there were no code changes.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #155
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 19:38:25 +02:00
julien 7579b25dfe feat(docs): vitepress site for docs/, mermaid rendering, ci build workflow (#154)
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m39s
CI / scan (push) Failing after 2m31s
CI / a11y (push) Successful in 2m14s
CI / perf (push) Successful in 4m12s
Docs site / build (push) Successful in 2m27s
## Summary

Implementation of [ADR-0022](docs/decisions/0022-docs-site-vitepress.md). Stands up the static documentation site that renders `docs/**/*.md` (architecture diagrams, daily-dev guide, ADRs, onboarding) via **VitePress + `vitepress-plugin-mermaid`**, behind a Gitea Actions build gate.

Local dev: `pnpm docs:dev`. Full build: `pnpm docs:build` (~9 s, output in `docs/.vitepress/dist/`).

## What lands

### Dependencies

`vitepress 1.6.4`, `vitepress-plugin-mermaid 2.0.17`, `mermaid 11.15.0` — workspace devDependencies. No runtime impact on `portal-shell` / `portal-admin` / `portal-bff`.

### [`docs/.vitepress/config.mts`](docs/.vitepress/config.mts)

The single source of truth for the site. Highlights:

- **`srcExclude`** drops `docs/README.md` (git/IDE-only index per ADR-0022's option A) and `docs/decisions/template.md` (authoring scaffold).
- **`rewrites`** maps `decisions/README.md` → `decisions/index.md` so `/decisions/` resolves to the curated tag-grouped landing while the source filename stays git-conventional.
- **`ignoreDeadLinks`** skips:
  - `localhost:*` URLs (Jaeger, OTLP — only resolve in a live dev session),
  - cross-repo references (`../CLAUDE`, `../../apps/**`, `../../infra/**`, `../../notes/**`) — intentional from git/IDE consumers; not the site's job to render them,
  - excluded targets (`./template`, `./README`) — file exists in the repo, just not in the site.
- **Auto-sidebar for `/decisions/`** — `adrSidebarItems()` walks `docs/decisions/00*-*.md` and emits sorted `ADR-NNNN — title` entries. Adding an ADR is a single-file change, no `config.mts` edit.
- **Hand-curated top-level nav** (Development, Architecture, Decisions, Onboarding).
- **Mermaid via `withMermaid()`** with `securityLevel: 'strict'` so diagrams can't inject arbitrary HTML.

### [`docs/index.md`](docs/index.md)

VitePress Hero landing with four feature cards (Architecture, Decisions, Development, Onboarding).

### [`docs/development.md`](docs/development.md) — two surgical fixes

- Line ~5: `[setup/](setup/)` → `[setup/01-wsl-terminal-setup.md](setup/01-wsl-terminal-setup.md)`. Folder-style links don't resolve cleanly under `cleanUrls: true`; pointing at the first onboarding page is both correct and useful.
- Line 330: wrap `${{ github.* }}` in `<code v-pre>…</code>`. VitePress runs every Markdown file through the Vue template compiler, which sees the inline `{{ … }}` as an interpolation. `v-pre` keeps the literal text intact. The rest of the source is unaffected.

### [`package.json`](package.json)

Three new scripts:

```
docs:dev      → vitepress dev docs
docs:build    → vitepress build docs
docs:preview  → vitepress preview docs
```

Pure pnpm scripts, no Nx project — the site has no cross-project dependency graph to track.

### [`.gitea/workflows/docs-site.yml`](.gitea/workflows/docs-site.yml)

Triggers on push to `main` and on PR, scoped by `paths:` to `docs/**`, `package.json`, `pnpm-lock.yaml`, and the workflow itself. Three steps:

1. `pnpm install --frozen-lockfile`
2. `pnpm docs:build`
3. Regression fence: `grep` ADR-0009's rendered HTML for `class="mermaid"` or `<svg>` so a silent Mermaid-plugin breakage on a major upgrade fails the workflow rather than ship a site with raw code blocks where diagrams should be.
4. On push only: upload `docs/.vitepress/dist/` as a `docs-site` artifact (30-day retention). The actual rsync to the static host lands when the future infrastructure ADR locks the deployment target.

### [`.gitignore`](.gitignore)

Excludes `docs/.vitepress/{cache,dist}/` so local builds don't leak into commits.

## Notes for the reviewer

- **Why `config.mts` and not `config.ts`?** VitePress is ESM-only, and `vitepress-plugin-mermaid` follows. Vite loads `.ts` config files via its CJS bundler in this workspace's setup and chokes on the ESM imports. `.mts` flips the loader to ESM and the build succeeds. Same pattern is used elsewhere in the workspace (`jest.config.cts`, app `vite.config.mts`).
- **Why no Nx project (`docs/project.json`)?** The doc site has no Nx-trackable dependencies (it consumes `.md` files, not TypeScript projects). Putting it in the Nx graph adds ceremony with no caching benefit — VitePress's incremental rebuilds are sub-second already, and the site never has cross-project `affected` semantics. Pure pnpm scripts keep the surface small.
- **Why the regression fence on Mermaid?** ADR-0022 §"Confirmation" promises it. The plugin is a community dep (sub-1.0 wrapper around the official Mermaid renderer); a major upgrade or a Mermaid runtime change could leave fenced ` ```mermaid ` blocks rendered as raw code without anyone noticing — until an RSSI clicks ADR-0009 and sees no diagram. Cheap grep gate, real signal.
- **Why upload as artifact, not deploy?** Per [ADR-0022](docs/decisions/0022-docs-site-vitepress.md) §"Deployment & CI": the host (`docs.portal.apf.fr` or a sub-path) is provisional. Locking an rsync target now would couple this PR to a not-yet-made infra decision. Artifact upload is the staging mechanism — manual drop on the host until the infrastructure ADR formalises the target.
- **Why `ignoreDeadLinks` rather than fixing every cross-repo reference?** The cross-repo links are genuinely useful from a git/IDE perspective (where the docs/ markdown is browsed alongside the rest of the codebase). Rewriting them to `https://git.unespace.com/julien/apf_portal/src/branch/main/…` would make them work on the site but lose the IDE quick-jump. Skipping at site-build time is the right trade-off — the site reader gets a graceful "link doesn't exist here" if they click, the IDE reader gets a working jump.

## Test plan

- [x] `pnpm docs:build` succeeds in ~9 s. Output at `docs/.vitepress/dist/` contains an `index.html`, every ADR, the development guide, the architecture diagrams, and the three setup pages.
- [x] Mermaid renders: `grep 'class="mermaid"' docs/.vitepress/dist/decisions/0009-…html` returns a match.
- [x] `pnpm exec nx run-many -t format:check lint test build` for the 6 main projects — 18/18 tasks green, no Nx regression from the new top-level config.
- [ ] **Manual smoke**: `pnpm docs:dev`, open `http://localhost:5173`, walk through:
  - Landing renders Hero + 4 feature cards.
  - Search box returns hits for "audit", "MFA", "OBO".
  - `/decisions/0009-…` renders the OIDC sequence diagram (Mermaid SVG, not raw text).
  - `/decisions/0010-…` ERD or `/architecture` C4 diagrams likewise.
  - Dark-mode toggle flips diagrams to dark theme without page reload.
  - Sidebar shows the 22 ADRs auto-listed under `/decisions/`.
  - The "Decisions" curated index at `/decisions/` lists ADRs by tag (no regression on the source markdown).

## What's next

Once the deployment target is fixed (future infra ADR), wire the rsync step into the workflow — that lands as a small follow-up PR. Until then the artifact carries the bundle.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #154
2026-05-15 19:14:14 +02:00
julien aa8ad97feb fix(portal-admin): adr refs point at gitea, not the madr template repo (#153)
CI / check (push) Successful in 1m47s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m48s
CI / a11y (push) Successful in 2m1s
CI / perf (push) Successful in 3m33s
## Summary

Records the decision to render `docs/**/*.md` as a separately-deployed static site using **VitePress + `vitepress-plugin-mermaid`**.

This is **ADR-only** — the implementation (install + `.vitepress/config.ts` + `docs/index.md` + Gitea Actions workflow) lands as the next chantier. Splitting them keeps the decision review focused on the *why* before the *how*.

## What lands

### [`docs/decisions/0022-docs-site-vitepress.md`](docs/decisions/0022-docs-site-vitepress.md)

Full MADR 4.0.0 ADR. Decision drivers, 4 considered options (VitePress, MkDocs Material, Docusaurus 3, Astro Starlight), site-structure mapping, Mermaid integration, deployment + CI plan, consequences, revisit triggers. Tags: `process`, `infrastructure`.

**Key choices captured:**

- VitePress wins on toolchain alignment (Vite already in the workspace via `@nx/vite`, `vite-plugin-angular`, Vitest). MkDocs Material was the strong runner-up; the Python runtime tax in Gitea Actions tipped the balance.
- `vitepress-plugin-mermaid` for ```` ```mermaid ```` blocks ([ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) sequence + [architecture.md](docs/architecture.md) C4 are hard requirements).
- Site sources entirely from `docs/`. Mapping:
  - `docs/index.md` (new, Hero layout) → `/`
  - `docs/development.md` → `/development`
  - `docs/architecture.md` → `/architecture`
  - `docs/decisions/README.md` → `/decisions/` (curated index, kept as section landing)
  - `docs/decisions/00NN-….md` → auto-listed in the sidebar by numeric prefix
  - `docs/setup/0N-….md` → `/setup/0N-…`
- **Excluded** via `srcExclude`: `docs/README.md` (stays as the git-side / IDE-preview index — option A from the prior discussion) and `docs/decisions/template.md` (authoring scaffold).
- Empty placeholder sections in `docs/README.md` (Operations runbooks, Security/perf/a11y rationales) are NOT pre-created as empty pages — they appear in the sidebar when real content lands.
- Deployment: dedicated hostname (provisional `docs.portal.apf.fr`) behind the same Caddy reverse-proxy as the apps, fed by a new `.gitea/workflows/docs-site.yml` triggered on `docs/**` push. Exact hostname follows the future infrastructure ADR; not locked here.

### [`docs/decisions/README.md`](docs/decisions/README.md)

ADR-0022 added to the index table.

### [`CLAUDE.md`](CLAUDE.md)

Bumps the ADR range from `0001 → 0021` to `0001 → 0022`. New bullet in the "Architecture (recorded in ADRs)" section describing the docs-site choice in one paragraph. Implementation tracked in "Still on the roadmap" until the next PR lands it.

## Notes for the reviewer

- **Why ADR before implementation?** The choice between VitePress / MkDocs Material / Docusaurus / Astro Starlight is exactly the "stable + recognized + innovative" trade-off [CLAUDE.md](CLAUDE.md) asks to document. Reviewing the rationale on its own (without dragging through the install diff) keeps the discussion focused.
- **Why not surface ADRs inside `portal-admin`?** Audience mismatch — [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Audience is disjoint" frames `portal-admin` around APF internal operators (CMS, audit, user directory), not architects. The full reasoning is in ADR-0022 §"Context and Problem Statement".
- **Why two index artefacts (`docs/README.md` + future `docs/index.md`)?** Option A from the structure discussion. Each serves a distinct audience: `README.md` is the flat link list that renders well in IDEs / Gitea source view; `index.md` will be the VitePress Hero landing for the web audience. Light duplication, no maintenance pressure (the IDE one only needs updating when sections appear/disappear).
- **Why `vitepress-plugin-mermaid` rather than Docusaurus's built-in Mermaid?** The community plugin is a sub-1.0 dependency on the wrapper (Mermaid itself is mature), but Mermaid is so mainstream that switching it out is a half-day rewrite if the plugin stalls. Trading that risk against Docusaurus's MDX-by-default footprint + React runtime is a net win.
- **Why `process` + `infrastructure` tags?** Mirrors [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md) (also a CI / deploy decision with content authoring implications) and is consistent with the [tag vocabulary](docs/decisions/README.md#tag-vocabulary). No new tag invented.

## Test plan

- [x] `docs/decisions/0022-docs-site-vitepress.md` validates as MADR 4.0.0 (frontmatter, section order). Index in [`docs/decisions/README.md`](docs/decisions/README.md) updated in the same PR per [ADR conventions](docs/decisions/README.md#conventions).
- [x] No code touched — `lint / test / build` matrix unaffected.
- [ ] Review for trade-off accuracy: did I get MkDocs Material's strengths right? Is Astro Starlight's maturity argument fair?
- [ ] Implementation chantier (next PR): `pnpm add -D vitepress vitepress-plugin-mermaid mermaid`, `docs/.vitepress/config.ts`, `docs/index.md`, `.gitea/workflows/docs-site.yml`, `package.json` scripts. Will land within the same week assuming this ADR holds.

## What's next

If accepted as-is, the immediate follow-up is:

1. Install VitePress + the Mermaid plugin.
2. Author `docs/.vitepress/config.ts` with the sidebar shape spelled out in this ADR (auto-generated sub-sidebar for `/decisions`, hand-curated top-level).
3. Author `docs/index.md` (Hero layout).
4. Add the `docs-site` Gitea Actions workflow.
5. Wire the dev script (`pnpm docs:dev`) into `package.json` so contributors can preview locally.

If reviewers want to push back on the toolchain choice (MkDocs Material in particular has a strong case for the theme polish), this is the right PR to surface that — implementation hasn't started.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #153
2026-05-15 18:48:07 +02:00
julien 035ea7c748 fix(portal-admin): adr refs point at gitea, not the madr template repo (#152)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m11s
CI / check (push) Successful in 2m40s
CI / a11y (push) Successful in 1m5s
CI / perf (push) Successful in 5m13s
## Summary

Two ADR references in `portal-admin` page intros (`/audit`, `/users`) linked to `https://github.com/adr/madr` — the **MADR template repository**, not our ADRs. Copy-paste artefact from the original authoring of those pages.

Both anchors now resolve to the actual ADR file on Gitea, so a reviewer who clicks lands on the right document.

| Page | Anchor | Was | Now |
| --- | --- | --- | --- |
| [audit.html](apps/portal-admin/src/app/pages/audit/audit.html) | ADR-0013 | `github.com/adr/madr` | [Gitea `0013-audit-trail-…`](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0013-audit-trail-separated-postgres-append-only.md) |
| [users.html](apps/portal-admin/src/app/pages/users/users.html) | ADR-0020 | `github.com/adr/madr` | [Gitea `0020-portal-admin-app`](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0020-portal-admin-app.md) |

## Why no in-app ADR viewer

A natural follow-up question — "could we render the ADRs inside portal-admin?" — was considered and **rejected** for v1:

- **Audience mismatch.** ADR-0020 §"Audience is disjoint" frames portal-admin around APF internal staff doing CMS / audit / user-directory work. ADRs are dev / architecture artefacts mentioning Prisma, Redis, MSAL Node, OBO trade-offs — content that doesn't help an operator and blurs the line between "ops tool" and "internal tech doc".
- **Architecture cost.** A live viewer would require a Markdown-rendering pipeline on the SPA, BFF↔filesystem coupling (or a build-time embedding that breaks the auto-update intent), inter-ADR link rewriting, and English-only fallback in an otherwise i18n-capable app.
- **Better alternative exists.** If discoverability of `docs/**/*.md` becomes a real need, a separate static-site (MkDocs Material / Docusaurus / VitePress) deployed on `docs.portal.apf.fr` with a CI hook on `docs/` changes is the battle-tested path. That's the chantier slot — a separate ADR + setup, not bolt-on inside portal-admin.

## Test plan

- [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — clean, 50 specs pass, lazy chunks unchanged.
- [ ] Visual smoke: open `/audit` and `/users` in portal-admin, click the inline ADR badge, confirm the Gitea ADR page loads in a new tab.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #152
2026-05-15 18:47:14 +02:00
julien ee51efb688 feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
CI / scan (push) Successful in 2m13s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 3m46s
## 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
2026-05-15 16:11:26 +02:00
julien 1160771061 feat(portal-admin): profile page on /profile guarded by authGuard (#150)
CI / scan (push) Successful in 2m1s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m40s
CI / a11y (push) Successful in 1m24s
CI / perf (push) Successful in 4m30s
## Summary

PR 2 of 3 from the user-menu / profile / cross-app-link chantier. Lands a `/profile` page on `portal-admin` so the Profile entry the user menu wired in PR 1 stops 404-ing. Portal-shell already had a demo `/profile` route that fits this slot — left as-is rather than churned.

| PR | Périmètre |
| --- | --- |
| PR 1  | Shared `UserMenu` dropdown + integration. |
| **PR 2 (this one)** | `/profile` page on `portal-admin`; `CurrentUser.roles` typed (the BFF already returns it). |
| PR 3 | `/api/me/capabilities` + sidebar role widget + cross-app links. |

## What lands

### New page — [`apps/portal-admin/src/app/pages/profile/`](apps/portal-admin/src/app/pages/profile/)

Lazy-loaded at `/profile`, guarded by `authGuard` (from `feature-auth`). Two cards:

- **Identity** — displayName, username, Entra `oid`, tenant `tid`. The two ids are monospaced + break-anywhere so they don't push the layout on narrow viewports.
- **App roles** — chips listing every Entra app-role claim the session carries (`Portal.Admin` for v1, future business roles once ADR-0011 step-up lands its real consumers). Hidden entirely when the `roles` array is empty so anonymous-then-promoted accounts don't see a "no roles" affordance that doesn't help anyone.

The `@if (user())` template guard narrows the type so the page is single-branch — `authGuard` already blocked anonymous traffic upstream.

Lazy chunk lands at **5.37 KB raw / 1.57 KB gzip** — comfortably under the per-chunk lazy budget.

### Route — [`apps/portal-admin/src/app/app.routes.ts`](apps/portal-admin/src/app/app.routes.ts)

```ts
{
  path: 'profile',
  canActivate: [authGuard],
  loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage),
  title: profileTitle,
}
```

`route.profile.title` added to [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) (FR target retained for parity with the other route titles, even though portal-admin doesn't expose a locale switcher in v1 per ADR-0020).

### Type — [`libs/feature/auth/src/lib/auth.types.ts`](libs/feature/auth/src/lib/auth.types.ts)

```ts
export interface CurrentUser {
  …
  /** Optional; present on `/api/admin/auth/me`, omitted on `/api/auth/me`. */
  readonly roles?: readonly string[];
}
```

`/api/admin/auth/me` already returns `roles` (PR #129), but `CurrentUser` was discarding it through the typed deserialization. Marking the field optional captures the existing BFF response without surfacing the claim on the user portal — ADR-0009's "curated public view" stays intact on `/api/auth/me`, which simply doesn't populate it.

## Notes for the reviewer

- **Why not refresh `portal-shell`'s `/profile` too?** The existing demo page already has the same shape (displayName, username, oid, tid) and is functionally fine. Refreshing for parity would be incidental scope creep against PR 2's goal (unblock the Profile menu entry on the admin surface). PR 3 may revisit when it adds the role display widget.
- **Why no `User profile` entry in the admin sidebar?** The user menu already exposes Profile and ADR-0020's v1 sidebar catalogue is "modules", not "self-service shortcuts". Adding a sidebar entry would duplicate the user-menu access path and break the rule that the sidebar maps to admin business modules.
- **Why a single English-source page, not FR-translated content?** Same posture as the audit + users pages: per ADR-0020 §"No locale switcher in v1", admin chrome stays in source locale. Route titles are i18n-marked because the prod build's `i18nMissingTranslation=error` policy would fail otherwise — the page body text doesn't need to be.
- **Why not show roles on the portal-shell side too?** That's PR 3, gated on the `/api/me/capabilities` design (the user picked the dedicated capabilities endpoint over `roles`-on-user-/me). Adding any role-related rendering here would pre-empt that choice.

## Test plan

- [x] `pnpm nx test portal-admin` — **50 specs pass** (was 46, +4 for the new `ProfilePage` spec).
- [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-admin, click the avatar → Profile entry, confirm: identity card populated, App roles card shows `Portal.Admin` (if you have the role assigned), Settings entry of the user menu still greyed with the Soon badge.

## What's next

PR 3 lands the cross-app machinery — `GET /api/me/capabilities` endpoint, real role surfacing on the user-portal sidebar widget (`Anonymous` → `Authenticated` / role label, no more hardcode), and the symmetric "Open Portal Admin" / "Open Portal Shell" entries in each app's user menu, gated on the capabilities response.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #150
2026-05-15 15:42:52 +02:00
julien a8ead65ea8 feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin (#149)
CI / scan (push) Successful in 2m28s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m43s
CI / perf (push) Successful in 3m58s
## 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
2026-05-15 15:23:39 +02:00
julien e3e7800ecb feat(portal-admin): header brand logo + sidebar background covers full column (#148)
CI / scan (push) Successful in 2m0s
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m35s
CI / a11y (push) Successful in 2m4s
CI / perf (push) Successful in 3m44s
## Summary

Two small portal-admin polish items so the admin shell stops looking like a scaffold:

1. **APF mark in the header**, before the `APF Portal` wordmark — same lock-up portal-shell already ships.
2. **Sidebar gray background + right border now cover the entire column**, not just the rail of nav links.

## What lands

### Header — brand logo

[`apps/portal-admin/src/app/components/header/header.html`](apps/portal-admin/src/app/components/header/header.html) — `<img>` slipped in before the wordmark inside the `.brand` flex row:

```html
<img src="logos/apf-logo.svg" alt="" aria-hidden="true" width="28" height="28" class="brand-logo" />
```

The image is **decorative**: `alt=""` + `aria-hidden="true"`. The accessible name of the brand link is already carried by `APF Portal` + the `Admin` badge — adding a non-empty `alt` here would just produce noise for screen-reader users (WCAG 1.1.1 / Decorative images).

Sized at 28×28 (1.75rem) to keep the lock-up proportionate to the admin header's compact 3.5rem height — portal-shell uses 36×36 in a 4rem header. The new `.brand-logo` SCSS rule pins `display: block; flex-shrink: 0; height/width: 1.75rem` so the SVG can't be squashed by other header content.

### Sidebar — full-column chrome

[`apps/portal-admin/src/app/components/sidebar/sidebar.scss`](apps/portal-admin/src/app/components/sidebar/sidebar.scss) — the gray background and right border move from the inner `<nav class="admin-sidebar">` to `:host` (i.e. the `<app-admin-sidebar>` element itself). Result: the chrome covers the whole flex column, not just the area the link list occupies.

Three things had to move together for the visual to land:

- **`background-color` + `border-right`** — what the user asked for.
- **`width: 16rem` + `flex: 0 0 16rem`** — the host is the direct flex child of `.shell-body`; sizing must live on the flex item, not on a grandchild.
- **`display: flex; flex-direction: column`** — Angular custom elements default to `display: inline`. Without an explicit display, background and border don't render on the host, regardless of the parent's `align-items: stretch`.

The inner `<nav>` now keeps just its content concerns: `padding` for the list breathing room, `overflow-y: auto` for the scroll, and `flex: 1 1 auto` so it grows to fill the host envelope.

Dark-mode variants follow the same move — `:host-context(.dark)` swaps the host's background + border-color directly.

### Assets

`apps/portal-admin/public/logos/{apf-logo.svg, apf-portal.svg}` mirror the portal-shell `public/logos/` layout; Angular's build pipeline picks them up at `/logos/*` per the standard static-asset convention.

## Test plan

- [x] `pnpm nx test portal-admin` — **45 specs pass**, unchanged. Header spec asserts on `.brand-wordmark` + `.brand-badge` (untouched); sidebar spec asserts on the `<nav>` landmark + RouterLinks (also untouched).
- [x] `pnpm nx lint portal-admin` — clean.
- [x] `pnpm nx build portal-admin` — clean; lazy chunks unchanged (`audit` 4.35 KB gzip, `users` 3.76 KB gzip).
- [ ] Visual smoke in dev — sign in, confirm the APF mark renders to the left of the wordmark, then navigate to `/audit` and `/users` and confirm the gray background + right border extend the full height of the sidebar column.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #148
2026-05-15 13:59:16 +02:00
julien 6120471b66 chore(workspace): tsconfig composite refs + axe-linter false-positive config (#147)
CI / scan (push) Successful in 2m0s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m41s
CI / a11y (push) Successful in 1m10s
CI / perf (push) Successful in 3m10s
## 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
2026-05-15 12:25:20 +02:00
APF Portal Bot 636a1661f5 chore(deps): update typescript tooling to v8.59.3 (#146)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m37s
CI / a11y (push) Successful in 3m18s
CI / check (push) Successful in 6m12s
CI / perf (push) Successful in 6m25s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@typescript-eslint/utils](https://typescript-eslint.io/packages/utils) ([source](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils)) | devDependencies | patch | [`8.59.2` -> `8.59.3`](https://renovatebot.com/diffs/npm/@typescript-eslint%2futils/8.59.2/8.59.3) |
| [typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint) ([source](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)) | devDependencies | patch | [`8.59.2` -> `8.59.3`](https://renovatebot.com/diffs/npm/typescript-eslint/8.59.2/8.59.3) |

---

### Release Notes

<details>
<summary>typescript-eslint/typescript-eslint (@&#8203;typescript-eslint/utils)</summary>

### [`v8.59.3`](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/utils/CHANGELOG.md#8593-2026-05-11)

[Compare Source](https://github.com/typescript-eslint/typescript-eslint/compare/v8.59.2...v8.59.3)

This was a version bump only for utils to align it with other projects, there were no code changes.

See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.3) for more information.

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

</details>

<details>
<summary>typescript-eslint/typescript-eslint (typescript-eslint)</summary>

### [`v8.59.3`](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8593-2026-05-11)

[Compare Source](https://github.com/typescript-eslint/typescript-eslint/compare/v8.59.2...v8.59.3)

This was a version bump only for typescript-eslint to align it with other projects, there were no code changes.

See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.3) for more information.

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #146
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 11:26:25 +02:00
julien 2480d0dd6d fix(portal-bff): align admin entra role name with Portal.Admin (#145)
CI / check (push) Successful in 2m47s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m47s
CI / a11y (push) Successful in 1m30s
CI / perf (push) Successful in 3m57s
## Summary

The [`AdminRoleGuard`](apps/portal-bff/src/admin/admin-role.guard.ts) was matching on the literal `'admin'`, but the Entra app registration declares the admin app role with `value: "Portal.Admin"`. End result: an authenticated user with the role assigned in Entra still landed with `roles: []` in their session (claim simply not present in the id token), and every request to `/api/admin/audit` and `/api/admin/users` returned a **403**.

Caught manually in the portal-admin SPA: login succeeded, sidebar links to "Audit log" / "User list" returned 403. The [`/api/admin/auth/me`](apps/portal-bff/src/admin/admin-auth.controller.ts) self-test confirmed the missing claim was the cause.

## What lands

### Constant value — single source of truth

[`apps/portal-bff/src/admin/admin-role.guard.ts:18`](apps/portal-bff/src/admin/admin-role.guard.ts#L18):

```diff
-export const ADMIN_ROLE = 'admin';
+export const ADMIN_ROLE = 'Portal.Admin';
```

[`admin-role.guard.spec.ts`](apps/portal-bff/src/admin/admin-role.guard.spec.ts) already imports `ADMIN_ROLE` from the source rather than hardcoding the literal, so the guard contract spec rolls through unchanged. The fixtures elsewhere ([`auth.service.spec.ts`](apps/portal-bff/src/auth/auth.service.spec.ts), [`admin.controller.spec.ts`](apps/portal-bff/src/admin/admin.controller.spec.ts), [`admin-auth.controller.spec.ts`](apps/portal-bff/src/admin/admin-auth.controller.spec.ts), [`require-mfa.guard.spec.ts`](apps/portal-bff/src/auth/require-mfa.guard.spec.ts)) keep `roles: ['admin']` as fixture data — those tests exercise the extraction / serialization pipeline, which is role-value-agnostic; touching them would be incidental cleanup with no behaviour signal.

### Doc-comment refresh

Inline references to the role name updated so future readers don't grep `'admin'` and find a phantom value:

- [`admin-role.guard.ts`](apps/portal-bff/src/admin/admin-role.guard.ts) — class doc-block (3 mentions).
- [`admin.controller.ts`](apps/portal-bff/src/admin/admin.controller.ts) — class doc-block + inline guard-contract comment.
- [`audit.service.ts`](apps/portal-bff/src/audit/audit.service.ts) — `adminAccessDenied` doc-block (2 mentions).

### Documentation

- [`docs/decisions/0020-portal-admin-app.md`](docs/decisions/0020-portal-admin-app.md) — 5 references to the role across §"How is admin access enforced", §"Auth — same Entra ID …", and the Consequences §.
- [`docs/architecture.md`](docs/architecture.md) — note next to the C4 container diagram describing the admin entry gate.
- [`CLAUDE.md`](CLAUDE.md) — "Admin application" project rule.

## Notes for the reviewer

- **Why `Portal.Admin` rather than `admin`?** Operator's call on the Entra side. The `<Application>.<Role>` namespace is the conventional Entra App Role pattern when the directory may host roles for multiple applications, and `admin` alone is ambiguous in a directory shared across products.
- **Why no migration / backfill?** The role value lives only in two places: Entra app-registration manifest (operator-managed) and the BFF constant (this PR). Existing Redis sessions captured `roles: []` (claim absent) — they'll naturally pick up the correct value on next sign-in. No persisted data references the old value.
- **No ADR.** ADR-0020 §"How is admin access enforced" already commits to "Entra ID role claim + BFF guard"; the literal role string is an implementation detail the ADR happened to spell. Updated the ADR's prose to the new value to keep the doc honest, but the decision is unchanged.

## Test plan

- [x] `pnpm nx test portal-bff` — **396 specs pass**, unchanged from `main`. The `AdminRoleGuard` contract spec (covers 401-on-no-session, 403-on-missing-role + audit emission, pass-through-on-role-present) imports `ADMIN_ROLE` and re-exercises with the new value.
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [ ] Manual verification — pending Entra-side App Role declaration with `value: "Portal.Admin"` + assignment to the test user. Once both exist: sign out + sign in on portal-admin, hit `/api/admin/auth/me` and confirm `roles: ["Portal.Admin"]`, then click "Audit log" + "User list" and confirm both render. An `admin.access_denied` row in `audit.events` is the negative-test signal (still emitted for any user without the role).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #145
2026-05-15 10:33:54 +02:00
APF Portal Bot 0335d992d9 chore(deps): update dependency vite to v8.0.13 (#144)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m51s
CI / a11y (push) Successful in 3m34s
CI / check (push) Successful in 6m43s
CI / perf (push) Successful in 6m52s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [vite](https://vite.dev) ([source](https://github.com/vitejs/vite/tree/HEAD/packages/vite)) | devDependencies | patch | [`8.0.12` -> `8.0.13`](https://renovatebot.com/diffs/npm/vite/8.0.12/8.0.13) |

---

### Release Notes

<details>
<summary>vitejs/vite (vite)</summary>

### [`v8.0.13`](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-8013-2026-05-14-small)

[Compare Source](https://github.com/vitejs/vite/compare/v8.0.12...v8.0.13)

##### Features

- **bundled-dev:** add lazy bundling support ([#&#8203;21406](https://github.com/vitejs/vite/issues/21406)) ([4f0949f](https://github.com/vitejs/vite/commit/4f0949f3f13e4b2b34d32bf7b2b4de5f26bea192))
- **optimizer:** improve the esbuild plugin converter to pass some properties of build result to `onEnd` ([#&#8203;22357](https://github.com/vitejs/vite/issues/22357)) ([47071ce](https://github.com/vitejs/vite/commit/47071ce53f21726cf39e999c4407c4828ecbe957))
- update rolldown to 1.0.1 ([#&#8203;22444](https://github.com/vitejs/vite/issues/22444)) ([8c766a6](https://github.com/vitejs/vite/commit/8c766a6c5ee014969c4e32f29cc265e8e2c96e18))

##### Bug Fixes

- **build:** copy public directory after building same environment with `write=false` ([#&#8203;22328](https://github.com/vitejs/vite/issues/22328)) ([158e8ae](https://github.com/vitejs/vite/commit/158e8ae8efdf7075ab295727e36b5ff68da3243e))
- **css:** await sass/less/styl worker disposal on teardown (fix [#&#8203;22274](https://github.com/vitejs/vite/issues/22274)) ([#&#8203;22275](https://github.com/vitejs/vite/issues/22275)) ([b7edcb7](https://github.com/vitejs/vite/commit/b7edcb7d0dd17ddfeef4ace78d610c099216dade))
- **css:** keep deprecated `name`/`originalFileName` in synthetic `assetFileNames` call ([#&#8203;22439](https://github.com/vitejs/vite/issues/22439)) ([8e59c97](https://github.com/vitejs/vite/commit/8e59c97a44d923c4c06f67287a793c9aa5a4ebaa))
- make `isBundled` per environment ([#&#8203;22257](https://github.com/vitejs/vite/issues/22257)) ([a576326](https://github.com/vitejs/vite/commit/a5763266170f8606836da5c6f987b4b2fd6ddc55))
- **ssr:** avoid rewriting labels that collide with imports ([#&#8203;22451](https://github.com/vitejs/vite/issues/22451)) ([d9b18e0](https://github.com/vitejs/vite/commit/d9b18e0387a253628d3d834288e79c5f7e85d566))

##### Miscellaneous Chores

- remove irrelevant commits from changelog ([#&#8203;22430](https://github.com/vitejs/vite/issues/22430)) ([6ea3838](https://github.com/vitejs/vite/commit/6ea383859aaf0ef8e673b458f164e84aeb6ff51d))
- update changelog ([#&#8203;22413](https://github.com/vitejs/vite/issues/22413)) ([fcdc87c](https://github.com/vitejs/vite/commit/fcdc87cc6799857e2bab0f44f333a681694fff74))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/144
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 09:58:51 +02:00
julien 1513ad327c feat(portal-bff): openapi spec + scalar api reference UI (dev-only) (#143)
CI / scan (push) Successful in 2m5s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m33s
CI / a11y (push) Successful in 1m22s
CI / perf (push) Successful in 3m34s
## Summary

Adds an OpenAPI 3 spec + a [Scalar API Reference](https://scalar.com/) UI to `portal-bff`, dev-only. The BFF previously had no way to *see* its HTTP surface short of grepping for `@Get` / `@Post`; this PR generates the spec from the existing Nest controllers via [`@nestjs/swagger`](https://docs.nestjs.com/openapi/introduction) and renders it through Scalar — a modern alternative to the classic Swagger UI (single-page, fast, dark-mode native, better typography).

## What lands

### Two new dev-only routes

| Route | What it serves |
| --- | --- |
| `GET /api/openapi.json` | Raw OpenAPI 3 document. External tools (Bruno / Insomnia / Postman) import from here. |
| `GET /api/docs` | Scalar API Reference HTML page. Loads the JSON spec at render time and renders the full endpoint catalogue with a "Try it" panel. |

Both routes are gated behind `process.env.NODE_ENV !== 'production'` in [`setupOpenApi`](apps/portal-bff/src/openapi/openapi.ts) — production deployments don't need the docs surface, and publishing it would hand an attacker a curated map of every authenticated endpoint + every DTO shape. If a future ops use-case wants the spec in prod (internal gateway, contract testing), the gate is one line away from an opt-in `OPENAPI_PUBLISH=true` env knob.

### Core implementation — [`apps/portal-bff/src/openapi/openapi.ts`](apps/portal-bff/src/openapi/openapi.ts)

Two exported helpers:

- **`buildOpenApiDocument(app)`** — wraps Nest's `DocumentBuilder` + `SwaggerModule.createDocument`. Sets title, description (mentions the CSRF caveat — see below), version, and registers **two** cookie security schemes:
  - `portal_session` for the user-portal surface ([ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md)).
  - `portal_admin_session` for the admin-portal surface ([ADR-0020](docs/decisions/0020-portal-admin-app.md)).
  No `@ApiBearerAuth` is declared — the BFF never exposes a bearer-auth surface (SPA never holds tokens per ADR-0009; downstream OBO tokens are server-side only per ADR-0014).

- **`setupOpenApi(app, globalPrefix)`** — short-circuits in production, otherwise binds the two routes via the Express adapter directly (`app.getHttpAdapter().get(...)` and `app.use(...)`). The OpenAPI JSON is a static asset and Scalar is a vanilla Express middleware — wrapping either in a Nest controller would add zero value and an extra layer of indirection.

Wired into bootstrap at [`apps/portal-bff/src/main.ts:220`](apps/portal-bff/src/main.ts#L220), immediately after the JWKS endpoint mount and before `app.listen()`.

### Controllers decorated with `@ApiTags` / `@ApiOperation` / `@ApiCookieAuth`

Annotations are cosmetic but make the spec actually browsable. Tag taxonomy:

| Controller | Tag | Security |
| --- | --- | --- |
| [`AppController`](apps/portal-bff/src/app/app.controller.ts) | `app (scaffolding)` | — |
| [`HealthController`](apps/portal-bff/src/health/health.controller.ts) | `health` | — |
| [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) | `auth (user portal)` | `portal_session` on `/me` + `/logout` |
| [`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) | `auth (admin portal)` | `portal_admin_session` on `/me` + `/logout` |
| [`AdminController`](apps/portal-bff/src/admin/admin.controller.ts) | `admin (self-test)` | class-level `portal_admin_session` |
| [`AdminAuditController`](apps/portal-bff/src/admin/admin-audit.controller.ts) | `admin (audit log)` | class-level `portal_admin_session` |
| [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts) | `admin (user directory)` | class-level `portal_admin_session` |

`@ApiOperation({ summary: … })` added on every route — populates the one-line description Scalar shows in its left-rail TOC.

### Deps + Jest

- `@nestjs/swagger ^11` (matches the Nest 11 major already pinned) and `@scalar/nestjs-api-reference` added to the workspace root.
- [`jest.config.cts`](apps/portal-bff/jest.config.cts) — widened `transformIgnorePatterns` from `/node_modules/(?!.*jose)/` to `/node_modules/(?!.*(jose|@scalar/))/`. `@scalar/client-side-rendering` (a transitive dep) ships ESM-only; without this widening the spec suite fails to load the module under ts-jest.

## Notes for the reviewer

- **Why two cookie schemes rather than one?** Scalar renders a per-endpoint lock icon driven by the security scheme name. Splitting `portal_session` / `portal_admin_session` keeps the indicator semantically truthful — `/api/auth/me` and `/api/admin/auth/me` look identical otherwise.
- **CSRF caveat.** Mutating routes (`POST` / `PUT` / `PATCH` / `DELETE`) require `X-CSRF-Token` per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md). The header must be set manually in Scalar's "Try it" panel to the value of the `portal_csrf` cookie when exercising those routes. The spec description mentions it; auto-injecting the header from the cookie is a future polish.
- **No ADR for this.** `@nestjs/swagger` is the framework's own first-party tooling; Scalar is a thin UI on top of a standard OpenAPI 3 document. Both replaceable without touching the controllers (the `@Api*` annotations are spec-standard). Dev-only, no prod surface — doesn't cross any of the bars that warrant an ADR per [CLAUDE.md](CLAUDE.md).
- **Express-layer routing.** Same pattern as the JWKS endpoint (#139): the OpenAPI JSON is a static asset and Scalar a vanilla Express handler, so wiring through Nest's router adds no value.

## Test plan

- [x] **5 new specs** in [`apps/portal-bff/src/openapi/openapi.spec.ts`](apps/portal-bff/src/openapi/openapi.spec.ts) — document shape (openapi version, title, version), both cookie schemes declared, smoke controller route captured in `paths`, production short-circuit (no routes mounted, no `app.use` called), dev mount (JSON at `/api/openapi.json` via the HTTP adapter, Scalar UI at `/api/docs` via `app.use`).
- [x] `pnpm nx test portal-bff` — **396 specs pass** (was 391).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Manual dev smoke: `pnpm nx serve portal-bff`, `curl /api/openapi.json | jq .info` returns title + version, open `/api/docs` in a browser, every controller's routes visible under their tag, lock icons match the cookie scheme on guarded routes.

## What's next — light follow-ups

Not blocking this PR; mentioned so they're not lost:

- Auto-inject the `X-CSRF-Token` header in Scalar from the `portal_csrf` cookie (custom Scalar config preset).
- Promote `@ApiOperation` summaries with multi-line `description`s on the more involved routes (`/api/admin/audit`, `/api/admin/users`).
- Annotate DTOs with `@ApiProperty` once the first contract-test consumer arrives — Nest can also pick them up automatically with the `@nestjs/swagger` ts-plugin if we wire it into the Nx build target. Deferred until the spec is consumed by tooling that benefits from the precision.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #143
2026-05-14 20:52:03 +02:00
julien e90e88ec45 feat(portal-admin): user directory viewer screen (#142)
CI / check (push) Successful in 2m57s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m16s
CI / a11y (push) Successful in 1m29s
CI / perf (push) Successful in 4m0s
## Summary

Final PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the SPA viewer at `/users` that consumes [`GET /api/admin/users`](apps/portal-bff/src/admin/admin-users.controller.ts) (PR #141) and renders a filter form + paginated table mirroring the audit viewer's shape (PR #136). The full chantier is now closed: schema + sign-in upsert (#140) → read endpoint (#141) → this viewer.

## What lands

### [`AdminUsersService`](apps/portal-admin/src/app/pages/users/admin-users.service.ts)

Thin `HttpClient` wrapper around `GET /api/admin/users`. Drops empty-string filter values (Nest's `ValidationPipe` rejects `?foo=` as `foo === ''`). `providedIn: 'root'` — the users page is the single v1 consumer.

### [`UsersPage`](apps/portal-admin/src/app/pages/users/users.ts)

Signal-driven page mirroring the `/audit` viewer:

| Knob | State |
| --- | --- |
| `username`, `displayName`, `audience`, `lastSeenAtFrom`, `lastSeenAtTo` | One signal per filter. |
| `limit`, `offset` | Pagination. Default 50, cap 200 to match the BFF's `MAX_LIMIT`. |
| `page`, `loading`, `error` | Async triplet. |
| `hasNextPage`, `hasPreviousPage`, `resultRange` | Computed for the pagination controls + the "1–50 of 137" status line. |

UI:

- **Filter form** — username (prefix), displayName (contains), audience enum, lastSeenAt range (`datetime-local` inputs → ISO), page-size selector. Apply + Reset.
- **Result table** — displayName, username, audience badge, firstSeen, lastSeen (locale-formatted), oid (monospaced).
- **Pagination** — Previous / Next disabled at boundaries. Offset resets to 0 on Apply Filters / Reset.
- **States** — explicit loading, empty (`"No users match the current filters."`), error. **403 surfaces "you do not have access"**; everything else collapses to a generic retry message — same posture as the audit viewer.

### Routing + i18n + sidebar

- `/users` route lazy-loaded in [`app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) with `route.users.title` i18n marker.
- [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) updated with the French translation (`Utilisateurs — Administration APF Portal`); the prod build's `i18nMissingTranslation=error` policy would fail otherwise.
- [`AdminSidebar`](apps/portal-admin/src/app/components/sidebar/sidebar.ts) "User list" entry promoted from `aria-disabled` placeholder ("Soon" badge) to a live `RouterLink` at `/users`. The matching spec is updated.

## Notes for the reviewer

- The shape mirrors `AuditPage` (#136) intentionally — same signal layout, same flush dance in the spec, same 403/5xx error split, same datetime-local-to-ISO conversion. A future contributor coming from one viewer lands familiar code on the other.
- Each viewer ships as a separate lazy chunk (`users` = 3.76 KB gzip, `audit` = 4.35 KB gzip). No shared filter-form lib promoted yet — the duplication is mechanical and small (~120 LOC each), and ADR-0020's next admin modules (CMS, menu management) may diverge enough that premature factoring would be expensive to undo.
- No sign-in counts column. The BFF endpoint exposes the directory data only (`firstSeenAt` / `lastSeenAt`). Joining sign-in counts from `audit.events` via `HashUserIdService` is deferred until admin demand justifies the extra query per row.

## Test plan

- [x] `pnpm nx test portal-admin` — **45 specs pass** (was 30; +15: AdminUsersService 3, UsersPage 11, sidebar 1).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Build emits the lazy `users` chunk at **14.59 KB raw / 3.76 KB gzip** — comfortably under the per-chunk lazy budget.
- [ ] e2e — pending Entra `admin` role assignment + at least a couple of sign-in events to populate the directory. Once both exist: sign in via `/api/admin/auth/login`, navigate to `/users`, see the directory populate with each row's displayName + lastSeen, exercise the filters, observe `admin.users.query` rows in `audit.events` per fetch.

## What's next — portal-admin v1 chantiers

This PR closes **User list**. Remaining ADR-0020 v1 modules:

- **Menu management** — CRUD on `cms.menu_items`; `GET /api/me/menu` consumed by portal-shell + `/api/admin/menu` admin CRUD. Medium chantier.
- **CMS pages** — multi-locale editorial content (`cms.pages` with slug + locale + body markdown), admin editor screen, portal-shell consumer for the rendering. Bigger chantier.

Out of immediate scope:

- The strategic security baseline ADR (paused awaiting RSSI input on ASVS / HDS / GDPR / NIS 2).
- Per-integration downstream ADRs (the strategy layer from #137 + #138 + #139 is ready to be assembled around the first real consumer).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #142
2026-05-14 20:15:21 +02:00
julien 1df99cd800 feat(portal-bff): admin user-directory read endpoint (#141)
CI / check (push) Successful in 3m1s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m34s
CI / a11y (push) Successful in 2m35s
CI / perf (push) Successful in 5m7s
## Summary

Second PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the **read side**: paginated, filterable HTTP endpoint that queries the `public.users` directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier.

## What lands

### [`AdminUsersQueryDto`](apps/portal-bff/src/admin/users-query.dto.ts)

Mirrors `AdminAuditQueryDto`'s posture — filters all optional, every unknown query key rejected by `forbidNonWhitelisted`, limit capped at **200** / default **50**.

| Filter | Type | Notes |
| --- | --- | --- |
| `username` | string ≤128 | Exact-prefix match (Prisma `startsWith`). |
| `displayName` | string ≤128 | Case-insensitive `contains` (display names vary in casing). |
| `audience` | enum | `workforce` \| `customer`. |
| `lastSeenAtFrom` | ISO-8601 | Inclusive lower bound. |
| `lastSeenAtTo` | ISO-8601 | Exclusive upper bound. |
| `limit` | int 1..200 | Default **50**. |
| `offset` | int ≥0 | Default **0**. |

### [`AdminUsersReader`](apps/portal-bff/src/admin/admin-users-reader.service.ts)

Prisma typed client against `public.users` — **no `SET LOCAL ROLE`** dance because `public.users` has no role-based privilege gate. The trust boundary is the controller's `@RequireAdmin` guard.

- **Order**: `last_seen_at DESC, oid ASC`. The second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp.
- **COUNT + SELECT in one Prisma transaction** so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries.
- **Hard cap on limit** at 200 even when a caller bypasses the DTO — defense in depth on the BFF's event loop.

### [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts)

`GET /api/admin/users`, `@RequireAdmin()` at the class level. Forwards the validated DTO to `AdminUsersReader`, then emits `admin.users.query` with `{ filters, resultCount }` — the fishing-expedition deterrent (mirror of `admin.audit.query` from PR #132).

### [`AuditWriter.adminUsersQuery()`](apps/portal-bff/src/audit/audit.service.ts)

New typed method + `AdminUsersQueryInput` type. Same `outcome=success` / payload shape as `adminAuditQuery` — two distinct event types so a reviewer can pivot directly on `eventType` without parsing payload.

## Notes for the reviewer

- The shape mirrors `AdminAuditController` (#132) deliberately. Future admin read endpoints (CMS pages, menu items if they grow read filters) should adopt the same shape — keeps the SPA's data-flow pattern consistent across modules.
- `public.users` queries don't need `SET LOCAL ROLE` because there's no append-only / read-only role split on the table. That's a deliberate v1 simplification; if the security review later asks for stricter isolation we'd add a `users_reader` role + the same SET-LOCAL pattern AuditReader uses.
- The future "sign-in counts" join from `audit.events` on `actor_id_hash` is **deferred**. The salted hash is computable on the fly via `HashUserIdService`, so adding it later is a service-level change — no schema migration required.
- The `actor_id_hash` is deliberately **NOT** stored on `public.users` (per ADR-0013's invariant — the salt stays inside the audit module).

## Test plan

- [x] `pnpm nx test portal-bff` — **391 specs pass** (was 365; +26 covering DTO validation, reader transaction shape + filter forwarding + pagination defaults + cap, controller path, audit typed method).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Prisma transaction shape verified: COUNT + SELECT both fire exactly once per call; tuple-return contract honoured.
- [x] Filter projections verified per filter: username `startsWith`, displayName case-insensitive `contains`, audience exact match, lastSeenAt `gte/lt` composition.
- [ ] e2e — pending the admin SPA viewer screen + a real admin session. Once both exist: `curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10` returns the matching subset and a new `admin.users.query` audit row lands.

## What's next

The chantier's final PR:

- **portal-admin `/users` screen** — SPA viewer with filter form + table + pagination. Same shape as the `/audit` page (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry from `aria-disabled` "Soon" badge to a live link.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #141
2026-05-14 20:01:38 +02:00
julien aca9e8d155 feat(portal-bff): user directory upserted at sign-in (#140)
CI / check (push) Successful in 3m2s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m54s
CI / a11y (push) Successful in 1m25s
CI / perf (push) Successful in 4m23s
## Summary

First PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the **write side** only:

1. A new `public.users` table that holds the BFF's local cache of identities seen sign in to either portal-shell or portal-admin.
2. A `UserDirectoryService.recordSignIn(user)` upsert called from `SessionEstablisher.establish` after the blocking audit write.

The read side (`GET /api/admin/users` + the admin viewer SPA screen) lands in two follow-up PRs of the same chantier.

## Schema

[`prisma/schema.prisma`](apps/portal-bff/prisma/schema.prisma) gains a `User` model in the `public` schema:

| Column | Type | Notes |
| --- | --- | --- |
| `oid` | TEXT, PK | Entra's stable per-user identifier inside the tenant. Per-tenant uniqueness is sufficient for v1's single-workforce-tenant design (ADR-0008). |
| `tid` | TEXT | Tenant id. Updated on every upsert because a dual-audience future may legitimately change it. |
| `audience` | TEXT | `'workforce'` \| `'customer'`. Hardcoded to `workforce` in v1 per ADR-0008's simplification; will read from session/claims when External ID activates. |
| `username` | TEXT | Updated on every upsert (Entra-side rename possible). |
| `display_name` | TEXT | Same. |
| `first_seen_at` | TIMESTAMPTZ | Set once at first sign-in via DEFAULT NOW(); **never overwritten** thereafter. Enables "users since <date>" without joining anything. |
| `last_seen_at` | TIMESTAMPTZ | Updated on every upsert. Enables "most recently active" without scanning `audit.events`. |

Indexes:
- `last_seen_at DESC` — admin default sort.
- `username` — prefix filtering.

Migration in [`prisma/migrations/20260514192014_users_directory/`](apps/portal-bff/prisma/migrations/20260514192014_users_directory/migration.sql).

## [`UserDirectoryService`](apps/portal-bff/src/users/user-directory.service.ts)

```ts
async recordSignIn(entry): Promise<void> {
  try {
    await prisma.user.upsert({
      where: { oid },
      create: { oid, tid, audience, username, displayName },
      update: { tid, audience, username, displayName, lastSeenAt: new Date() },
    });
  } catch (err) {
    // logged, never propagated
  }
}
```

**Best-effort write.** Catches its own errors, logs a Pino warn (`user_directory.record_sign_in_failed`), returns `undefined`. The directory is a convenience for admin browsing, not a security boundary — a Postgres hiccup must not lock a user out of sign-in. ADR-0013's "no audit ⇒ no action" applies to the audit module only.

## [`SessionEstablisher`](apps/portal-bff/src/auth/session-establisher.service.ts) wiring

The directory call lands right after the existing audit emission:

```ts
await this.audit.signIn({ actor: user, sessionId: req.sessionID }); // blocking per ADR-0013
await this.userDirectory.recordSignIn({ ...user, audience: 'workforce' }); // best-effort
this.logger.log(...);
```

Two invariants the tests pin:

1. **Audit-first**: when `audit.signIn` throws, `userDirectory.recordSignIn` is NOT called. The directory never holds a row for a sign-in the audit log doesn't carry.
2. **Awaited**: an admin who just signed in sees themselves on the user list immediately — no race between the upsert and the response.

## Module wiring

[`UsersModule`](apps/portal-bff/src/users/users.module.ts) is declared `@Global()` so `SessionEstablisher` (which lives in `AuthModule`) injects `UserDirectoryService` without forcing `AuthModule` to import `UsersModule`. The directory is a true cross-cutting concern: one writer (the auth callback) and one future reader (the admin endpoint).

Wired into [`AppModule`](apps/portal-bff/src/app/app.module.ts) alongside the other v1 modules. `auth.module.spec.ts` updated to also import `UsersModule` in its slice-of-graph compile (otherwise the test fails to resolve the new `SessionEstablisher` dep).

## Notes for the reviewer

- The directory write **awaits** (not fire-and-forget). The cost is one round-trip per sign-in on the response-critical path; the benefit is the no-race property called out above. If sign-in p95 becomes an issue we can revisit (e.g. background job) but the simpler shape is correct first.
- `firstSeenAt` is intentionally absent from the `update` payload. The Prisma upsert's `update` block is precisely what changes on conflict; omitting the field leaves it untouched at the column level (Postgres-side default doesn't refire on UPDATE).
- The model lives in `public`, not in a dedicated `identity` or `cms` schema. ADR-0020 enumerates `cms.*` for editorial data and `audit.*` for the audit ledger but doesn't require a separate schema for user-directory data. We can promote it to its own schema later if a role-isolation need emerges; the migration would be a `ALTER TABLE users SET SCHEMA …`.
- `audit.events.actor_id_hash` is **not** stored on `public.users`. A future admin endpoint that joins sign-in counts from `audit.events` can compute the hash on-the-fly via `HashUserIdService` — keeping the salted-hash invariant from ADR-0013 intact (the salt stays inside the audit module).

## Test plan

- [x] `pnpm nx test portal-bff` — **365 specs pass** (was 358; +7: UserDirectoryService 4, SessionEstablisher integration 3).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing rate-limit warnings + one unused-eslint-disable from PR #137 are unrelated).
- [x] Prisma `migrate diff` confirms the model matches the migration SQL.
- [ ] e2e — after merge: sign in via portal-shell or portal-admin, expect a row in `public.users` with the right `oid` / `last_seen_at`; sign in again, expect the same row's `last_seen_at` to advance and `first_seen_at` to stay put.

## What's next

The chantier sequence:

1. **This PR** — write side: schema + service + sign-in upsert.
2. **PR 2** — BFF `GET /api/admin/users` (paginated + filterable, gated by `@RequireAdmin`, emits `admin.users.query` audit).
3. **PR 3** — portal-admin `/users` screen (table + filter form), promote the sidebar entry from "Soon" badge to live link.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #140
2026-05-14 19:30:12 +02:00
julien 96339cc99b fix(portal-bff): serve /.well-known/jwks.json via express (path-to-regexp v8 ducks the dot) (#139)
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m23s
CI / scan (push) Successful in 2m29s
CI / a11y (push) Successful in 59s
CI / perf (push) Successful in 4m0s
## Summary

The Nest `@Controller('.well-known/jwks.json')` declared in PR #138 combined with `setGlobalPrefix('api', { exclude: [...] })` landed the JWKS route at **neither** `/.well-known/jwks.json` (intended) **nor** `/api/.well-known/jwks.json` (with-prefix fallback). Both URLs 404'd. The user reported it on the merged PR; this fix reroutes the endpoint so the JWKS lands at the correct RFC 8615 bare-root path.

## Root cause

Nest 11 routes via [path-to-regexp v8.4.2](https://github.com/pillarjs/path-to-regexp/blob/main/Readme.md), whose grammar broke backward compatibility on several leading-character cases. The combination of a leading-dot path segment (`.well-known`) plus the `setGlobalPrefix` `exclude` rewrite falls into one of those cases — the route registers but matches no incoming request. Without the `exclude`, it would register under `/api/.well-known/jwks.json`, which would at least be reachable, but with `exclude` enabled it ends up in a path-to-regexp limbo.

## Fix

Sidestep Nest's router for this one route. The JWKS payload-builder stays in the Nest DI graph (renamed `JwksController` → `JwksPublisher`, just the decorators stripped), and [`main.ts`](apps/portal-bff/src/main.ts) resolves it from the container then registers a plain Express GET handler at `/.well-known/jwks.json`. Express's router accepts the leading dot verbatim and the route lands exactly where RFC 8615 says it should.

```ts
const jwksPublisher = app.get(JwksPublisher);
app.getHttpAdapter().get('/.well-known/jwks.json', (_req, res) => {
  res.json(jwksPublisher.jwks());
});
```

## Touched

- [`jwks.controller.{ts,spec.ts}`](apps/portal-bff/src/downstream/) → [`jwks.publisher.{ts,spec.ts}`](apps/portal-bff/src/downstream/). Same constructor, same `jwks()` method shape — only the `@Controller` / `@Get` decorators are gone. The DI signature is unchanged so the existing tests rename → green without other edits.
- [`downstream.module.ts`](apps/portal-bff/src/downstream/downstream.module.ts): drops the `controllers` array, lists `JwksPublisher` as a provider + export so `main.ts` can resolve it.
- [`main.ts`](apps/portal-bff/src/main.ts): drops the `setGlobalPrefix` `exclude` option, drops the `RequestMethod` import, registers an Express GET handler at the bare-root JWKS path immediately before `app.listen()`.

## Verification

Verified locally against a running BFF (with a generated RSA-3072 key + `BFF_JWKS_KID=bff-2026-05`):

```bash
$ curl -s http://localhost:3000/.well-known/jwks.json | jq .
{
  "keys": [
    {
      "kty": "RSA",
      "n": "ppDvWBUEQTD6sv-7FFG-UfCPALG…",
      "e": "AQAB",
      "kid": "bff-2026-05",
      "alg": "RS256",
      "use": "sig"
    }
  ]
}
```

## Test plan

- [x] `pnpm nx test portal-bff` — **358 specs pass** (unchanged: the publisher's `jwks()` method shape is identical, the rename-only spec delta keeps the existing coverage).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Manual: `curl http://localhost:3000/.well-known/jwks.json` returns the JWKS with the configured `kid`, `alg=RS256`, `use=sig`. No private RSA components (`d` / `p` / `q` / `dp` / `dq` / `qi`) in the response.

## Notes for the reviewer

- The "use Express directly when path-to-regexp v8 fights you" escape hatch is rare. It's the right move here because the path is fixed by RFC 8615 — we can't compromise on the URL shape. For any other route we'd let Nest's router handle it.
- The publisher class is still injectable, still in the DI graph, still trivially mockable in tests. The only thing that's "outside Nest" is the route binding in `main.ts`. Production behaviour is identical to a Nest-routed controller; only the registration mechanism differs.
- No new specs were added because the routing fix is a wiring change. A controller-spec-style integration test using Nest's `TestingModule` wouldn't exercise the actual Express route binding either, so the manual curl + the publisher's existing unit tests are the right coverage.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #139
2026-05-14 19:12:38 +02:00
julien 282a972346 feat(portal-bff): signed-assertion strategy + /.well-known/jwks.json (#138)
CI / scan (push) Successful in 2m40s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m38s
CI / a11y (push) Successful in 1m38s
CI / perf (push) Successful in 3m36s
## Summary

Second half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the **signed-assertion strategy** (non-Entra downstreams) and the **JWKS publishing endpoint** as testable primitives, completing the strategy layer the OBO PR (#137) started. The framework around them (DownstreamApiClientFactory, cockatiel, audience pre-check, error translation) still waits for the first concrete integration per the ADR's own "until then" clause.

After this PR the BFF has, ready to plug into a future integration:

- `OboStrategy` — Entra-protected downstreams (PR #137)
- `SignedAssertionStrategy` — non-Entra downstreams (this PR)
- `DownstreamTokenCache` — encrypted-at-rest OBO token cache (PR #137)
- `GET /.well-known/jwks.json` — public key publication (this PR)

## What lands

### [`assertJwksConfig`](apps/portal-bff/src/config/check-jwks-config.ts)

Boot validator for `BFF_JWKS_PRIVATE_KEY_PATH` + `BFF_JWKS_KID`. Reads the PEM file once at startup, refuses missing / unreadable / weak material (RSA < 2048, Ed25519, unknown key type), derives the JOSE algorithm (`RS256` / `ES256` / `ES384`) from the key shape, and validates the kid against `[A-Za-z0-9_-]{4,128}` so the value lives unescaped in JWT headers + JWKS payloads.

### [`BffSigningKey`](apps/portal-bff/src/downstream/bff-signing-key.ts)

Singleton holding `{ config: JwksConfig, publicJwk: JWK }`. The `publicJwk` is derived from the **public half** of the key (via `jose.exportJWK` on a `createPublicKey`-derived `KeyObject`) so no private material can leak through. Single DI source for both consumers (strategy + JWKS controller) so a key rotation only changes one provider.

### [`SignedAssertionStrategy`](apps/portal-bff/src/downstream/strategies/signed-assertion.strategy.ts)

Wraps `jose.SignJWT` with the ADR-0014 claim shape:

```json
{
  "iss": "portal-bff",
  "sub": "<actor_id_hash>",
  "aud": "<downstream-name>",
  "audience": "workforce" | "customer",
  "claims": { /* curated subset */ },
  "exp": <now + 60s>,
  "iat": <now>,
  "trace_id": "<W3C trace id>"
}
```

- **60 s TTL** hard-coded — the ADR mandates it.
- **No JWT cache** — at 60 s lifetime the savings would be negligible and a cache would let replayed assertions linger past their useful life. The signing operation itself is cheap (~hundreds of µs for RS256 with a 3 KB key).
- **kid in the protected header** matches the JWKS so a downstream picks the right key during rotation.
- Supports **RS256 / ES256 / ES384** transparently — picks the alg the validator derived at boot.

### [`JwksController`](apps/portal-bff/src/downstream/jwks.controller.ts)

`GET /.well-known/jwks.json` returns `{ keys: [<single jwk>] }`. v1 publishes one key; the rotation chantier will add a second entry + window-based eviction so a downstream that cached the previous JWK keeps verifying during cut-over.

[`main.ts`](apps/portal-bff/src/main.ts) excludes `/.well-known/*` from the global `/api` prefix so the route lands at the bare root per RFC 8615. No auth gate — the JWKS is the verification anchor; gating it would defeat the purpose. The CSRF middleware already exempts GET methods, so the route comes out clean.

## Required env update (mandatory at boot)

Generate the key:

```bash
mkdir -p apps/portal-bff/.secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
  -out apps/portal-bff/.secrets/jwks.pem
```

Set in `apps/portal-bff/.env`:

```env
BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem
BFF_JWKS_KID=bff-2026-05
```

The repo's existing `*.pem` / `*.key` gitignore patterns cover `.secrets/`.

## Dependency

- **`jose@^6`** added as a direct dep (was transitive via MSAL). Pinned at the workspace root since the BFF is the only consumer today and the package isn't part of the Angular bundle graph.
- `jest.config.cts`: `jose` ships ESM-only, so its `node_modules` path is removed from `transformIgnorePatterns`. The pattern walks pnpm's deep `.pnpm/` layout — anything under `/node_modules/` whose path also contains `jose` somewhere gets transformed by ts-jest.

## Out of scope (deferred until the first concrete integration)

Per ADR-0014's "until then" clause:

- `DownstreamApiClientFactory` + per-service typed `DownstreamApiConfig`.
- `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead).
- Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit).
- Error translation tables per service.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- The framework code that actually calls `SignedAssertionStrategy.sign()` and attaches `X-User-Assertion` + the `ServiceCredential` auth header to an outbound HTTP request.
- Key rotation (the JWKS lists one key for now; the rotation chantier adds the second entry + eviction policy).

These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs.

## Test plan

- [x] `pnpm nx test portal-bff` — **358 specs pass** (was 334; +24: env validators 11, signing key 4, strategy 6, controller 3).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Env validator: missing path, unreadable file, garbage PEM, RSA-1024 (weak), Ed25519 (unsupported), missing kid, illegal kid charset, kid too short.
- [x] Signing key: RSA / EC P-256 / EC P-384 round-trip to public JWK with no private material (`d`, `p`, `q`, `dp`, `dq`, `qi` all absent from the published JWK).
- [x] Strategy: claim shape matches ADR-0014, `exp - iat == 60`, audience mismatch rejected, signature mismatch rejected, EC P-256 signing path (ES256), per-call freshness.
- [x] Controller: returns JWKS with the single public key, no private material leaks.
- [ ] Manual smoke: generate a key locally + set the two env vars + `curl http://localhost:3000/.well-known/jwks.json` should return the JWKS shape with the chosen kid.

## Notes for the reviewer

- The strategy uses `setProtectedHeader({ alg, kid })` — the kid in the protected header is the canonical way to tell a verifier "use the entry with this kid in the JWKS". Without it, a verifier holding two keys during rotation has to try both.
- The `60 s` TTL is intentionally not env-overridable. ADR-0014 mandates it; making it tunable would create a tempting knob to widen the replay window for "performance".
- `jose` was already in the tree transitively (likely via MSAL). Promoting it to a direct dep + pinning means a future hoist deduplication can't silently remove it without our review.

## What's next

The chantier's strategy layer is complete. Open follow-ups on the roadmap:

- **First concrete downstream integration** — when a real consumer arrives, the framework gets built around the two strategies (DownstreamApiClientFactory, cockatiel resilience, audience pre-check, error translation, OTel spans, audit events). Until then the strategies + cache + JWKS sit ready.
- **Strategic security baseline ADR** — RSSI sign-off on ASVS / HDS / GDPR / NIS 2. Paused per [CLAUDE.md](CLAUDE.md) §"Repository status".
- **portal-admin v1 modules** — CMS pages, menu management, user list. Each is its own self-contained chantier.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #138
2026-05-14 18:34:07 +02:00
julien d665c66c4e feat(portal-bff): obo strategy + encrypted downstream token cache (#137)
CI / check (push) Successful in 3m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m31s
CI / a11y (push) Successful in 1m45s
CI / perf (push) Successful in 3m25s
## Summary

First half of the **DownstreamApiClient + OBO** chantier per [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md). Ships the OBO auth strategy and its encrypted-at-rest token cache as testable primitives — explicitly **not** the full `DownstreamApiClientFactory` + cockatiel + audience-pre-check framework.

The scope is dictated by ADR-0014 §"Consequences":

> *"Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. **Mitigated by writing the framework code only in the same iteration as the first concrete integration; until then, this ADR plus mock-driven unit tests on the strategies (OBO, signed-assertion) keep the design honest.**"*

The framework gets assembled when the first real downstream integration arrives, with that integration as the validation surface. The next PR in this chantier ships the symmetric signed-assertion strategy + the JWKS endpoint.

## What lands

### [`assertOboCacheEncryptionKey`](apps/portal-bff/src/config/check-obo-cache-encryption-key.ts)

Boot validator mirroring `assertSessionEncryptionKey`. AES-256-GCM, 32-byte requirement, placeholder rejection, fail-fast posture. Plus one extra defense in depth:

> *Refuses a value identical to `SESSION_ENCRYPTION_KEY`* — ADR-0014 §"Token cache (for OBO)" mandates dedicated keys; catching the copy-paste regression at boot prevents a silent trust-boundary downgrade.

Wired in [`main.ts`](apps/portal-bff/src/main.ts) alongside the other `assertX()` validators.

### [`DownstreamTokenCache`](apps/portal-bff/src/downstream/downstream-token-cache.service.ts)

Redis-backed cache, key shape `obo:{actorIdHash}:{resource}`. Encrypts each entry via the shared AES-256-GCM helpers from `session-crypto` but under a **dedicated key** (`OBO_CACHE_KEY`).

| Path | Behaviour |
| --- | --- |
| Cache miss | Returns `null`. |
| Tampered ciphertext | Returns `null` + Pino warn `downstream.obo_cache.decrypt_failed`. |
| Wrong-key ciphertext | Returns `null` (GCM auth-tag mismatch). |
| Decrypted but malformed shape | Returns `null` + Pino warn. |
| Redis read failure | Returns `null` + Pino warn `downstream.obo_cache.read_failed`. |
| Write of a token already inside the 60 s buffer | Skipped (TTL would be useless). |
| Redis write failure | Logged, non-fatal. |

Reads never throw — every failure collapses to a miss, the strategy re-acquires from Entra.

### [`OboStrategy`](apps/portal-bff/src/downstream/strategies/obo.strategy.ts)

Wraps MSAL Node's `acquireTokenOnBehalfOf` with the cache.

```
acquire(input):
  cached = cache.get(...)
  if cached && cached.expiresAt - now > 60s → return cached
  result = msal.acquireTokenOnBehalfOf({ oboAssertion, scopes })
  if !result || !result.accessToken || !result.expiresOn → throw OboAcquireError(msal-no-result)
  cache.set(...)
  return result
```

`OboAcquireError` carries a typed `reason` discriminator (`msal-refused` / `msal-no-result`) the future framework will translate to a **502 + `auth.token.validation.failed`** audit event per ADR-0014 — "the BFF does NOT silently fall back to the user's original token".

### One scope nuance from ADR-0014

ADR-0014 §"OBO strategy" says *"uses MSAL Node's `acquireTokenOnBehalfOf` with the user's current Entra access token (read from session via CLS)"*. v1 sessions don't persist the user's access token (ADR-0009 omits `offline_access` deliberately). For now the strategy takes the user access token as an **input parameter** — when the first concrete integration ships, the framework will fetch it from CLS / MSAL's token cache and forward here. That keeps the strategy a testable primitive without coupling to a session shape that doesn't exist yet.

### [`DownstreamModule`](apps/portal-bff/src/downstream/downstream.module.ts)

Provides `OBO_CACHE_KEY` (via the validator at factory time), `DownstreamTokenCache`, `OboStrategy`. Imports `AuthModule` for the shared `MSAL_CLIENT` and `RedisModule` for the shared `ioredis` client. Wired into `AppModule` though no runtime consumer yet — the registration makes the strategy injectable for the future integration without that integration having to also touch the module graph.

## Required env update (mandatory at boot)

```env
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
```

Generate with `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`. Must differ from `SESSION_ENCRYPTION_KEY` — the boot validator refuses identical values.

## Out of scope (deferred until the first concrete integration)

Per ADR-0014's "until then" clause:

- `DownstreamApiClientFactory` + per-service typed config.
- `cockatiel` resilience composition (timeout, retry, circuit breaker, bulkhead).
- Audience pre-check at the call site (`audienceConstraint` → `authz.deny` audit event).
- Error-translation tables per service.
- OTel custom spans `downstream.<service>.<verb>.<path>`.
- The `auth.token.validation.failed` audit event itself (the discriminator is on `OboAcquireError`, the audit-emission glue lives in the future framework).
- The framework wiring that reads the user access token from CLS instead of accepting it as a parameter.

These land alongside the first concrete integration so the framework shape is validated against a real consumer, not speculative needs.

## Test plan

- [x] `pnpm nx test portal-bff` — **334 specs pass** (was 308; +26: env validator 8, token cache 9, OBO strategy 9).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Env validator refuses placeholder, wrong length, non-base64url, AND identical-to-`SESSION_ENCRYPTION_KEY`. Boot-order tolerant: accepts the value when `SESSION_ENCRYPTION_KEY` is unset.
- [x] Token cache round-trip verified: written ciphertext starts with `v1.`, never contains the plaintext sentinel.
- [x] Tamper rejection verified: flipping the last char of the GCM-encrypted blob fails decryption and collapses to a miss.
- [x] Wrong-key rejection verified: writing with one key, reading with another, returns `null`.
- [x] TTL math verified: PX TTL = `expiresAt − now − 60 000`. Write skipped when token already inside the buffer.
- [x] OBO strategy: cache-hit short-circuit, stale-cache re-acquire, cold-cache → MSAL → cache.set, MSAL refusal → typed error, MSAL null-result → typed error, empty access token → typed error, null expiresOn → typed error.

## Notes for the reviewer

- The strategy file uses `override readonly cause` on `OboAcquireError` because TS `strict.exactOptionalPropertyTypes + noImplicitOverride` flags shadowing the built-in `Error.cause`. The shadowing is intentional — we want the typed cause property visible in error consumers — so the `override` keyword is the canonical way.
- `DownstreamTokenCache.get`'s "never throws" posture is deliberate. A cache failure must not poison a downstream call: the strategy re-acquires from Entra. The trade-off is that a key-rotation gone wrong shows up as silent re-acquisitions (no errors, just extra MSAL load); the structured Pino warns are the ops signal.
- The `DownstreamModule` is wired into `AppModule` even though nothing consumes the strategy at runtime. Without the wiring, the first integration PR would have to also touch the module graph; with it, the integration is just "inject `OboStrategy` and call `.acquire()`".

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #137
2026-05-14 18:13:30 +02:00
julien d86f3f9663 feat(portal-admin): audit log viewer screen (#136)
CI / check (push) Successful in 2m19s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m26s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 4m28s
## Summary

Final piece of the **portal-admin chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope" item 4. The new `/audit` route consumes [`GET /api/admin/audit`](apps/portal-bff/src/admin/admin-audit.controller.ts) (PR #132), renders a filter form + paginated results table, and trips the `admin.audit.query` deterrent on every fetch. Closes the loop from "BFF emits audit events" (PRs #120, #127, #128) through "BFF exposes them via a guarded endpoint" (PR #132) to "an admin can actually read them in a browser".

## What lands

### [`AuditEventsService`](apps/portal-admin/src/app/pages/audit/audit-events.service.ts)

Thin `HttpClient` wrapper around `GET /api/admin/audit`. Builds `HttpParams` from the filter shape, **dropping empty strings** (Nest's `ValidationPipe` treats `?foo=` as `foo === ''` and 400s). `providedIn: 'root'` — the audit page is the only consumer in v1.

### [`AuditPage`](apps/portal-admin/src/app/pages/audit/audit.ts)

Signal-driven page. State surface:

| Signal | Role |
| --- | --- |
| `eventType`, `actorIdHash`, `audience`, `outcome`, `subjectPrefix`, `createdAtFrom`, `createdAtTo` | One per filter field, bound via `[ngModel]` / `(ngModelChange)`. |
| `limit`, `offset` | Pagination. `limit` defaults to **50**, capped at **200** to mirror the BFF's `MAX_LIMIT`. |
| `page`, `loading`, `error` | Async result triplet. |
| `hasNextPage`, `hasPreviousPage`, `resultRange` | Computed signals for the pagination controls + the "1–50 of 1 234" status line. |

UI structure:

- **Filter form** — 8 inputs in a responsive auto-fit grid, plus "Apply filters" + "Reset" buttons. Submitting via Enter respects the form action; pressing Reset zeroes every signal.
- **Result table** — timestamp (locale-formatted via `Date.toLocaleString`), event type (monospaced), audience + outcome with **color-coded badges** (`success` green / `failure` red / `denied` amber), actor hash + subject stacked, trace id, and a `<details>` disclosure for the JSON payload so the row stays scannable.
- **Pagination** — Previous / Next disabled at boundaries. Applying a filter resets offset to 0 so a narrower query never inherits a stale page index.
- **States** — explicit loading line, empty state (`"No audit events match the current filters."`), error message. **403 surfaces "you do not have access"**; everything else collapses to a generic retry message so the admin UI doesn't leak BFF internals.

### Routing + i18n

- [`app.routes.ts`](apps/portal-admin/src/app/app.routes.ts) — `/audit` lazy-loaded.
- Title: `Audit log — APF Portal Admin` (EN) / `Journal d'audit — Administration APF Portal` (FR). The matching `route.audit.title` trans-unit lands in [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) — required because the admin app's prod build uses `i18nMissingTranslation: "error"`.
- The sidebar's "Audit log" link (already shipped in PR #134) is now active end-to-end.

## Implementation notes

- **`@RequireMfa` not applied** to the BFF endpoint in v1. The admin surface already sits behind a freshly-MFA'd session (`/api/admin/auth/login` enforces it via Entra CA), and the per-query `admin.audit.query` audit row is the deterrent against fishing expeditions. Adding `@RequireMfa({ freshness: 600 })` is a one-line change when the security review asks — the SPA's `bffUnauthorizedInterceptor` already handles the resulting 401 gracefully.
- **Page-size cap mirrors the BFF**. The SPA offers 25 / 50 / 100 / 200 in the dropdown; the BFF `AuditReader.findEvents` clamps `limit` to 200 regardless of what comes over the wire — defense in depth.
- **`AdminAuditQuery` interface** uses `?: T | undefined` so callers can build the query object with `undefined` placeholders under `exactOptionalPropertyTypes: true` — the page's `buildFilters()` does exactly that.
- **SCSS budget** — 4.98 KB / 5 KB warning. Below the 6 KB error ceiling. The audit page's chrome is intentionally chunky because the table needs distinct visual lanes for six columns; I trimmed the redundant `font-family` stacks and unused `text-transform` / `letter-spacing` declarations to fit.

## Test plan

- [x] `pnpm nx test portal-admin` — **30 specs pass** (was 15; +15: AuditEventsService 3, AuditPage 12).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] AuditEventsService verified to: GET the correct URL, forward populated filters as `HttpParams`, drop empty strings, omit `undefined`.
- [x] AuditPage covered: initial fetch on init, result-range string, empty / loading / error states (incl. 403 vs 5xx differentiation), Apply filters resets offset to 0, Reset clears every signal + re-queries, Next disabled when page covers total, Previous disabled at offset 0, outcome-badge variants match row data, payload disclosure only renders for rows with a payload.
- [ ] e2e — sign in via `/api/admin/auth/login` (requires the Entra `admin` app role assignment), navigate to `/audit`, expect the most recent `auth.sign_in` / `admin.audit.query` events from earlier sessions, exercise the filter form, observe a new `admin.audit.query` row in `audit.events` per fetch.

## Notes for the reviewer

- The error-state branch on 403 surfaces a permission-specific message rather than the generic "Could not load the audit log…". The admin role can be revoked mid-session (Entra-side), and a clear message + a retry path is friendlier than "server error".
- Payload rendering uses `<details>` rather than a modal or always-on JSON viewer — the table stays scannable at glance, and an auditor pivoting into a specific row gets the structured detail one click away. No third-party JSON-tree dependency added in v1.
- The "Audit log" sidebar entry from PR #134 was a `aria-disabled` placeholder until now; this PR makes it live. The other three v1 modules (CMS, menu management, user list) remain placeholders and will graduate as they land.

## What's next

This PR closes the portal-admin chantier. Open follow-ups from the roadmap:

- **`DownstreamApiClient` + OBO** ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — first real consumer when an Entra-protected business API needs the BFF as a passthrough.
- **Strategic security baseline ADR** — RSSI sign-off on ASVS / HDS / GDPR / NIS 2 framing. Currently paused per [CLAUDE.md](CLAUDE.md) §"Repository status".
- **CMS pages / menu management / user list** — the other three ADR-0020 v1 admin modules. Each is its own self-contained chantier.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #136
2026-05-14 17:50:17 +02:00