feat: add audit log dashboard with filtering and service integration

This commit is contained in:
pradeepkumar
2026-04-28 11:12:59 +05:30
parent 2991535624
commit b3a1c3b252
4 changed files with 423 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
'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>
);
}

View File

@@ -134,6 +134,20 @@ const menuItems = [
</svg>
),
},
{
name: 'Audit Log',
href: '/dashboard/audit-log',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9h6m-6 4h6m-6-8h6"
/>
</svg>
),
},
{
name: 'Support Chat',
href: '/dashboard/support-chat',

View File

@@ -0,0 +1,60 @@
import api, { ApiResponse } from './api';
export interface AuditLogActor {
id: string;
email: string;
role: string;
userProfile?: { firstName: string | null; lastName: string | null } | null;
agentProfile?: { firstName: string | null; lastName: string | null } | null;
}
export interface AuditLogEntry {
id: string;
actorId: string | null;
actorRole: string | null;
action: string;
resourceType: string | null;
resourceId: string | null;
metadata: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
actor?: AuditLogActor | null;
}
export interface AuditLogQuery {
actorId?: string;
action?: string;
resourceType?: string;
resourceId?: string;
from?: string;
to?: string;
page?: number;
limit?: number;
}
export interface AuditLogListResponse {
items: AuditLogEntry[];
total: number;
page: number;
limit: number;
}
class AuditLogService {
private basePath = '/admin/audit-log';
async list(query: AuditLogQuery = {}): Promise<AuditLogListResponse> {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
});
const response = await api.get<ApiResponse<AuditLogListResponse>>(
`${this.basePath}?${params.toString()}`,
);
return response.data.data;
}
}
export const auditLogService = new AuditLogService();

View File

@@ -100,3 +100,12 @@ export type {
SubscriptionsListResponse,
RevenueStats,
} from './stripe.service';
// Audit Log Service
export { auditLogService } from './audit-log.service';
export type {
AuditLogEntry,
AuditLogActor,
AuditLogQuery,
AuditLogListResponse,
} from './audit-log.service';