mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user