feat(CC-078): add Export Control page (audit log filtered to export events + GDPR notes)

This commit is contained in:
admin-valentin 2026-08-02 12:53:52 +00:00
parent fffc37baab
commit 7c3e79e4b7

View 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>
);
}