feat(CC-077): add Security Events page (filtered audit log with severity classification)

This commit is contained in:
admin-valentin 2026-08-02 12:50:20 +00:00
parent a11d5421db
commit 4f9718dce4

View file

@ -0,0 +1,120 @@
'use client';
import { 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; ipAddress: string | null;
details: Record<string, unknown> | null; severity: string | null;
createdAt: string;
}
const SEVERITY_META: Record<string, { label: string; cls: string }> = {
info: { label: 'INFO', cls: 'bg-muted text-ink-faint' },
warning: { label: 'WARNING', cls: 'bg-warn/10 text-warn' },
critical: { label: 'CRITICAL', cls: 'bg-signal-danger/10 text-signal-danger' },
};
const SECURITY_ACTIONS = ['login', 'logout', 'login_failed', 'password_change', 'api_key', 'permission', 'role', 'delete', 'export', 'access', 'auth', 'session', 'token'];
function isSecurityEvent(entry: AuditEntry) {
const action = entry.action.toLowerCase();
return SECURITY_ACTIONS.some((s) => action.includes(s)) || entry.severity === 'critical';
}
export default function SecurityEventsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [filter, setFilter] = useState<'all' | 'security' | 'critical'>('all');
const { data: entries = [], isLoading } = useQuery({
queryKey: ['audit-security', tenantId],
queryFn: () => apiFetch<AuditEntry[]>('/v1/audit-log?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000, refetchInterval: 60_000,
});
const filtered = entries.filter((e) => {
if (filter === 'security') return isSecurityEvent(e);
if (filter === 'critical') return e.severity === 'critical' || e.severity === 'warning';
return true;
});
const critical = entries.filter((e) => e.severity === 'critical');
const security = entries.filter(isSecurityEvent);
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Evenimente Securitate</h1>
<p className="text-sm text-ink-faint mt-1">
{entries.length} intrări audit · {security.length} securitate
{critical.length > 0 && <span className="ml-1 text-signal-danger font-semibold">{critical.length} critice</span>}
</p>
</div>
<Link href="/dashboard/privacy/audit" className="text-sm text-primary hover:underline">
Audit complet
</Link>
</div>
{critical.length > 0 && (
<div className="card p-4 border-signal-danger/30 bg-signal-danger/5">
<p className="text-sm font-semibold text-signal-danger mb-2"> {critical.length} evenimente critice detectate</p>
{critical.slice(0, 3).map((e) => (
<p key={e.id} className="text-xs text-ink-faint">
{e.action} · {e.userEmail ?? e.userId ?? 'sistem'} · {new Date(e.createdAt).toLocaleString('ro-RO')}
</p>
))}
</div>
)}
{/* Filter */}
<div className="flex gap-1 border-b border-border/50">
{([['all', `Toate (${entries.length})`], ['security', `Securitate (${security.length})`], ['critical', `Critice (${critical.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-6">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">🔒</p>
<p className="text-sm text-ink-faint">Niciun eveniment în această categorie.</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{filtered.slice(0, 50).map((e) => {
const sm = SEVERITY_META[e.severity ?? 'info'] ?? SEVERITY_META.info;
return (
<div key={e.id} className="flex items-start gap-3 p-3">
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${sm.cls}`}>{sm.label}</span>
<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 mt-0.5">
{e.userEmail && <span>{e.userEmail}</span>}
{e.entityType && <span>{e.entityType}{e.entityId ? `:${e.entityId.slice(0, 8)}` : ''}</span>}
{e.ipAddress && <span>IP: {e.ipAddress}</span>}
</div>
</div>
<span className="text-[10px] text-ink-faint shrink-0">
{new Date(e.createdAt).toLocaleString('ro-RO', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short' })}
</span>
</div>
);
})}
{filtered.length > 50 && (
<p className="text-xs text-center text-ink-faint p-3">și alte {filtered.length - 50} intrări vezi Audit complet</p>
)}
</div>
)}
</div>
);
}