Qassure-frontend/src/pages/tenant/AuditLogs.tsx

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;