feat(portal-admin): audit log viewer screen #136
@@ -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 ("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<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 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<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 d’audit — Administration APF Portal</target>
|
||||
</trans-unit>
|
||||
|
||||
<!-- page.home -->
|
||||
<trans-unit id="page.home.title" datatype="html">
|
||||
|
||||
Reference in New Issue
Block a user