Append-only who/what/when trail for admin actions + scheduler run summaries, generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never per-row (a scheduler run touching thousands of videos is ONE row). Backend: - migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action, target_type/id, summary, before/after JSON, created_at). - app/audit.py: AuditAction constants (single source of truth, i18n'd on the client) + record() (adds to the caller's txn) + gc(retention_days). - producers wired: role change / suspend / delete / invite approve-deny-add / demo add-remove-reset / discovery purge (admin.py); config set/reset — secret VALUES never logged, key only (config.py); scheduler interval + maintenance tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job() choke point writes ONE run-summary row per run — manual runs + errors always, automatic runs only when they changed something (no no-op-poll spam). - retention: audit_gc scheduler job prunes rows older than audit_retention_days (sysconfig, default 180; 0 = keep forever). - admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails resolved, System for background) + clear (leaves one tamper-evidence row). Frontend: - new admin-only "Audit log" nav page (ScrollText) using the shared DataTable (first admin page to reuse it) — sort/filter/paginate/persist, action select filter, actor text filter, before→after detail, Clear-all with confirm. - AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type. - trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group + scheduler job labels; Audit config group (retention days).
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Trash2 } from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import { LS } from "../lib/storage";
|
|
import { Section } from "./ui/form";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import DataTable from "./DataTable";
|
|
import { auditColumns } from "./auditColumns";
|
|
|
|
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
|
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
|
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
|
export default function AuditLog() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.adminAudit() });
|
|
const data = q.data;
|
|
const rows = useMemo(() => data?.items ?? [], [data]);
|
|
|
|
const actionOptions = useMemo(() => {
|
|
const present = Array.from(new Set(rows.map((r) => r.action))).sort();
|
|
return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }));
|
|
}, [rows, t]);
|
|
const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]);
|
|
|
|
const clear = useMutation({
|
|
mutationFn: () => api.clearAudit(),
|
|
onSuccess: (r) => {
|
|
qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
|
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
|
});
|
|
const onClear = async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("audit.clearTitle"),
|
|
message: t("audit.clearConfirm"),
|
|
confirmLabel: t("audit.clear"),
|
|
danger: true,
|
|
})
|
|
)
|
|
clear.mutate();
|
|
};
|
|
|
|
const truncated = !!data && data.total > data.returned;
|
|
|
|
return (
|
|
<div className="p-4 max-w-5xl w-full mx-auto">
|
|
<Section card title={t("audit.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
|
{q.isLoading ? (
|
|
<div className="text-muted py-8">{t("common.loading")}</div>
|
|
) : rows.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
rows={rows}
|
|
columns={columns}
|
|
rowKey={(r) => String(r.id)}
|
|
persistKey={LS.auditTable}
|
|
controlsPosition="top"
|
|
controlsLeading={
|
|
<button
|
|
onClick={onClear}
|
|
disabled={clear.isPending}
|
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
{t("audit.clear")}
|
|
</button>
|
|
}
|
|
emptyText={t("audit.empty")}
|
|
/>
|
|
{truncated && (
|
|
<p className="text-[11px] text-muted mt-2">
|
|
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|