feat: enhance audit logs with advanced filtering, CSV export, and role-based views

This commit is contained in:
Yashwin 2026-04-03 13:39:55 +05:30
parent 435375fc9f
commit 040d710ec7
5 changed files with 481 additions and 193 deletions

View File

@ -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];
}
});

View File

@ -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<AuditLog[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [limit, setLimit] = useState<number>(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<string | null>(null);
const [methodFilter, setMethodFilter] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState<string | null>(null);
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
const fetchAuditLogs = async (
page: number,
itemsPerPage: number,
method: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
// 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<void> => {
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<void> => {
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) => (
<span className="text-sm font-normal text-[#0f1724]">{formatDate(log.created_at)}</span>
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">{formatDate(log.created_at)}</span>
),
mobileLabel: 'Time',
},
{
key: 'tenant',
label: 'Tenant',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">
{log.tenant ? log.tenant.name : 'N/A'}
</span>
),
},
{
key: 'resource_type',
label: 'Resource Type',
@ -151,21 +214,12 @@ const AuditLogs = (): ReactElement => {
</StatusBadge>
),
},
{
key: 'resource_id',
label: 'Resource ID',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724] font-mono truncate max-w-[150px]">
{log.resource_id || 'N/A'}
</span>
),
},
{
key: 'user',
label: 'User',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">
{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')}
</span>
),
},
@ -187,15 +241,6 @@ const AuditLogs = (): ReactElement => {
</span>
),
},
{
key: 'ip_address',
label: 'IP Address',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">
{log.ip_address || 'N/A'}
</span>
),
},
{
key: 'actions',
label: 'Actions',
@ -220,6 +265,7 @@ const AuditLogs = (): ReactElement => {
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{log.resource_type}</h3>
<p className="text-xs text-[#9aa6b2] mb-1">{log.tenant ? log.tenant.name : 'System'}</p>
<div className="mt-1">
<StatusBadge variant={getActionVariant(log.action)}>
{log.action}
@ -242,33 +288,15 @@ const AuditLogs = (): ReactElement => {
<div>
<span className="text-[#9aa6b2]">User:</span>
<p className="text-[#0f1724] font-normal mt-1">
{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')}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Method:</span>
<div className="mt-1">
<StatusBadge variant={getMethodVariant(log.request_method)}>
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Status:</span>
<p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}>
{log.response_status || 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Resource ID:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
{log.resource_id || 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">IP Address:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono">{log.ip_address || 'N/A'}</p>
</div>
</div>
</div>
);
@ -277,66 +305,152 @@ const AuditLogs = (): ReactElement => {
<Layout
currentPage="Audit Logs"
pageHeader={{
title: 'Audit Logs',
description: 'View and manage all audit logs in the QAssure platform.',
title: 'Platform Audit Logs',
description: 'Global monitoring of all actions, logins, and changes across all tenants in the platform.',
}}
>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Method Filter */}
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Tenant Selector */}
<FilterDropdown
label="Tenant"
options={tenants.map(t => ({ value: t.id, label: t.name }))}
value={tenantFilter}
onChange={(value) => {
setTenantFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Tenants"
/>
{/* Action Filter */}
<FilterDropdown
label="Action"
options={[
{ value: 'LOGIN', label: 'LOGIN' },
{ value: 'CREATE', label: 'CREATE' },
{ value: 'UPDATE', label: 'UPDATE' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'SUBMIT', label: 'SUBMIT' },
]}
value={actionFilter}
onChange={(value) => {
setActionFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Actions"
/>
{/* Resource Filter */}
<FilterDropdown
label="Resource"
options={[
{ value: 'document', label: 'Document' },
{ value: 'user', label: 'User' },
{ value: 'tenant', label: 'Tenant' },
{ value: 'role', label: 'Role' },
]}
value={resourceTypeFilter}
onChange={(value) => {
setResourceTypeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Resources"
/>
{/* Method Filter */}
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Methods"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleExport}
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export Platform Logs</span>
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 md:gap-6 pt-1 border-t border-gray-50 mt-1 pt-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">Start Date:</span>
<input
type="date"
value={startDate}
onChange={(e) => {
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"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">End Date:</span>
<input
type="date"
value={endDate}
onChange={(e) => {
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"
/>
</div>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
label="Sort"
options={[
{ value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' },
{ value: ['action', 'asc'], label: 'Action (A-Z)' },
{ value: ['action', 'desc'], label: 'Action (Z-A)' },
{ value: ['resource_type', 'asc'], label: 'Resource Type (A-Z)' },
{ value: ['resource_type', 'desc'], label: 'Resource Type (Z-A)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
placeholder="Newest"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter) && (
<button
onClick={() => {
setStartDate('');
setEndDate('');
setTenantFilter(null);
setActionFilter(null);
setResourceTypeFilter(null);
setCurrentPage(1);
}}
className="text-xs text-[#ef4444] hover:underline ml-auto"
>
Reset All Filters
</button>
)}
</div>
</div>
@ -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 */}

View File

@ -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<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [limit, setLimit] = useState<number>(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<string | null>(null);
const [actionFilter, setActionFilter] = useState<string | null>(null);
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
const fetchAuditLogs = async (
page: number,
itemsPerPage: number,
method: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
const fetchAuditLogs = async (): Promise<void> => {
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<void> => {
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<AuditLog> => {
const response = await auditLogService.getById(id);
@ -135,7 +182,7 @@ const AuditLogs = (): ReactElement => {
key: 'created_at',
label: 'Timestamp',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">{formatDate(log.created_at)}</span>
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">{formatDate(log.created_at)}</span>
),
mobileLabel: 'Time',
},
@ -155,21 +202,12 @@ const AuditLogs = (): ReactElement => {
</StatusBadge>
),
},
{
key: 'resource_id',
label: 'Resource ID',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724] font-mono truncate max-w-[150px]">
{log.resource_id || 'N/A'}
</span>
),
},
{
key: 'user',
label: 'User',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">
{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')}
</span>
),
},
@ -246,7 +284,7 @@ const AuditLogs = (): ReactElement => {
<div>
<span className="text-[#9aa6b2]">User:</span>
<p className="text-[#0f1724] font-normal mt-1">
{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')}
</p>
</div>
<div>
@ -263,16 +301,6 @@ const AuditLogs = (): ReactElement => {
{log.response_status || 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Resource ID:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
{log.resource_id || 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">IP Address:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono">{log.ip_address || 'N/A'}</p>
</div>
</div>
</div>
);
@ -281,66 +309,155 @@ const AuditLogs = (): ReactElement => {
<Layout
currentPage="Audit Logs"
pageHeader={{
title: 'Audit Logs',
description: 'View and manage all audit logs in the QAssure platform.',
title: isTenantAdmin ? 'System Audit Logs' : 'My Activity',
description: isTenantAdmin
? 'Monitor all activities and changes across the quality platform.'
: 'View a chronological history of your own actions on the platform.',
}}
>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Method Filter */}
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{isTenantAdmin && (
<>
{/* Action Filter */}
<FilterDropdown
label="Action"
options={[
{ value: 'LOGIN', label: 'LOGIN' },
{ value: 'CREATE', label: 'CREATE' },
{ value: 'UPDATE', label: 'UPDATE' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'SUBMIT', label: 'SUBMIT' },
{ value: 'APPROVE', label: 'APPROVE' },
{ value: 'REJECT', label: 'REJECT' },
]}
value={actionFilter}
onChange={(value) => {
setActionFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Actions"
/>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' },
{ value: ['action', 'asc'], label: 'Action (A-Z)' },
{ value: ['action', 'desc'], label: 'Action (Z-A)' },
{ value: ['resource_type', 'asc'], label: 'Resource Type (A-Z)' },
{ value: ['resource_type', 'desc'], label: 'Resource Type (Z-A)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
{/* Resource Type Filter */}
<FilterDropdown
label="Resource"
options={[
{ value: 'document', label: 'Document' },
{ value: 'user', label: 'User' },
{ value: 'capa', label: 'CAPA' },
{ value: 'workflow', label: 'Workflow' },
{ value: 'training', label: 'Training' },
{ value: 'project', label: 'Project' },
]}
value={resourceTypeFilter}
onChange={(value) => {
setResourceTypeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Resources"
/>
{/* Method Filter */}
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Methods"
/>
</>
)}
{/* Sort Filter - Always show as it's useful for everyone */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' },
{ value: ['action', 'asc'], label: 'Action (A-Z)' },
{ value: ['resource_type', 'asc'], label: 'Resource Type (A-Z)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Newest"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{isTenantAdmin && (
<button
type="button"
onClick={handleExport}
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
{/* Date Filters - Separated row for better spacing */}
<div className="flex flex-wrap items-center gap-3 md:gap-6 pt-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">From:</span>
<input
type="date"
value={startDate}
onChange={(e) => {
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"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">To:</span>
<input
type="date"
value={endDate}
onChange={(e) => {
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"
/>
</div>
{(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter) && (
<button
onClick={() => {
setStartDate('');
setEndDate('');
setActionFilter(null);
setResourceTypeFilter(null);
setMethodFilter(null);
setCurrentPage(1);
}}
className="text-xs text-[#ef4444] hover:underline"
>
Clear all filters
</button>
)}
</div>
</div>
@ -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 */}

View File

@ -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<AuditLogsResponse> => {
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<AuditLogsResponse>(`/audit-logs?${params.toString()}`);
return response.data;
},
getMyLogs: async (page: number = 1, limit: number = 50): Promise<AuditLogsResponse> => {
const response = await apiClient.get<AuditLogsResponse>(
`/audit-logs/my-logs?page=${page}&limit=${limit}`
);
return response.data;
},
getById: async (id: string): Promise<GetAuditLogResponse> => {
const response = await apiClient.get<GetAuditLogResponse>(`/audit-logs/${id}`);
return response.data;
},
getStats: async (params: { tenantId?: string; startDate?: string; endDate?: string } = {}): Promise<any> => {
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<any> => {
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;
},
};

View File

@ -29,6 +29,7 @@ export interface AuditLog {
metadata: Record<string, unknown> | null;
created_at: string;
updated_at: string;
user_email: string | null;
user: AuditLogUser | null;
tenant: AuditLogTenant | null;
}