591 lines
20 KiB
TypeScript
591 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import { Layout } from '@/components/layout/Layout';
|
|
import {
|
|
ViewAuditLogModal,
|
|
DataTable,
|
|
Pagination,
|
|
FilterDropdown,
|
|
StatusBadge,
|
|
type Column,
|
|
} from '@/components/shared';
|
|
import { Download, ArrowUpDown, Search } from 'lucide-react';
|
|
import { auditLogService } from '@/services/audit-log-service';
|
|
import { moduleService } from '@/services/module-service';
|
|
import type { AuditLog } from '@/types/audit-log';
|
|
import { useAppTheme } from '@/hooks/useAppTheme';
|
|
import { PrimaryButton } from '@/components/shared';
|
|
import { useAppSelector } from '@/hooks/redux-hooks';
|
|
|
|
// Helper function to format date
|
|
const formatDate = (dateString: string): string => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
// Helper function to get action badge variant
|
|
const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => {
|
|
const lowerAction = action.toLowerCase();
|
|
if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success';
|
|
if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info';
|
|
if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure';
|
|
if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process';
|
|
return 'info';
|
|
};
|
|
|
|
// Helper function to get method badge variant
|
|
const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => {
|
|
if (!method) return 'info';
|
|
const upperMethod = method.toUpperCase();
|
|
if (upperMethod === 'GET') return 'success';
|
|
if (upperMethod === 'POST') return 'info';
|
|
if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process';
|
|
if (upperMethod === 'DELETE') return 'failure';
|
|
return 'info';
|
|
};
|
|
|
|
// Helper function to get status badge color based on response status
|
|
const getStatusColor = (status: number | null): string => {
|
|
if (!status) return 'text-[#6b7280]';
|
|
if (status >= 200 && status < 300) return 'text-[#10b981]';
|
|
if (status >= 300 && status < 400) return 'text-[#f59e0b]';
|
|
if (status >= 400) return 'text-[#ef4444]';
|
|
return 'text-[#6b7280]';
|
|
};
|
|
|
|
const AuditLogs = (): ReactElement => {
|
|
const { primaryColor } = useAppTheme();
|
|
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 [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
|
|
const [modules, setModules] = useState<{ value: string; label: string }[]>([]);
|
|
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>(10);
|
|
const [pagination, setPagination] = useState<{
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasMore: boolean;
|
|
}>({
|
|
page: 1,
|
|
limit: 10,
|
|
total: 0,
|
|
totalPages: 1,
|
|
hasMore: false,
|
|
});
|
|
|
|
// Filter state
|
|
const [methodFilter, setMethodFilter] = useState<string | null>(null);
|
|
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
|
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
|
|
const [moduleIdFilter, setModuleIdFilter] = useState<string | null>(null); // New module_id filter
|
|
const [startDate, setStartDate] = useState<string>('');
|
|
const [endDate, setEndDate] = useState<string>('');
|
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
|
const [search, setSearch] = useState<string>('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
|
|
|
// View modal
|
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
|
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
|
|
|
|
const fetchResourceTypes = async (): Promise<void> => {
|
|
try {
|
|
const response = await auditLogService.getResourceTypesDropdown(tenantId as string | undefined);
|
|
if (response.success) {
|
|
const options = response.data.map((rt: any) => ({
|
|
value: rt.value,
|
|
label: rt.label
|
|
}));
|
|
setResourceTypes(options);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load resource types', err);
|
|
}
|
|
};
|
|
|
|
const fetchModules = async (): Promise<void> => {
|
|
try {
|
|
const response = await moduleService.getMyModules(tenantId as string | undefined);
|
|
if (response.success) {
|
|
const options = response.data.map((m: any) => ({
|
|
value: m.id,
|
|
label: m.name
|
|
}));
|
|
setModules(options);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load modules', err);
|
|
}
|
|
};
|
|
|
|
const fetchAuditLogs = async (): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
let response;
|
|
if (isTenantAdmin) {
|
|
response = await auditLogService.getAll(
|
|
currentPage,
|
|
limit,
|
|
{
|
|
method: methodFilter,
|
|
action: actionFilter,
|
|
resource_type: resourceTypeFilter,
|
|
module_id: moduleIdFilter, // Pass module_id filter
|
|
startDate: startDate || null,
|
|
endDate: endDate || null,
|
|
tenant_id: tenantId as string | null,
|
|
search: debouncedSearch || null
|
|
},
|
|
orderBy
|
|
);
|
|
} else {
|
|
response = await auditLogService.getMyLogs(currentPage, limit, moduleIdFilter, debouncedSearch || null);
|
|
}
|
|
|
|
if (response.success) {
|
|
setAuditLogs(response.data);
|
|
setPagination(response.pagination);
|
|
} else {
|
|
setError('Failed to load audit logs');
|
|
}
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.error?.message || 'Failed to load audit logs');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fetch resource types and modules on mount
|
|
useEffect(() => {
|
|
fetchResourceTypes();
|
|
fetchModules();
|
|
}, [tenantId]);
|
|
|
|
// Debouncing for Search
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedSearch(search);
|
|
setCurrentPage(1);
|
|
}, 500);
|
|
return () => clearTimeout(timer);
|
|
}, [search]);
|
|
|
|
// Fetch audit logs on mount and when pagination/filters change
|
|
useEffect(() => {
|
|
fetchAuditLogs();
|
|
}, [currentPage, limit, methodFilter, actionFilter, resourceTypeFilter, moduleIdFilter, startDate, endDate, orderBy, tenantId, debouncedSearch]);
|
|
|
|
// View audit log handler
|
|
const handleViewAuditLog = (auditLogId: string): void => {
|
|
setSelectedAuditLogId(auditLogId);
|
|
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);
|
|
return response.data;
|
|
};
|
|
|
|
// Define table columns
|
|
const columns: Column<AuditLog>[] = [
|
|
{
|
|
key: 'created_at',
|
|
label: 'Timestamp',
|
|
render: (log) => (
|
|
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">{formatDate(log.created_at)}</span>
|
|
),
|
|
mobileLabel: 'Time',
|
|
},
|
|
{
|
|
key: 'resource_type',
|
|
label: 'Resource Type',
|
|
render: (log) => (
|
|
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'module',
|
|
label: 'Module',
|
|
render: (log) => (
|
|
<span className="text-sm font-normal text-[#475569]">
|
|
{log.module?.name || 'Platform'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'action',
|
|
label: 'Action',
|
|
render: (log) => (
|
|
<StatusBadge variant={getActionVariant(log.action)}>
|
|
{log.action}
|
|
</StatusBadge>
|
|
),
|
|
},
|
|
...(isTenantAdmin ? [{
|
|
key: 'user',
|
|
label: 'User',
|
|
render: (log: AuditLog) => (
|
|
<span className="text-sm font-normal text-[#0f1724]">
|
|
{log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')}
|
|
</span>
|
|
),
|
|
}] : []),
|
|
{
|
|
key: 'request_method',
|
|
label: 'Method',
|
|
render: (log) => (
|
|
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
|
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
|
|
</StatusBadge>
|
|
),
|
|
},
|
|
{
|
|
key: 'response_status',
|
|
label: 'Status',
|
|
render: (log) => (
|
|
<span className={`text-sm font-normal ${getStatusColor(log.response_status)}`}>
|
|
{log.response_status || 'N/A'}
|
|
</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',
|
|
align: 'right',
|
|
render: (log) => (
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewAuditLog(log.id)}
|
|
className="text-sm font-medium transition-colors hover:opacity-80"
|
|
style={{ color: primaryColor }}
|
|
>
|
|
View
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
// Mobile card renderer
|
|
const mobileCardRenderer = (log: AuditLog) => (
|
|
<div className="p-4">
|
|
<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>
|
|
<div className="mt-1">
|
|
<StatusBadge variant={getActionVariant(log.action)}>
|
|
{log.action}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewAuditLog(log.id)}
|
|
className="text-sm font-medium transition-colors shrink-0 hover:opacity-80"
|
|
style={{ color: primaryColor }}
|
|
>
|
|
View
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Timestamp:</span>
|
|
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Module:</span>
|
|
<p className="text-[#0f1724] font-normal mt-1">{log.module?.name || 'Platform'}</p>
|
|
</div>
|
|
<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}` : (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>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Audit Logs"
|
|
pageHeader={{
|
|
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 gap-4">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
{/* Search and Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Global Search */}
|
|
<div className="relative w-full md:w-64">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search logs & metadata..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all bg-gray-50/30"
|
|
onFocus={(e) => {
|
|
e.currentTarget.style.borderColor = primaryColor;
|
|
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
|
}}
|
|
onBlur={(e) => {
|
|
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{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 Type"
|
|
options={resourceTypes}
|
|
value={resourceTypeFilter}
|
|
onChange={(value) => {
|
|
setResourceTypeFilter(value as string | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="All Resources"
|
|
isSearchable
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Module Filter */}
|
|
<FilterDropdown
|
|
label="Module"
|
|
options={modules}
|
|
value={moduleIdFilter}
|
|
onChange={(value) => {
|
|
setModuleIdFilter(value as string | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="All Modules"
|
|
/>
|
|
|
|
{/* 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 && (
|
|
<PrimaryButton
|
|
onClick={handleExport}
|
|
size="small"
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Export</span>
|
|
</PrimaryButton>
|
|
)}
|
|
</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 || moduleIdFilter || search) && (
|
|
<button
|
|
onClick={() => {
|
|
setStartDate('');
|
|
setEndDate('');
|
|
setActionFilter(null);
|
|
setResourceTypeFilter(null);
|
|
setMethodFilter(null);
|
|
setModuleIdFilter(null);
|
|
setSearch('');
|
|
setCurrentPage(1);
|
|
}}
|
|
className="text-xs hover:underline decoration-offset-2"
|
|
style={{ color: primaryColor }}
|
|
>
|
|
Clear all filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<DataTable
|
|
columns={columns}
|
|
data={auditLogs}
|
|
keyExtractor={(log) => log.id}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
mobileCardRenderer={mobileCardRenderer}
|
|
emptyMessage={isTenantAdmin ? "No audit logs found matching your criteria" : "No activity recorded for your account yet"}
|
|
/>
|
|
|
|
{/* Pagination */}
|
|
{pagination.total > 0 && (
|
|
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
|
|
<Pagination
|
|
currentPage={pagination.page}
|
|
totalPages={pagination.totalPages}
|
|
totalItems={pagination.total}
|
|
limit={limit}
|
|
onPageChange={(page: number) => setCurrentPage(page)}
|
|
onLimitChange={(newLimit: number) => {
|
|
setLimit(newLimit);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* View Audit Log Modal */}
|
|
<ViewAuditLogModal
|
|
isOpen={viewModalOpen}
|
|
onClose={() => {
|
|
setViewModalOpen(false);
|
|
setSelectedAuditLogId(null);
|
|
}}
|
|
auditLogId={selectedAuditLogId}
|
|
onLoadAuditLog={loadAuditLog}
|
|
/>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default AuditLogs;
|