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 = { 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 = ( ); 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({ 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 (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 ; } 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 ; } const columns: DataColumn[] = [ { 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) => ( ), }, { 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 ( setShowCleanup(true)} > Vyčistit } /> {/* Cleanup Modal */} !cleanupMutation.isPending && setShowCleanup(false)} onSubmit={handleCleanup} title="Vyčistit audit log" submitText="Smazat" loading={cleanupMutation.isPending} > Smazat záznamy starší než: handleFilterChange("action", val)} options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]} />