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

@@ -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>
);
};