diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index f96422a..e24c72b 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -63,6 +63,14 @@ export default function AppLayout({ children }: { children: ReactNode }) { Benutzer ) : null} + {user.role === "ADMIN" ? ( + + Audit-Log + + ) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx new file mode 100644 index 0000000..eb52f1c --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.spec.tsx @@ -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({ui}); +} + +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(); + + 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(); + + expect(await screen.findByRole("alert")).toHaveTextContent(/nicht geladen/i); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx new file mode 100644 index 0000000..e422812 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx @@ -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 ( +
+

Audit-Log

+ +
{ + event.preventDefault(); + setAppliedFilter({ entityType, entityId }); + }} + > +
+ + setEntityType(event.target.value)} + className="w-48" + /> +
+
+ + setEntityId(event.target.value)} + className="w-64" + /> +
+ +
+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Audit-Log konnte nicht geladen werden. +

+ ) : null} + + {entries ? ( + + + + Zeitpunkt + Entitätstyp + Entitäts-ID + Aktion + Benutzer + + + + {entries.map((entry) => ( + + {new Date(entry.timestamp).toLocaleString("de-DE")} + {entry.entityType} + {entry.entityId} + {entry.action} + {entry.userId ?? "—"} + + ))} + +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/lib/audit-log/api.ts b/ApiCore-Frontend/src/lib/audit-log/api.ts new file mode 100644 index 0000000..7a1f9d5 --- /dev/null +++ b/ApiCore-Frontend/src/lib/audit-log/api.ts @@ -0,0 +1,10 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { AuditLogEntry, AuditLogFilter } from "./types"; + +export function fetchAuditLog(filter: AuditLogFilter = {}): Promise { + 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(`/audit-log${query ? `?${query}` : ""}`); +} diff --git a/ApiCore-Frontend/src/lib/audit-log/types.ts b/ApiCore-Frontend/src/lib/audit-log/types.ts new file mode 100644 index 0000000..6028dca --- /dev/null +++ b/ApiCore-Frontend/src/lib/audit-log/types.ts @@ -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; +} diff --git a/TASKS.md b/TASKS.md index 41cbd21..3c8a824 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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