initial commit: rocket.new export of broadcastbeat
This commit is contained in:
619
src/app/admin/audit-log/page.tsx
Normal file
619
src/app/admin/audit-log/page.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
performed_by: string | null;
|
||||
performed_by_email: string | null;
|
||||
affected_item_id: string | null;
|
||||
affected_item_type: string | null;
|
||||
affected_item_title: string | null;
|
||||
old_value: Record<string, unknown> | null;
|
||||
new_value: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
user_profiles?: { full_name: string; email: string } | null;
|
||||
}
|
||||
|
||||
type ActionFilter = 'all' | 'article' | 'user' | 'import';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
article_created: 'Article Created',
|
||||
article_edited: 'Article Edited',
|
||||
article_published: 'Article Published',
|
||||
article_unpublished: 'Article Unpublished',
|
||||
article_deleted: 'Article Deleted',
|
||||
article_archived: 'Article Archived',
|
||||
article_status_changed: 'Status Changed',
|
||||
user_role_changed: 'Role Changed',
|
||||
user_activated: 'User Activated',
|
||||
user_deactivated: 'User Deactivated',
|
||||
user_invited: 'User Invited',
|
||||
user_invitation_revoked: 'Invitation Revoked',
|
||||
import_completed: 'Import Completed',
|
||||
import_failed: 'Import Failed',
|
||||
};
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
article_created: 'text-green-400 bg-green-400/10',
|
||||
article_edited: 'text-blue-400 bg-blue-400/10',
|
||||
article_published: 'text-green-400 bg-green-400/10',
|
||||
article_unpublished: 'text-yellow-400 bg-yellow-400/10',
|
||||
article_deleted: 'text-red-400 bg-red-400/10',
|
||||
article_archived: 'text-[#888] bg-[#1a1a1a]',
|
||||
article_status_changed: 'text-purple-400 bg-purple-400/10',
|
||||
user_role_changed: 'text-orange-400 bg-orange-400/10',
|
||||
user_activated: 'text-green-400 bg-green-400/10',
|
||||
user_deactivated: 'text-red-400 bg-red-400/10',
|
||||
user_invited: 'text-blue-400 bg-blue-400/10',
|
||||
user_invitation_revoked: 'text-[#888] bg-[#1a1a1a]',
|
||||
import_completed: 'text-teal-400 bg-teal-400/10',
|
||||
import_failed: 'text-red-400 bg-red-400/10',
|
||||
};
|
||||
|
||||
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||
article_created: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
article_edited: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
),
|
||||
article_published: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
article_deleted: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
),
|
||||
user_role_changed: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
),
|
||||
user_invited: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
import_completed: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getActionCategory(action: string): ActionFilter {
|
||||
if (action.startsWith('article_')) return 'article';
|
||||
if (action.startsWith('user_')) return 'user';
|
||||
if (action.startsWith('import_')) return 'import';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function getDefaultIcon(action: string): React.ReactNode {
|
||||
const category = getActionCategory(action);
|
||||
if (category === 'article') return ACTION_ICONS['article_edited'];
|
||||
if (category === 'user') return ACTION_ICONS['user_role_changed'];
|
||||
if (category === 'import') return ACTION_ICONS['import_completed'];
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
accent: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent, icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-4 flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${accent}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-display font-bold text-white">{value.toLocaleString()}</p>
|
||||
<p className="text-xs text-[#888] font-body">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface DetailModalProps {
|
||||
log: AuditLog;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function DetailModal({ log, onClose }: DetailModalProps) {
|
||||
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#1a1a1a]';
|
||||
const actionLabel = ACTION_LABELS[log.action] || log.action;
|
||||
const performerName = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
|
||||
<div className="relative bg-[#111] border border-[#252525] rounded-xl w-full max-w-lg shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-[#252525]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${actionColor}`}>
|
||||
{ACTION_ICONS[log.action] || getDefaultIcon(log.action)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-display font-semibold text-sm">{actionLabel}</h3>
|
||||
<p className="text-[#555] text-xs font-body">{formatDateTime(log.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[#555] hover:text-white transition-colors p-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Performer */}
|
||||
<div>
|
||||
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">Performed By</p>
|
||||
<p className="text-white text-sm font-body">{performerName}</p>
|
||||
{log.performed_by_email && log.user_profiles?.full_name && (
|
||||
<p className="text-[#555] text-xs font-body">{log.performed_by_email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Affected Item */}
|
||||
{log.affected_item_title && (
|
||||
<div>
|
||||
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">Affected Item</p>
|
||||
<p className="text-white text-sm font-body">{log.affected_item_title}</p>
|
||||
{log.affected_item_type && (
|
||||
<p className="text-[#555] text-xs font-body capitalize">{log.affected_item_type}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes */}
|
||||
{(log.old_value || log.new_value) && (
|
||||
<div>
|
||||
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-2">Changes</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{log.old_value && (
|
||||
<div className="bg-red-400/5 border border-red-400/20 rounded-lg p-3">
|
||||
<p className="text-red-400 text-xs font-body font-semibold mb-1.5">Before</p>
|
||||
<pre className="text-[#aaa] text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(log.old_value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{log.new_value && (
|
||||
<div className="bg-green-400/5 border border-green-400/20 rounded-lg p-3">
|
||||
<p className="text-green-400 text-xs font-body font-semibold mb-1.5">After</p>
|
||||
<pre className="text-[#aaa] text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(log.new_value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||
<div>
|
||||
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">Metadata</p>
|
||||
<div className="bg-[#0d0d0d] border border-[#1e1e1e] rounded-lg p-3">
|
||||
<pre className="text-[#aaa] text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(log.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log ID */}
|
||||
<div>
|
||||
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">Log ID</p>
|
||||
<p className="text-[#444] text-xs font-mono">{log.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace('/account');
|
||||
}, [user, loading, router]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
let query = supabase
|
||||
.from('audit_logs')
|
||||
.select('*, user_profiles(full_name, email)')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
||||
|
||||
if (actionFilter !== 'all') {
|
||||
query = query.like('action', `${actionFilter}_%`);
|
||||
}
|
||||
|
||||
const { data, error: fetchError } = await query;
|
||||
if (fetchError) throw fetchError;
|
||||
setLogs(data || []);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load audit logs';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [user, actionFilter, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) fetchLogs();
|
||||
}, [user, fetchLogs]);
|
||||
|
||||
// Reset page when filter changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [actionFilter, searchQuery]);
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
(log.affected_item_title || '').toLowerCase().includes(q) ||
|
||||
(log.performed_by_email || '').toLowerCase().includes(q) ||
|
||||
(log.user_profiles?.full_name || '').toLowerCase().includes(q) ||
|
||||
(ACTION_LABELS[log.action] || log.action).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// Stats
|
||||
const articleCount = logs.filter((l) => l.action.startsWith('article_')).length;
|
||||
const userCount = logs.filter((l) => l.action.startsWith('user_')).length;
|
||||
const importCount = logs.filter((l) => l.action.startsWith('import_')).length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link href="/admin" className="text-[#555] hover:text-[#888] text-sm font-body transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
<svg className="w-3.5 h-3.5 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-[#888] text-sm font-body">Audit Log</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-display font-bold text-white">Audit Log</h1>
|
||||
<p className="text-[#555] text-sm font-body mt-0.5">Track all admin actions across the platform</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#111] border border-[#252525] rounded-lg text-[#888] hover:text-white hover:border-[#333] transition-all text-sm font-body"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
label="Total Events"
|
||||
value={logs.length}
|
||||
accent="bg-[#3b82f6]/10 text-[#3b82f6]"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Article Actions"
|
||||
value={articleCount}
|
||||
accent="bg-green-400/10 text-green-400"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="User Actions"
|
||||
value={userCount}
|
||||
accent="bg-orange-400/10 text-orange-400"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Import Events"
|
||||
value={importCount}
|
||||
accent="bg-teal-400/10 text-teal-400"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-5">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#444]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8" /><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by user, item, or action…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-[#111] border border-[#252525] rounded-lg pl-9 pr-4 py-2.5 text-sm text-white placeholder-[#444] focus:outline-none focus:border-[#3b82f6] font-body"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Type Filter */}
|
||||
<div className="flex items-center gap-1 bg-[#111] border border-[#252525] rounded-lg p-1">
|
||||
{(['all', 'article', 'user', 'import'] as ActionFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setActionFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold capitalize transition-all ${
|
||||
actionFilter === f
|
||||
? 'bg-[#3b82f6] text-white'
|
||||
: 'text-[#555] hover:text-[#888]'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All Events' : `${f.charAt(0).toUpperCase() + f.slice(1)}s`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-400/10 border border-red-400/20 rounded-lg p-4 mb-5 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" /><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01" />
|
||||
</svg>
|
||||
<p className="text-red-400 text-sm font-body">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-[#111] border border-[#252525] rounded-xl overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="hidden md:grid grid-cols-[2fr_1.5fr_1.5fr_1fr_auto] gap-4 px-5 py-3 border-b border-[#1e1e1e] bg-[#0d0d0d]">
|
||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Action</p>
|
||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Performed By</p>
|
||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Affected Item</p>
|
||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Timestamp</p>
|
||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Details</p>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loadingData && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-[#555] text-sm font-body">Loading audit logs…</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!loadingData && filteredLogs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-[#1a1a1a] flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[#555] text-sm font-body">No audit log entries found</p>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="text-[#3b82f6] text-xs font-body hover:underline">
|
||||
Clear search
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rows */}
|
||||
{!loadingData && filteredLogs.map((log, idx) => {
|
||||
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#1a1a1a]';
|
||||
const actionLabel = ACTION_LABELS[log.action] || log.action;
|
||||
const icon = ACTION_ICONS[log.action] || getDefaultIcon(log.action);
|
||||
const performerName = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`grid grid-cols-1 md:grid-cols-[2fr_1.5fr_1.5fr_1fr_auto] gap-3 md:gap-4 px-5 py-4 hover:bg-[#0d0d0d] transition-colors cursor-pointer ${
|
||||
idx !== filteredLogs.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
{/* Action */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${actionColor}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className={`text-xs font-body font-semibold px-2 py-0.5 rounded-full ${actionColor}`}>
|
||||
{actionLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Performed By */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] border border-[#252525] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[#888] text-xs font-body font-bold">
|
||||
{performerName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white text-sm font-body truncate">{performerName}</p>
|
||||
{log.performed_by_email && log.user_profiles?.full_name && (
|
||||
<p className="text-[#444] text-xs font-body truncate">{log.performed_by_email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affected Item */}
|
||||
<div className="min-w-0">
|
||||
{log.affected_item_title ? (
|
||||
<>
|
||||
<p className="text-white text-sm font-body truncate">{log.affected_item_title}</p>
|
||||
{log.affected_item_type && (
|
||||
<p className="text-[#444] text-xs font-body capitalize">{log.affected_item_type}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[#333] text-sm font-body">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className="text-[#888] text-xs font-body">{timeAgo(log.created_at)}</p>
|
||||
<p className="text-[#444] text-xs font-body hidden lg:block">
|
||||
{new Date(log.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Details Button */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedLog(log); }}
|
||||
className="text-[#444] hover:text-[#3b82f6] transition-colors p-1"
|
||||
aria-label="View details"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loadingData && filteredLogs.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-[#555] text-xs font-body">
|
||||
Showing {page * PAGE_SIZE + 1}–{page * PAGE_SIZE + filteredLogs.length} events
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1.5 bg-[#111] border border-[#252525] rounded-lg text-xs font-body text-[#888] hover:text-white hover:border-[#333] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-[#555] text-xs font-body px-2">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={filteredLogs.length < PAGE_SIZE}
|
||||
className="px-3 py-1.5 bg-[#111] border border-[#252525] rounded-lg text-xs font-body text-[#888] hover:text-white hover:border-[#333] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedLog && (
|
||||
<DetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user