feat(portal-admin): audit log viewer screen (#136)
## 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
This commit was merged in pull request #136.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
||||
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{
|
||||
@@ -9,6 +10,11 @@ export const appRoutes: Route[] = [
|
||||
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
|
||||
title: homeTitle,
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
loadComponent: () => import('./pages/audit/audit').then((m) => m.AuditPage),
|
||||
title: auditTitle,
|
||||
},
|
||||
// Catch-all — unknown paths bounce to home. Same role as the
|
||||
// wildcard in portal-shell (per PR #96): in production each locale
|
||||
// bundle ships with its own `<base href="/{locale}/">` and the
|
||||
|
||||
Reference in New Issue
Block a user