Files
adminpanel/src/app/dashboard/audit-log/page.tsx

341 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
auditLogService,
type AuditLogEntry,
type AuditLogQuery,
} from '@/services';
const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [
{ value: '', label: 'All actions', group: '' },
// Auth
{ value: 'AUTH_SIGNUP', label: 'Auth: Signup', group: 'Auth' },
{ value: 'AUTH_LOGIN_SUCCESS', label: 'Auth: Login Success', group: 'Auth' },
{ value: 'AUTH_LOGIN_FAIL', label: 'Auth: Login Fail', group: 'Auth' },
{ value: 'AUTH_LOGOUT', label: 'Auth: Logout', group: 'Auth' },
{ value: 'AUTH_PASSWORD_RESET_REQUEST', label: 'Auth: Password Reset Requested', group: 'Auth' },
{ value: 'AUTH_PASSWORD_RESET_CONFIRM', label: 'Auth: Password Reset Confirmed', group: 'Auth' },
{ value: 'AUTH_PASSWORD_CHANGE', label: 'Auth: Password Change', group: 'Auth' },
{ value: 'AUTH_EMAIL_CHANGE', label: 'Auth: Email Change', group: 'Auth' },
{ value: 'AUTH_EMAIL_VERIFY', label: 'Auth: Email Verify', group: 'Auth' },
// User / role
{ value: 'USER_UPDATE', label: 'User: Update', group: 'User' },
{ value: 'USER_DELETE', label: 'User: Delete', group: 'User' },
{ value: 'USER_SUSPEND', label: 'User: Suspend', group: 'User' },
{ value: 'USER_UNSUSPEND', label: 'User: Unsuspend', group: 'User' },
{ value: 'USER_ROLE_CHANGE', label: 'User: Role Change', group: 'User' },
// Agent
{ value: 'AGENT_PROFILE_UPDATE', label: 'Agent: Profile Update', group: 'Agent' },
{ value: 'AGENT_VERIFICATION_SUBMIT', label: 'Agent: Verification Submit', group: 'Agent' },
{ value: 'AGENT_VERIFICATION_APPROVE', label: 'Agent: Verification Approve', group: 'Agent' },
{ value: 'AGENT_VERIFICATION_REJECT', label: 'Agent: Verification Reject', group: 'Agent' },
// Billing
{ value: 'SUBSCRIPTION_CREATE', label: 'Billing: Subscription Create', group: 'Billing' },
{ value: 'SUBSCRIPTION_CANCEL', label: 'Billing: Subscription Cancel', group: 'Billing' },
{ value: 'PAYMENT_SUCCESS', label: 'Billing: Payment Success', group: 'Billing' },
{ value: 'PAYMENT_FAILURE', label: 'Billing: Payment Failure', group: 'Billing' },
// Moderation
{ value: 'REPORT_STATUS_CHANGE', label: 'Moderation: Report Status Change', group: 'Moderation' },
{ value: 'CONTACT_MESSAGE_RESPOND', label: 'Moderation: Contact Message', group: 'Moderation' },
{ value: 'SUPPORT_TICKET_CLOSE', label: 'Moderation: Support Ticket Close', group: 'Moderation' },
// Schema
{ value: 'PROFILE_FIELD_UPDATE', label: 'Schema: Profile Field Update', group: 'Schema' },
{ value: 'PROFILE_SECTION_UPDATE', label: 'Schema: Profile Section Update', group: 'Schema' },
{ value: 'AGENT_TYPE_UPDATE', label: 'Schema: Agent Type Update', group: 'Schema' },
// Connections
{ value: 'CONNECTION_REQUEST_ACCEPT', label: 'Connection: Accept', group: 'Connection' },
{ value: 'CONNECTION_REQUEST_REJECT', label: 'Connection: Reject', group: 'Connection' },
{ value: 'CONNECTION_REQUEST_CANCEL', label: 'Connection: Cancel', group: 'Connection' },
// CMS
{ value: 'CMS_BLOG_PUBLISH', label: 'CMS: Publish', group: 'CMS' },
{ value: 'CMS_BLOG_UNPUBLISH', label: 'CMS: Unpublish', group: 'CMS' },
{ value: 'CMS_BLOG_DELETE', label: 'CMS: Delete', group: 'CMS' },
];
const RESOURCE_OPTIONS = [
'', 'User', 'AgentProfile', 'AgentSubscription', 'Payment', 'UserReport',
'ContactMessage', 'SupportChat', 'CmsContent', 'ProfileField', 'ProfileSection',
'AgentType', 'ConnectionRequest',
];
function formatActor(entry: AuditLogEntry): string {
if (!entry.actor) {
if (entry.actorRole === 'SYSTEM') return 'SYSTEM';
return entry.actorId ? `${entry.actorId.slice(0, 8)}` : 'anonymous';
}
const profile = entry.actor.agentProfile ?? entry.actor.userProfile;
const name = [profile?.firstName, profile?.lastName].filter(Boolean).join(' ');
return name || entry.actor.email;
}
function formatRelative(iso: string): string {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
return d.toLocaleDateString();
}
export default function AuditLogPage() {
const [items, setItems] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const limit = 25;
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filters
const [actionFilter, setActionFilter] = useState('');
const [resourceFilter, setResourceFilter] = useState('');
const [actorIdFilter, setActorIdFilter] = useState('');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const query: AuditLogQuery = {
page,
limit,
action: actionFilter || undefined,
resourceType: resourceFilter || undefined,
actorId: actorIdFilter || undefined,
from: fromDate ? new Date(fromDate).toISOString() : undefined,
to: toDate ? new Date(toDate + 'T23:59:59').toISOString() : undefined,
};
const res = await auditLogService.list(query);
setItems(res.items);
setTotal(res.total);
} catch (err) {
console.error('Failed to fetch audit log:', err);
setError('Failed to load audit log');
} finally {
setLoading(false);
}
}, [page, actionFilter, resourceFilter, actorIdFilter, fromDate, toDate]);
useEffect(() => {
fetchData();
}, [fetchData]);
const totalPages = Math.max(1, Math.ceil(total / limit));
const resetFilters = () => {
setActionFilter('');
setResourceFilter('');
setActorIdFilter('');
setFromDate('');
setToDate('');
setPage(1);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
<p className="text-sm text-gray-600 mt-1">
Who did what, when, and from where. Filter to narrow the trail.
</p>
</div>
<div className="bg-white rounded-lg shadow p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Action</label>
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
>
{ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Resource Type</label>
<select
value={resourceFilter}
onChange={(e) => { setResourceFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
>
{RESOURCE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt || 'All resources'}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Actor ID</label>
<input
type="text"
value={actorIdFilter}
onChange={(e) => setActorIdFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && setPage(1)}
placeholder="UUID"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white placeholder:text-gray-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">From</label>
<input
type="date"
value={fromDate}
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">To</label>
<input
type="date"
value={toDate}
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
/>
</div>
<div className="lg:col-span-5 flex justify-end gap-2">
<button
onClick={resetFilters}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
>
Reset filters
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : items.length === 0 ? (
<div className="py-12 text-center text-gray-500 text-sm">No audit entries match your filters.</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">When</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{items.map((entry) => (
<>
<tr key={entry.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900" title={new Date(entry.createdAt).toLocaleString()}>
{formatRelative(entry.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<div className="font-medium">{formatActor(entry)}</div>
<div className="text-xs text-gray-500">{entry.actorRole ?? 'SYSTEM'}</div>
</td>
<td className="px-4 py-3 text-sm">
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{entry.resourceType ? (
<>
<div>{entry.resourceType}</div>
{entry.resourceId && (
<div className="text-xs text-gray-500 font-mono" title={entry.resourceId}>
{entry.resourceId.slice(0, 8)}
</div>
)}
</>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 font-mono">
{entry.ipAddress ?? '—'}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
{expandedId === entry.id ? 'Hide' : 'Details'}
</button>
</td>
</tr>
{expandedId === entry.id && (
<tr key={`${entry.id}-detail`} className="bg-gray-50">
<td colSpan={6} className="px-4 py-3">
<div className="text-xs text-gray-700 space-y-1">
<div><span className="font-semibold">Full timestamp:</span> {new Date(entry.createdAt).toLocaleString()}</div>
<div><span className="font-semibold">Actor ID:</span> <span className="font-mono">{entry.actorId ?? '(none)'}</span></div>
{entry.actor?.email && (
<div><span className="font-semibold">Actor email:</span> {entry.actor.email}</div>
)}
{entry.userAgent && (
<div><span className="font-semibold">User Agent:</span> <span className="font-mono">{entry.userAgent}</span></div>
)}
{entry.metadata && (
<div>
<span className="font-semibold">Metadata:</span>
<pre className="mt-1 p-2 bg-white border border-gray-200 rounded text-xs font-mono overflow-x-auto">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</div>
)}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
{!loading && items.length > 0 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {(page - 1) * limit + 1}{Math.min(page * limit, total)} of {total}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
Previous
</button>
<span className="px-3 py-1 text-sm text-gray-700">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}