From 472b2b2c8c0c576802b5c2a5ee552e0abe5a1a6d Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 17:44:35 +0200 Subject: [PATCH] feat(portal-admin): audit log viewer screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/portal-admin/src/app/app.routes.ts | 6 + .../pages/audit/audit-events.service.spec.ts | 90 +++++ .../app/pages/audit/audit-events.service.ts | 82 ++++ .../src/app/pages/audit/audit.html | 184 +++++++++ .../src/app/pages/audit/audit.scss | 366 ++++++++++++++++++ .../src/app/pages/audit/audit.spec.ts | 200 ++++++++++ .../portal-admin/src/app/pages/audit/audit.ts | 209 ++++++++++ apps/portal-admin/src/locale/messages.fr.xlf | 4 + 8 files changed, 1141 insertions(+) create mode 100644 apps/portal-admin/src/app/pages/audit/audit-events.service.spec.ts create mode 100644 apps/portal-admin/src/app/pages/audit/audit-events.service.ts create mode 100644 apps/portal-admin/src/app/pages/audit/audit.html create mode 100644 apps/portal-admin/src/app/pages/audit/audit.scss create mode 100644 apps/portal-admin/src/app/pages/audit/audit.spec.ts create mode 100644 apps/portal-admin/src/app/pages/audit/audit.ts diff --git a/apps/portal-admin/src/app/app.routes.ts b/apps/portal-admin/src/app/app.routes.ts index fcb3747..8dc4a65 100644 --- a/apps/portal-admin/src/app/app.routes.ts +++ b/apps/portal-admin/src/app/app.routes.ts @@ -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 `` and the diff --git a/apps/portal-admin/src/app/pages/audit/audit-events.service.spec.ts b/apps/portal-admin/src/app/pages/audit/audit-events.service.spec.ts new file mode 100644 index 0000000..b8fcbeb --- /dev/null +++ b/apps/portal-admin/src/app/pages/audit/audit-events.service.spec.ts @@ -0,0 +1,90 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import { AuditEventsService, type AdminAuditPage } from './audit-events.service'; + +const BFF_BASE = 'http://bff.test/api'; +const AUDIT_URL = `${BFF_BASE}/admin/audit`; + +const PAGE: AdminAuditPage = { + items: [ + { + id: '11111111-1111-1111-1111-111111111111', + createdAt: '2026-05-13T10:30:00.000Z', + eventType: 'auth.sign_in', + audience: 'workforce', + actorIdHash: 'hash(jane)', + traceId: 'trace-abc', + subject: 'session:sid-1', + outcome: 'success', + payload: { amr: ['pwd', 'mfa'] }, + }, + ], + total: 1, + limit: 50, + offset: 0, +}; + +function setup() { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + ], + }); + return { + service: TestBed.inject(AuditEventsService), + http: TestBed.inject(HttpTestingController), + }; +} + +describe('AuditEventsService.query', () => { + it('GETs /admin/audit with the populated filter fields as URL params', async () => { + const { service, http } = setup(); + const promise = firstValueFrom( + service.query({ + eventType: 'auth.sign_in', + audience: 'workforce', + outcome: 'success', + limit: 25, + offset: 50, + }), + ); + const req = http.expectOne((r) => r.url === AUDIT_URL); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('eventType')).toBe('auth.sign_in'); + expect(req.request.params.get('audience')).toBe('workforce'); + expect(req.request.params.get('outcome')).toBe('success'); + expect(req.request.params.get('limit')).toBe('25'); + expect(req.request.params.get('offset')).toBe('50'); + req.flush(PAGE); + await expect(promise).resolves.toEqual(PAGE); + }); + + it('omits filter fields that are empty strings (BFF rejects `?foo=` as invalid)', async () => { + const { service, http } = setup(); + void firstValueFrom( + service.query({ + eventType: '', + subjectPrefix: '', + limit: 50, + }), + ); + const req = http.expectOne((r) => r.url === AUDIT_URL); + expect(req.request.params.has('eventType')).toBe(false); + expect(req.request.params.has('subjectPrefix')).toBe(false); + expect(req.request.params.get('limit')).toBe('50'); + req.flush(PAGE); + }); + + it('omits undefined filter fields', async () => { + const { service, http } = setup(); + void firstValueFrom(service.query({})); + const req = http.expectOne((r) => r.url === AUDIT_URL); + expect(req.request.params.keys().length).toBe(0); + req.flush(PAGE); + }); +}); diff --git a/apps/portal-admin/src/app/pages/audit/audit-events.service.ts b/apps/portal-admin/src/app/pages/audit/audit-events.service.ts new file mode 100644 index 0000000..385a4ac --- /dev/null +++ b/apps/portal-admin/src/app/pages/audit/audit-events.service.ts @@ -0,0 +1,82 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import type { Observable } from 'rxjs'; + +/** + * One row of the BFF's `GET /api/admin/audit` response. Mirrors + * `AdminAuditEventDto` on the BFF side — ISO-string timestamp, + * already-parsed JSON payload, hashed actor id. + */ +export interface AdminAuditEvent { + readonly id: string; + readonly createdAt: string; + readonly eventType: string; + readonly audience: string; + readonly actorIdHash: string | null; + readonly traceId: string | null; + readonly subject: string | null; + readonly outcome: string; + readonly payload: Record | null; +} + +/** Paginated response shape — mirrors `AdminAuditPage` on the BFF. */ +export interface AdminAuditPage { + readonly items: readonly AdminAuditEvent[]; + readonly total: number; + readonly limit: number; + readonly offset: number; +} + +/** + * Filter+pagination shape sent as query params. All fields are + * optional; missing values fall back to the BFF defaults (limit=50, + * offset=0, no filter). + */ +export interface AdminAuditQuery { + // Both optional AND explicitly nullable: `exactOptionalPropertyTypes` + // rejects assigning a `T | undefined` to a `?: T`, but the + // AuditPage `buildFilters()` builds objects with `undefined` + // placeholders for missing user input. Allowing both `?:` and + // `| undefined` lets callers omit the key entirely OR set it to + // undefined, both meaning "no filter". + readonly eventType?: string | undefined; + readonly actorIdHash?: string | undefined; + readonly audience?: 'workforce' | 'customer' | undefined; + readonly outcome?: 'success' | 'failure' | 'denied' | undefined; + readonly subjectPrefix?: string | undefined; + readonly createdAtFrom?: string | undefined; + readonly createdAtTo?: string | undefined; + readonly limit?: number | undefined; + readonly offset?: number | undefined; +} + +/** + * Thin HttpClient wrapper. Lives in `providedIn: 'root'` because the + * audit page is the single v1 consumer and per-page DI overhead is + * not worth it. The wrapper exists at all (vs HttpClient directly in + * the component) so the audit page's logic stays signal-only and the + * spec can mock the service rather than threading + * `HttpTestingController` through every assertion. + */ +@Injectable({ providedIn: 'root' }) +export class AuditEventsService { + private readonly http = inject(HttpClient); + private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL); + + query(filters: AdminAuditQuery): Observable { + let params = new HttpParams(); + for (const [key, value] of Object.entries(filters)) { + // Drop empty strings — Nest's ValidationPipe treats `?foo=` + // as `foo === ''` which trips the IsISO8601 / IsString + // validators and surfaces as a 400. The SPA passes empty + // inputs when the user hasn't filled a filter; this strips + // them out so the BFF only sees populated fields. + if (value === undefined || value === null || value === '') { + continue; + } + params = params.set(key, String(value)); + } + return this.http.get(`${this.bffBaseUrl}/admin/audit`, { params }); + } +} diff --git a/apps/portal-admin/src/app/pages/audit/audit.html b/apps/portal-admin/src/app/pages/audit/audit.html new file mode 100644 index 0000000..261f4e0 --- /dev/null +++ b/apps/portal-admin/src/app/pages/audit/audit.html @@ -0,0 +1,184 @@ +
+
+

Audit log

+

+ Read-only view of audit.events per + ADR-0013. Every query + run on this page emits its own admin.audit.query row — read access is itself + auditable. +

+
+ +
+

Filters

+
+ + + + + + + + +
+
+ + +
+
+ +
+ @if (loading()) { + Loading… + } @else if (error(); as msg) { + {{ msg }} + } @else if (page(); as p) { + {{ resultRange() }} + } +
+ + @if (page(); as p) { @if (p.items.length === 0) { +

No audit events match the current filters.

+ } @else { +
+ + + + + + + + + + + + + @for (event of p.items; track event.id) { + + + + + + + + + } + +
TimestampEventAudience / outcomeActor / subjectTracePayload
{{ formatTimestamp(event.createdAt) }}{{ event.eventType }} + {{ event.audience }} + {{ event.outcome }} + +
{{ event.actorIdHash ?? '(anonymous)' }}
+ @if (event.subject; as subject) { +
{{ subject }}
+ } +
{{ event.traceId ?? '—' }} + @if (event.payload) { +
+ view +
{{ formatPayload(event.payload) }}
+
+ } @else { + + } +
+
+ + + } } +
diff --git a/apps/portal-admin/src/app/pages/audit/audit.scss b/apps/portal-admin/src/app/pages/audit/audit.scss new file mode 100644 index 0000000..8b9d8ff --- /dev/null +++ b/apps/portal-admin/src/app/pages/audit/audit.scss @@ -0,0 +1,366 @@ +.audit { + max-width: 1200px; + margin: 0 auto; +} + +.audit-header { + margin-bottom: 1.5rem; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.25rem; +} + +.intro { + margin: 0; + font-size: 0.875rem; + color: #6b7280; + + code { + padding: 0.0625rem 0.25rem; + border-radius: 0.25rem; + background-color: rgba(0, 0, 0, 0.05); + font-family: ui-monospace, monospace; + font-size: 0.875em; + } + + a { + color: var(--color-brand-primary-600, #2563eb); + text-decoration: underline; + } +} + +:host-context(.dark) .intro { + color: #9ca3af; + + code { + background-color: rgba(255, 255, 255, 0.08); + } + a { + color: var(--color-brand-primary-300, #93c5fd); + } +} + +.filters { + padding: 1rem; + margin-bottom: 1rem; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} + +:host-context(.dark) .filters { + background-color: #111827; + border-color: #1f2937; +} + +.filters-heading { + margin: 0 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.filters-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.filter { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.filter-label { + font-size: 0.75rem; + font-weight: 500; + color: #6b7280; +} + +:host-context(.dark) .filter-label { + color: #9ca3af; +} + +.filter input, +.filter select { + height: 2rem; + padding: 0 0.5rem; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + background-color: #ffffff; + color: inherit; + font-size: 0.875rem; + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500, #2563eb); + outline-offset: 1px; + border-color: transparent; + } +} + +:host-context(.dark) .filter input, +:host-context(.dark) .filter select { + background-color: #0f172a; + border-color: #374151; + color: #e5e7eb; +} + +.filters-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.875rem; +} + +.btn { + display: inline-flex; + align-items: center; + height: 2.25rem; + padding: 0 0.875rem; + border: 1px solid transparent; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500, #2563eb); + outline-offset: 2px; + } +} + +.btn--primary { + background-color: var(--color-brand-primary-600, #2563eb); + color: #ffffff; + + &:hover:not(:disabled) { + background-color: var(--color-brand-primary-700, #1d4ed8); + } +} + +.btn--secondary { + background-color: transparent; + border-color: #d1d5db; + color: inherit; + + &:hover:not(:disabled) { + background-color: rgba(0, 0, 0, 0.04); + } +} + +:host-context(.dark) .btn--secondary:hover:not(:disabled) { + background-color: rgba(255, 255, 255, 0.06); +} + +.status-bar { + margin-bottom: 0.5rem; + min-height: 1.25rem; + font-size: 0.875rem; + color: #6b7280; +} + +.status-line--error { + color: #b91c1c; + font-weight: 500; +} + +:host-context(.dark) .status-bar { + color: #9ca3af; +} + +:host-context(.dark) .status-line--error { + color: #fca5a5; +} + +.empty { + padding: 1.5rem; + text-align: center; + color: #6b7280; + background-color: #ffffff; + border: 1px dashed #d1d5db; + border-radius: 0.5rem; +} + +:host-context(.dark) .empty { + background-color: #111827; + border-color: #374151; + color: #9ca3af; +} + +.table-wrap { + overflow-x: auto; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; +} + +:host-context(.dark) .table-wrap { + background-color: #111827; + border-color: #1f2937; +} + +.audit-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + th, + td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; + } + + th { + font-weight: 600; + background-color: #f9fafb; + position: sticky; + top: 0; + } +} + +:host-context(.dark) .audit-table { + th, + td { + border-bottom-color: #1f2937; + } + + th { + background-color: #1f2937; + } +} + +.cell-timestamp { + white-space: nowrap; +} + +.cell-event, +.cell-trace, +.actor-hash, +.subject { + font-family: ui-monospace, monospace; +} + +.cell-trace, +.actor-hash, +.subject { + word-break: break-all; + font-size: 0.75rem; +} + +.actor-hash { + color: #1f2937; +} + +.subject { + color: #6b7280; + margin-top: 0.125rem; +} + +:host-context(.dark) .actor-hash { + color: #e5e7eb; +} + +:host-context(.dark) .subject { + color: #9ca3af; +} + +.aud-badge, +.outcome-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + margin-right: 0.25rem; +} + +.aud-badge { + background-color: rgba(0, 0, 0, 0.06); + color: #1f2937; +} + +.outcome-badge--success { + background-color: rgba(34, 197, 94, 0.18); + color: #166534; +} + +.outcome-badge--failure { + background-color: rgba(239, 68, 68, 0.18); + color: #991b1b; +} + +.outcome-badge--denied { + background-color: rgba(234, 179, 8, 0.22); + color: #854d0e; +} + +:host-context(.dark) { + .aud-badge { + background-color: rgba(255, 255, 255, 0.08); + color: #e5e7eb; + } + .outcome-badge--success { + background-color: rgba(34, 197, 94, 0.22); + color: #86efac; + } + .outcome-badge--failure { + background-color: rgba(239, 68, 68, 0.22); + color: #fecaca; + } + .outcome-badge--denied { + background-color: rgba(234, 179, 8, 0.22); + color: #fde68a; + } +} + +.cell-payload { + details { + cursor: pointer; + } + + summary { + color: var(--color-brand-primary-600, #2563eb); + font-size: 0.75rem; + } + + pre { + margin: 0.25rem 0 0; + padding: 0.5rem; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 0.25rem; + font-size: 0.6875rem; + font-family: ui-monospace, monospace; + word-break: break-all; + } +} + +:host-context(.dark) .cell-payload { + summary { + color: var(--color-brand-primary-300, #93c5fd); + } + + pre { + background-color: rgba(255, 255, 255, 0.05); + } +} + +.dim { + color: #9ca3af; +} + +.pagination { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.75rem; +} diff --git a/apps/portal-admin/src/app/pages/audit/audit.spec.ts b/apps/portal-admin/src/app/pages/audit/audit.spec.ts new file mode 100644 index 0000000..3b3c432 --- /dev/null +++ b/apps/portal-admin/src/app/pages/audit/audit.spec.ts @@ -0,0 +1,200 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AuditPage } from './audit'; +import { + AuditEventsService, + type AdminAuditPage, + type AdminAuditQuery, +} from './audit-events.service'; + +const PAGE: AdminAuditPage = { + items: [ + { + id: '11111111-1111-1111-1111-111111111111', + createdAt: '2026-05-13T10:30:00.000Z', + eventType: 'auth.sign_in', + audience: 'workforce', + actorIdHash: 'hash(jane)', + traceId: 'trace-abc', + subject: 'session:sid-1', + outcome: 'success', + payload: { amr: ['pwd', 'mfa'] }, + }, + { + id: '22222222-2222-2222-2222-222222222222', + createdAt: '2026-05-13T10:31:00.000Z', + eventType: 'admin.access_denied', + audience: 'workforce', + actorIdHash: 'hash(mallory)', + traceId: null, + subject: 'GET /api/admin/me', + outcome: 'denied', + payload: null, + }, + ], + total: 247, + limit: 50, + offset: 0, +}; + +interface Fixture { + fixture: ReturnType>; + query: ReturnType; +} + +function setup(opts?: { initial?: AdminAuditPage | 'error'; status?: number }): Fixture { + const initial = opts?.initial ?? PAGE; + const query = vi.fn().mockImplementation(() => { + if (initial === 'error') { + return throwError(() => ({ status: opts?.status ?? 500 })); + } + return of(initial); + }); + TestBed.configureTestingModule({ + imports: [AuditPage], + providers: [{ provide: AuditEventsService, useValue: { query } }], + }); + const fixture = TestBed.createComponent(AuditPage); + return { fixture, query }; +} + +async function flush(fixture: Fixture['fixture']): Promise { + // Drain microtasks twice — the initial fetch fires from ngOnInit + // and awaits firstValueFrom (one tick) then updates the signal + // (another tick) before the DOM reflects the result. + fixture.detectChanges(); + await Promise.resolve(); + await Promise.resolve(); + fixture.detectChanges(); + await fixture.whenStable(); +} + +describe('AuditPage', () => { + it('runs an initial unfiltered query on init and renders the result table', async () => { + const { fixture, query } = setup(); + await flush(fixture); + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 })); + const root = fixture.nativeElement as HTMLElement; + const rows = root.querySelectorAll('.audit-table tbody tr'); + expect(rows.length).toBe(2); + }); + + it('shows the result range ("1–2 of 247") in the status bar', async () => { + const { fixture } = setup(); + await flush(fixture); + expect( + (fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent, + ).toContain('1–2 of 247'); + }); + + it('renders the empty state when the page has no items', async () => { + const { fixture } = setup({ + initial: { items: [], total: 0, limit: 50, offset: 0 }, + }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('.empty')?.textContent).toContain('No audit events'); + expect(root.querySelector('.audit-table')).toBeNull(); + }); + + it('renders an error message when the service rejects (500)', async () => { + const { fixture } = setup({ initial: 'error', status: 500 }); + await flush(fixture); + const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error'); + expect(status?.textContent).toContain('Could not load the audit log'); + }); + + it('renders a permission-aware error message on 403', async () => { + const { fixture } = setup({ initial: 'error', status: 403 }); + await flush(fixture); + const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error'); + expect(status?.textContent).toContain('do not have access'); + }); + + it('passes the populated filter fields to the service on Apply', async () => { + const { fixture, query } = setup(); + await flush(fixture); + const component = fixture.componentInstance; + // Simulate user input by setting the signals directly. + (component as unknown as { eventType: { set: (v: string) => void } }).eventType.set( + 'auth.sign_in', + ); + (component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce'); + await component.search(); + await flush(fixture); + expect(query).toHaveBeenCalledTimes(2); + const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery; + expect(lastCall.eventType).toBe('auth.sign_in'); + expect(lastCall.audience).toBe('workforce'); + expect(lastCall.offset).toBe(0); + }); + + it('resets the offset to 0 on each Apply (no stale page when filters narrow)', async () => { + const { fixture, query } = setup(); + await flush(fixture); + const component = fixture.componentInstance; + // Advance to next page. + await component.next(); + await flush(fixture); + expect((query.mock.calls[1]?.[0] as AdminAuditQuery).offset).toBe(50); + // Apply a new filter — offset must drop back to 0. + await component.search(); + await flush(fixture); + expect((query.mock.calls[2]?.[0] as AdminAuditQuery).offset).toBe(0); + }); + + it('clearFilters empties every filter signal and re-queries from offset 0', async () => { + const { fixture, query } = setup(); + await flush(fixture); + const component = fixture.componentInstance; + (component as unknown as { eventType: { set: (v: string) => void } }).eventType.set('x'); + (component as unknown as { outcome: { set: (v: string) => void } }).outcome.set('denied'); + await component.clearFilters(); + await flush(fixture); + const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery; + expect(lastCall.eventType).toBeUndefined(); + expect(lastCall.outcome).toBeUndefined(); + expect(lastCall.offset).toBe(0); + }); + + it('Next is disabled when the loaded page covers the total', async () => { + const { fixture } = setup({ + initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 }, + }); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const nextBtn = Array.from(root.querySelectorAll('.pagination .btn')).find( + (b) => b.textContent?.trim() === 'Next', + ); + expect(nextBtn?.disabled).toBe(true); + }); + + it('Previous is disabled at offset 0', async () => { + const { fixture } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const prevBtn = Array.from(root.querySelectorAll('.pagination .btn')).find( + (b) => b.textContent?.trim() === 'Previous', + ); + expect(prevBtn?.disabled).toBe(true); + }); + + it('renders the outcome badge variant matching the row', async () => { + const { fixture } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const rows = root.querySelectorAll('.audit-table tbody tr'); + expect(rows[0]?.querySelector('.outcome-badge--success')).not.toBeNull(); + expect(rows[1]?.querySelector('.outcome-badge--denied')).not.toBeNull(); + }); + + it('shows the payload disclosure only for rows that have a payload', async () => { + const { fixture } = setup(); + await flush(fixture); + const root = fixture.nativeElement as HTMLElement; + const rows = root.querySelectorAll('.audit-table tbody tr'); + expect(rows[0]?.querySelector('details')).not.toBeNull(); + expect(rows[1]?.querySelector('details')).toBeNull(); + }); +}); diff --git a/apps/portal-admin/src/app/pages/audit/audit.ts b/apps/portal-admin/src/app/pages/audit/audit.ts new file mode 100644 index 0000000..45529bc --- /dev/null +++ b/apps/portal-admin/src/app/pages/audit/audit.ts @@ -0,0 +1,209 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { + AuditEventsService, + type AdminAuditPage, + type AdminAuditQuery, +} from './audit-events.service'; + +/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */ +const PAGE_SIZES = [25, 50, 100, 200] as const; + +/** + * Audit log viewer per ADR-0020's v1 admin catalogue. Consumes + * `GET /api/admin/audit` from the BFF (PR #132) and renders: + * + * - A filter row binding to the same fields the BFF accepts + * (event type, audience, outcome, subject prefix, time range). + * - A page-size selector + previous / next buttons. Pagination is + * offset-based to match the BFF's v1 implementation. + * - A table of results with the timestamp (locale-formatted), + * event type, audience+outcome cell, actor hash + subject, and + * trace id. Payload is rendered as JSON in a details disclosure + * so the table stays scannable but the structured detail is one + * click away. + * + * The page emits its own `admin.audit.query` audit row server-side on + * every fetch — the BFF deterrent against fishing expeditions is on + * the read endpoint itself, not in the SPA. + */ +@Component({ + selector: 'app-audit-page', + imports: [FormsModule], + templateUrl: './audit.html', + styleUrl: './audit.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuditPage { + private readonly service = inject(AuditEventsService); + + protected readonly pageSizes = PAGE_SIZES; + + // Filter form state. Each field is a separate signal so a change + // to one doesn't churn the others through ngModel's reference + // equality. All start empty — the BFF returns the most recent + // events on an unfiltered query. + protected readonly eventType = signal(''); + protected readonly actorIdHash = signal(''); + protected readonly audience = signal<'' | 'workforce' | 'customer'>(''); + protected readonly outcome = signal<'' | 'success' | 'failure' | 'denied'>(''); + protected readonly subjectPrefix = signal(''); + protected readonly createdAtFrom = signal(''); + protected readonly createdAtTo = signal(''); + + protected readonly limit = signal(50); + protected readonly offset = signal(0); + + // Result state. `page` is the current server response; `loading` + // is true while a fetch is in flight; `error` carries a short + // string for the alert region. + protected readonly page = signal(null); + protected readonly loading = signal(false); + protected readonly error = signal(null); + + /** True when the loaded page does NOT cover the next offset boundary. */ + protected readonly hasNextPage = computed(() => { + const p = this.page(); + if (p === null) return false; + return p.offset + p.items.length < p.total; + }); + + protected readonly hasPreviousPage = computed(() => { + const p = this.page(); + if (p === null) return false; + return p.offset > 0; + }); + + // Human-readable "results 1–50 of 1 234" string. Defensive — when + // the page is empty we report "no results" rather than "1–0". + protected readonly resultRange = computed(() => { + const p = this.page(); + if (p === null) return ''; + if (p.items.length === 0) return p.total === 0 ? 'No results' : `0 of ${p.total}`; + const first = p.offset + 1; + const last = p.offset + p.items.length; + return `${first}–${last} of ${p.total}`; + }); + + /** + * Run a new query with the current filter signals at offset 0. + * Called by the "Apply filters" button + the initial render via + * the template's `@defer (on viewport)` block. + */ + async search(): Promise { + this.offset.set(0); + await this.fetch(); + } + + /** Page navigation — moves the offset by `limit`, then fetches. */ + async next(): Promise { + if (!this.hasNextPage()) return; + this.offset.update((o) => o + this.limit()); + await this.fetch(); + } + + async previous(): Promise { + if (!this.hasPreviousPage()) return; + this.offset.update((o) => Math.max(0, o - this.limit())); + await this.fetch(); + } + + /** Reset every filter to its empty value and re-query. */ + async clearFilters(): Promise { + this.eventType.set(''); + this.actorIdHash.set(''); + this.audience.set(''); + this.outcome.set(''); + this.subjectPrefix.set(''); + this.createdAtFrom.set(''); + this.createdAtTo.set(''); + this.offset.set(0); + await this.fetch(); + } + + /** Format an ISO timestamp into the user's locale for the table. */ + protected formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + // Fall back to the raw ISO string if Date refuses it — better + // than rendering "Invalid Date" in the table. + return iso; + } + } + + /** Stringify the JSONB payload for the disclosure widget. */ + protected formatPayload(payload: Record | null): string { + if (payload === null) return '—'; + try { + return JSON.stringify(payload, null, 2); + } catch { + return String(payload); + } + } + + private async fetch(): Promise { + this.loading.set(true); + this.error.set(null); + try { + const filters = this.buildFilters(); + const page = await firstValueFrom(this.service.query(filters)); + this.page.set(page); + } catch (err) { + // Don't surface the raw error message to the admin — it's + // either a 401 (handled by the unauthorized interceptor), a + // 403 (admin role revoked mid-session), or a 5xx. A short + // generic string keeps the UI predictable; the structured + // error envelope is in the network tab for ops. + const status = errorStatus(err); + this.error.set( + status === 403 + ? 'You do not have access to the audit log on this session.' + : 'Could not load the audit log. Try again in a moment.', + ); + this.page.set(null); + } finally { + this.loading.set(false); + } + } + + private buildFilters(): AdminAuditQuery { + return { + eventType: this.eventType().trim() || undefined, + actorIdHash: this.actorIdHash().trim() || undefined, + audience: this.audience() || undefined, + outcome: this.outcome() || undefined, + subjectPrefix: this.subjectPrefix().trim() || undefined, + createdAtFrom: this.createdAtFrom() ? toIso(this.createdAtFrom()) : undefined, + createdAtTo: this.createdAtTo() ? toIso(this.createdAtTo()) : undefined, + limit: this.limit(), + offset: this.offset(), + }; + } + + ngOnInit(): void { + // Fire the initial unfiltered query so the table renders with + // the most recent events on first paint. Fire-and-forget — the + // page handles its own loading / error state internally. + void this.fetch(); + } +} + +function toIso(localValue: string): string { + // `` emits `YYYY-MM-DDTHH:mm` (no + // timezone). Treat that as the user's local time and convert to + // a true ISO 8601 string so the BFF's IsISO8601 validator accepts + // it. Falls back to the raw value if Date can't parse it (the + // BFF will then return a 400 with a clear validation error). + const d = new Date(localValue); + return Number.isNaN(d.getTime()) ? localValue : d.toISOString(); +} + +function errorStatus(err: unknown): number | null { + if (typeof err === 'object' && err !== null && 'status' in err) { + const s = (err as { status: unknown }).status; + return typeof s === 'number' ? s : null; + } + return null; +} diff --git a/apps/portal-admin/src/locale/messages.fr.xlf b/apps/portal-admin/src/locale/messages.fr.xlf index d1bd242..0d5ec9a 100644 --- a/apps/portal-admin/src/locale/messages.fr.xlf +++ b/apps/portal-admin/src/locale/messages.fr.xlf @@ -13,6 +13,10 @@ APF Portal Admin Administration APF Portal + + Audit log — APF Portal Admin + Journal d’audit — Administration APF Portal + -- 2.30.2