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

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

View 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] });
};
}

View 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;

View File

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