mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Added Audit Page
This commit is contained in:
@@ -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<AuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState<ActionFilter>('ALL');
|
||||
const [entityTypeFilter, setEntityTypeFilter] = useState<EntityTypeFilter>('ALL');
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(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<string, string> | null => {
|
||||
if (!details) return null;
|
||||
try {
|
||||
return JSON.parse(details);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading audit logs...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-md w-full">
|
||||
<div className="text-center">
|
||||
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Error Loading Audit Logs</h2>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">{error}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
<button
|
||||
onClick={() => fetchAuditLogs()}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="p-2 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Back to Home"
|
||||
>
|
||||
<svg className="h-6 w-6 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Audit Log</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'}
|
||||
{filteredLogs.length !== logs.length && ` (of ${logs.length} total)`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchAuditLogs(true)}
|
||||
disabled={refreshing}
|
||||
className="px-4 py-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{refreshing ? 'Refreshing...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by user, entity, action or details..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Filter */}
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="ALL">All Actions</option>
|
||||
<option value="CREATE">Create</option>
|
||||
<option value="UPDATE">Update</option>
|
||||
<option value="DELETE">Delete</option>
|
||||
</select>
|
||||
|
||||
{/* Entity Type Filter */}
|
||||
<select
|
||||
value={entityTypeFilter}
|
||||
onChange={(e) => setEntityTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{entityTypes.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{type === 'ALL' ? 'All Entity Types' : type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Audit Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<svg className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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-gray-500 dark:text-gray-400 text-lg">No audit log entries found</p>
|
||||
{(searchQuery || actionFilter !== 'ALL' || entityTypeFilter !== 'ALL') && (
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">Try adjusting your filters</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-750">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Entity Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Entity ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Performed By
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredLogs.map((log) => {
|
||||
const details = parseDetails(log.details);
|
||||
const isExpanded = expandedRow === log.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer"
|
||||
onClick={() => setExpandedRow(isExpanded ? null : log.id)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getActionBadge(log.action)}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{log.entityType}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 max-w-[200px] truncate" title={log.entityId}>
|
||||
{log.entityId}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{log.performedBy}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{details ? (
|
||||
<div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedRow(isExpanded ? null : log.id);
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-xs font-medium"
|
||||
>
|
||||
{isExpanded ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg text-xs space-y-1">
|
||||
{Object.entries(details).map(([key, value]) => (
|
||||
<div key={key} className="flex">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-300 mr-2 min-w-[100px]">{key}:</span>
|
||||
<span className="text-gray-800 dark:text-gray-200 break-all">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -306,6 +306,18 @@ export default function Navbar() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Audit Log */}
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<Link href="/audit" className="block w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors" onClick={() => setShowSettings(false)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Audit Log</span>
|
||||
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Admin Configuration - Galactic Admin Only */}
|
||||
{galacticAdminMode && (
|
||||
<div className="border-t border-purple-200 pt-3">
|
||||
|
||||
Reference in New Issue
Block a user