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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user