diff --git a/src/app/dashboard/audit-log/page.tsx b/src/app/dashboard/audit-log/page.tsx new file mode 100644 index 0000000..ca6daec --- /dev/null +++ b/src/app/dashboard/audit-log/page.tsx @@ -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([]); + 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(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 ( +
+
+

Audit Log

+

+ Who did what, when, and from where. Filter to narrow the trail. +

+
+ +
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+ + { 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" + /> +
+
+ + { 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" + /> +
+
+ +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +
No audit entries match your filters.
+ ) : ( + + + + + + + + + + + + + {items.map((entry) => ( + <> + + + + + + + + + {expandedId === entry.id && ( + + + + )} + + ))} + +
WhenActorActionResourceIP
+ {formatRelative(entry.createdAt)} + +
{formatActor(entry)}
+
{entry.actorRole ?? 'SYSTEM'}
+
+ + {entry.action} + + + {entry.resourceType ? ( + <> +
{entry.resourceType}
+ {entry.resourceId && ( +
+ {entry.resourceId.slice(0, 8)}… +
+ )} + + ) : ( + + )} +
+ {entry.ipAddress ?? '—'} + + +
+
+
Full timestamp: {new Date(entry.createdAt).toLocaleString()}
+
Actor ID: {entry.actorId ?? '(none)'}
+ {entry.actor?.email && ( +
Actor email: {entry.actor.email}
+ )} + {entry.userAgent && ( +
User Agent: {entry.userAgent}
+ )} + {entry.metadata && ( +
+ Metadata: +
+{JSON.stringify(entry.metadata, null, 2)}
+                              
+
+ )} +
+
+ )} + + {!loading && items.length > 0 && ( +
+
+ Showing {(page - 1) * limit + 1}–{Math.min(page * limit, total)} of {total} +
+
+ + + Page {page} of {totalPages} + + +
+
+ )} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index f2b736a..f64aea1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -134,6 +134,20 @@ const menuItems = [ ), }, + { + name: 'Audit Log', + href: '/dashboard/audit-log', + icon: ( + + + + ), + }, { name: 'Support Chat', href: '/dashboard/support-chat', diff --git a/src/services/audit-log.service.ts b/src/services/audit-log.service.ts new file mode 100644 index 0000000..8eb7c3c --- /dev/null +++ b/src/services/audit-log.service.ts @@ -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 | 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 { + 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>( + `${this.basePath}?${params.toString()}`, + ); + return response.data.data; + } +} + +export const auditLogService = new AuditLogService(); diff --git a/src/services/index.ts b/src/services/index.ts index 3adf665..e90f303 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -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';