feat(CC-078): add Export Control page (audit log filtered to export events + GDPR notes)
This commit is contained in:
parent
fffc37baab
commit
7c3e79e4b7
1 changed files with 128 additions and 0 deletions
128
src/app/dashboard/data/export-control/page.tsx
Normal file
128
src/app/dashboard/data/export-control/page.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface AuditEntry {
|
||||
id: string; action: string; entityType: string | null; entityId: string | null;
|
||||
userId: string | null; userEmail: string | null; details: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const EXPORT_ACTIONS = ['export', 'download', 'bulk_export', 'csv', 'pdf', 'xlsx', 'extract', 'backup'];
|
||||
|
||||
function isExportEvent(e: AuditEntry) {
|
||||
const action = e.action.toLowerCase();
|
||||
return EXPORT_ACTIONS.some((k) => action.includes(k));
|
||||
}
|
||||
|
||||
function groupByDay(entries: AuditEntry[]) {
|
||||
const map: Record<string, number> = {};
|
||||
for (const e of entries) {
|
||||
const d = e.createdAt.slice(0, 10);
|
||||
map[d] = (map[d] ?? 0) + 1;
|
||||
}
|
||||
return Object.entries(map).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
export default function ExportControlPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const [filter, setFilter] = useState<'all' | 'exports'>('all');
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery({
|
||||
queryKey: ['audit-export', tenantId],
|
||||
queryFn: () => apiFetch<AuditEntry[]>('/v1/audit-log?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const exportEvents = useMemo(() => entries.filter(isExportEvent), [entries]);
|
||||
const shown = filter === 'exports' ? exportEvents : entries;
|
||||
const byDay = useMemo(() => groupByDay(exportEvents), [exportEvents]);
|
||||
const maxDay = Math.max(...byDay.map(([, c]) => c), 1);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Control Export Date</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${exportEvents.length} evenimente export din ${entries.length} intrări audit`}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/privacy/audit" className="text-sm text-primary hover:underline">Audit complet →</Link>
|
||||
</div>
|
||||
|
||||
{/* Activity chart */}
|
||||
{byDay.length > 0 && (
|
||||
<div className="card p-4 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Activitate export (ultimele zile)</p>
|
||||
<div className="flex items-end gap-0.5 h-16">
|
||||
{byDay.slice(-30).map(([day, count]) => (
|
||||
<div key={day} className="flex-1 flex flex-col items-center gap-0.5" title={`${day}: ${count} exporturi`}>
|
||||
<div className="w-full rounded-sm bg-primary/60" style={{ height: `${(count / maxDay) * 60}px` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-faint">Ultimele {Math.min(30, byDay.length)} zile cu activitate</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GDPR info */}
|
||||
<div className="card p-4 bg-primary/5 border-primary/20 space-y-1">
|
||||
<p className="text-xs font-semibold text-ink">Cerințe GDPR art. 30 & 33</p>
|
||||
<div className="text-[10px] text-ink-faint space-y-0.5">
|
||||
<p>• Exporturile de date personale (C2/C3) trebuie documentate</p>
|
||||
<p>• Destinatarul exportului trebuie înregistrat când conțin date personale</p>
|
||||
<p>• Incidentele de securitate ce implică exporturi neautorizate se raportează în 72h</p>
|
||||
</div>
|
||||
<Link href="/dashboard/privacy/retention" className="text-[10px] text-primary hover:underline">
|
||||
Politici de retenție →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex gap-1 border-b border-border/50">
|
||||
{([['all', `Toate acțiunile (${entries.length})`], ['exports', `Exporturi (${exportEvents.length})`]] as const).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setFilter(key as typeof filter)}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${filter === key ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : shown.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">📦</p>
|
||||
<p className="text-sm text-ink-faint">Nicio activitate de export înregistrată.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{shown.slice(0, 100).map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 p-3">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${isExportEvent(e) ? 'bg-warn' : 'bg-border'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-ink">{e.action}</p>
|
||||
<div className="flex flex-wrap gap-2 text-[10px] text-ink-faint">
|
||||
{e.userEmail && <span>{e.userEmail}</span>}
|
||||
{e.entityType && <span>{e.entityType}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-faint shrink-0">
|
||||
{new Date(e.createdAt).toLocaleString('ro-RO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{shown.length > 100 && (
|
||||
<p className="text-xs text-center text-ink-faint p-3">și alte {shown.length - 100} intrări</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue