feat(beheer): admin audit view at /beheer/audit (finishes WP-42)

The WP-41 GET /admin/audit trail now has an FE view: a beheer audit page (domain
AuditEntry + adapter/parse + store) rendering the data-minimised trail as a read-only
table, capability-gated on cases:manage. Added to ADMIN_LINKS (header nav + dashboard
Beheer section) and to the role.interceptor ROLE_AWARE list so the admin-gated call
carries X-Role. Closes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 16:05:36 +02:00
co-authored by Claude Opus 4.8
parent 0f30143c5d
commit 8cd925717f
11 changed files with 443 additions and 51 deletions
@@ -0,0 +1,45 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient } from '@shared/infrastructure/api-client';
import type { AuthzAuditDto } from '@shared/infrastructure/api-client';
import { AuditEntry } from '@beheer/domain/audit-entry';
/**
* Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`,
* WP-41). The single place the ApiClient lives for audit; the store parses at the boundary.
*/
@Injectable({ providedIn: 'root' })
export class AuditAdapter {
private client = inject(ApiClient);
list(): Promise<AuthzAuditDto[]> {
return this.client.audit();
}
}
/** Trust-boundary parse of the audit rows. */
export function parseAuditEntries(json: unknown): Result<string, AuditEntry[]> {
if (!Array.isArray(json)) return err('audit: not an array');
const out: AuditEntry[] = [];
for (const item of json) {
if (typeof item !== 'object' || item === null) return err('audit: row not an object');
const d = item as AuthzAuditDto;
if (
typeof d.at !== 'string' ||
typeof d.action !== 'string' ||
typeof d.resource !== 'string' ||
typeof d.role !== 'string' ||
typeof d.correlationId !== 'string'
)
return err('audit: missing fields');
out.push({
at: d.at,
action: d.action,
resource: d.resource,
decision: d.decision === 'allow' ? 'allow' : 'deny',
role: d.role,
correlationId: d.correlationId,
});
}
return ok(out);
}