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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
59
frontend/src/features/admin/hooks/useAuditLogs.ts
Normal file
59
frontend/src/features/admin/hooks/useAuditLogs.ts
Normal 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] });
|
||||
};
|
||||
}
|
||||
321
frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx
Normal file
321
frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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