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
8 changed files with 1141 additions and 0 deletions
Showing only changes of commit 472b2b2c8c - Show all commits
+6
View File
@@ -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
@@ -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);
});
});
@@ -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<string, unknown> | 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<AdminAuditPage> {
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<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
}
}
@@ -0,0 +1,184 @@
<section class="audit">
<header class="audit-header">
<h1 class="title">Audit log</h1>
<p class="intro">
Read-only view of <code>audit.events</code> per
<a href="https://github.com/adr/madr" rel="noopener" target="_blank">ADR-0013</a>. Every query
run on this page emits its own <code>admin.audit.query</code> row — read access is itself
auditable.
</p>
</header>
<form class="filters" (submit)="$event.preventDefault(); search()" aria-labelledby="filters-h">
<h2 id="filters-h" class="filters-heading">Filters</h2>
<div class="filters-grid">
<label class="filter">
<span class="filter-label">Event type</span>
<input
type="text"
name="eventType"
[ngModel]="eventType()"
(ngModelChange)="eventType.set($event)"
placeholder="auth.sign_in"
/>
</label>
<label class="filter">
<span class="filter-label">Actor id hash</span>
<input
type="text"
name="actorIdHash"
[ngModel]="actorIdHash()"
(ngModelChange)="actorIdHash.set($event)"
placeholder="64 hex chars"
/>
</label>
<label class="filter">
<span class="filter-label">Audience</span>
<select name="audience" [ngModel]="audience()" (ngModelChange)="audience.set($event)">
<option value="">Any</option>
<option value="workforce">workforce</option>
<option value="customer">customer</option>
</select>
</label>
<label class="filter">
<span class="filter-label">Outcome</span>
<select name="outcome" [ngModel]="outcome()" (ngModelChange)="outcome.set($event)">
<option value="">Any</option>
<option value="success">success</option>
<option value="failure">failure</option>
<option value="denied">denied</option>
</select>
</label>
<label class="filter">
<span class="filter-label">Subject prefix</span>
<input
type="text"
name="subjectPrefix"
[ngModel]="subjectPrefix()"
(ngModelChange)="subjectPrefix.set($event)"
placeholder="session:"
/>
</label>
<label class="filter">
<span class="filter-label">From</span>
<input
type="datetime-local"
name="createdAtFrom"
[ngModel]="createdAtFrom()"
(ngModelChange)="createdAtFrom.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">To</span>
<input
type="datetime-local"
name="createdAtTo"
[ngModel]="createdAtTo()"
(ngModelChange)="createdAtTo.set($event)"
/>
</label>
<label class="filter">
<span class="filter-label">Page size</span>
<select name="limit" [ngModel]="limit()" (ngModelChange)="limit.set(+$event)">
@for (size of pageSizes; track size) {
<option [value]="size">{{ size }}</option>
}
</select>
</label>
</div>
<div class="filters-actions">
<button type="submit" class="btn btn--primary" [disabled]="loading()">Apply filters</button>
<button
type="button"
class="btn btn--secondary"
(click)="clearFilters()"
[disabled]="loading()"
>
Reset
</button>
</div>
</form>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (page(); as p) {
<span class="status-line">{{ resultRange() }}</span>
}
</div>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No audit events match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Event</th>
<th scope="col">Audience / outcome</th>
<th scope="col">Actor / subject</th>
<th scope="col">Trace</th>
<th scope="col">Payload</th>
</tr>
</thead>
<tbody>
@for (event of p.items; track event.id) {
<tr>
<td class="cell-timestamp">{{ formatTimestamp(event.createdAt) }}</td>
<td class="cell-event">{{ event.eventType }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ event.audience }}</span>
<span
class="outcome-badge"
[class.outcome-badge--success]="event.outcome === 'success'"
[class.outcome-badge--failure]="event.outcome === 'failure'"
[class.outcome-badge--denied]="event.outcome === 'denied'"
>{{ event.outcome }}</span
>
</td>
<td class="cell-actor">
<div class="actor-hash">{{ event.actorIdHash ?? '(anonymous)' }}</div>
@if (event.subject; as subject) {
<div class="subject">{{ subject }}</div>
}
</td>
<td class="cell-trace">{{ event.traceId ?? '—' }}</td>
<td class="cell-payload">
@if (event.payload) {
<details>
<summary>view</summary>
<pre>{{ formatPayload(event.payload) }}</pre>
</details>
} @else {
<span class="dim"></span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
@@ -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;
}
@@ -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<typeof TestBed.createComponent<AuditPage>>;
query: ReturnType<typeof vi.fn>;
}
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<void> {
// 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<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows.length).toBe(2);
});
it('shows the result range ("12 of 247") in the status bar', async () => {
const { fixture } = setup();
await flush(fixture);
expect(
(fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
).toContain('12 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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLTableRowElement>('.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<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[0]?.querySelector('details')).not.toBeNull();
expect(rows[1]?.querySelector('details')).toBeNull();
});
});
@@ -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<number>(50);
protected readonly offset = signal<number>(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<AdminAuditPage | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(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 150 of 1 234" string. Defensive — when
// the page is empty we report "no results" rather than "10".
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<void> {
this.offset.set(0);
await this.fetch();
}
/** Page navigation — moves the offset by `limit`, then fetches. */
async next(): Promise<void> {
if (!this.hasNextPage()) return;
this.offset.update((o) => o + this.limit());
await this.fetch();
}
async previous(): Promise<void> {
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<void> {
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<string, unknown> | null): string {
if (payload === null) return '—';
try {
return JSON.stringify(payload, null, 2);
} catch {
return String(payload);
}
}
private async fetch(): Promise<void> {
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 {
// `<input type="datetime-local">` 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;
}
@@ -13,6 +13,10 @@
<source>APF Portal Admin</source>
<target>Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.audit.title" datatype="html">
<source>Audit log — APF Portal Admin</source>
<target>Journal daudit — Administration APF Portal</target>
</trans-unit>
<!-- page.home -->
<trans-unit id="page.home.title" datatype="html">