feat(frontend): Audit-Log-Ansicht (Modul 19)

Neue Seite /settings/audit-log (nur Administrator) mit Filter
nach Entitätstyp/-ID, Navigation um Audit-Log-Link ergaenzt.
This commit is contained in:
Nick Szaunig committed 2026-08-22 09:53:36 +02:00
1 parent 8a6769dbfd
commit ad787f8155
6 files changed
+188 -1

No files matched your search

@@ -63,6 +63,14 @@ export default function AppLayout({ children }: { children: ReactNode }) {
Benutzer
</Link>
) : null}
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
href="/settings/audit-log"
>
Audit-Log
</Link>
) : null}
</div>
</nav>
<div className="flex shrink-0 items-center gap-3 text-sm">
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import AuditLogPage from "./page";
import { fetchAuditLog } from "@/lib/audit-log/api";
vi.mock("@/lib/audit-log/api", () => ({
fetchAuditLog: vi.fn(),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("AuditLogPage", () => {
beforeEach(() => {
vi.mocked(fetchAuditLog).mockReset();
});
it("renders the fetched audit log entries", async () => {
vi.mocked(fetchAuditLog).mockResolvedValueOnce([
{
id: "1",
entityType: "Apiary",
entityId: "apiary-1",
action: "CREATE",
userId: "user-1",
timestamp: "2026-01-01T10:00:00.000Z",
beforeData: null,
afterData: { name: "Stand A" },
},
]);
renderWithClient(<AuditLogPage />);
expect(await screen.findByText("Apiary")).toBeInTheDocument();
expect(screen.getByText("apiary-1")).toBeInTheDocument();
expect(screen.getByText("CREATE")).toBeInTheDocument();
});
it("shows an error message when loading the audit log fails", async () => {
vi.mocked(fetchAuditLog).mockRejectedValueOnce(new Error("boom"));
renderWithClient(<AuditLogPage />);
expect(await screen.findByRole("alert")).toHaveTextContent(/nicht geladen/i);
});
});
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchAuditLog } from "@/lib/audit-log/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function AuditLogPage() {
const [entityType, setEntityType] = useState("");
const [entityId, setEntityId] = useState("");
const [appliedFilter, setAppliedFilter] = useState({ entityType: "", entityId: "" });
const { data: entries, isLoading, isError } = useQuery({
queryKey: ["audit-log", appliedFilter],
queryFn: () =>
fetchAuditLog({
entityType: appliedFilter.entityType || undefined,
entityId: appliedFilter.entityId || undefined,
}),
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<h1 className="text-xl font-semibold">Audit-Log</h1>
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
setAppliedFilter({ entityType, entityId });
}}
>
<div className="flex flex-col gap-1">
<label className="text-sm text-muted-foreground" htmlFor="entityType">
Entitätstyp
</label>
<Input
id="entityType"
placeholder="z. B. Colony"
value={entityType}
onChange={(event) => setEntityType(event.target.value)}
className="w-48"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm text-muted-foreground" htmlFor="entityId">
Entitäts-ID
</label>
<Input
id="entityId"
placeholder="UUID"
value={entityId}
onChange={(event) => setEntityId(event.target.value)}
className="w-64"
/>
</div>
<Button type="submit" variant="outline">
Filtern
</Button>
</form>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
Audit-Log konnte nicht geladen werden.
</p>
) : null}
{entries ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitpunkt</TableHead>
<TableHead>Entitätstyp</TableHead>
<TableHead>Entitäts-ID</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Benutzer</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell>{new Date(entry.timestamp).toLocaleString("de-DE")}</TableCell>
<TableCell>{entry.entityType}</TableCell>
<TableCell className="font-mono text-xs">{entry.entityId}</TableCell>
<TableCell>{entry.action}</TableCell>
<TableCell>{entry.userId ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</main>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client";
import { AuditLogEntry, AuditLogFilter } from "./types";
export function fetchAuditLog(filter: AuditLogFilter = {}): Promise<AuditLogEntry[]> {
const params = new URLSearchParams();
if (filter.entityType) params.set("entityType", filter.entityType);
if (filter.entityId) params.set("entityId", filter.entityId);
const query = params.toString();
return apiFetch<AuditLogEntry[]>(`/audit-log${query ? `?${query}` : ""}`);
}
@@ -0,0 +1,15 @@
export interface AuditLogEntry {
id: string;
entityType: string;
entityId: string;
action: string;
userId: string | null;
timestamp: string;
beforeData: unknown;
afterData: unknown;
}
export interface AuditLogFilter {
entityType?: string;
entityId?: string;
}
+1 -1
View File
@@ -140,7 +140,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Endpunkte: `GET /api/v1/audit-log?entityType=&entityId=`
- Frontend: `/settings/audit-log` (nur Administrator)
- [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet
- [x] Frontend-Views fertig, getestet, grün, committet
### 20. Backupfähigkeit