Files
motovaultpro/frontend/src/pages/admin/AdminCatalogPage.tsx
Eric Gullickson c98211f4a2
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
feat: Implement centralized audit logging admin interface (refs #10)
- 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>
2026-01-11 11:09:09 -06:00

671 lines
21 KiB
TypeScript

/**
* @ai-summary Admin Vehicle Catalog page with search-first UI
* @ai-context Uses server-side search and pagination instead of loading all data
*/
import React, { useState, useCallback, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import {
Box,
Button,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
InputAdornment,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
Alert,
Collapse,
} from '@mui/material';
import {
Search,
Delete,
FileDownload,
FileUpload,
Clear,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import {
useCatalogSearch,
useExportCatalog,
useImportPreview,
useImportApply,
} from '../../features/admin/hooks/useCatalog';
import { adminApi } from '../../features/admin/api/admin.api';
import {
AdminSectionHeader,
} from '../../features/admin/components';
import {
CatalogSearchResult,
ImportPreviewResult,
ImportApplyResult,
} from '../../features/admin/types/admin.types';
const PAGE_SIZE_OPTIONS = [25, 50, 100];
export const AdminCatalogPage: React.FC = () => {
const { loading: authLoading, isAdmin } = useAdminAccess();
// Search state
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(50);
// Selection state
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CatalogSearchResult | null>(null);
const [deleting, setDeleting] = useState(false);
// Import state
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
const [errorsExpanded, setErrorsExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Hooks
const { data: searchData, isLoading: searchLoading, refetch } = useCatalogSearch(
searchQuery,
page + 1, // API uses 1-based pages
pageSize
);
const exportMutation = useExportCatalog();
const importPreviewMutation = useImportPreview();
const importApplyMutation = useImportApply();
// Search handlers
const handleSearch = useCallback(() => {
setPage(0);
setSelectedIds(new Set());
setSearchQuery(searchInput);
}, [searchInput]);
const handleSearchKeyPress = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
const handleClearSearch = useCallback(() => {
setSearchInput('');
setSearchQuery('');
setPage(0);
setSelectedIds(new Set());
}, []);
// Pagination handlers
const handlePageChange = useCallback((_: unknown, newPage: number) => {
setPage(newPage);
setSelectedIds(new Set());
}, []);
const handlePageSizeChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
setSelectedIds(new Set());
},
[]
);
// Selection handlers
const handleSelectAll = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked && searchData?.items) {
setSelectedIds(new Set(searchData.items.map((item) => item.id)));
} else {
setSelectedIds(new Set());
}
},
[searchData]
);
const handleSelectRow = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Delete handlers
const handleDeleteClick = useCallback((row: CatalogSearchResult) => {
setDeleteTarget(row);
setDeleteDialogOpen(true);
}, []);
const handleBulkDeleteClick = useCallback(() => {
setDeleteTarget(null);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
setDeleting(true);
try {
if (deleteTarget) {
// Single delete - delete from vehicle_options
await adminApi.deleteEngine(deleteTarget.id.toString());
toast.success('Configuration deleted');
} else {
// Bulk delete
const idsToDelete = Array.from(selectedIds);
await Promise.all(
idsToDelete.map((id) => adminApi.deleteEngine(id.toString()))
);
toast.success(`${idsToDelete.length} configurations deleted`);
setSelectedIds(new Set());
}
setDeleteDialogOpen(false);
setDeleteTarget(null);
refetch();
} catch (error) {
toast.error('Failed to delete configuration(s)');
} finally {
setDeleting(false);
}
}, [deleteTarget, selectedIds, refetch]);
// Import handlers
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const result = await importPreviewMutation.mutateAsync(file);
setImportPreview(result);
setImportDialogOpen(true);
} catch (error) {
// Error is handled by mutation
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[importPreviewMutation]
);
const handleImportConfirm = useCallback(async () => {
if (!importPreview?.previewId) return;
try {
const result = await importApplyMutation.mutateAsync(importPreview.previewId);
setImportResult(result);
if (result.errors.length > 0) {
toast.error(
`Import completed with ${result.errors.length} error(s): ${result.created} created, ${result.updated} updated`
);
// Keep dialog open for error review
} else {
toast.success(
`Import completed successfully: ${result.created} created, ${result.updated} updated`
);
// Auto-close on complete success
setImportDialogOpen(false);
setImportPreview(null);
setImportResult(null);
}
refetch();
} catch (error) {
// Error is handled by mutation's onError
}
}, [importPreview, importApplyMutation, refetch]);
const handleImportDialogClose = useCallback(() => {
if (importApplyMutation.isPending) return;
setImportDialogOpen(false);
setImportPreview(null);
setImportResult(null);
setErrorsExpanded(false);
}, [importApplyMutation.isPending]);
// Export handler
const handleExport = useCallback(() => {
exportMutation.mutate();
}, [exportMutation]);
// Auth loading
if (authLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
}}
>
<CircularProgress />
</Box>
);
}
// Not admin
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
const items = searchData?.items || [];
const total = searchData?.total || 0;
const hasResults = searchQuery.length > 0 && items.length > 0;
const noResults = searchQuery.length > 0 && items.length === 0 && !searchLoading;
return (
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminSectionHeader
title="Vehicle Catalog"
stats={[{ label: 'Total Results', value: total }]}
/>
{/* Search and Actions Bar */}
<Paper elevation={1} sx={{ p: 2, borderRadius: 1.5 }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 2,
alignItems: { xs: 'stretch', md: 'center' },
}}
>
{/* Search Input */}
<TextField
placeholder="Search vehicles (e.g., 2024 Toyota Camry)"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyPress={handleSearchKeyPress}
fullWidth
sx={{ flex: 1 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search color="action" />
</InputAdornment>
),
endAdornment: searchInput && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
{/* Search Button */}
<Button
variant="contained"
onClick={handleSearch}
disabled={searchLoading || !searchInput.trim()}
sx={{ textTransform: 'none', minWidth: 100 }}
>
{searchLoading ? <CircularProgress size={20} /> : 'Search'}
</Button>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Import CSV">
<Button
variant="outlined"
onClick={handleImportClick}
startIcon={<FileUpload />}
sx={{ textTransform: 'none' }}
>
Import
</Button>
</Tooltip>
<Tooltip title="Export CSV">
<Button
variant="outlined"
onClick={handleExport}
disabled={exportMutation.isPending}
startIcon={<FileDownload />}
sx={{ textTransform: 'none' }}
>
Export
</Button>
</Tooltip>
</Box>
</Box>
{/* Hidden file input for import */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".csv"
style={{ display: 'none' }}
/>
</Paper>
{/* Instructions when no search */}
{!searchQuery && (
<Paper elevation={1} sx={{ p: 4, borderRadius: 1.5, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Search for Vehicles
</Typography>
<Typography variant="body2" color="text.secondary">
Enter a search term like "2024 Toyota Camry" or "Honda Civic" to find vehicles in the
catalog.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Use Import/Export buttons to manage catalog data in bulk via CSV files.
</Typography>
</Paper>
)}
{/* No Results */}
{noResults && (
<Paper elevation={1} sx={{ p: 4, borderRadius: 1.5, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
No Results Found
</Typography>
<Typography variant="body2" color="text.secondary">
No vehicles match "{searchQuery}". Try a different search term.
</Typography>
</Paper>
)}
{/* Results Table */}
{hasResults && (
<Paper elevation={1} sx={{ borderRadius: 1.5 }}>
{/* Bulk Actions */}
{selectedIds.size > 0 && (
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Button
variant="outlined"
color="error"
startIcon={<Delete />}
onClick={handleBulkDeleteClick}
sx={{ textTransform: 'none' }}
>
Delete Selected ({selectedIds.size})
</Button>
</Box>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedIds.size > 0 && selectedIds.size < items.length}
checked={selectedIds.size === items.length && items.length > 0}
onChange={handleSelectAll}
size="small"
/>
</TableCell>
<TableCell>Year</TableCell>
<TableCell>Make</TableCell>
<TableCell>Model</TableCell>
<TableCell>Trim</TableCell>
<TableCell>Engine</TableCell>
<TableCell>Transmission</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((row) => (
<TableRow
key={row.id}
hover
selected={selectedIds.has(row.id)}
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedIds.has(row.id)}
onChange={() => handleSelectRow(row.id)}
size="small"
/>
</TableCell>
<TableCell>{row.year}</TableCell>
<TableCell>{row.make}</TableCell>
<TableCell>{row.model}</TableCell>
<TableCell>{row.trim}</TableCell>
<TableCell>{row.engineName || '-'}</TableCell>
<TableCell>{row.transmissionType || '-'}</TableCell>
<TableCell align="right">
<Tooltip title="Delete">
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(row)}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handlePageChange}
rowsPerPage={pageSize}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={PAGE_SIZE_OPTIONS}
/>
</Paper>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleting && setDeleteDialogOpen(false)}>
<DialogTitle>
{deleteTarget
? 'Delete Configuration'
: `Delete ${selectedIds.size} Configuration${selectedIds.size > 1 ? 's' : ''}`}
</DialogTitle>
<DialogContent>
{deleteTarget ? (
<Typography>
Are you sure you want to delete the configuration for{' '}
<strong>
{deleteTarget.year} {deleteTarget.make} {deleteTarget.model} {deleteTarget.trim}
</strong>
?
</Typography>
) : (
<Typography>
Are you sure you want to delete {selectedIds.size} selected configuration
{selectedIds.size > 1 ? 's' : ''}?
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
sx={{ textTransform: 'none' }}
>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
disabled={deleting}
color="error"
variant="contained"
sx={{ textTransform: 'none' }}
>
{deleting ? <CircularProgress size={20} /> : 'Delete'}
</Button>
</DialogActions>
</Dialog>
{/* Import Preview/Results Dialog */}
<Dialog
open={importDialogOpen}
onClose={handleImportDialogClose}
maxWidth="md"
fullWidth
>
<DialogTitle>
{importResult ? 'Import Results' : 'Import Preview'}
</DialogTitle>
<DialogContent>
{/* Preview Mode */}
{importPreview && !importResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Box sx={{ display: 'flex', gap: 3 }}>
<Typography>
<strong>To Create:</strong> {importPreview.toCreate.length}
</Typography>
<Typography>
<strong>To Update:</strong> {importPreview.toUpdate.length}
</Typography>
</Box>
{importPreview.errors.length > 0 && (
<Alert severity="error">
<Typography variant="subtitle2" gutterBottom>
{importPreview.errors.length} Error(s) Found:
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{importPreview.errors.slice(0, 10).map((err, idx) => (
<li key={idx}>
Row {err.row}: {err.error}
</li>
))}
{importPreview.errors.length > 10 && (
<li>...and {importPreview.errors.length - 10} more errors</li>
)}
</Box>
</Alert>
)}
{importPreview.valid ? (
<Alert severity="success">
The import file is valid and ready to be applied.
</Alert>
) : (
<Alert severity="warning">
Please fix the errors above before importing.
</Alert>
)}
</Box>
)}
{/* Results Mode */}
{importResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Box sx={{ display: 'flex', gap: 3 }}>
<Typography>
<strong>Created:</strong> {importResult.created}
</Typography>
<Typography>
<strong>Updated:</strong> {importResult.updated}
</Typography>
</Box>
{importResult.errors.length > 0 && (
<Box sx={{ border: 1, borderColor: 'error.main', borderRadius: 1 }}>
<Box
onClick={() => setErrorsExpanded(!errorsExpanded)}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bgcolor: 'error.light',
cursor: 'pointer',
'&:hover': { bgcolor: 'error.main', color: 'white' },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{importResult.errors.length} Error(s) Occurred
</Typography>
<IconButton size="small" sx={{ color: 'inherit' }}>
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Collapse in={errorsExpanded}>
<Box sx={{ maxHeight: 400, overflow: 'auto', p: 2, bgcolor: 'background.paper' }}>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{importResult.errors.map((err, idx) => (
<Typography
component="li"
key={idx}
variant="body2"
sx={{ mb: 1, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
<strong>Row {err.row}:</strong> {err.error}
</Typography>
))}
</Box>
</Box>
</Collapse>
</Box>
)}
{importResult.errors.length === 0 && (
<Alert severity="success">
Import completed successfully with no errors.
</Alert>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button
onClick={handleImportDialogClose}
disabled={importApplyMutation.isPending}
sx={{ textTransform: 'none' }}
>
{importResult ? 'Close' : 'Cancel'}
</Button>
{!importResult && (
<Button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
variant="contained"
sx={{ textTransform: 'none' }}
>
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
</Button>
)}
</DialogActions>
</Dialog>
</Box>
);
};