130 lines
6.1 KiB
TypeScript
130 lines
6.1 KiB
TypeScript
'use client';
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { auditApi, type AuditEntry } from '@/lib/api-client';
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
const ACTION_COLORS: Record<string, string> = {
|
||
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
|
||
'admin.mfa.status': 'bg-purple-100 text-purple-700',
|
||
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
|
||
'auth.login': 'bg-blue-100 text-blue-700',
|
||
'auth.logout': 'bg-gray-100 text-gray-600',
|
||
'product.created': 'bg-green-100 text-green-700',
|
||
'product.updated': 'bg-green-100 text-green-700',
|
||
'product.deleted': 'bg-red-100 text-red-700',
|
||
'order.placed': 'bg-indigo-100 text-indigo-700',
|
||
'order.state_changed': 'bg-indigo-100 text-indigo-700',
|
||
};
|
||
|
||
function colorForAction(action: string): string {
|
||
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
|
||
}
|
||
|
||
export default function AuditLogPage() {
|
||
const [items, setItems] = useState<AuditEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [filter, setFilter] = useState('');
|
||
const [debounced, setDebounced] = useState('');
|
||
const [page, setPage] = useState(0);
|
||
const [total, setTotal] = useState(0);
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebounced(filter), 400);
|
||
return () => clearTimeout(t);
|
||
}, [filter]);
|
||
|
||
useEffect(() => { setPage(0); }, [debounced]);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true); setError('');
|
||
try {
|
||
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
|
||
setItems(data.items ?? []);
|
||
setTotal(data.total ?? 0);
|
||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||
finally { setLoading(false); }
|
||
}, [page, debounced]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
|
||
<p className="text-sm text-gray-500 mt-0.5">
|
||
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="max-w-xs">
|
||
<div className="relative">
|
||
<input type="text" placeholder="Filtrar por acción..." value={filter}
|
||
onChange={e => setFilter(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||
) : error ? (
|
||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||
) : items.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
|
||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
|
||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
|
||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
|
||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{items.map(entry => (
|
||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
|
||
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
|
||
{entry.action}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
|
||
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
|
||
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
|
||
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{total > PAGE_SIZE && (
|
||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||
<span className="text-sm text-gray-500">
|
||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
|
||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|