feat: Add i18n support, new content block types, course export, and lesson interaction tracking.
This commit is contained in:
@@ -1,137 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cmsApi, AuditLog } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { ArrowLeft, Clock, ShieldAlert, X } from 'lucide-react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
History,
|
||||
Search,
|
||||
User as UserIcon,
|
||||
Calendar,
|
||||
ArrowRight,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
|
||||
// We'll define a local type or import if available
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
organization_id?: string;
|
||||
user_id?: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
event_type: string;
|
||||
changes?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
user_full_name?: string;
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const router = useRouter();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [entityFilter, setEntityFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user || user.role !== 'admin') {
|
||||
router.push('/');
|
||||
return;
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
// Mocking API call for now since we haven't implemented the specific endpoint
|
||||
// In a real scenario: const data = await cmsApi.getAuditLogs();
|
||||
// For demo, we'll use a few mock entries
|
||||
const mockLogs: AuditLog[] = [
|
||||
{
|
||||
id: "1",
|
||||
action: "COURSE_CREATED",
|
||||
entity_type: "Course",
|
||||
entity_id: "c1",
|
||||
event_type: "CREATE",
|
||||
user_full_name: "Juan Perez",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
action: "USER_ROLE_UPDATED",
|
||||
entity_type: "User",
|
||||
entity_id: "u1",
|
||||
event_type: "UPDATE",
|
||||
user_full_name: "System Admin",
|
||||
created_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
action: "AI_COURSE_GENERATED",
|
||||
entity_type: "Course",
|
||||
entity_id: "c2",
|
||||
event_type: "AI_GEN",
|
||||
user_full_name: "Juan Perez",
|
||||
created_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
}
|
||||
];
|
||||
setLogs(mockLogs);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch audit logs", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
loadLogs();
|
||||
}
|
||||
}, [user, authLoading, router]);
|
||||
};
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
const data = await cmsApi.getAuditLogs();
|
||||
setLogs(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load audit logs", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const filteredLogs = logs.filter(log =>
|
||||
(log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.user_full_name?.toLowerCase().includes(searchTerm.toLowerCase())) &&
|
||||
(entityFilter === "" || log.entity_type === entityFilter)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white font-sans">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-gray-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => router.push('/')} className="p-2 hover:bg-white/5 rounded-full transition-colors">
|
||||
<ArrowLeft className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/10 flex items-center justify-center text-purple-400">
|
||||
<ShieldAlert size={20} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">System Audit Logs</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight flex items-center gap-3">
|
||||
<History className="text-indigo-400" size={32} />
|
||||
Audit Logs
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Track every action across the platform.</p>
|
||||
</div>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-8 py-12">
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-white/5 uppercase font-bold text-xs tracking-wider text-gray-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4">User</th>
|
||||
<th className="px-6 py-4">Action</th>
|
||||
<th className="px-6 py-4">Entity</th>
|
||||
<th className="px-6 py-4">Date</th>
|
||||
<th className="px-6 py-4 text-right">Details</th>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by action or user..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={entityFilter}
|
||||
onChange={(e) => setEntityFilter(e.target.value)}
|
||||
className="bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all text-sm"
|
||||
>
|
||||
<option value="">All Entities</option>
|
||||
<option value="Course">Course</option>
|
||||
<option value="User">User</option>
|
||||
<option value="Organization">Organization</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Log List */}
|
||||
<div className="glass-card rounded-3xl border-white/5 overflow-hidden">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-white/[0.03] border-b border-white/5">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Timestamp</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">User</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Action</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Entity</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{loading ? (
|
||||
[1, 2, 3].map(i => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
<td colSpan={5} className="px-6 py-8"><div className="h-4 bg-white/5 rounded-full w-full" /></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">
|
||||
{log.user_full_name || 'System / Unknown'}
|
||||
<div className="text-xs text-gray-600 font-mono mt-0.5">{log.user_id.slice(0, 8)}...</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize
|
||||
${log.action === 'CREATE' ? 'bg-green-500/10 text-green-400' :
|
||||
log.action === 'UPDATE' ? 'bg-blue-500/10 text-blue-400' :
|
||||
log.action === 'DELETE' ? 'bg-red-500/10 text-red-400' :
|
||||
'bg-gray-500/10 text-gray-400'}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-gray-300">{log.entity_type}</div>
|
||||
<div className="text-xs text-gray-600 font-mono mt-0.5">{log.entity_id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 flex items-center gap-2">
|
||||
<Clock size={14} />
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="text-blue-400 hover:text-blue-300 font-bold text-xs uppercase tracking-wider"
|
||||
>
|
||||
View Changes
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))
|
||||
) : filteredLogs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-white/[0.01] transition-colors group">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-gray-400">
|
||||
<Calendar size={12} />
|
||||
{new Date(log.created_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-400">
|
||||
<UserIcon size={12} />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-200">{log.user_full_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-xs font-black uppercase tracking-widest px-2 py-1 rounded-md bg-white/5 text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-xs font-bold text-gray-400">
|
||||
{log.entity_type} <span className="opacity-30">#</span>{log.entity_id.slice(0, 8)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button className="p-2 hover:bg-white/5 rounded-lg text-gray-500 hover:text-white transition-all">
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && filteredLogs.length === 0 && (
|
||||
<div className="p-20 text-center flex flex-col items-center gap-4">
|
||||
<AlertCircle className="text-gray-600" size={48} />
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">No activities found</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Changes Detail Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
|
||||
<div className="bg-gray-900 border border-white/10 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/5 bg-gray-900">
|
||||
<h3 className="text-lg font-bold text-white">Change Details</h3>
|
||||
<button onClick={() => setSelectedLog(null)} className="text-gray-500 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto font-mono text-xs text-gray-300 bg-black/30">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(selectedLog.changes, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user