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
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:
@@ -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>
|
||||
|
||||
401
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
401
frontend/src/pages/admin/AdminLogsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user