From 040d710ec728b31c3e3623ce98da8d2193564a96 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Fri, 3 Apr 2026 13:39:55 +0530 Subject: [PATCH] feat: enhance audit logs with advanced filtering, CSV export, and role-based views --- .../shared/WorkflowDefinitionModal.tsx | 17 +- src/pages/superadmin/AuditLogs.tsx | 296 ++++++++++++------ src/pages/tenant/AuditLogs.tsx | 293 +++++++++++------ src/services/audit-log-service.ts | 67 +++- src/types/audit-log.ts | 1 + 5 files changed, 481 insertions(+), 193 deletions(-) diff --git a/src/components/shared/WorkflowDefinitionModal.tsx b/src/components/shared/WorkflowDefinitionModal.tsx index d928665..284f906 100644 --- a/src/components/shared/WorkflowDefinitionModal.tsx +++ b/src/components/shared/WorkflowDefinitionModal.tsx @@ -36,9 +36,18 @@ const stepSchema = z requires_signature: z.boolean().default(false), requires_comment: z.boolean().default(false), requires_attachment: z.boolean().default(false), - sla_hours: z.number().optional().nullable(), - sla_warning_hours: z.number().optional().nullable(), - sla_escalation_hours: z.number().optional().nullable(), + sla_hours: z.preprocess( + (v) => (v === "" || v === null || Number.isNaN(v) ? undefined : v), + z.number().int().nonnegative().optional().nullable(), + ), + sla_warning_hours: z.preprocess( + (v) => (v === "" || v === null || Number.isNaN(v) ? undefined : v), + z.number().int().nonnegative().optional().nullable(), + ), + sla_escalation_hours: z.preprocess( + (v) => (v === "" || v === null || Number.isNaN(v) ? undefined : v), + z.number().int().nonnegative().optional().nullable(), + ), }) .superRefine((data, ctx) => { // Skip all assignee/action validation for terminal steps @@ -428,7 +437,7 @@ export const WorkflowDefinitionModal = ({ // Clean up SLA fields ['sla_hours', 'sla_warning_hours', 'sla_escalation_hours'].forEach(field => { - if (cleanedStep[field] === 0 || cleanedStep[field] === null || cleanedStep[field] === "") { + if (cleanedStep[field] === 0 || cleanedStep[field] === null || cleanedStep[field] === "" || Number.isNaN(cleanedStep[field])) { delete cleanedStep[field]; } }); diff --git a/src/pages/superadmin/AuditLogs.tsx b/src/pages/superadmin/AuditLogs.tsx index 941848b..d77b53e 100644 --- a/src/pages/superadmin/AuditLogs.tsx +++ b/src/pages/superadmin/AuditLogs.tsx @@ -11,7 +11,9 @@ import { } from '@/components/shared'; import { Download, ArrowUpDown } from 'lucide-react'; import { auditLogService } from '@/services/audit-log-service'; +import { tenantService } from '@/services/tenant-service'; import type { AuditLog } from '@/types/audit-log'; +import type { Tenant } from '@/types/tenant'; // Helper function to format date const formatDate = (dateString: string): string => { @@ -57,12 +59,13 @@ const getStatusColor = (status: number | null): string => { const AuditLogs = (): ReactElement => { const [auditLogs, setAuditLogs] = useState([]); + const [tenants, setTenants] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Pagination state const [currentPage, setCurrentPage] = useState(1); - const [limit, setLimit] = useState(5); + const [limit, setLimit] = useState(10); const [pagination, setPagination] = useState<{ page: number; limit: number; @@ -71,30 +74,57 @@ const AuditLogs = (): ReactElement => { hasMore: boolean; }>({ page: 1, - limit: 5, + limit: 10, total: 0, totalPages: 1, hasMore: false, }); // Filter state + const [tenantFilter, setTenantFilter] = useState(null); const [methodFilter, setMethodFilter] = useState(null); + const [actionFilter, setActionFilter] = useState(null); + const [resourceTypeFilter, setResourceTypeFilter] = useState(null); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); const [orderBy, setOrderBy] = useState(null); // View modal const [viewModalOpen, setViewModalOpen] = useState(false); const [selectedAuditLogId, setSelectedAuditLogId] = useState(null); - const fetchAuditLogs = async ( - page: number, - itemsPerPage: number, - method: string | null = null, - sortBy: string[] | null = null - ): Promise => { + // Fetch tenants on mount for the selector + useEffect(() => { + const fetchTenants = async () => { + try { + const response = await tenantService.getAll(1, 100); + if (response.success) { + setTenants(response.data); + } + } catch (err) { + console.error('Failed to load tenants:', err); + } + }; + fetchTenants(); + }, []); + + const fetchAuditLogs = async (): Promise => { try { setIsLoading(true); setError(null); - const response = await auditLogService.getAll(page, itemsPerPage, method, sortBy); + const response = await auditLogService.getAll( + currentPage, + limit, + { + tenant_id: tenantFilter, + method: methodFilter, + action: actionFilter, + resource_type: resourceTypeFilter, + startDate: startDate || null, + endDate: endDate || null, + }, + orderBy + ); if (response.success) { setAuditLogs(response.data); setPagination(response.pagination); @@ -110,8 +140,32 @@ const AuditLogs = (): ReactElement => { // Fetch audit logs on mount and when pagination/filters change useEffect(() => { - fetchAuditLogs(currentPage, limit, methodFilter, orderBy); - }, [currentPage, limit, methodFilter, orderBy]); + fetchAuditLogs(); + }, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy]); + + // Handle Export + const handleExport = async (): Promise => { + try { + const response = await auditLogService.export({ + format: 'json', + startDate: startDate || undefined, + endDate: endDate || undefined, + tenantId: tenantFilter || undefined + }); + if (response.success) { + const blob = new Blob([JSON.stringify(response.data.records, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `platform-audit-logs-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } catch (err: any) { + alert('Export failed: ' + err.message); + } + }; // View audit log handler const handleViewAuditLog = (auditLogId: string): void => { @@ -131,10 +185,19 @@ const AuditLogs = (): ReactElement => { key: 'created_at', label: 'Timestamp', render: (log) => ( - {formatDate(log.created_at)} + {formatDate(log.created_at)} ), mobileLabel: 'Time', }, + { + key: 'tenant', + label: 'Tenant', + render: (log) => ( + + {log.tenant ? log.tenant.name : 'N/A'} + + ), + }, { key: 'resource_type', label: 'Resource Type', @@ -151,21 +214,12 @@ const AuditLogs = (): ReactElement => { ), }, - { - key: 'resource_id', - label: 'Resource ID', - render: (log) => ( - - {log.resource_id || 'N/A'} - - ), - }, { key: 'user', label: 'User', render: (log) => ( - {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} + {log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')} ), }, @@ -187,15 +241,6 @@ const AuditLogs = (): ReactElement => { ), }, - { - key: 'ip_address', - label: 'IP Address', - render: (log) => ( - - {log.ip_address || 'N/A'} - - ), - }, { key: 'actions', label: 'Actions', @@ -220,6 +265,7 @@ const AuditLogs = (): ReactElement => {

{log.resource_type}

+

{log.tenant ? log.tenant.name : 'System'}

{log.action} @@ -242,33 +288,15 @@ const AuditLogs = (): ReactElement => {
User:

- {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} + {log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')}

-
- Method: -
- - {log.request_method ? log.request_method.toUpperCase() : 'N/A'} - -
-
Status:

{log.response_status || 'N/A'}

-
- Resource ID: -

- {log.resource_id || 'N/A'} -

-
-
- IP Address: -

{log.ip_address || 'N/A'}

-
); @@ -277,66 +305,152 @@ const AuditLogs = (): ReactElement => { {/* Table Container */}
{/* Table Header with Filters */} -
- {/* Filters */} -
- {/* Method Filter */} - { - setMethodFilter(value as string | null); - setCurrentPage(1); - }} - placeholder="All" - /> +
+
+ {/* Filters */} +
+ {/* Tenant Selector */} + ({ value: t.id, label: t.name }))} + value={tenantFilter} + onChange={(value) => { + setTenantFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Tenants" + /> + + {/* Action Filter */} + { + setActionFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Actions" + /> + + {/* Resource Filter */} + { + setResourceTypeFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Resources" + /> + + {/* Method Filter */} + { + setMethodFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Methods" + /> +
+ + {/* Actions */} +
+ +
+
+ +
+
+ Start Date: + { + setStartDate(e.target.value); + setCurrentPage(1); + }} + className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" + /> +
+
+ End Date: + { + setEndDate(e.target.value); + setCurrentPage(1); + }} + className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" + /> +
- {/* Sort Filter */} { setOrderBy(value as string[] | null); setCurrentPage(1); }} - placeholder="Default" + placeholder="Newest" showIcon icon={} /> -
- {/* Actions */} -
- {/* Export Button */} - + {(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter) && ( + + )}
@@ -348,7 +462,7 @@ const AuditLogs = (): ReactElement => { isLoading={isLoading} error={error} mobileCardRenderer={mobileCardRenderer} - emptyMessage="No audit logs found" + emptyMessage="No platform-wide audit logs found" /> {/* Pagination */} diff --git a/src/pages/tenant/AuditLogs.tsx b/src/pages/tenant/AuditLogs.tsx index 62d6426..faf7b03 100644 --- a/src/pages/tenant/AuditLogs.tsx +++ b/src/pages/tenant/AuditLogs.tsx @@ -57,14 +57,17 @@ const getStatusColor = (status: number | null): string => { }; const AuditLogs = (): ReactElement => { + const roles = useAppSelector((state) => state.auth.roles); const tenantId = useAppSelector((state) => state.auth.tenantId); + const isTenantAdmin = roles?.includes('tenant_admin'); + const [auditLogs, setAuditLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Pagination state const [currentPage, setCurrentPage] = useState(1); - const [limit, setLimit] = useState(5); + const [limit, setLimit] = useState(10); const [pagination, setPagination] = useState<{ page: number; limit: number; @@ -73,7 +76,7 @@ const AuditLogs = (): ReactElement => { hasMore: boolean; }>({ page: 1, - limit: 5, + limit: 10, total: 0, totalPages: 1, hasMore: false, @@ -81,22 +84,40 @@ const AuditLogs = (): ReactElement => { // Filter state const [methodFilter, setMethodFilter] = useState(null); + const [actionFilter, setActionFilter] = useState(null); + const [resourceTypeFilter, setResourceTypeFilter] = useState(null); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); const [orderBy, setOrderBy] = useState(null); // View modal const [viewModalOpen, setViewModalOpen] = useState(false); const [selectedAuditLogId, setSelectedAuditLogId] = useState(null); - const fetchAuditLogs = async ( - page: number, - itemsPerPage: number, - method: string | null = null, - sortBy: string[] | null = null - ): Promise => { + const fetchAuditLogs = async (): Promise => { try { setIsLoading(true); setError(null); - const response = await auditLogService.getAll(page, itemsPerPage, method, sortBy, tenantId); + + let response; + if (isTenantAdmin) { + response = await auditLogService.getAll( + currentPage, + limit, + { + method: methodFilter, + action: actionFilter, + resource_type: resourceTypeFilter, + startDate: startDate || null, + endDate: endDate || null, + tenant_id: tenantId + }, + orderBy + ); + } else { + response = await auditLogService.getMyLogs(currentPage, limit); + } + if (response.success) { setAuditLogs(response.data); setPagination(response.pagination); @@ -112,10 +133,8 @@ const AuditLogs = (): ReactElement => { // Fetch audit logs on mount and when pagination/filters change useEffect(() => { - if (tenantId) { - fetchAuditLogs(currentPage, limit, methodFilter, orderBy); - } - }, [currentPage, limit, methodFilter, orderBy, tenantId]); + fetchAuditLogs(); + }, [currentPage, limit, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, tenantId]); // View audit log handler const handleViewAuditLog = (auditLogId: string): void => { @@ -123,6 +142,34 @@ const AuditLogs = (): ReactElement => { setViewModalOpen(true); }; + // Export handler + const handleExport = async (): Promise => { + try { + const response = await auditLogService.export({ + format: 'json', + startDate: startDate || undefined, + endDate: endDate || undefined, + tenantId: tenantId || undefined + }); + + if (response.success) { + // In a real app, we'd trigger a file download here. + // For now, we'll just log and show a message since the response is JSON. + console.log('Export data:', response.data.records); + const blob = new Blob([JSON.stringify(response.data.records, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } catch (err: any) { + alert('Failed to export audit logs: ' + (err.message || 'Unknown error')); + } + }; + // Load audit log for view const loadAuditLog = async (id: string): Promise => { const response = await auditLogService.getById(id); @@ -135,7 +182,7 @@ const AuditLogs = (): ReactElement => { key: 'created_at', label: 'Timestamp', render: (log) => ( - {formatDate(log.created_at)} + {formatDate(log.created_at)} ), mobileLabel: 'Time', }, @@ -155,21 +202,12 @@ const AuditLogs = (): ReactElement => { ), }, - { - key: 'resource_id', - label: 'Resource ID', - render: (log) => ( - - {log.resource_id || 'N/A'} - - ), - }, { key: 'user', label: 'User', render: (log) => ( - {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} + {log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')} ), }, @@ -246,7 +284,7 @@ const AuditLogs = (): ReactElement => {
User:

- {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} + {log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')}

@@ -263,16 +301,6 @@ const AuditLogs = (): ReactElement => { {log.response_status || 'N/A'}

-
- Resource ID: -

- {log.resource_id || 'N/A'} -

-
-
- IP Address: -

{log.ip_address || 'N/A'}

-
); @@ -281,66 +309,155 @@ const AuditLogs = (): ReactElement => { {/* Table Container */}
{/* Table Header with Filters */} -
- {/* Filters */} -
- {/* Method Filter */} - { - setMethodFilter(value as string | null); - setCurrentPage(1); - }} - placeholder="All" - /> +
+
+ {/* Filters */} +
+ {isTenantAdmin && ( + <> + {/* Action Filter */} + { + setActionFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Actions" + /> - {/* Sort Filter */} - { - setOrderBy(value as string[] | null); - setCurrentPage(1); - }} - placeholder="Default" - showIcon - icon={} - /> + {/* Resource Type Filter */} + { + setResourceTypeFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Resources" + /> + + {/* Method Filter */} + { + setMethodFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Methods" + /> + + )} + + {/* Sort Filter - Always show as it's useful for everyone */} + { + setOrderBy(value as string[] | null); + setCurrentPage(1); + }} + placeholder="Newest" + showIcon + icon={} + /> +
+ + {/* Actions */} +
+ {isTenantAdmin && ( + + )} +
- {/* Actions */} -
- {/* Export Button */} - + {/* Date Filters - Separated row for better spacing */} +
+
+ From: + { + setStartDate(e.target.value); + setCurrentPage(1); + }} + className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" + /> +
+
+ To: + { + setEndDate(e.target.value); + setCurrentPage(1); + }} + className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" + /> +
+ {(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter) && ( + + )}
@@ -352,7 +469,7 @@ const AuditLogs = (): ReactElement => { isLoading={isLoading} error={error} mobileCardRenderer={mobileCardRenderer} - emptyMessage="No audit logs found" + emptyMessage={isTenantAdmin ? "No audit logs found matching your criteria" : "No activity recorded for your account yet"} /> {/* Pagination */} diff --git a/src/services/audit-log-service.ts b/src/services/audit-log-service.ts index 82b41f5..915ba52 100644 --- a/src/services/audit-log-service.ts +++ b/src/services/audit-log-service.ts @@ -4,29 +4,76 @@ import type { AuditLogsResponse, GetAuditLogResponse } from '@/types/audit-log'; export const auditLogService = { getAll: async ( page: number = 1, - limit: number = 20, - method?: string | null, - orderBy?: string[] | null, - tenantId?: string | null + limit: number = 100, + filters: { + method?: string | null; + action?: string | null; + resource_type?: string | null; + user_id?: string | null; + response_status?: number | null; + tenant_id?: string | null; + startDate?: string | null; + endDate?: string | null; + } = {}, + orderBy?: string[] | null ): Promise => { const params = new URLSearchParams(); params.append('page', String(page)); params.append('limit', String(limit)); - if (method) { - params.append('method', method); - } - if (tenantId) { - params.append('tenant_id', tenantId); - } + + if (filters.method) params.append('method', filters.method); + if (filters.action) params.append('action', filters.action); + if (filters.resource_type) params.append('resource_type', filters.resource_type); + if (filters.user_id) params.append('user_id', filters.user_id); + if (filters.response_status) params.append('response_status', String(filters.response_status)); + if (filters.tenant_id) params.append('tenant_id', filters.tenant_id); + if (filters.startDate) params.append('startDate', filters.startDate); + if (filters.endDate) params.append('endDate', filters.endDate); + if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) { params.append('orderBy[]', orderBy[0]); params.append('orderBy[]', orderBy[1]); } + const response = await apiClient.get(`/audit-logs?${params.toString()}`); return response.data; }, + + getMyLogs: async (page: number = 1, limit: number = 50): Promise => { + const response = await apiClient.get( + `/audit-logs/my-logs?page=${page}&limit=${limit}` + ); + return response.data; + }, + getById: async (id: string): Promise => { const response = await apiClient.get(`/audit-logs/${id}`); return response.data; }, + + getStats: async (params: { tenantId?: string; startDate?: string; endDate?: string } = {}): Promise => { + const searchParams = new URLSearchParams(); + if (params.tenantId) searchParams.append('tenantId', params.tenantId); + if (params.startDate) searchParams.append('startDate', params.startDate); + if (params.endDate) searchParams.append('endDate', params.endDate); + + const response = await apiClient.get(`/audit-logs/stats?${searchParams.toString()}`); + return response.data; + }, + + export: async (params: { + format?: string; + startDate?: string; + endDate?: string; + tenantId?: string; + } = {}): Promise => { + const searchParams = new URLSearchParams(); + if (params.format) searchParams.append('format', params.format); + if (params.startDate) searchParams.append('startDate', params.startDate); + if (params.endDate) searchParams.append('endDate', params.endDate); + if (params.tenantId) searchParams.append('tenantId', params.tenantId); + + const response = await apiClient.get(`/audit-logs/export?${searchParams.toString()}`); + return response.data; + }, }; diff --git a/src/types/audit-log.ts b/src/types/audit-log.ts index 7ff51e0..3a41356 100644 --- a/src/types/audit-log.ts +++ b/src/types/audit-log.ts @@ -29,6 +29,7 @@ export interface AuditLog { metadata: Record | null; created_at: string; updated_at: string; + user_email: string | null; user: AuditLogUser | null; tenant: AuditLogTenant | null; }