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

391 lines
13 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 } from 'lucide-react';
import { auditLogService } from '@/services/audit-log-service';
import type { AuditLog } from '@/types/audit-log';
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 tenantId = useAppSelector((state) => state.auth.tenantId);
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 [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
const [methodFilter, setMethodFilter] = useState<string | null>(null);
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> => {
try {
setIsLoading(true);
setError(null);
const response = await auditLogService.getAll(page, itemsPerPage, method, sortBy, tenantId);
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 audit logs on mount and when pagination/filters change
useEffect(() => {
if (tenantId) {
fetchAuditLogs(currentPage, limit, methodFilter, orderBy);
}
}, [currentPage, limit, methodFilter, orderBy, tenantId]);
// View audit log handler
const handleViewAuditLog = (auditLogId: string): void => {
setSelectedAuditLogId(auditLogId);
setViewModalOpen(true);
};
// 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]">{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: 'action',
label: 'Action',
render: (log) => (
<StatusBadge variant={getActionVariant(log.action)}>
{log.action}
</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'}
</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 text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
>
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 text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
>
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]">User:</span>
<p className="text-[#0f1724] font-normal mt-1">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : '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>
);
return (
<Layout
currentPage="Audit Logs"
pageHeader={{
title: 'Audit Logs',
description: 'View and manage all audit logs in the QAssure 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"
/>
{/* 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]" />}
/>
</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>
</div>
</div>
{/* Table */}
<DataTable
columns={columns}
data={auditLogs}
keyExtractor={(log) => log.id}
isLoading={isLoading}
error={error}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No audit logs found"
/>
{/* 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;