"use client"; /** * TASK-0133 — Audit tab: the org audit viewer. * * - filters: action (autocomplete from the catalog), actor id, resource * type, workspace, date range (≤90 days, server-enforced) * - paged table (newest first), immutable (the API exposes NO audit * mutation route — the integration router-walk test asserts it) * - detail drawer: the FULL metadata JSON + ip + user-agent + a deep link * when the resource still exists * - CSV export: ≤10k matching rows → synchronous inline download; >10k → * `202 {exportId}` (a worker job streams it to S3, capped at 500k rows * `truncated`), then poll + a SINGLE-USE 15-minute download URL. */ import { useMemo, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Download, Search, X } from "lucide-react"; import { orgAdminApi, type AuditRow } from "@/lib/org-admin"; import { isApiError } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import { Section, Field, inputCls } from "@/components/settings/settings-shell"; const PAGE_SIZE = 25; const RESOURCE_TYPES = ["page", "database", "record", "user", "file", "share", "api_key", "webhook", "automation", "organization", "session", "integration"]; interface Filters { action: string; actorId: string; resourceType: string; workspaceId: string; from: string; // yyyy-mm-dd to: string; // yyyy-mm-dd } const EMPTY: Filters = { action: "", actorId: "", resourceType: "", workspaceId: "", from: "", to: "" }; function downloadCsv(csv: string, filename: string) { const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } export function AdminAuditTab({ orgId }: { orgId: string }) { const [draft, setDraft] = useState(EMPTY); const [applied, setApplied] = useState(EMPTY); const [page, setPage] = useState(1); const [detail, setDetail] = useState(null); const actions = useQuery({ queryKey: ["orgs", orgId, "admin", "audit", "actions"], queryFn: () => orgAdminApi.auditActions(orgId), retry: false, }); const filters = useMemo( () => ({ action: applied.action || null, actorId: applied.actorId || null, resourceType: applied.resourceType || null, workspaceId: applied.workspaceId || null, from: applied.from || null, to: applied.to || null, page, pageSize: PAGE_SIZE, }), [applied, page] ); const list = useQuery({ queryKey: ["orgs", orgId, "admin", "audit", filters], queryFn: () => orgAdminApi.audit(orgId, filters), retry: false, }); const apply = (f: Filters) => { setApplied(f); setPage(1); }; const set = (k: keyof Filters, v: string) => setDraft((d) => ({ ...d, [k]: v })); const exportMut = useMutation({ mutationFn: async () => { const start = await orgAdminApi.auditExport(orgId, { action: applied.action || null, actorId: applied.actorId || null, resourceType: applied.resourceType || null, workspaceId: applied.workspaceId || null, from: applied.from || null, to: applied.to || null, }); if (start.kind === "sync" && start.csv) { downloadCsv(start.csv, `audit-${orgId.slice(0, 8)}.csv`); toast("success", `Exported ${start.total} rows (CSV)`); return null; } if (start.kind === "async") return start.exportId as string; return null; }, onError: (e) => toast("error", "Export failed", isApiError(e) ? e.message : undefined), }); const exportId = exportMut.data as string | null; const exportStatus = useQuery({ queryKey: ["orgs", orgId, "admin", "audit", "export", exportId], queryFn: () => orgAdminApi.auditExportStatus(orgId, exportId!, true), enabled: Boolean(exportId), refetchInterval: (q) => { const s = (q.state.data as { status?: string } | undefined)?.status; return s === "ready" || s === "failed" ? false : 1500; }, retry: false, }); const exp = exportStatus.data; const rows = list.data?.data ?? []; const total = list.data?.total ?? 0; const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); return (
{ e.preventDefault(); apply(draft); }} > set("action", e.target.value)} placeholder="e.g. user.login" className={inputCls} aria-label="Filter by action" /> {(actions.data?.data ?? []).map((a) => ( set("actorId", e.target.value)} placeholder="user or key id" className={inputCls} aria-label="Filter by actor id" /> set("workspaceId", e.target.value)} placeholder="workspace id" className={inputCls} aria-label="Filter by workspace id" /> set("from", e.target.value)} className={inputCls} aria-label="From date" /> set("to", e.target.value)} className={inputCls} aria-label="To date" />
{(JSON.stringify(applied) !== JSON.stringify(EMPTY) || page > 1) && ( )}
{total} row{total === 1 ? "" : "s"} {exp?.truncated ? ` (showing first ${PAGE_SIZE})` : ""}
{/* async export progress (single-use URL on ready) */} {exportId && (
{exp ? ( exp.status === "ready" ? (
Export ready ({exp.rows} rows{exp.truncated ? ", truncated at the 500k cap" : ""}). {exp.downloadUrl && ( exportMut.reset()} > Download (single-use, expires in {Math.max(0, Math.round((exp.urlTtlSec ?? 900) / 60))} min) )}
) : exp.status === "failed" ? ( Export failed: {exp.error ?? "unknown error"} ) : ( Exporting… ({exp.rows} of {exp.totalRows} rows so far) ) ) : ( Preparing export… )}
)}
{list.isLoading ? (

Loading…

) : list.isError ? (

Could not load the audit log.

) : rows.length === 0 ? (

No audit events match the filters.

) : ( <> {rows.map((r) => ( setDetail(r)}> ))}
Organization audit events
Time Action Resource Actor IP
{new Date(r.createdAt).toLocaleString()} {r.action} {r.resourceType}{" "} {r.resourceId ? r.resourceId.slice(0, 12) + "…" : "—"} {r.actorId?.slice(0, 12) ?? "—"} {r.ipAddress ?? "—"}
page {page} / {pages}
)}
{/* detail drawer */} {detail && ( )}
); } function DetailRow({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); }