1"use client";
2
3
16import { useMemo, useState } from "react";
17import { useMutation, useQuery } from "@tanstack/react-query";
18import { Download, Search, X } from "lucide-react";
19import { orgAdminApi, type AuditRow } from "@/lib/org-admin";
20import { isApiError } from "@/lib/api";
21import { toast } from "@/stores/ui-store";
22import { Section, Field, inputCls } from "@/components/settings/settings-shell";
23
24const PAGE_SIZE = 25;
25
26const RESOURCE_TYPES = ["page", "database", "record", "user", "file", "share", "api_key", "webhook", "automation", "organization", "session", "integration"];
27
28interface Filters {
29 action: string;
30 actorId: string;
31 resourceType: string;
32 workspaceId: string;
33 from: string;
34 to: string;
35}
36
37const EMPTY: Filters = { action: "", actorId: "", resourceType: "", workspaceId: "", from: "", to: "" };
38
39function downloadCsv(csv: string, filename: string) {
40 const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
41 const url = URL.createObjectURL(blob);
42 const a = document.createElement("a");
43 a.href = url;
44 a.download = filename;
45 document.body.appendChild(a);
46 a.click();
47 a.remove();
48 URL.revokeObjectURL(url);
49}
50
51export function AdminAuditTab({ orgId }: { orgId: string }) {
52 const [draft, setDraft] = useState<Filters>(EMPTY);
53 const [applied, setApplied] = useState<Filters>(EMPTY);
54 const [page, setPage] = useState(1);
55 const [detail, setDetail] = useState<AuditRow | null>(null);
56
57 const actions = useQuery({
58 queryKey: ["orgs", orgId, "admin", "audit", "actions"],
59 queryFn: () => orgAdminApi.auditActions(orgId),
60 retry: false,
61 });
62
63 const filters = useMemo(
64 () => ({
65 action: applied.action || null,
66 actorId: applied.actorId || null,
67 resourceType: applied.resourceType || null,
68 workspaceId: applied.workspaceId || null,
69 from: applied.from || null,
70 to: applied.to || null,
71 page,
72 pageSize: PAGE_SIZE,
73 }),
74 [applied, page]
75 );
76
77 const list = useQuery({
78 queryKey: ["orgs", orgId, "admin", "audit", filters],
79 queryFn: () => orgAdminApi.audit(orgId, filters),
80 retry: false,
81 });
82
83 const apply = (f: Filters) => {
84 setApplied(f);
85 setPage(1);
86 };
87 const set = (k: keyof Filters, v: string) => setDraft((d) => ({ ...d, [k]: v }));
88
89 const exportMut = useMutation({
90 mutationFn: async () => {
91 const start = await orgAdminApi.auditExport(orgId, {
92 action: applied.action || null,
93 actorId: applied.actorId || null,
94 resourceType: applied.resourceType || null,
95 workspaceId: applied.workspaceId || null,
96 from: applied.from || null,
97 to: applied.to || null,
98 });
99 if (start.kind === "sync" && start.csv) {
100 downloadCsv(start.csv, `audit-${orgId.slice(0, 8)}.csv`);
101 toast("success", `Exported ${start.total} rows (CSV)`);
102 return null;
103 }
104 if (start.kind === "async") return start.exportId as string;
105 return null;
106 },
107 onError: (e) => toast("error", "Export failed", isApiError(e) ? e.message : undefined),
108 });
109
110 const exportId = exportMut.data as string | null;
111 const exportStatus = useQuery({
112 queryKey: ["orgs", orgId, "admin", "audit", "export", exportId],
113 queryFn: () => orgAdminApi.auditExportStatus(orgId, exportId!, true),
114 enabled: Boolean(exportId),
115 refetchInterval: (q) => {
116 const s = (q.state.data as { status?: string } | undefined)?.status;
117 return s === "ready" || s === "failed" ? false : 1500;
118 },
119 retry: false,
120 });
121 const exp = exportStatus.data;
122
123 const rows = list.data?.data ?? [];
124 const total = list.data?.total ?? 0;
125 const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
126
127 return (
128 <div className="space-y-4">
129 <Section
130 title="Audit log"
131 description="Immutable, org-scoped (workspace rows + org-level rows). Read-only — there is no audit mutation endpoint."
132 >
133 <form
134 className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-6"
135 onSubmit={(e) => {
136 e.preventDefault();
137 apply(draft);
138 }}
139 >
140 <Field label="Action">
141 <input list="audit-actions" value={draft.action} onChange={(e) => set("action", e.target.value)} placeholder="e.g. user.login" className={inputCls} aria-label="Filter by action" />
142 <datalist id="audit-actions">
143 {(actions.data?.data ?? []).map((a) => (
144 <option key={a} value={a} />
145 ))}
146 </datalist>
147 </Field>
148 <Field label="Actor id">
149 <input value={draft.actorId} onChange={(e) => set("actorId", e.target.value)} placeholder="user or key id" className={inputCls} aria-label="Filter by actor id" />
150 </Field>
151 <Field label="Resource type">
152 <select value={draft.resourceType} onChange={(e) => set("resourceType", e.target.value)} className={inputCls} aria-label="Filter by resource type">
153 <option value="">Any</option>
154 {RESOURCE_TYPES.map((t) => (
155 <option key={t} value={t}>
156 {t}
157 </option>
158 ))}
159 </select>
160 </Field>
161 <Field label="Workspace">
162 <input value={draft.workspaceId} onChange={(e) => set("workspaceId", e.target.value)} placeholder="workspace id" className={inputCls} aria-label="Filter by workspace id" />
163 </Field>
164 <Field label="From">
165 <input type="date" value={draft.from} onChange={(e) => set("from", e.target.value)} className={inputCls} aria-label="From date" />
166 </Field>
167 <Field label="To">
168 <input type="date" value={draft.to} onChange={(e) => set("to", e.target.value)} className={inputCls} aria-label="To date" />
169 </Field>
170 <div className="col-span-2 flex items-end gap-2 md:col-span-3 lg:col-span-6">
171 <button type="submit" className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
172 <Search className="size-3.5" aria-hidden /> Apply
173 </button>
174 {(JSON.stringify(applied) !== JSON.stringify(EMPTY) || page > 1) && (
175 <button type="button" onClick={() => apply(EMPTY)} className="text-sm text-muted-foreground hover:text-foreground hover:underline">
176 Clear
177 </button>
178 )}
179 <div className="ml-auto flex items-center gap-3">
180 <span className="text-xs text-muted-foreground">
181 {total} row{total === 1 ? "" : "s"}
182 {exp?.truncated ? ` (showing first ${PAGE_SIZE})` : ""}
183 </span>
184 <button
185 type="button"
186 onClick={() => exportMut.mutate()}
187 disabled={exportMut.isPending || exportStatus.isFetching || total === 0}
188 className="inline-flex items-center gap-1.5 rounded-md border border-input px-3 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50"
189 >
190 <Download className="size-3.5" aria-hidden /> Export CSV
191 </button>
192 </div>
193 </div>
194 </form>
195
196 {}
197 {exportId && (
198 <div className="mt-3 rounded-md border border-border p-3 text-sm" aria-live="polite">
199 {exp ? (
200 exp.status === "ready" ? (
201 <div className="flex flex-wrap items-center gap-3">
202 <span>
203 Export ready ({exp.rows} rows{exp.truncated ? ", truncated at the 500k cap" : ""}).
204 </span>
205 {exp.downloadUrl && (
206 <a
207 href={exp.downloadUrl}
208 className="font-medium underline underline-offset-2"
209 onClick={() => exportMut.reset()}
210 >
211 Download (single-use, expires in {Math.max(0, Math.round((exp.urlTtlSec ?? 900) / 60))} min)
212 </a>
213 )}
214 </div>
215 ) : exp.status === "failed" ? (
216 <span className="text-destructive">Export failed: {exp.error ?? "unknown error"}</span>
217 ) : (
218 <span className="text-muted-foreground">Exporting… ({exp.rows} of {exp.totalRows} rows so far)</span>
219 )
220 ) : (
221 <span className="text-muted-foreground">Preparing export…</span>
222 )}
223 </div>
224 )}
225 </Section>
226
227 <Section title="Events">
228 {list.isLoading ? (
229 <p className="text-sm text-muted-foreground" role="status">Loading…</p>
230 ) : list.isError ? (
231 <p className="text-sm text-destructive" role="alert">Could not load the audit log.</p>
232 ) : rows.length === 0 ? (
233 <p className="text-sm text-muted-foreground">No audit events match the filters.</p>
234 ) : (
235 <>
236 <table className="w-full text-sm">
237 <caption className="sr-only">Organization audit events</caption>
238 <thead>
239 <tr className="border-b border-border text-left text-xs text-muted-foreground">
240 <th className="py-2 pr-3 font-medium">Time</th>
241 <th className="py-2 pr-3 font-medium">Action</th>
242 <th className="py-2 pr-3 font-medium">Resource</th>
243 <th className="py-2 pr-3 font-medium">Actor</th>
244 <th className="py-2 font-medium">IP</th>
245 </tr>
246 </thead>
247 <tbody>
248 {rows.map((r) => (
249 <tr key={r.id} className="cursor-pointer border-b border-border/60 last:border-0 hover:bg-muted/40" onClick={() => setDetail(r)}>
250 <td className="whitespace-nowrap py-2 pr-3 text-xs text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</td>
251 <td className="py-2 pr-3 font-mono text-xs">{r.action}</td>
252 <td className="py-2 pr-3 text-xs">
253 <span className="text-muted-foreground">{r.resourceType}</span>{" "}
254 <span className="font-mono">{r.resourceId ? r.resourceId.slice(0, 12) + "…" : "—"}</span>
255 </td>
256 <td className="py-2 pr-3 font-mono text-xs">{r.actorId?.slice(0, 12) ?? "—"}</td>
257 <td className="py-2 text-xs text-muted-foreground">{r.ipAddress ?? "—"}</td>
258 </tr>
259 ))}
260 </tbody>
261 </table>
262 <div className="mt-3 flex items-center justify-end gap-2 text-sm">
263 <button
264 type="button"
265 onClick={() => setPage((p) => Math.max(1, p - 1))}
266 disabled={page <= 1}
267 className="rounded-md border border-input px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50"
268 >
269 ← Prev
270 </button>
271 <span className="text-xs text-muted-foreground">
272 page {page} / {pages}
273 </span>
274 <button
275 type="button"
276 onClick={() => setPage((p) => Math.min(pages, p + 1))}
277 disabled={page >= pages}
278 className="rounded-md border border-input px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50"
279 >
280 Next →
281 </button>
282 </div>
283 </>
284 )}
285 </Section>
286
287 {}
288 {detail && (
289 <aside
290 className="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-border bg-background shadow-xl"
291 role="dialog"
292 aria-label="Audit event detail"
293 >
294 <div className="flex items-center justify-between border-b border-border p-4">
295 <div>
296 <div className="font-mono text-sm font-medium">{detail.action}</div>
297 <div className="text-xs text-muted-foreground">{new Date(detail.createdAt).toLocaleString()}</div>
298 </div>
299 <button type="button" onClick={() => setDetail(null)} className="rounded-md p-1 hover:bg-muted" aria-label="Close detail">
300 <X className="size-4" />
301 </button>
302 </div>
303 <div className="flex-1 space-y-4 overflow-y-auto p-4 text-sm">
304 <dl className="space-y-2">
305 <DetailRow label="Resource" value={`${detail.resourceType} ${detail.resourceId ?? ""}`} />
306 <DetailRow label="Actor" value={`${detail.actorId ?? "—"}`} />
307 <DetailRow label="Workspace" value={detail.workspaceId ?? "org-level"} />
308 <DetailRow label="IP" value={detail.ipAddress ?? "—"} />
309 <DetailRow label="User agent" value={detail.userAgent ?? "—"} />
310 <DetailRow label="Event id" value={detail.id} />
311 </dl>
312 <div>
313 <div className="mb-1 text-xs font-medium text-muted-foreground">Metadata (full JSON)</div>
314 <pre className="max-h-72 overflow-auto rounded-md border border-border bg-muted/30 p-2 text-xs">
315 {JSON.stringify(detail.metadata ?? {}, null, 2)}
316 </pre>
317 </div>
318 </div>
319 </aside>
320 )}
321 </div>
322 );
323}
324
325function DetailRow({ label, value }: { label: string; value: string }) {
326 return (
327 <div className="flex justify-between gap-3 border-b border-border/40 pb-1">
328 <dt className="text-xs text-muted-foreground">{label}</dt>
329 <dd className="truncate font-mono text-xs" title={value}>
330 {value}
331 </dd>
332 </div>
333 );
334}
335