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_signature: z.boolean().default(false),
requires_comment: z.boolean().default(false), requires_comment: z.boolean().default(false),
requires_attachment: z.boolean().default(false), requires_attachment: z.boolean().default(false),
sla_hours: z.number().optional().nullable(), sla_hours: z.preprocess(
sla_warning_hours: z.number().optional().nullable(), (v) => (v === "" || v === null || Number.isNaN(v) ? undefined : v),
sla_escalation_hours: z.number().optional().nullable(), 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) => { .superRefine((data, ctx) => {
// Skip all assignee/action validation for terminal steps // Skip all assignee/action validation for terminal steps
@ -428,7 +437,7 @@ export const WorkflowDefinitionModal = ({
// Clean up SLA fields // Clean up SLA fields
['sla_hours', 'sla_warning_hours', 'sla_escalation_hours'].forEach(field => { ['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]; delete cleanedStep[field];
} }
}); });

View File

@ -11,7 +11,9 @@ import {
} from '@/components/shared'; } from '@/components/shared';
import { Download, ArrowUpDown } from 'lucide-react'; import { Download, ArrowUpDown } from 'lucide-react';
import { auditLogService } from '@/services/audit-log-service'; import { auditLogService } from '@/services/audit-log-service';
import { tenantService } from '@/services/tenant-service';
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from '@/types/audit-log';
import type { Tenant } from '@/types/tenant';
// Helper function to format date // Helper function to format date
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@ -57,12 +59,13 @@ const getStatusColor = (status: number | null): string => {
const AuditLogs = (): ReactElement => { const AuditLogs = (): ReactElement => {
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5); const [limit, setLimit] = useState<number>(10);
const [pagination, setPagination] = useState<{ const [pagination, setPagination] = useState<{
page: number; page: number;
limit: number; limit: number;
@ -71,30 +74,57 @@ const AuditLogs = (): ReactElement => {
hasMore: boolean; hasMore: boolean;
}>({ }>({
page: 1, page: 1,
limit: 5, limit: 10,
total: 0, total: 0,
totalPages: 1, totalPages: 1,
hasMore: false, hasMore: false,
}); });
// Filter state // Filter state
const [tenantFilter, setTenantFilter] = useState<string | null>(null);
const [methodFilter, setMethodFilter] = 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); const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View modal // View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null); const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
const fetchAuditLogs = async ( // Fetch tenants on mount for the selector
page: number, useEffect(() => {
itemsPerPage: number, const fetchTenants = async () => {
method: string | null = null, try {
sortBy: string[] | null = null const response = await tenantService.getAll(1, 100);
): Promise<void> => { if (response.success) {
setTenants(response.data);
}
} catch (err) {
console.error('Failed to load tenants:', err);
}
};
fetchTenants();
}, []);
const fetchAuditLogs = async (): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); 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) { if (response.success) {
setAuditLogs(response.data); setAuditLogs(response.data);
setPagination(response.pagination); setPagination(response.pagination);
@ -110,8 +140,32 @@ const AuditLogs = (): ReactElement => {
// Fetch audit logs on mount and when pagination/filters change // Fetch audit logs on mount and when pagination/filters change
useEffect(() => { useEffect(() => {
fetchAuditLogs(currentPage, limit, methodFilter, orderBy); fetchAuditLogs();
}, [currentPage, limit, methodFilter, orderBy]); }, [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 // View audit log handler
const handleViewAuditLog = (auditLogId: string): void => { const handleViewAuditLog = (auditLogId: string): void => {
@ -131,10 +185,19 @@ const AuditLogs = (): ReactElement => {
key: 'created_at', key: 'created_at',
label: 'Timestamp', label: 'Timestamp',
render: (log) => ( 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', 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', key: 'resource_type',
label: 'Resource Type', label: 'Resource Type',
@ -151,21 +214,12 @@ const AuditLogs = (): ReactElement => {
</StatusBadge> </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', key: 'user',
label: 'User', label: 'User',
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#0f1724]"> <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> </span>
), ),
}, },
@ -187,15 +241,6 @@ const AuditLogs = (): ReactElement => {
</span> </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', key: 'actions',
label: 'Actions', label: 'Actions',
@ -220,6 +265,7 @@ const AuditLogs = (): ReactElement => {
<div className="flex items-start justify-between gap-3 mb-3"> <div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{log.resource_type}</h3> <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"> <div className="mt-1">
<StatusBadge variant={getActionVariant(log.action)}> <StatusBadge variant={getActionVariant(log.action)}>
{log.action} {log.action}
@ -242,33 +288,15 @@ const AuditLogs = (): ReactElement => {
<div> <div>
<span className="text-[#9aa6b2]">User:</span> <span className="text-[#9aa6b2]">User:</span>
<p className="text-[#0f1724] font-normal mt-1"> <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> </p>
</div> </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> <div>
<span className="text-[#9aa6b2]">Status:</span> <span className="text-[#9aa6b2]">Status:</span>
<p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}> <p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}>
{log.response_status || 'N/A'} {log.response_status || 'N/A'}
</p> </p>
</div> </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>
</div> </div>
); );
@ -277,16 +305,64 @@ const AuditLogs = (): ReactElement => {
<Layout <Layout
currentPage="Audit Logs" currentPage="Audit Logs"
pageHeader={{ pageHeader={{
title: 'Audit Logs', title: 'Platform Audit Logs',
description: 'View and manage all audit logs in the QAssure platform.', description: 'Global monitoring of all actions, logins, and changes across all tenants in the platform.',
}} }}
> >
{/* Table Container */} {/* 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"> <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 */} {/* 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"> <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 */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <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 */} {/* Method Filter */}
<FilterDropdown <FilterDropdown
label="Method" label="Method"
@ -295,48 +371,86 @@ const AuditLogs = (): ReactElement => {
{ value: 'POST', label: 'POST' }, { value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' }, { value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' }, { value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]} ]}
value={methodFilter} value={methodFilter}
onChange={(value) => { onChange={(value) => {
setMethodFilter(value as string | null); setMethodFilter(value as string | null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="All" 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 <FilterDropdown
label="Sort by" label="Sort"
options={[ options={[
{ value: ['created_at', 'desc'], label: 'Newest First' }, { value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest 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} value={orderBy}
onChange={(value) => { onChange={(value) => {
setOrderBy(value as string[] | null); setOrderBy(value as string[] | null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Default" placeholder="Newest"
showIcon showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />} icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/> />
</div>
{/* Actions */} {(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter) && (
<div className="flex items-center gap-2">
{/* Export Button */}
<button <button
type="button" onClick={() => {
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" setStartDate('');
setEndDate('');
setTenantFilter(null);
setActionFilter(null);
setResourceTypeFilter(null);
setCurrentPage(1);
}}
className="text-xs text-[#ef4444] hover:underline ml-auto"
> >
<Download className="w-3.5 h-3.5" /> Reset All Filters
<span>Export</span>
</button> </button>
)}
</div> </div>
</div> </div>
@ -348,7 +462,7 @@ const AuditLogs = (): ReactElement => {
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
mobileCardRenderer={mobileCardRenderer} mobileCardRenderer={mobileCardRenderer}
emptyMessage="No audit logs found" emptyMessage="No platform-wide audit logs found"
/> />
{/* Pagination */} {/* Pagination */}

View File

@ -57,14 +57,17 @@ const getStatusColor = (status: number | null): string => {
}; };
const AuditLogs = (): ReactElement => { const AuditLogs = (): ReactElement => {
const roles = useAppSelector((state) => state.auth.roles);
const tenantId = useAppSelector((state) => state.auth.tenantId); const tenantId = useAppSelector((state) => state.auth.tenantId);
const isTenantAdmin = roles?.includes('tenant_admin');
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5); const [limit, setLimit] = useState<number>(10);
const [pagination, setPagination] = useState<{ const [pagination, setPagination] = useState<{
page: number; page: number;
limit: number; limit: number;
@ -73,7 +76,7 @@ const AuditLogs = (): ReactElement => {
hasMore: boolean; hasMore: boolean;
}>({ }>({
page: 1, page: 1,
limit: 5, limit: 10,
total: 0, total: 0,
totalPages: 1, totalPages: 1,
hasMore: false, hasMore: false,
@ -81,22 +84,40 @@ const AuditLogs = (): ReactElement => {
// Filter state // Filter state
const [methodFilter, setMethodFilter] = 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); const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View modal // View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null); const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
const fetchAuditLogs = async ( const fetchAuditLogs = async (): Promise<void> => {
page: number,
itemsPerPage: number,
method: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); 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) { if (response.success) {
setAuditLogs(response.data); setAuditLogs(response.data);
setPagination(response.pagination); setPagination(response.pagination);
@ -112,10 +133,8 @@ const AuditLogs = (): ReactElement => {
// Fetch audit logs on mount and when pagination/filters change // Fetch audit logs on mount and when pagination/filters change
useEffect(() => { useEffect(() => {
if (tenantId) { fetchAuditLogs();
fetchAuditLogs(currentPage, limit, methodFilter, orderBy); }, [currentPage, limit, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, tenantId]);
}
}, [currentPage, limit, methodFilter, orderBy, tenantId]);
// View audit log handler // View audit log handler
const handleViewAuditLog = (auditLogId: string): void => { const handleViewAuditLog = (auditLogId: string): void => {
@ -123,6 +142,34 @@ const AuditLogs = (): ReactElement => {
setViewModalOpen(true); 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 // Load audit log for view
const loadAuditLog = async (id: string): Promise<AuditLog> => { const loadAuditLog = async (id: string): Promise<AuditLog> => {
const response = await auditLogService.getById(id); const response = await auditLogService.getById(id);
@ -135,7 +182,7 @@ const AuditLogs = (): ReactElement => {
key: 'created_at', key: 'created_at',
label: 'Timestamp', label: 'Timestamp',
render: (log) => ( 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', mobileLabel: 'Time',
}, },
@ -155,21 +202,12 @@ const AuditLogs = (): ReactElement => {
</StatusBadge> </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', key: 'user',
label: 'User', label: 'User',
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#0f1724]"> <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> </span>
), ),
}, },
@ -246,7 +284,7 @@ const AuditLogs = (): ReactElement => {
<div> <div>
<span className="text-[#9aa6b2]">User:</span> <span className="text-[#9aa6b2]">User:</span>
<p className="text-[#0f1724] font-normal mt-1"> <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> </p>
</div> </div>
<div> <div>
@ -263,16 +301,6 @@ const AuditLogs = (): ReactElement => {
{log.response_status || 'N/A'} {log.response_status || 'N/A'}
</p> </p>
</div> </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>
</div> </div>
); );
@ -281,16 +309,60 @@ const AuditLogs = (): ReactElement => {
<Layout <Layout
currentPage="Audit Logs" currentPage="Audit Logs"
pageHeader={{ pageHeader={{
title: 'Audit Logs', title: isTenantAdmin ? 'System Audit Logs' : 'My Activity',
description: 'View and manage all audit logs in the QAssure platform.', description: isTenantAdmin
? 'Monitor all activities and changes across the quality platform.'
: 'View a chronological history of your own actions on the platform.',
}} }}
> >
{/* Table Container */} {/* 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"> <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 */} {/* 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"> <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 */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <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"
/>
{/* 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 */} {/* Method Filter */}
<FilterDropdown <FilterDropdown
label="Method" label="Method"
@ -299,33 +371,32 @@ const AuditLogs = (): ReactElement => {
{ value: 'POST', label: 'POST' }, { value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' }, { value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' }, { value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]} ]}
value={methodFilter} value={methodFilter}
onChange={(value) => { onChange={(value) => {
setMethodFilter(value as string | null); setMethodFilter(value as string | null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="All" placeholder="All Methods"
/> />
</>
)}
{/* Sort Filter */} {/* Sort Filter - Always show as it's useful for everyone */}
<FilterDropdown <FilterDropdown
label="Sort by" label="Sort by"
options={[ options={[
{ value: ['created_at', 'desc'], label: 'Newest First' }, { value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' }, { value: ['created_at', 'asc'], label: 'Oldest First' },
{ value: ['action', 'asc'], label: 'Action (A-Z)' }, { 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', 'asc'], label: 'Resource Type (A-Z)' },
{ value: ['resource_type', 'desc'], label: 'Resource Type (Z-A)' },
]} ]}
value={orderBy} value={orderBy}
onChange={(value) => { onChange={(value) => {
setOrderBy(value as string[] | null); setOrderBy(value as string[] | null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Default" placeholder="Newest"
showIcon showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />} icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/> />
@ -333,14 +404,60 @@ const AuditLogs = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {isTenantAdmin && (
<button <button
type="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" 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" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button>
)}
</div>
</div>
{/* 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>
</div> </div>
@ -352,7 +469,7 @@ const AuditLogs = (): ReactElement => {
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
mobileCardRenderer={mobileCardRenderer} mobileCardRenderer={mobileCardRenderer}
emptyMessage="No audit logs found" emptyMessage={isTenantAdmin ? "No audit logs found matching your criteria" : "No activity recorded for your account yet"}
/> />
{/* Pagination */} {/* Pagination */}

View File

@ -4,29 +4,76 @@ import type { AuditLogsResponse, GetAuditLogResponse } from '@/types/audit-log';
export const auditLogService = { export const auditLogService = {
getAll: async ( getAll: async (
page: number = 1, page: number = 1,
limit: number = 20, limit: number = 100,
method?: string | null, filters: {
orderBy?: string[] | null, method?: string | null;
tenantId?: 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> => { ): Promise<AuditLogsResponse> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('page', String(page)); params.append('page', String(page));
params.append('limit', String(limit)); params.append('limit', String(limit));
if (method) {
params.append('method', method); if (filters.method) params.append('method', filters.method);
} if (filters.action) params.append('action', filters.action);
if (tenantId) { if (filters.resource_type) params.append('resource_type', filters.resource_type);
params.append('tenant_id', tenantId); 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) { if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
params.append('orderBy[]', orderBy[0]); params.append('orderBy[]', orderBy[0]);
params.append('orderBy[]', orderBy[1]); params.append('orderBy[]', orderBy[1]);
} }
const response = await apiClient.get<AuditLogsResponse>(`/audit-logs?${params.toString()}`); const response = await apiClient.get<AuditLogsResponse>(`/audit-logs?${params.toString()}`);
return response.data; 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> => { getById: async (id: string): Promise<GetAuditLogResponse> => {
const response = await apiClient.get<GetAuditLogResponse>(`/audit-logs/${id}`); const response = await apiClient.get<GetAuditLogResponse>(`/audit-logs/${id}`);
return response.data; 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; metadata: Record<string, unknown> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
user_email: string | null;
user: AuditLogUser | null; user: AuditLogUser | null;
tenant: AuditLogTenant | null; tenant: AuditLogTenant | null;
} }