feat(audit): admin audit log (Notification P3)
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).
This commit is contained in:
parent
c1d38eea21
commit
318fdc4812
35 changed files with 720 additions and 29 deletions
91
frontend/src/components/auditColumns.tsx
Normal file
91
frontend/src/components/auditColumns.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { relativeTime, formatDate } from "../lib/format";
|
||||
import i18n from "../i18n";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import type { Column } from "./DataTable";
|
||||
|
||||
function actionLabel(t: TFunction, action: string): string {
|
||||
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||
return t(`auditActions.${action}`, action);
|
||||
}
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
return v == null || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
|
||||
function changeText(row: AuditRow): string | null {
|
||||
const { before, after } = row;
|
||||
if (before && after) {
|
||||
return Object.keys(after)
|
||||
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
|
||||
.join(", ");
|
||||
}
|
||||
if (after) {
|
||||
return Object.entries(after)
|
||||
.map(([k, v]) => `${k}: ${fmt(v)}`)
|
||||
.join(", ");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
|
||||
* the data (built by the page) so the Action column's select filter only offers real values. */
|
||||
export function auditColumns(
|
||||
t: TFunction,
|
||||
actionOptions: { value: string; label: string }[],
|
||||
): Column<AuditRow>[] {
|
||||
return [
|
||||
{
|
||||
key: "when",
|
||||
header: t("audit.cols.when"),
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (r) => r.created_at ?? "",
|
||||
cardPrimary: true,
|
||||
render: (r) => (
|
||||
<span
|
||||
className="text-muted tabular-nums"
|
||||
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
|
||||
>
|
||||
{r.created_at ? relativeTime(r.created_at) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actor",
|
||||
header: t("audit.cols.actor"),
|
||||
sortable: true,
|
||||
sortValue: (r) => r.actor ?? "",
|
||||
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
|
||||
render: (r) =>
|
||||
r.actor ? (
|
||||
<span className="truncate">{r.actor}</span>
|
||||
) : (
|
||||
<span className="text-muted italic">{t("audit.system")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: t("audit.cols.action"),
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (r) => actionLabel(t, r.action),
|
||||
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
|
||||
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
|
||||
},
|
||||
{
|
||||
key: "details",
|
||||
header: t("audit.cols.details"),
|
||||
render: (r) => {
|
||||
const change = changeText(r);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{r.summary && <div className="truncate">{r.summary}</div>}
|
||||
{change && <div className="text-[11px] text-muted truncate">{change}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue