feat: Implement centralized audit logging admin interface (refs #10)
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s

- Add audit_logs table with categories, severities, and indexes
- Create AuditLogService and AuditLogRepository
- Add REST API endpoints for viewing and exporting logs
- Wire audit logging into auth, vehicles, admin, and backup features
- Add desktop AdminLogsPage with filters and CSV export
- Add mobile AdminLogsMobileScreen with card layout
- Implement 90-day retention cleanup job
- Remove old AuditLogPanel from AdminCatalogPage

Security fixes:
- Escape LIKE special characters to prevent pattern injection
- Limit CSV export to 5000 records to prevent memory exhaustion
- Add truncation warning headers for large exports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 11:09:09 -06:00
parent 8c7de98a9a
commit c98211f4a2
30 changed files with 2897 additions and 11 deletions

View File

@@ -34,12 +34,14 @@ const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m
const AdminCatalogPage = lazy(() => import('./pages/admin/AdminCatalogPage').then(m => ({ default: m.AdminCatalogPage })));
const AdminEmailTemplatesPage = lazy(() => import('./pages/admin/AdminEmailTemplatesPage').then(m => ({ default: m.AdminEmailTemplatesPage })));
const AdminBackupPage = lazy(() => import('./pages/admin/AdminBackupPage').then(m => ({ default: m.AdminBackupPage })));
const AdminLogsPage = lazy(() => import('./pages/admin/AdminLogsPage').then(m => ({ default: m.AdminLogsPage })));
// Admin mobile screens (lazy-loaded)
const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminUsersMobileScreen').then(m => ({ default: m.AdminUsersMobileScreen })));
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobile/AdminEmailTemplatesMobileScreen'));
const AdminBackupMobileScreen = lazy(() => import('./features/admin/mobile/AdminBackupMobileScreen'));
const AdminLogsMobileScreen = lazy(() => import('./features/admin/mobile/AdminLogsMobileScreen'));
// Admin Community Stations (lazy-loaded)
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
@@ -919,6 +921,31 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "AdminLogs" && (
<motion.div
key="admin-logs"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="AdminLogs">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
Loading audit logs...
</div>
</div>
</GlassCard>
</div>
}>
<AdminLogsMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
@@ -990,6 +1017,7 @@ function App() {
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
<Route path="/garage/settings/admin/backup" element={<AdminBackupPage />} />
<Route path="/garage/settings/admin/logs" element={<AdminLogsPage />} />
<Route path="*" element={<Navigate to="/garage/dashboard" replace />} />
</Routes>
</RouteSuspense>

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { safeStorage } from '../utils/safe-storage';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {

View File

@@ -49,6 +49,9 @@ import {
UpdateScheduleRequest,
RestorePreviewResponse,
ExecuteRestoreRequest,
// Unified Audit Log types
UnifiedAuditLogsResponse,
AuditLogFilters,
} from '../types/admin.types';
export interface AuditLogsResponse {
@@ -507,4 +510,36 @@ export const adminApi = {
},
},
},
// Unified Audit Logs (new centralized audit system)
unifiedAuditLogs: {
list: async (filters: AuditLogFilters = {}): Promise<UnifiedAuditLogsResponse> => {
const params: Record<string, string | number> = {};
if (filters.search) params.search = filters.search;
if (filters.category) params.category = filters.category;
if (filters.severity) params.severity = filters.severity;
if (filters.startDate) params.startDate = filters.startDate;
if (filters.endDate) params.endDate = filters.endDate;
if (filters.limit) params.limit = filters.limit;
if (filters.offset) params.offset = filters.offset;
const response = await apiClient.get<UnifiedAuditLogsResponse>('/admin/audit-logs', { params });
return response.data;
},
export: async (filters: AuditLogFilters = {}): Promise<Blob> => {
const params: Record<string, string | number> = {};
if (filters.search) params.search = filters.search;
if (filters.category) params.category = filters.category;
if (filters.severity) params.severity = filters.severity;
if (filters.startDate) params.startDate = filters.startDate;
if (filters.endDate) params.endDate = filters.endDate;
const response = await apiClient.get('/admin/audit-logs/export', {
params,
responseType: 'blob',
});
return response.data;
},
},
};

View File

@@ -0,0 +1,59 @@
/**
* @ai-summary React Query hooks for unified audit log management
* @ai-context Handles fetching, filtering, and exporting audit logs
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../api/admin.api';
import { AuditLogFilters, UnifiedAuditLogsResponse } from '../types/admin.types';
const AUDIT_LOGS_KEY = 'unifiedAuditLogs';
/**
* Hook to fetch unified audit logs with filtering and pagination
*/
export function useUnifiedAuditLogs(filters: AuditLogFilters = {}) {
return useQuery<UnifiedAuditLogsResponse, Error>({
queryKey: [AUDIT_LOGS_KEY, filters],
queryFn: () => adminApi.unifiedAuditLogs.list(filters),
staleTime: 30000, // 30 seconds
});
}
/**
* Hook to export audit logs as CSV
*/
export function useExportAuditLogs() {
return useMutation({
mutationFn: async (filters: AuditLogFilters) => {
const blob = await adminApi.unifiedAuditLogs.export(filters);
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Generate filename with current date
const date = new Date().toISOString().split('T')[0];
link.download = `audit-logs-${date}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return true;
},
});
}
/**
* Hook to invalidate audit logs cache (useful after actions that create logs)
*/
export function useInvalidateAuditLogs() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: [AUDIT_LOGS_KEY] });
};
}

View File

@@ -0,0 +1,321 @@
/**
* @ai-summary Mobile screen for viewing centralized audit logs
* @ai-context Touch-friendly card layout with collapsible filters
*/
import React, { useState, useCallback } from 'react';
import dayjs from 'dayjs';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { useUnifiedAuditLogs, useExportAuditLogs } from '../hooks/useAuditLogs';
import {
AuditLogCategory,
AuditLogSeverity,
AuditLogFilters,
UnifiedAuditLog,
} from '../types/admin.types';
// Helper to format date
const formatDate = (dateString: string): string => {
return dayjs(dateString).format('MMM DD HH:mm');
};
// Severity colors for badges
const severityColors: Record<AuditLogSeverity, string> = {
info: 'bg-blue-100 text-blue-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
};
// Category colors for badges
const categoryColors: Record<AuditLogCategory, string> = {
auth: 'bg-purple-100 text-purple-800',
vehicle: 'bg-green-100 text-green-800',
user: 'bg-indigo-100 text-indigo-800',
system: 'bg-gray-100 text-gray-800',
admin: 'bg-orange-100 text-orange-800',
};
const categoryLabels: Record<AuditLogCategory, string> = {
auth: 'Auth',
vehicle: 'Vehicle',
user: 'User',
system: 'System',
admin: 'Admin',
};
const AdminLogsMobileScreen: React.FC = () => {
// Filter state
const [showFilters, setShowFilters] = useState(false);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<AuditLogCategory | ''>('');
const [severity, setSeverity] = useState<AuditLogSeverity | ''>('');
const [page, setPage] = useState(0);
const pageSize = 20;
// Build filters object
const filters: AuditLogFilters = {
...(search && { search }),
...(category && { category }),
...(severity && { severity }),
limit: pageSize,
offset: page * pageSize,
};
// Query
const { data, isLoading, error, refetch } = useUnifiedAuditLogs(filters);
const exportMutation = useExportAuditLogs();
// Handlers
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setPage(0);
}, []);
const handleCategoryChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setCategory(e.target.value as AuditLogCategory | '');
setPage(0);
}, []);
const handleSeverityChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setSeverity(e.target.value as AuditLogSeverity | '');
setPage(0);
}, []);
const handleClearFilters = useCallback(() => {
setSearch('');
setCategory('');
setSeverity('');
setPage(0);
}, []);
const handleExport = useCallback(() => {
const exportFilters: AuditLogFilters = {
...(search && { search }),
...(category && { category }),
...(severity && { severity }),
};
exportMutation.mutate(exportFilters);
}, [search, category, severity, exportMutation]);
const handleNextPage = useCallback(() => {
if (data && (page + 1) * pageSize < data.total) {
setPage(p => p + 1);
}
}, [data, page, pageSize]);
const handlePrevPage = useCallback(() => {
if (page > 0) {
setPage(p => p - 1);
}
}, [page]);
const hasActiveFilters = search || category || severity;
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
return (
<div className="space-y-4 pb-20">
{/* Header */}
<GlassCard>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-slate-800">Admin Logs</h1>
<button
onClick={() => setShowFilters(!showFilters)}
className="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors"
aria-label="Toggle filters"
>
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</button>
</div>
<p className="text-sm text-slate-500">
View audit logs across all system activities
</p>
</div>
</GlassCard>
{/* Collapsible Filters */}
{showFilters && (
<GlassCard>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Filters</span>
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="text-xs text-blue-600 hover:text-blue-800"
>
Clear All
</button>
)}
</div>
{/* Search */}
<input
type="text"
placeholder="Search actions..."
value={search}
onChange={handleSearchChange}
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Category & Severity Row */}
<div className="flex gap-2">
<select
value={category}
onChange={handleCategoryChange}
className="flex-1 px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="">All Categories</option>
<option value="auth">Authentication</option>
<option value="vehicle">Vehicle</option>
<option value="user">User</option>
<option value="system">System</option>
<option value="admin">Admin</option>
</select>
<select
value={severity}
onChange={handleSeverityChange}
className="flex-1 px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="">All Severities</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={exportMutation.isPending}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{exportMutation.isPending ? (
<span className="animate-spin">...</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
)}
Export CSV
</button>
</div>
</GlassCard>
)}
{/* Error State */}
{error && (
<GlassCard>
<div className="p-4 text-center">
<p className="text-red-600 text-sm mb-2">Failed to load audit logs</p>
<button
onClick={() => refetch()}
className="text-sm text-blue-600 hover:text-blue-800"
>
Try Again
</button>
</div>
</GlassCard>
)}
{/* Loading State */}
{isLoading && (
<GlassCard>
<div className="p-6 text-center">
<div className="animate-pulse text-slate-500">Loading logs...</div>
</div>
</GlassCard>
)}
{/* Empty State */}
{!isLoading && data?.logs.length === 0 && (
<GlassCard>
<div className="p-6 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-slate-500 text-sm">No audit logs found</p>
</div>
</GlassCard>
)}
{/* Log Cards */}
{!isLoading && data?.logs.map((log: UnifiedAuditLog) => (
<GlassCard key={log.id}>
<div className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-2">
<div className="flex flex-wrap gap-1.5">
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${categoryColors[log.category]}`}>
{categoryLabels[log.category]}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${severityColors[log.severity]}`}>
{log.severity}
</span>
</div>
<span className="text-xs text-slate-400">
{formatDate(log.createdAt)}
</span>
</div>
{/* Action */}
<p className="text-sm text-slate-800 mb-2 line-clamp-2">
{log.action}
</p>
{/* Metadata */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
{log.userId && (
<span className="truncate max-w-[150px]">
User: {log.userId.substring(0, 16)}...
</span>
)}
{log.resourceType && log.resourceId && (
<span className="truncate max-w-[150px]">
{log.resourceType}: {log.resourceId.substring(0, 10)}...
</span>
)}
</div>
</div>
</GlassCard>
))}
{/* Pagination */}
{!isLoading && data && data.total > pageSize && (
<GlassCard>
<div className="p-3 flex items-center justify-between">
<button
onClick={handlePrevPage}
disabled={page === 0}
className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-slate-500">
Page {page + 1} of {totalPages}
</span>
<button
onClick={handleNextPage}
disabled={(page + 1) * pageSize >= data.total}
className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</GlassCard>
)}
{/* Total Count */}
{!isLoading && data && (
<div className="text-center text-xs text-slate-400">
{data.total} total log{data.total !== 1 ? 's' : ''}
</div>
)}
</div>
);
};
export default AdminLogsMobileScreen;

View File

@@ -375,3 +375,39 @@ export interface RestorePreviewResponse {
export interface ExecuteRestoreRequest {
createSafetyBackup?: boolean;
}
// ============================================
// Unified Audit Log types (new centralized audit system)
// ============================================
export type AuditLogCategory = 'auth' | 'vehicle' | 'user' | 'system' | 'admin';
export type AuditLogSeverity = 'info' | 'warning' | 'error';
export interface UnifiedAuditLog {
id: string;
category: AuditLogCategory;
severity: AuditLogSeverity;
userId: string | null;
action: string;
resourceType: string | null;
resourceId: string | null;
details: Record<string, unknown> | null;
createdAt: string;
}
export interface UnifiedAuditLogsResponse {
logs: UnifiedAuditLog[];
total: number;
limit: number;
offset: number;
}
export interface AuditLogFilters {
search?: string;
category?: AuditLogCategory;
severity?: AuditLogSeverity;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}

View File

@@ -50,7 +50,6 @@ import {
import { adminApi } from '../../features/admin/api/admin.api';
import {
AdminSectionHeader,
AuditLogPanel,
} from '../../features/admin/components';
import {
CatalogSearchResult,
@@ -489,9 +488,6 @@ export const AdminCatalogPage: React.FC = () => {
</Paper>
)}
{/* Audit Log */}
<AuditLogPanel resourceType="catalog" />
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleting && setDeleteDialogOpen(false)}>
<DialogTitle>

View File

@@ -0,0 +1,401 @@
/**
* @ai-summary Admin Logs page for viewing centralized audit logs
* @ai-context Desktop version with search, filters, and CSV export
*/
import React, { useState, useCallback } from 'react';
import { Navigate } from 'react-router-dom';
import dayjs from 'dayjs';
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Paper,
InputAdornment,
Stack,
} from '@mui/material';
import {
Search,
Download,
FilterList,
Clear,
} from '@mui/icons-material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import { useUnifiedAuditLogs, useExportAuditLogs } from '../../features/admin/hooks/useAuditLogs';
import {
AuditLogCategory,
AuditLogSeverity,
AuditLogFilters,
UnifiedAuditLog,
} from '../../features/admin/types/admin.types';
// Helper to format date
const formatDate = (dateString: string): string => {
return dayjs(dateString).format('MMM DD, YYYY HH:mm:ss');
};
// Severity chip colors
const severityColors: Record<AuditLogSeverity, 'info' | 'warning' | 'error'> = {
info: 'info',
warning: 'warning',
error: 'error',
};
// Category labels for display
const categoryLabels: Record<AuditLogCategory, string> = {
auth: 'Authentication',
vehicle: 'Vehicle',
user: 'User',
system: 'System',
admin: 'Admin',
};
export const AdminLogsPage: React.FC = () => {
const { loading: authLoading, isAdmin } = useAdminAccess();
// Filter state
const [search, setSearch] = useState('');
const [category, setCategory] = useState<AuditLogCategory | ''>('');
const [severity, setSeverity] = useState<AuditLogSeverity | ''>('');
const [startDate, setStartDate] = useState<dayjs.Dayjs | null>(null);
const [endDate, setEndDate] = useState<dayjs.Dayjs | null>(null);
// Pagination state
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
// Build filters object
const filters: AuditLogFilters = {
...(search && { search }),
...(category && { category }),
...(severity && { severity }),
...(startDate && { startDate: startDate.toISOString() }),
...(endDate && { endDate: endDate.toISOString() }),
limit: rowsPerPage,
offset: page * rowsPerPage,
};
// Query
const { data, isLoading, error } = useUnifiedAuditLogs(filters);
const exportMutation = useExportAuditLogs();
// Handlers
const handleSearch = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSearch(event.target.value);
setPage(0); // Reset to first page
}, []);
const handleCategoryChange = useCallback((event: { target: { value: string } }) => {
setCategory(event.target.value as AuditLogCategory | '');
setPage(0);
}, []);
const handleSeverityChange = useCallback((event: { target: { value: string } }) => {
setSeverity(event.target.value as AuditLogSeverity | '');
setPage(0);
}, []);
const handleStartDateChange = useCallback((date: dayjs.Dayjs | null) => {
setStartDate(date);
setPage(0);
}, []);
const handleEndDateChange = useCallback((date: dayjs.Dayjs | null) => {
setEndDate(date);
setPage(0);
}, []);
const handleClearFilters = useCallback(() => {
setSearch('');
setCategory('');
setSeverity('');
setStartDate(null);
setEndDate(null);
setPage(0);
}, []);
const handleExport = useCallback(() => {
const exportFilters: AuditLogFilters = {
...(search && { search }),
...(category && { category }),
...(severity && { severity }),
...(startDate && { startDate: startDate.toISOString() }),
...(endDate && { endDate: endDate.toISOString() }),
};
exportMutation.mutate(exportFilters);
}, [search, category, severity, startDate, endDate, exportMutation]);
const handleChangePage = useCallback((_: unknown, newPage: number) => {
setPage(newPage);
}, []);
const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
}, []);
// Loading state
if (authLoading) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
</Container>
);
}
// Redirect non-admins
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
const hasActiveFilters = search || category || severity || startDate || endDate;
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" gutterBottom>
Admin Logs
</Typography>
<Typography variant="body2" color="text.secondary">
View and search centralized audit logs across all system activities
</Typography>
</Box>
{/* Filters */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
<FilterList color="action" />
<Typography variant="subtitle1">Filters</Typography>
{hasActiveFilters && (
<Button
size="small"
startIcon={<Clear />}
onClick={handleClearFilters}
>
Clear All
</Button>
)}
</Stack>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(5, 1fr)' },
gap: 2,
}}
>
{/* Search */}
<TextField
label="Search"
placeholder="Search actions..."
value={search}
onChange={handleSearch}
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
{/* Category */}
<FormControl size="small">
<InputLabel>Category</InputLabel>
<Select
value={category}
onChange={handleCategoryChange}
label="Category"
>
<MenuItem value="">All Categories</MenuItem>
<MenuItem value="auth">Authentication</MenuItem>
<MenuItem value="vehicle">Vehicle</MenuItem>
<MenuItem value="user">User</MenuItem>
<MenuItem value="system">System</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
</Select>
</FormControl>
{/* Severity */}
<FormControl size="small">
<InputLabel>Severity</InputLabel>
<Select
value={severity}
onChange={handleSeverityChange}
label="Severity"
>
<MenuItem value="">All Severities</MenuItem>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warning">Warning</MenuItem>
<MenuItem value="error">Error</MenuItem>
</Select>
</FormControl>
{/* Start Date */}
<DatePicker
label="Start Date"
value={startDate}
onChange={handleStartDateChange}
slotProps={{ textField: { size: 'small' } }}
/>
{/* End Date */}
<DatePicker
label="End Date"
value={endDate}
onChange={handleEndDateChange}
slotProps={{ textField: { size: 'small' } }}
/>
</Box>
{/* Export Button */}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="outlined"
startIcon={exportMutation.isPending ? <CircularProgress size={16} /> : <Download />}
onClick={handleExport}
disabled={exportMutation.isPending}
>
Export CSV
</Button>
</Box>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card sx={{ mb: 3, bgcolor: 'error.light' }}>
<CardContent>
<Typography color="error">
Failed to load audit logs: {error.message}
</Typography>
</CardContent>
</Card>
)}
{/* Logs Table */}
<Card>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Timestamp</TableCell>
<TableCell>Category</TableCell>
<TableCell>Severity</TableCell>
<TableCell>User</TableCell>
<TableCell>Action</TableCell>
<TableCell>Resource</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<CircularProgress size={24} />
<Typography variant="body2" sx={{ mt: 1 }}>
Loading logs...
</Typography>
</TableCell>
</TableRow>
) : data?.logs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
No audit logs found
</Typography>
</TableCell>
</TableRow>
) : (
data?.logs.map((log: UnifiedAuditLog) => (
<TableRow key={log.id} hover>
<TableCell sx={{ whiteSpace: 'nowrap' }}>
{formatDate(log.createdAt)}
</TableCell>
<TableCell>
<Chip
label={categoryLabels[log.category]}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>
<Chip
label={log.severity}
size="small"
color={severityColors[log.severity]}
/>
</TableCell>
<TableCell>
{log.userId ? (
<Typography variant="body2" sx={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{log.userId.substring(0, 20)}...
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
System
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{log.action}
</Typography>
</TableCell>
<TableCell>
{log.resourceType && log.resourceId ? (
<Typography variant="body2" color="text.secondary">
{log.resourceType}: {log.resourceId.substring(0, 12)}...
</Typography>
) : (
<Typography variant="body2" color="text.disabled">
-
</Typography>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* Pagination */}
<TablePagination
component="div"
count={data?.total || 0}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
</Card>
</Container>
</LocalizationProvider>
);
};