diff --git a/src/Web/autopilot-monitor-web/app/audit/page.tsx b/src/Web/autopilot-monitor-web/app/audit/page.tsx new file mode 100644 index 00000000..ebea6459 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/audit/page.tsx @@ -0,0 +1,350 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTenant } from '../../contexts/TenantContext'; +import { useAuth } from '../../contexts/AuthContext'; +import { ProtectedRoute } from '../../components/ProtectedRoute'; +import { API_BASE_URL } from '@/lib/config'; + +interface AuditLogEntry { + id: string; + tenantId: string; + action: string; + entityType: string; + entityId: string; + performedBy: string; + timestamp: string; + details: string; +} + +type ActionFilter = 'ALL' | 'DELETE' | 'UPDATE' | 'CREATE'; +type EntityTypeFilter = string; + +export default function AuditPage() { + const router = useRouter(); + const { tenantId } = useTenant(); + const { getAccessToken } = useAuth(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [actionFilter, setActionFilter] = useState('ALL'); + const [entityTypeFilter, setEntityTypeFilter] = useState('ALL'); + const [expandedRow, setExpandedRow] = useState(null); + + const fetchAuditLogs = async (showRefreshing = false) => { + try { + if (showRefreshing) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/audit/logs`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch audit logs: ${response.statusText}`); + } + + const data = await response.json(); + if (data.success) { + setLogs(data.logs || []); + } else { + throw new Error(data.message || 'Failed to fetch audit logs'); + } + } catch (err) { + console.error('Error fetching audit logs:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch audit logs'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchAuditLogs(); + }, []); + + const entityTypes = ['ALL', ...Array.from(new Set(logs.map(l => l.entityType).filter(Boolean)))]; + + const filteredLogs = logs.filter(log => { + if (actionFilter !== 'ALL' && log.action !== actionFilter) return false; + if (entityTypeFilter !== 'ALL' && log.entityType !== entityTypeFilter) return false; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + return ( + log.performedBy?.toLowerCase().includes(q) || + log.entityId?.toLowerCase().includes(q) || + log.entityType?.toLowerCase().includes(q) || + log.action?.toLowerCase().includes(q) || + log.details?.toLowerCase().includes(q) + ); + } + return true; + }); + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleString(); + }; + + const getActionBadge = (action: string) => { + switch (action?.toUpperCase()) { + case 'DELETE': + return 'bg-red-100 text-red-800'; + case 'CREATE': + return 'bg-green-100 text-green-800'; + case 'UPDATE': + return 'bg-blue-100 text-blue-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const parseDetails = (details: string): Record | null => { + if (!details) return null; + try { + return JSON.parse(details); + } catch { + return null; + } + }; + + if (loading) { + return ( +
+
+
+

Loading audit logs...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +

Error Loading Audit Logs

+

{error}

+
+ + +
+
+
+
+ ); + } + + return ( + +
+
+ {/* Header */} +
+
+
+ +
+

Audit Log

+

+ {filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'} + {filteredLogs.length !== logs.length && ` (of ${logs.length} total)`} +

+
+
+ +
+
+ + {/* Filters */} +
+ {/* Search */} +
+ + + + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* Action Filter */} + + + {/* Entity Type Filter */} + +
+ + {/* Audit Table */} +
+ {filteredLogs.length === 0 ? ( +
+ + + +

No audit log entries found

+ {(searchQuery || actionFilter !== 'ALL' || entityTypeFilter !== 'ALL') && ( +

Try adjusting your filters

+ )} +
+ ) : ( +
+ + + + + + + + + + + + + {filteredLogs.map((log) => { + const details = parseDetails(log.details); + const isExpanded = expandedRow === log.id; + + return ( + setExpandedRow(isExpanded ? null : log.id)} + > + + + + + + + + ); + })} + +
+ Timestamp + + Action + + Entity Type + + Entity ID + + Performed By + + Details +
+ {formatTimestamp(log.timestamp)} + + + {log.action} + + + {log.entityType} + + {log.entityId} + + {log.performedBy} + + {details ? ( +
+ + {isExpanded && ( +
+ {Object.entries(details).map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+ )} +
+ ) : ( + - + )} +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index b79cd26e..6e863086 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -306,6 +306,18 @@ export default function Navbar() { + {/* Audit Log */} +
+ setShowSettings(false)}> +
+ Audit Log + + + +
+ +
+ {/* Admin Configuration - Galactic Admin Only */} {galacticAdminMode && (