feat(audit): add Audit Log page — read-only table of tenant audit entries
This commit is contained in:
parent
643fe527a2
commit
6ee566143b
1 changed files with 102 additions and 0 deletions
102
src/app/dashboard/privacy/audit/page.tsx
Normal file
102
src/app/dashboard/privacy/audit/page.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch, type AuditLogEntry } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/\./g, ' · ').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function severityColor(action: string): string {
|
||||
if (action.includes('deleted') || action.includes('removed')) return 'text-signal-danger';
|
||||
if (action.includes('created') || action.includes('added')) return 'text-signal-ok';
|
||||
if (action.includes('updated') || action.includes('changed') || action.includes('selected'))
|
||||
return 'text-signal-warn';
|
||||
return 'text-ink-faint';
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery({
|
||||
queryKey: ['audit-log', tenantId],
|
||||
queryFn: () => apiFetch<AuditLogEntry[]>('/v1/audit-log?limit=200', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Audit Log</h1>
|
||||
<p className="text-sm text-ink-faint">
|
||||
Toate operațiunile materiale din workspace — imutabil, doar citire.
|
||||
{entries.length > 0 && ` · Ultimele ${entries.length} înregistrări.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-ink-faint">Se încarcă…</p>}
|
||||
|
||||
{!isLoading && entries.length === 0 && (
|
||||
<p className="card p-6 text-sm text-ink-faint">
|
||||
Nicio înregistrare de audit. Jurnalul se completează automat cu fiecare operațiune.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-line bg-paper-sunken text-left">
|
||||
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Data
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Acțiune
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Resursă
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Actor
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry, i) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={`border-b border-ink-line last:border-0 ${
|
||||
i % 2 === 0 ? '' : 'bg-paper-sunken/40'
|
||||
}`}
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-xs text-ink-faint">
|
||||
{new Date(entry.createdAt).toLocaleString('ro-RO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 font-medium ${severityColor(entry.action)}`}>
|
||||
{formatAction(entry.action)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-ink-soft">
|
||||
{entry.resource}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-ink-faint">
|
||||
{entry.actorId ? entry.actorId.slice(0, 8) + '…' : entry.actorType}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue