Voller Merge von TEN-05/IAM-08/RBAC-05/LIC-04/AUD-04/CFG-04/OPS-02 (SHL-01 bereits Bestandteil) nach feature/qa-09-barrierefreiheits-audit. WCAG-2.1-AA- Audit ueber alle 7 Core-Frontends (15 Seiten): automatisierte Pruefung (pa11y/HTML_CodeSniffer WCAG2AA) und automatisiertes Tastatur-Traversal (Puppeteer) auf 192.168.1.131 durchgefuehrt, Ergebnis nach Fixes 0/15 Verstoesse, 100% Tastaturerreichbarkeit ohne Fallen. Vier reale WCAG-Verstoesse gefunden und behoben: ungueltiger autocomplete="username" auf type="email"-Feldern (account, 2 Seiten) sowie fehlende zugaengliche Namen auf Filter-/Eingabefeldern in audit-log, lic-admin und tenant-admin (aria-label ergaenzt). Zusaetzlich ein realer Testinfrastruktur-Fehler in internal/adminapi gefunden: lichandler_test.go's Cleanup rief `DELETE FROM tenant_licenses WHERE tenant_id = $1` OHNE das $1-Argument auf (Fehler durch `_, _ =` verschluckt) - die Tenant-Zeile blieb dadurch ueber eine FK-Constraint dauerhaft haengen und verfaelschte internal/migrate im vollen Testlauf, analog zu den in QA-04 gefundenen defer/t.Cleanup-Bugs. Nur Testcode betroffen. Ein Restbefund terminiert: echter Bildschirmleser-Durchlauf (NVDA/ VoiceOver) steht mangels grafischer Testumgebung noch aus, Frist vor QA-05 (siehe docs/QA-09-BARRIEREFREIHEITS-AUDIT.md Abschnitt 5). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { fetchRecords, exportURL, type AuditRecord, type AuditFilter } from "@/lib/api";
|
|
|
|
const EMPTY_FILTER: AuditFilter = { tenant: "", actor: "", action: "", from: "", to: "" };
|
|
|
|
export default function Page() {
|
|
const [caller, setCaller] = useState("");
|
|
const [filter, setFilter] = useState<AuditFilter>(EMPTY_FILTER);
|
|
const [records, setRecords] = useState<AuditRecord[] | null>(null);
|
|
const [selected, setSelected] = useState<AuditRecord | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function onSearch() {
|
|
setError(null);
|
|
setSelected(null);
|
|
try {
|
|
const data = await fetchRecords(caller.trim(), filter);
|
|
setRecords(data);
|
|
} catch (e: any) {
|
|
setError(e.message ?? "Unbekannter Fehler");
|
|
setRecords(null);
|
|
}
|
|
}
|
|
|
|
function onExport(format: "csv" | "json") {
|
|
const url = exportURL(caller.trim(), filter, format);
|
|
// Echter Datei-Download ueber Browser-Navigation, KEIN erneutes Parsen
|
|
// im Frontend (Akzeptanzkriterium 2: Export aus AUD-03 direkt ausloesbar).
|
|
window.open(url, "_blank");
|
|
}
|
|
|
|
return (
|
|
<main style={{ maxWidth: 1000, margin: "0 auto", padding: "2rem 1rem" }}>
|
|
<h1>Audit-Log</h1>
|
|
|
|
<div style={{ display: "grid", gap: "0.5rem", gridTemplateColumns: "repeat(3, 1fr)", marginBottom: "1rem" }}>
|
|
<input
|
|
value={caller}
|
|
onChange={(e) => setCaller(e.target.value)}
|
|
placeholder="Admin-Token (Berechtigung)"
|
|
aria-label="Admin-Token (Berechtigung)"
|
|
style={{ padding: "0.5rem", gridColumn: "span 3" }}
|
|
/>
|
|
<input
|
|
value={filter.tenant}
|
|
onChange={(e) => setFilter({ ...filter, tenant: e.target.value })}
|
|
placeholder="Tenant"
|
|
aria-label="Tenant"
|
|
style={{ padding: "0.5rem" }}
|
|
/>
|
|
<input
|
|
value={filter.actor}
|
|
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
|
|
placeholder="Akteur"
|
|
aria-label="Akteur"
|
|
style={{ padding: "0.5rem" }}
|
|
/>
|
|
<input
|
|
value={filter.action}
|
|
onChange={(e) => setFilter({ ...filter, action: e.target.value })}
|
|
placeholder="Aktion"
|
|
aria-label="Aktion"
|
|
style={{ padding: "0.5rem" }}
|
|
/>
|
|
<label>
|
|
Von
|
|
<input
|
|
type="datetime-local"
|
|
value={filter.from}
|
|
onChange={(e) =>
|
|
setFilter({ ...filter, from: e.target.value ? new Date(e.target.value).toISOString() : "" })
|
|
}
|
|
style={{ width: "100%", padding: "0.4rem" }}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Bis
|
|
<input
|
|
type="datetime-local"
|
|
value={filter.to}
|
|
onChange={(e) =>
|
|
setFilter({ ...filter, to: e.target.value ? new Date(e.target.value).toISOString() : "" })
|
|
}
|
|
style={{ width: "100%", padding: "0.4rem" }}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
|
<button onClick={onSearch}>Suchen</button>
|
|
<button onClick={() => onExport("csv")}>Export als CSV</button>
|
|
<button onClick={() => onExport("json")}>Export als JSON</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p style={{ color: "#c62828" }} role="alert">
|
|
Fehler: {error}
|
|
</p>
|
|
)}
|
|
|
|
{records && (
|
|
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
|
<thead>
|
|
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
|
|
<th style={{ padding: "0.5rem" }}>Zeitpunkt</th>
|
|
<th style={{ padding: "0.5rem" }}>Tenant</th>
|
|
<th style={{ padding: "0.5rem" }}>Akteur</th>
|
|
<th style={{ padding: "0.5rem" }}>Aktion</th>
|
|
<th style={{ padding: "0.5rem" }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.map((r, i) => (
|
|
<tr key={i} style={{ borderBottom: "1px solid #eee" }}>
|
|
<td style={{ padding: "0.5rem" }}>{new Date(r.occurred_at).toLocaleString("de-DE")}</td>
|
|
<td style={{ padding: "0.5rem" }}>{r.tenant_slug}</td>
|
|
<td style={{ padding: "0.5rem" }}>{r.actor}</td>
|
|
<td style={{ padding: "0.5rem" }}>{r.action}</td>
|
|
<td style={{ padding: "0.5rem" }}>
|
|
<button onClick={() => setSelected(r)}>Details</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{records.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} style={{ padding: "0.5rem" }}>
|
|
Keine Einträge gefunden.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{selected && (
|
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
|
<h2>Eintrag-Details</h2>
|
|
<dl>
|
|
<dt>Zeitpunkt</dt>
|
|
<dd>{new Date(selected.occurred_at).toLocaleString("de-DE")}</dd>
|
|
<dt>Tenant</dt>
|
|
<dd>{selected.tenant_slug}</dd>
|
|
<dt>Akteur</dt>
|
|
<dd>{selected.actor}</dd>
|
|
<dt>Aktion</dt>
|
|
<dd>{selected.action}</dd>
|
|
<dt>Ziel</dt>
|
|
<dd>{selected.target}</dd>
|
|
<dt>Metadaten</dt>
|
|
<dd>
|
|
<pre style={{ whiteSpace: "pre-wrap", background: "#f5f6f8", padding: "0.5rem" }}>
|
|
{JSON.stringify(selected.metadata, null, 2)}
|
|
</pre>
|
|
</dd>
|
|
</dl>
|
|
<button onClick={() => setSelected(null)}>Schließen</button>
|
|
</section>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|