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>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* @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;
|