AuditLog uses a raw useQuery with the filters in the queryKey and a page-level early return (if isPending && no rows → <LoadingState/>), which sits ABOVE <PageEnter>. On any filter change the new queryKey had no cached data, so isPending flipped true with empty rows, the early return unmounted the whole PageEnter subtree, and it remounted (replaying the staggered entrance) when data arrived. Add placeholderData: keepPreviousData so rows persist through the refetch (isPending stays false, subtree never unmounts), and dim the table via isFetching instead of isPending. Verified by DOM node-identity: the PageEnter root survives a filter (no remount). The paginated list pages were already immune (usePaginatedQuery bakes in keepPreviousData). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import Forbidden from "../components/Forbidden";
|
|
import { czechPlural } from "../utils/formatters";
|
|
import apiFetch from "../utils/api";
|
|
import { useApiMutation } from "../lib/queries/mutations";
|
|
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
|
import {
|
|
Button,
|
|
Card,
|
|
DataTable,
|
|
Pagination,
|
|
Modal,
|
|
Select,
|
|
DateField,
|
|
TextField,
|
|
StatusChip,
|
|
PageHeader,
|
|
PageEnter,
|
|
FilterBar,
|
|
EmptyState,
|
|
LoadingState,
|
|
type DataColumn,
|
|
} from "../ui";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
const ACTION_LABELS: Record<string, string> = {
|
|
create: "Vytvoření",
|
|
update: "Úprava",
|
|
delete: "Smazání",
|
|
login: "Přihlášení",
|
|
login_failed: "Neúspěšné přihlášení",
|
|
logout: "Odhlášení",
|
|
view: "Zobrazení",
|
|
activate: "Aktivace",
|
|
deactivate: "Deaktivace",
|
|
password_change: "Změna hesla",
|
|
permission_change: "Změna oprávnění",
|
|
access_denied: "Přístup odepřen",
|
|
};
|
|
|
|
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
|
|
const ACTION_CHIP_COLOR: Record<
|
|
string,
|
|
"success" | "info" | "warning" | "error" | "default"
|
|
> = {
|
|
create: "success",
|
|
update: "info",
|
|
delete: "error",
|
|
login: "default",
|
|
login_failed: "error",
|
|
logout: "default",
|
|
view: "info",
|
|
activate: "success",
|
|
deactivate: "warning",
|
|
password_change: "info",
|
|
permission_change: "warning",
|
|
access_denied: "error",
|
|
};
|
|
|
|
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
|
value,
|
|
label,
|
|
}));
|
|
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
|
|
([value, label]) => ({ value, label }),
|
|
);
|
|
|
|
const CLEANUP_DAYS_OPTIONS = [
|
|
{ value: "30", label: "30 dní" },
|
|
{ value: "60", label: "60 dní" },
|
|
{ value: "90", label: "90 dní" },
|
|
{ value: "180", label: "180 dní" },
|
|
{ value: "365", label: "1 rok" },
|
|
{ value: "0", label: "Vše" },
|
|
];
|
|
|
|
const TrashIcon = (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
);
|
|
|
|
interface AuditLogEntry {
|
|
id: number;
|
|
created_at: string;
|
|
username: string | null;
|
|
action: string;
|
|
entity_type: string | null;
|
|
description: string | null;
|
|
user_ip: string | null;
|
|
}
|
|
|
|
interface Filters {
|
|
search: string;
|
|
action: string;
|
|
entity_type: string;
|
|
date_from: string;
|
|
date_to: string;
|
|
}
|
|
|
|
export default function AuditLog() {
|
|
const { hasPermission } = useAuth();
|
|
const alert = useAlert();
|
|
const [filters, setFilters] = useState<Filters>({
|
|
search: "",
|
|
action: "",
|
|
entity_type: "",
|
|
date_from: "",
|
|
date_to: "",
|
|
});
|
|
const [page, setPage] = useState(1);
|
|
const [perPage] = useState(50);
|
|
const [showCleanup, setShowCleanup] = useState(false);
|
|
// cleanupDays stored as string for the kit Select; converted to number at the boundary
|
|
const [cleanupDaysStr, setCleanupDaysStr] = useState("90");
|
|
|
|
const {
|
|
data: logsData,
|
|
isPending,
|
|
isFetching,
|
|
} = useQuery({
|
|
queryKey: [
|
|
"audit-log",
|
|
{
|
|
search: filters.search,
|
|
action: filters.action,
|
|
entityType: filters.entity_type,
|
|
dateFrom: filters.date_from,
|
|
dateTo: filters.date_to,
|
|
page,
|
|
perPage,
|
|
},
|
|
],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams({
|
|
page: String(page),
|
|
per_page: String(perPage),
|
|
});
|
|
if (filters.search) params.set("search", filters.search);
|
|
if (filters.action) params.set("action", filters.action);
|
|
if (filters.entity_type) params.set("entity_type", filters.entity_type);
|
|
if (filters.date_from) params.set("date_from", filters.date_from);
|
|
if (filters.date_to) params.set("date_to", filters.date_to);
|
|
|
|
const response = await apiFetch(
|
|
`${API_BASE}/audit-log?${params.toString()}`,
|
|
);
|
|
if (response.status === 401) throw new Error("Unauthorized");
|
|
const result = await response.json();
|
|
if (!result.success)
|
|
throw new Error(result.error || "Nepodařilo se načíst audit log");
|
|
return {
|
|
data: Array.isArray(result.data) ? result.data : [],
|
|
pagination: {
|
|
total: result.pagination?.total ?? 0,
|
|
page: result.pagination?.page ?? 1,
|
|
per_page: result.pagination?.limit ?? perPage,
|
|
total_pages: result.pagination?.total_pages ?? 1,
|
|
},
|
|
};
|
|
},
|
|
// Keep the current rows on screen while a filter refetches, so the page
|
|
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
|
// whole entrance animation on every filter change).
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
|
|
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
|
const pagination = logsData?.pagination ?? null;
|
|
|
|
if (!hasPermission("settings.audit")) {
|
|
return <Forbidden />;
|
|
}
|
|
|
|
const cleanupMutation = useApiMutation<
|
|
{ days: number; confirm?: string },
|
|
{ message?: string; error?: string }
|
|
>({
|
|
url: () => `${API_BASE}/audit-log/cleanup`,
|
|
method: () => "POST",
|
|
invalidate: ["audit-log"],
|
|
onSuccess: (data) => {
|
|
alert.success(data?.message || "Hotovo");
|
|
setShowCleanup(false);
|
|
},
|
|
});
|
|
|
|
const handleFilterChange = (key: keyof Filters, value: string) => {
|
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
setPage(1);
|
|
};
|
|
|
|
const handleCleanup = async () => {
|
|
const cleanupDays = Number(cleanupDaysStr);
|
|
try {
|
|
// Backend requires this literal confirmation token when wiping everything.
|
|
await cleanupMutation.mutateAsync({
|
|
days: cleanupDays,
|
|
...(cleanupDays === 0 ? { confirm: "DELETE_ALL_AUDIT" } : {}),
|
|
});
|
|
} catch (e) {
|
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
|
}
|
|
};
|
|
|
|
const formatDatetime = (dateString: string | null): string => {
|
|
if (!dateString) return "-";
|
|
return new Date(dateString).toLocaleString("cs-CZ");
|
|
};
|
|
|
|
if (isPending && logs.length === 0) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
const columns: DataColumn<AuditLogEntry>[] = [
|
|
{
|
|
key: "created_at",
|
|
header: "Čas",
|
|
width: "16%",
|
|
mono: true,
|
|
render: (log) => formatDatetime(log.created_at),
|
|
},
|
|
{
|
|
key: "username",
|
|
header: "Uživatel",
|
|
width: "14%",
|
|
bold: true,
|
|
render: (log) => log.username || "-",
|
|
},
|
|
{
|
|
key: "action",
|
|
header: "Akce",
|
|
width: "14%",
|
|
render: (log) => (
|
|
<StatusChip
|
|
label={ACTION_LABELS[log.action] || log.action}
|
|
color={ACTION_CHIP_COLOR[log.action] ?? "default"}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "entity_type",
|
|
header: "Typ entity",
|
|
width: "14%",
|
|
render: (log) =>
|
|
ENTITY_TYPE_LABELS[log.entity_type || ""] || log.entity_type || "-",
|
|
},
|
|
{
|
|
key: "description",
|
|
header: "Popis",
|
|
width: "30%",
|
|
render: (log) => log.description || "-",
|
|
},
|
|
{
|
|
key: "user_ip",
|
|
header: "IP",
|
|
width: "12%",
|
|
mono: true,
|
|
render: (log) => log.user_ip || "-",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageEnter>
|
|
<PageHeader
|
|
title="Audit log"
|
|
subtitle={
|
|
pagination
|
|
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
|
|
: undefined
|
|
}
|
|
actions={
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={TrashIcon}
|
|
onClick={() => setShowCleanup(true)}
|
|
>
|
|
Vyčistit
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{/* Cleanup Modal */}
|
|
<Modal
|
|
isOpen={showCleanup}
|
|
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
|
onSubmit={handleCleanup}
|
|
title="Vyčistit audit log"
|
|
submitText="Smazat"
|
|
loading={cleanupMutation.isPending}
|
|
>
|
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
Smazat záznamy starší než:
|
|
</Typography>
|
|
<Box sx={{ maxWidth: 200, mb: 1 }}>
|
|
<Select
|
|
value={cleanupDaysStr}
|
|
onChange={setCleanupDaysStr}
|
|
options={CLEANUP_DAYS_OPTIONS}
|
|
/>
|
|
</Box>
|
|
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
|
|
Tato akce je nevratná.
|
|
</Typography>
|
|
</Modal>
|
|
|
|
<FilterBar>
|
|
<Box sx={{ flex: "1 1 320px" }}>
|
|
<TextField
|
|
value={filters.search}
|
|
onChange={(e) => handleFilterChange("search", e.target.value)}
|
|
placeholder="Popis, uživatel..."
|
|
fullWidth
|
|
/>
|
|
</Box>
|
|
<Box sx={{ flex: "0 0 160px" }}>
|
|
<Select
|
|
value={filters.action}
|
|
onChange={(val) => handleFilterChange("action", val)}
|
|
options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ flex: "0 0 220px" }}>
|
|
<Select
|
|
value={filters.entity_type}
|
|
onChange={(val) => handleFilterChange("entity_type", val)}
|
|
options={[
|
|
{ value: "", label: "Všechny entity" },
|
|
...ENTITY_OPTIONS,
|
|
]}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ flex: "0 0 150px" }}>
|
|
<DateField
|
|
value={filters.date_from}
|
|
onChange={(val) => handleFilterChange("date_from", val)}
|
|
label="Od"
|
|
/>
|
|
</Box>
|
|
<Box sx={{ flex: "0 0 150px" }}>
|
|
<DateField
|
|
value={filters.date_to}
|
|
onChange={(val) => handleFilterChange("date_to", val)}
|
|
label="Do"
|
|
/>
|
|
</Box>
|
|
</FilterBar>
|
|
|
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
|
<DataTable<AuditLogEntry>
|
|
columns={columns}
|
|
rows={logs}
|
|
rowKey={(log) => log.id}
|
|
empty={<EmptyState title="Žádné záznamy k zobrazení" />}
|
|
/>
|
|
<Pagination
|
|
page={page}
|
|
pageCount={pagination?.total_pages ?? 1}
|
|
onChange={setPage}
|
|
/>
|
|
</Card>
|
|
</PageEnter>
|
|
);
|
|
}
|