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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user