294 lines
9.2 KiB
TypeScript
294 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Eye, Filter, RotateCcw } from "lucide-react";
|
|
|
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
|
import SearchInput from "@/components/common/SearchInput";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { api } from "@/lib/api";
|
|
import type { ApiSuccess, AuditLog, AuditLogListResponse } from "@/types/audit";
|
|
|
|
const pageSize = 20;
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
if (typeof error === "object" && error !== null && "response" in error) {
|
|
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
|
return response?.data?.message ?? response?.data?.detail ?? "Audit Logs konnten nicht geladen werden";
|
|
}
|
|
return "Audit Logs konnten nicht geladen werden";
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "short",
|
|
timeStyle: "medium",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function formatJson(value: unknown) {
|
|
if (value === null || value === undefined) {
|
|
return "-";
|
|
}
|
|
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
export default function AuditLogsPage() {
|
|
const [items, setItems] = useState<AuditLog[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState("");
|
|
const [entityType, setEntityType] = useState("");
|
|
const [action, setAction] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
|
|
|
const loadAuditLogs = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: String(page),
|
|
page_size: String(pageSize),
|
|
});
|
|
|
|
if (entityType.trim()) {
|
|
params.set("entity_type", entityType.trim());
|
|
}
|
|
|
|
if (action.trim()) {
|
|
params.set("action", action.trim());
|
|
}
|
|
|
|
const response = await api.get<ApiSuccess<AuditLogListResponse>>(`/audit-logs?${params.toString()}`);
|
|
setItems(response.data.data.items);
|
|
setTotal(response.data.data.total);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [action, entityType, page]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadAuditLogs();
|
|
});
|
|
}, [loadAuditLogs]);
|
|
|
|
const filteredItems = useMemo(() => {
|
|
const normalized = search.trim().toLowerCase();
|
|
if (!normalized) {
|
|
return items;
|
|
}
|
|
|
|
return items.filter((item) => (
|
|
item.actor_username.toLowerCase().includes(normalized)
|
|
|| item.action.toLowerCase().includes(normalized)
|
|
|| item.entity_type.toLowerCase().includes(normalized)
|
|
|| item.entity_label.toLowerCase().includes(normalized)
|
|
));
|
|
}, [items, search]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
const columns: DataTableColumn<AuditLog>[] = [
|
|
{
|
|
key: "created_at",
|
|
label: "Zeitpunkt",
|
|
render: (log) => <span className="whitespace-nowrap">{formatDate(log.created_at)}</span>,
|
|
},
|
|
{
|
|
key: "actor_username",
|
|
label: "Benutzer",
|
|
render: (log) => log.actor_username || "System",
|
|
},
|
|
{
|
|
key: "action",
|
|
label: "Aktion",
|
|
render: (log) => <span className="font-medium text-slate-950">{log.action}</span>,
|
|
},
|
|
{
|
|
key: "entity",
|
|
label: "Objekt",
|
|
render: (log) => (
|
|
<div>
|
|
<p className="font-medium text-slate-900">{log.entity_label || "-"}</p>
|
|
<p className="text-xs text-slate-500">{log.entity_type}{log.entity_id ? ` #${log.entity_id}` : ""}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "ip_address",
|
|
label: "IP",
|
|
render: (log) => log.ip_address || "-",
|
|
},
|
|
{
|
|
key: "details",
|
|
label: "",
|
|
className: "px-4 py-3 text-right",
|
|
render: (log) => (
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedLog(log)}>
|
|
<Eye size={16} />
|
|
Details
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-slate-950">Audit Logs</h1>
|
|
<p className="mt-1 text-sm text-slate-500">Nachvollziehbare Systemereignisse und Änderungen</p>
|
|
</div>
|
|
<Button type="button" variant="outline" onClick={() => void loadAuditLogs()}>
|
|
<RotateCcw size={16} />
|
|
Aktualisieren
|
|
</Button>
|
|
</div>
|
|
|
|
<section className="rounded-lg border bg-white p-4">
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_220px_220px_auto] lg:items-end">
|
|
<SearchInput value={search} onChange={setSearch} placeholder="Audit Logs durchsuchen..." />
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="entity-type">Objekttyp</Label>
|
|
<Input
|
|
id="entity-type"
|
|
value={entityType}
|
|
onChange={(event) => {
|
|
setEntityType(event.target.value);
|
|
setPage(1);
|
|
}}
|
|
placeholder="customers"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="action">Aktion</Label>
|
|
<Input
|
|
id="action"
|
|
value={action}
|
|
onChange={(event) => {
|
|
setAction(event.target.value);
|
|
setPage(1);
|
|
}}
|
|
placeholder="users.update"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSearch("");
|
|
setEntityType("");
|
|
setAction("");
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<Filter size={16} />
|
|
Zurücksetzen
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
rows={filteredItems}
|
|
rowKey={(row) => row.id}
|
|
sortKey="created_at"
|
|
sortDirection="desc"
|
|
loading={loading}
|
|
error={error}
|
|
emptyTitle="Keine Audit Logs gefunden"
|
|
emptyDescription="Für die aktuellen Filter sind keine Einträge vorhanden."
|
|
onSort={() => undefined}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between text-sm text-slate-600">
|
|
<span>{total} Einträge</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
>
|
|
Zurück
|
|
</Button>
|
|
<span>Seite {page} von {totalPages}</span>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
|
>
|
|
Weiter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={selectedLog !== null} onOpenChange={(open) => !open && setSelectedLog(null)}>
|
|
<DialogContent className="max-w-5xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Audit Log Details</DialogTitle>
|
|
</DialogHeader>
|
|
{selectedLog && (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 text-sm md:grid-cols-3">
|
|
<Detail label="Aktion" value={selectedLog.action} />
|
|
<Detail label="Benutzer" value={selectedLog.actor_username || "System"} />
|
|
<Detail label="Zeitpunkt" value={formatDate(selectedLog.created_at)} />
|
|
<Detail label="Objekt" value={`${selectedLog.entity_type}${selectedLog.entity_id ? ` #${selectedLog.entity_id}` : ""}`} />
|
|
<Detail label="Label" value={selectedLog.entity_label || "-"} />
|
|
<Detail label="IP" value={selectedLog.ip_address || "-"} />
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-3">
|
|
<JsonPanel title="Vorher" value={selectedLog.before_data} />
|
|
<JsonPanel title="Nachher" value={selectedLog.after_data} />
|
|
<JsonPanel title="Metadaten" value={selectedLog.metadata_data} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Detail({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="rounded-lg border bg-slate-50 p-3">
|
|
<p className="text-xs font-semibold uppercase text-slate-500">{label}</p>
|
|
<p className="mt-1 break-words text-slate-950">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JsonPanel({ title, value }: { title: string; value: unknown }) {
|
|
return (
|
|
<div>
|
|
<h2 className="mb-2 text-sm font-semibold text-slate-950">{title}</h2>
|
|
<pre className="max-h-96 overflow-auto rounded-lg border bg-slate-950 p-3 text-xs text-slate-100">
|
|
{formatJson(value)}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|