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
|
Benutzer
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
{user.role === "ADMIN" ? (
|
||||||
|
<Link
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
href="/settings/audit-log"
|
||||||
|
>
|
||||||
|
Audit-Log
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-sm">
|
<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=`
|
- Endpunkte: `GET /api/v1/audit-log?entityType=&entityId=`
|
||||||
- Frontend: `/settings/audit-log` (nur Administrator)
|
- Frontend: `/settings/audit-log` (nur Administrator)
|
||||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
- [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
|
### 20. Backupfähigkeit
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user