Merge pull request #1338 from Tria-plc/alpha

feat: ( audit ) resolve the actor from the session and audit all back…
This commit is contained in:
Abubeker Yasin
2026-08-18 16:19:15 +03:00
committed by GitHub
32 changed files with 3068 additions and 420 deletions

View File

@@ -1,58 +1,110 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Eye, Download } from 'lucide-react';
import { Eye, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { auditApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import type { AuditLog } from '@/types/edr';
export default function AuditLogsPage() {
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
const PAGE_SIZE = 50;
/** Fallbacks used until GET /audit/vocabulary answers, and if it ever fails. */
const FALLBACK_ACTIONS = ['CREATE', 'UPDATE', 'DELETE', 'STATUS_CHANGE', 'CANCEL', 'BOARD'];
const FALLBACK_ENTITY_TYPES = ['Station', 'Train', 'Coach', 'Seat', 'Route', 'Schedule', 'Ticket'];
/** Actions that read as destructive/irreversible, for colouring only. */
const DESTRUCTIVE_ACTIONS = new Set(['DELETE', 'CANCEL', 'BOARD_DENIED']);
const CREATIVE_ACTIONS = new Set(['CREATE', 'BULK_CREATE', 'RESTORE', 'BOARD', 'PAY']);
function actionVariant(action: string) {
if (CREATIVE_ACTIONS.has(action)) return 'success';
if (DESTRUCTIVE_ACTIONS.has(action)) return 'danger';
if (action === 'LOGIN' || action === 'LOGOUT') return 'info';
return 'primary';
}
function AuditLogsPageContent() {
const [filters, setFilters] = useState({
search: '',
action: '',
entityType: '',
from: '',
to: '',
});
const [page, setPage] = useState(0);
const [selectedLog, setSelectedLog] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [exporting, setExporting] = useState(false);
// Any filter change invalidates the current offset.
useEffect(() => setPage(0), [filters]);
const query = { ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE };
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () => auditApi.getLogs(filters),
refetchInterval: 30000, // Refetch every 30 seconds
queryKey: ['audit-logs', query],
queryFn: () => auditApi.getLogs(query),
refetchInterval: 30000,
});
const getActionBadgeColor = (action: string) => {
switch (action) {
case 'CREATE':
return 'success';
case 'UPDATE':
return 'primary';
case 'DELETE':
return 'danger';
case 'LOGIN':
return 'info';
case 'LOGOUT':
return 'secondary';
default:
return 'secondary';
const { data: vocabulary } = useQuery({
queryKey: ['audit-vocabulary'],
queryFn: () => auditApi.getVocabulary(),
staleTime: Infinity,
});
// Per-action tallies come from the API's own `total` under the same filters, not from the
// rows on screen — counting the current page made "Total Logs" cap out at the page size.
// limit: 1 keeps these to a COUNT plus a single row.
const creates = useQuery({
queryKey: ['audit-count', 'CREATE', filters],
queryFn: () => auditApi.getLogs({ ...filters, action: 'CREATE', limit: 1, offset: 0 }),
refetchInterval: 60000,
});
const updates = useQuery({
queryKey: ['audit-count', 'UPDATE', filters],
queryFn: () => auditApi.getLogs({ ...filters, action: 'UPDATE', limit: 1, offset: 0 }),
refetchInterval: 60000,
});
const deletes = useQuery({
queryKey: ['audit-count', 'DELETE', filters],
queryFn: () => auditApi.getLogs({ ...filters, action: 'DELETE', limit: 1, offset: 0 }),
refetchInterval: 60000,
});
const logs: AuditLog[] = Array.isArray(data?.items) ? data.items : [];
const total: number = typeof data?.total === 'number' ? data.total : logs.length;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const actionOptions: string[] = vocabulary?.actions ?? FALLBACK_ACTIONS;
const entityTypeOptions: string[] = vocabulary?.entityTypes ?? FALLBACK_ENTITY_TYPES;
const formatJsonData = (value: any) => {
if (!value) return 'N/A';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const formatJsonData = (data: any) => {
if (!data) return 'N/A';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
/** Who acted, falling back through the identity the API actually returns. */
const actorName = (log: AuditLog) => log.userName || 'System';
const actorDetail = (log: AuditLog) => log.userPhone || log.iamUserId || 'N/A';
const columns = [
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => (
render: (log: AuditLog) => (
<div className="text-sm">
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
@@ -63,17 +115,13 @@ export default function AuditLogsPage() {
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
),
render: (log: AuditLog) => <Badge className={actionVariant(log.action)}>{log.action}</Badge>,
},
{
key: 'entityType',
label: 'Entity Type',
sortable: true,
render: (log: any) => (
render: (log: AuditLog) => (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
{log.entityType}
</span>
@@ -82,29 +130,27 @@ export default function AuditLogsPage() {
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
render: (log: AuditLog) => (
<span className="font-mono text-xs text-muted-foreground">
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
{log.entityId ? log.entityId.substring(0, 12) : ''}
</span>
),
},
{
key: 'user',
label: 'User',
render: (log: any) => (
render: (log: AuditLog) => (
<div>
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
<div className="font-medium text-sm">{actorName(log)}</div>
<div className="text-xs text-muted-foreground font-mono">{actorDetail(log)}</div>
</div>
),
},
{
key: 'ipAddress',
label: 'IP Address',
render: (log: any) => (
<span className="text-xs text-muted-foreground font-mono">
{log.ipAddress || 'N/A'}
</span>
render: (log: AuditLog) => (
<span className="text-xs text-muted-foreground font-mono">{log.ipAddress || 'N/A'}</span>
),
},
];
@@ -112,7 +158,7 @@ export default function AuditLogsPage() {
const actions = [
{
label: 'View Details',
onClick: (log: any) => {
onClick: (log: AuditLog) => {
setSelectedLog(log);
setShowDetailsModal(true);
},
@@ -121,12 +167,60 @@ export default function AuditLogsPage() {
},
];
const logs: any[] = Array.isArray(data?.items) ? data.items : [];
const stats = {
total: logs.length,
creates: logs.filter((l: any) => l.action === 'CREATE').length,
updates: logs.filter((l: any) => l.action === 'UPDATE').length,
deletes: logs.filter((l: any) => l.action === 'DELETE').length,
/**
* Exports every row matching the current filters, not just the page on screen — pulled in
* API-sized batches so a year of logs doesn't arrive as one request.
*/
const exportCsv = async () => {
setExporting(true);
try {
const batch = 200;
const rows: AuditLog[] = [];
for (let offset = 0; offset < total; offset += batch) {
const chunk = await auditApi.getLogs({ ...filters, limit: batch, offset });
const items: AuditLog[] = Array.isArray(chunk?.items) ? chunk.items : [];
if (!items.length) break;
rows.push(...items);
}
if (!rows.length) return;
const headers = [
'Timestamp',
'Action',
'Entity Type',
'Entity ID',
'User Name',
'User Phone',
'User ID',
'IP Address',
'Old Data',
'New Data',
];
const body = rows.map((l) => [
formatDateTime(l.createdAt),
l.action,
l.entityType,
l.entityId || '',
l.userName || '',
l.userPhone || '',
l.iamUserId || '',
l.ipAddress || '',
l.oldData ? JSON.stringify(l.oldData) : '',
l.newData ? JSON.stringify(l.newData) : '',
]);
const csv = [headers, ...body]
.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
};
return (
@@ -136,63 +230,39 @@ export default function AuditLogsPage() {
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={() => {
const items: any[] = Array.isArray(data?.items) ? data!.items : [];
if (!items.length) return;
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
const rows = items.map((l: any) => [
formatDateTime(l.createdAt),
l.action,
l.entityType,
l.entityId || '',
l.iamUserId || l.userId || '',
l.ipAddress || '',
]);
const csv = [headers, ...rows].map(r => r.map((v: string) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}}
>
Export CSV
<ActionButton icon={Download} variant="secondary" onClick={exportCsv} disabled={exporting || !total}>
{exporting ? 'Exporting…' : 'Export CSV'}
</ActionButton>
</div>
{/* Stats Cards */}
{/* Stats — counts come from the API under the active filters, not the visible page */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
<div className="text-2xl font-bold mt-2">{stats.total}</div>
<div className="text-2xl font-bold mt-2">{total}</div>
</div>
<div className="card">
<div className="text-green-600 text-sm font-medium">Created</div>
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
<div className="text-2xl font-bold mt-2">{creates.data?.total ?? '—'}</div>
</div>
<div className="card">
<div className="text-blue-600 text-sm font-medium">Updated</div>
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
<div className="text-2xl font-bold mt-2">{updates.data?.total ?? '—'}</div>
</div>
<div className="card">
<div className="text-red-600 text-sm font-medium">Deleted</div>
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
<div className="text-2xl font-bold mt-2">{deletes.data?.total ?? '—'}</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="label">Search (User/Entity ID)</label>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="lg:col-span-2">
<label className="label">Search (User / Entity ID)</label>
<input
type="text"
placeholder="Search logs..."
placeholder="Name, phone, user ID, entity ID…"
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
@@ -206,11 +276,11 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
>
<option value="">All Actions</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
{actionOptions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div>
@@ -221,55 +291,42 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
<option value="">All Types</option>
<optgroup label="Master Data">
<option value="Station">Station</option>
<option value="Route">Route</option>
<option value="RouteStop">Route Stop</option>
<option value="Train">Train</option>
<option value="TrainSchedule">Train Schedule</option>
<option value="Coach">Coach</option>
<option value="CoachType">Coach Type</option>
<option value="SeatClass">Seat Class</option>
<option value="FareRule">Fare Rule</option>
<option value="RouteFareRule">Route Fare Rule</option>
<option value="SegmentFareRule">Segment Fare Rule</option>
<option value="BaggageAllowance">Baggage Allowance</option>
</optgroup>
<optgroup label="Operations">
<option value="Booking">Booking</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<option value="Seat">Seat</option>
<option value="SeatBlock">Seat Block</option>
</optgroup>
<optgroup label="Users & Access">
<option value="User">User</option>
<option value="Agent">Agent</option>
<option value="Passenger">Passenger</option>
</optgroup>
<optgroup label="System & Features">
<option value="Notification">Notification</option>
<option value="Promotion">Promotion</option>
<option value="Loyalty">Loyalty</option>
<option value="Wallet">Wallet</option>
<option value="FraudAlert">Fraud Alert</option>
<option value="FraudRule">Fraud Rule</option>
</optgroup>
{entityTypeOptions.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
<div>
<label className="label">From</label>
<input
type="date"
className="input"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
/>
</div>
<div>
<label className="label">To</label>
<input
type="date"
className="input"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '', from: '', to: '' })}
>
Clear Filters
</ActionButton>
</div>
</div>
{/* Data Table */}
<DataTable
data={logs}
columns={columns}
@@ -278,136 +335,205 @@ export default function AuditLogsPage() {
emptyMessage="No audit logs found"
/>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{total === 0
? 'No results'
: `Showing ${page * PAGE_SIZE + 1}${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`}
</p>
<div className="flex items-center gap-2">
<ActionButton
variant="secondary"
icon={ChevronLeft}
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Previous
</ActionButton>
<span className="text-sm text-muted-foreground px-2">
Page {page + 1} of {pageCount}
</span>
<ActionButton
variant="secondary"
icon={ChevronRight}
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => p + 1)}
>
Next
</ActionButton>
</div>
</div>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title="Audit Log Details"
size="xl"
>
{selectedLog && (() => {
const l = selectedLog;
const actionColor: Record<string, string> = {
CREATE: 'from-emerald-600 to-emerald-700',
UPDATE: 'from-blue-600 to-blue-700',
DELETE: 'from-red-600 to-red-700',
LOGIN: 'from-violet-600 to-violet-700',
LOGOUT: 'from-gray-600 to-gray-700',
};
const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
{selectedLog &&
(() => {
const l: AuditLog = selectedLog;
const gradient = CREATIVE_ACTIONS.has(l.action)
? 'from-emerald-600 to-emerald-700'
: DESTRUCTIVE_ACTIONS.has(l.action)
? 'from-red-600 to-red-700'
: 'from-blue-600 to-blue-700';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
<p className="text-white text-2xl font-bold">{l.action}</p>
</div>
<div className="text-right shrink-0">
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">User</p>
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
</div>
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">IP Address</p>
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
</div>
</div>
const Field = ({
label,
value,
mono = false,
truncate = false,
}: {
label: string;
value?: string;
mono?: boolean;
truncate?: boolean;
}) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p
className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`}
title={value}
>
{value || '—'}
</p>
</div>
);
<div className="space-y-6">
<section>
<SectionHeader title="Event Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Action" value={l.action} />
<Field label="Entity Type" value={l.entityType} mono />
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />
{title}
</h3>
);
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
<p className="text-white text-2xl font-bold">{l.action}</p>
</div>
<div className="text-right shrink-0">
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">
{l.entityType}
</span>
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
</div>
</div>
</section>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">User</p>
<p className="text-white text-sm font-bold truncate">{actorName(l)}</p>
</div>
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">IP Address</p>
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
</div>
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Event Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Action" value={l.action} />
<Field label="Entity Type" value={l.entityType} mono />
<Field label="Entity ID" value={l.entityId} mono truncate />
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
</div>
</section>
{l.user && (
<section>
<SectionHeader title="User Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={l.user.fullName} />
<Field label="Email" value={l.user.email} truncate />
<Field label="User ID" value={l.userId} mono truncate />
<Field label="Name" value={l.userName} />
<Field label="Phone" value={l.userPhone} mono />
<Field label="IAM User ID" value={l.iamUserId} mono truncate />
</div>
</section>
)}
{(l.ipAddress || l.userAgent) && (
<section>
<SectionHeader title="Network Information" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="IP Address" value={l.ipAddress} mono />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
{(l.ipAddress || l.userAgent) && (
<section>
<SectionHeader title="Network Information" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="IP Address" value={l.ipAddress} mono />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
<p className="text-xs font-mono text-foreground break-all leading-relaxed">
{l.userAgent || '—'}
</p>
</div>
</div>
</div>
</section>
)}
</section>
)}
{(l.oldData || l.newData) && (
<section>
<SectionHeader title="Data Changes" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{l.oldData && (
<div>
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide">
Before
</p>
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.oldData)}
</pre>
</div>
)}
{l.newData && (
<div>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide">
After
</p>
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.newData)}
</pre>
</div>
)}
</div>
</section>
)}
{(l.oldData || l.newData) && (
<section>
<SectionHeader title="Data Changes" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{l.oldData && (
<div>
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide"> Before</p>
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.oldData)}
</pre>
</div>
)}
{l.newData && (
<div>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide"> After</p>
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.newData)}
</pre>
</div>
)}
<SectionHeader title="System" />
<div className="grid grid-cols-1 gap-3">
<Field label="Log ID" value={l.id} mono truncate />
</div>
</section>
)}
</div>
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-1 gap-3">
<Field label="Log ID" value={l.id} mono truncate />
</div>
</section>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton
variant="secondary"
onClick={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
>
Close
</ActionButton>
</div>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
</div>
</div>
);
})()}
);
})()}
</Modal>
</div>
);
}
export default function AuditLogsPage() {
return (
<PermissionGuard permission={PERMS.audit.view}>
<AuditLogsPageContent />
</PermissionGuard>
);
}

View File

@@ -380,7 +380,11 @@ export const verifaydaApi = {
// Audit API
export const auditApi = {
getLogs: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
// Drop empty filters so a blank search box doesn't send `search=` and match nothing.
const entries = Object.entries(params ?? {}).filter(
([, v]) => v !== undefined && v !== null && v !== '',
);
const query = new URLSearchParams(entries as [string, string][]).toString();
const response = await apiClient.get<any>(`/audit/logs${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
@@ -388,6 +392,7 @@ export const auditApi = {
return Array.isArray(response) ? { items: response } : response;
},
getLog: (id: string) => apiClient.get<any>(`/audit/logs/${id}`),
getVocabulary: () => apiClient.get<any>('/audit/vocabulary'),
};
// Live Tracking API

View File

@@ -301,7 +301,11 @@ export interface FraudRule {
// Audit Types
export interface AuditLog {
id: string;
userId?: string;
/** IAM id of the staff member who performed the action. The API field is `iamUserId`. */
iamUserId?: string;
/** Actor's name and phone, denormalized by the API at write time. */
userName?: string;
userPhone?: string;
action: string;
entityType: string;
entityId?: string;