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