feat(CC-077): add Security Events page (filtered audit log with severity classification)
This commit is contained in:
parent
a11d5421db
commit
4f9718dce4
1 changed files with 120 additions and 0 deletions
120
src/app/dashboard/privacy/security/page.tsx
Normal file
120
src/app/dashboard/privacy/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue