feat(portal-admin): audit log viewer screen #136

Merged
julien merged 1 commits from feat/portal-admin-audit-viewer into main 2026-05-14 17:50:19 +02:00
Owner

Summary

Final piece of the portal-admin chantier per ADR-0020 §"v1 scope" item 4. The new /audit route consumes GET /api/admin/audit (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

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

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/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 — 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

  • pnpm nx test portal-admin30 specs pass (was 15; +15: AuditEventsService 3, AuditPage 12).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean.
  • AuditEventsService verified to: GET the correct URL, forward populated filters as HttpParams, drop empty strings, omit undefined.
  • 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) — 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 §"Repository status".
  • CMS pages / menu management / user list — the other three ADR-0020 v1 admin modules. Each is its own self-contained chantier.
## 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.
julien added 1 commit 2026-05-14 17:47:41 +02:00
feat(portal-admin): audit log viewer screen
CI / scan (pull_request) Successful in 2m21s
CI / commits (pull_request) Successful in 2m31s
CI / check (pull_request) Successful in 2m40s
CI / a11y (pull_request) Successful in 1m56s
CI / perf (pull_request) Successful in 5m44s
472b2b2c8c
Final piece of the chantier portal-admin per ADR-0020 §"v1 scope"
item 4. The new /audit page consumes GET /api/admin/audit (PR #132),
renders a filter form + paginated results table, and trips the
admin.audit.query deterrent on every fetch.

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
  invalid). providedIn: 'root' — the audit page is the single v1
  consumer, no per-page DI overhead worth it.

- AuditPage component (apps/portal-admin/src/app/pages/audit/
  audit.ts): signal-driven page composing:
  - Filter form: eventType, actorIdHash, audience, outcome,
    subjectPrefix, createdAtFrom/To (datetime-local → ISO),
    page-size selector (25 / 50 / 100 / 200 matching BFF MAX_LIMIT).
  - Result table: timestamp (locale-formatted), event type,
    audience+outcome with color-coded badges, actor hash + subject
    stacked, trace id, payload disclosure (details/summary).
  - Pagination: previous/next disabled at boundaries, resets
    offset to 0 on every Apply Filters or Reset.
  - States: loading line, empty state, error message (403
    surfaces "you do not have access"; 5xx surfaces a generic
    retry message — raw status leaks to ops via the network tab,
    not to the admin UI).

- Route: /audit, lazy-loaded, title 'Audit log — APF Portal Admin'
  + matching FR translation in messages.fr.xlf
  ('Journal d'audit — Administration APF Portal').

Notes

- @RequireMfa NOT applied to the BFF endpoint in v1 — the admin
  surface already sits behind a freshly-MFA'd session per ADR-0020,
  and the per-query audit row is the deterrent. The endpoint will
  pick up @RequireMfa({ freshness: 600 }) when the security review
  asks for it; the SPA already lives behind the unauthorized
  interceptor so it would gracefully refresh on the 401.
- Page-size cap mirrors the BFF MAX_LIMIT (200). A future caller
  bypassing the SPA cap is still safe because the BFF DTO clamps
  to 200 regardless (PR #132 §AuditReader.findEvents).
- SCSS budget: 4.98 KB / 5 KB warning (1.21 KB transfer). Well
  below the 6 KB error ceiling.
- AdminAuditQuery interface uses `?: T | undefined` so callers can
  build the query object with `undefined` placeholders for absent
  filters under `exactOptionalPropertyTypes: true` — the page does
  exactly that in `buildFilters()`.

Tests: +15 specs (AuditEventsService 3, AuditPage 12: initial query,
filter forwarding, offset-reset on Apply, clearFilters, pagination
disabled at boundaries, outcome-badge variants, payload disclosure
visibility, empty / loading / error states).
julien merged commit d86f3f9663 into main 2026-05-14 17:50:19 +02:00
julien deleted branch feat/portal-admin-audit-viewer 2026-05-14 17:50:21 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#136