'use client'; import { useEffect, useState, useCallback, useMemo } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { formatDistanceToNow } from 'date-fns'; import { formatDate } from '@/lib/utils/format-date'; import { Download, History, Search, X } from 'lucide-react'; import { toast } from 'sonner'; import { DataTable } from '@/components/shared/data-table'; import { PageHeader } from '@/components/shared/page-header'; import { EmptyState } from '@/components/shared/empty-state'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { AuditLogCard } from './audit-log-card'; interface AuditEntry { id: string; userId: string | null; action: string; entityType: string; entityId: string | null; fieldChanged: string | null; oldValue: Record | null; newValue: Record | null; metadata: Record | null; ipAddress: string | null; userAgent: string | null; severity: 'info' | 'warning' | 'error' | 'critical'; source: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'; createdAt: string; actor: { id: string; email: string; name: string } | null; } interface AuditResponse { data: AuditEntry[]; pagination: { nextCursor: { createdAt: string; id: string } | null }; } const ACTION_COLORS: Record = { create: 'bg-green-600', update: 'bg-blue-500', delete: 'bg-red-600', archive: 'bg-orange-500', restore: 'bg-teal-500', login: 'bg-slate-500', logout: 'bg-slate-400', permission_denied: 'bg-red-800', merge: 'bg-purple-500', revert: 'bg-amber-500', hard_delete: 'bg-red-900', request_hard_delete_code: 'bg-orange-700', send: 'bg-indigo-500', view: 'bg-gray-400', webhook_delivered: 'bg-emerald-500', webhook_failed: 'bg-amber-600', webhook_dead_letter: 'bg-red-700', webhook_retried: 'bg-indigo-600', job_failed: 'bg-rose-700', cron_run: 'bg-sky-500', }; const SEVERITY_BADGE: Record = { info: 'bg-slate-200 text-slate-800', warning: 'bg-amber-200 text-amber-900', error: 'bg-red-200 text-red-900', critical: 'bg-red-600 text-white', }; const SOURCE_LABEL: Record = { user: 'User', system: 'System', auth: 'Auth', webhook: 'Webhook', cron: 'Cron', job: 'Job', }; // L-AU03: entity types that mutations can target but the filter dropdown // didn't expose. Reps querying the audit log for, e.g., an email-account // toggle (H-05 fix) couldn't pick it from the dropdown. const ENTITY_TYPES = [ 'client', 'interest', 'berth', 'document', 'expense', 'invoice', 'reminder', 'user', 'role', 'port', 'setting', 'tag', 'webhook', 'yacht', 'company', 'reservation', 'email_account', 'portal_session', 'portal_user', 'file', ]; function useDebounced(value: T, ms = 300): T { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } export function AuditLogList() { const [entries, setEntries] = useState([]); const [nextCursor, setNextCursor] = useState<{ createdAt: string; id: string; } | null>(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [loadError, setLoadError] = useState(null); // Filter state - debounce text inputs. const [search, setSearch] = useState(''); const [entityType, setEntityType] = useState('all'); const [action, setAction] = useState('all'); const [severity, setSeverity] = useState('all'); const [source, setSource] = useState('all'); const [userId, setUserId] = useState(''); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); // Per-row detail is surfaced inline via a Popover anchored to the // Details button (see column cell below). Lets the rep inspect the // full oldValue / newValue / metadata / IP / UA payload without // leaving the table or opening a Sheet. const debouncedSearch = useDebounced(search); const debouncedUserId = useDebounced(userId); const queryString = useMemo(() => { const params = new URLSearchParams({ limit: '50' }); if (entityType !== 'all') params.set('entityType', entityType); if (action !== 'all') params.set('action', action); if (severity !== 'all') params.set('severity', severity); if (source !== 'all') params.set('source', source); if (debouncedSearch) params.set('search', debouncedSearch); if (debouncedUserId) params.set('userId', debouncedUserId); // Skip the date filters when From > To - the inline warning below // tells the user to fix it; we don't want to fire a request with a // useless empty range either. const datesValid = !(dateFrom && dateTo && dateFrom > dateTo); if (datesValid && dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString()); if (datesValid && dateTo) { const end = new Date(dateTo); end.setHours(23, 59, 59, 999); params.set('dateTo', end.toISOString()); } return params.toString(); }, [entityType, action, severity, source, debouncedSearch, debouncedUserId, dateFrom, dateTo]); const fetchFirstPage = useCallback(async () => { setLoading(true); setLoadError(null); try { const res = await apiFetch(`/api/v1/admin/audit?${queryString}`); setEntries(res.data); setNextCursor(res.pagination.nextCursor); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to load audit log'; setLoadError(msg); toast.error(msg); } finally { setLoading(false); } }, [queryString]); const loadMore = useCallback(async () => { if (!nextCursor) return; setLoadingMore(true); try { const params = new URLSearchParams(queryString); params.set('cursorAt', nextCursor.createdAt); params.set('cursorId', nextCursor.id); const res = await apiFetch(`/api/v1/admin/audit?${params}`); setEntries((prev) => [...prev, ...res.data]); setNextCursor(res.pagination.nextCursor); } catch (err) { toastError(err, 'Failed to load more audit entries'); } finally { setLoadingMore(false); } }, [queryString, nextCursor]); useEffect(() => { // Refetch on filter change. Migrating this list to useInfiniteQuery // would be the proper fix but is deferred - the fetch-on-effect // pattern here is functionally correct and gated by the queryString // memo so it only fires when filters actually change. // eslint-disable-next-line react-hooks/set-state-in-effect void fetchFirstPage(); }, [fetchFirstPage]); function clearFilters() { setSearch(''); setEntityType('all'); setAction('all'); setSeverity('all'); setSource('all'); setUserId(''); setDateFrom(''); setDateTo(''); } const hasActiveFilter = Boolean(search) || entityType !== 'all' || action !== 'all' || severity !== 'all' || source !== 'all' || Boolean(userId) || Boolean(dateFrom) || Boolean(dateTo); const dateRangeInvalid = Boolean(dateFrom && dateTo && dateFrom > dateTo); const columns: ColumnDef[] = [ { accessorKey: 'createdAt', header: 'Time', cell: ({ row }) => (
{formatDate(row.original.createdAt, 'datetime.medium')}
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
), size: 180, }, { accessorKey: 'action', header: 'Action', cell: ({ row }) => { const verbLabel = row.original.action.replace(/_/g, ' '); const entityLabel = row.original.entityType.replace(/_/g, ' '); return (
{verbLabel} {row.original.severity !== 'info' && ( {row.original.severity} )}
{entityLabel}
); }, size: 180, }, { accessorKey: 'source', header: 'Source', cell: ({ row }) => ( {SOURCE_LABEL[row.original.source] ?? row.original.source} ), size: 80, }, { accessorKey: 'entityType', header: 'Entity', cell: ({ row }) => (
{row.original.entityType} {row.original.entityId ? ( {row.original.entityId.slice(0, 8)}… ) : null}
), }, { id: 'changes', header: 'Changes', cell: ({ row }) => { const { newValue, fieldChanged } = row.original; if (fieldChanged) return {fieldChanged}; if (newValue) { const keys = Object.keys(newValue); return ( {keys.slice(0, 3).join(', ')} {keys.length > 3 ? ` +${keys.length - 3} more` : ''} ); } return -; }, }, { id: 'actor', header: 'Actor', cell: ({ row }) => { const { actor, userId: rawId } = row.original; if (actor) { return (
{actor.name}
{actor.email}
); } if (rawId) { return {rawId.slice(0, 8)}…; } return system; }, size: 180, }, { id: 'ip', header: 'IP', cell: ({ row }) => row.original.ipAddress ? ( {row.original.ipAddress} ) : ( - ), size: 130, }, { id: 'details', header: '', cell: ({ row }) => { const e = row.original; const hasDetail = Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent); if (!hasDetail) return null; return (

{e.action.replace(/_/g, ' ')} - {e.entityType}

{formatDate(e.createdAt, 'datetime.medium')} {e.actor ? ` · ${e.actor.name}` : ''}

{e.oldValue ? (
Old value
                      {JSON.stringify(e.oldValue, null, 2)}
                    
) : null} {e.newValue ? (
New value
                      {JSON.stringify(e.newValue, null, 2)}
                    
) : null} {e.metadata ? (
Metadata
                      {JSON.stringify(e.metadata, null, 2)}
                    
) : null} {e.ipAddress || e.userAgent ? (
{e.ipAddress ? ( <>
IP address
{e.ipAddress}
) : null} {e.userAgent ? ( <>
User agent
{e.userAgent}
) : null}
) : null}
); }, size: 80, }, ]; return (
setSearch(e.target.value)} data-testid="audit-search" />
setUserId(e.target.value)} />
{/* M-AU03: CSV export inherits the current filter set. The endpoint streams up to 10 000 rows; reps wanting deeper history narrow the filter first. */} {hasActiveFilter ? ( ) : null}
{dateRangeInvalid && (

From date must be on or before To date - date filter ignored.

)} {loadError && !loading && entries.length === 0 ? (

Couldn’t load audit log: {loadError}

) : (
row.id} cardRender={(row) => } virtual virtualHeightPx={640} virtualRowHeightPx={56} emptyState={ } />
)} {nextCursor ? (
) : null}
); }