Added Audit Page

This commit is contained in:
Oliver Kieselbach
2026-02-08 14:25:20 +01:00
parent 289e5ccda8
commit 7bb05ff4fe
2 changed files with 362 additions and 0 deletions
@@ -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">