Files
app/src/admin/pages/AuditLog.tsx
BOHA b23d15453d fix(audit-log): stop replaying the page entrance animation on every filter
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>
2026-06-07 15:34:06 +02:00

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>
);
}