733 lines
35 KiB
TypeScript
733 lines
35 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import Link from 'next/link';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface RolePermission {
|
|
id: string;
|
|
role: string;
|
|
can_publish_articles: boolean;
|
|
can_edit_articles: boolean;
|
|
can_delete_articles: boolean;
|
|
can_manage_comments: boolean;
|
|
can_moderate_forum: boolean;
|
|
can_pin_threads: boolean;
|
|
can_lock_threads: boolean;
|
|
can_flag_content: boolean;
|
|
can_view_analytics: boolean;
|
|
can_manage_newsletter: boolean;
|
|
can_invite_users: boolean;
|
|
can_manage_users: boolean;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface UserProfile {
|
|
id: string;
|
|
email: string;
|
|
full_name: string;
|
|
role: string;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
avatar_url: string | null;
|
|
}
|
|
|
|
interface ActivityLog {
|
|
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; role: string } | null;
|
|
}
|
|
|
|
type Tab = 'assign' | 'permissions' | 'activity';
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const ROLES = ['admin', 'editor', 'moderator', 'contributor', 'reader'];
|
|
|
|
const ROLE_COLORS: Record<string, string> = {
|
|
admin: 'text-red-400 bg-red-400/10 border-red-400/20',
|
|
editor: 'text-purple-400 bg-purple-400/10 border-purple-400/20',
|
|
moderator: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
|
contributor: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
|
reader: 'text-[#888] bg-[#1a1a1a] border-[#333]',
|
|
};
|
|
|
|
const ROLE_ICONS: Record<string, React.ReactNode> = {
|
|
admin: (
|
|
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
),
|
|
editor: (
|
|
<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>
|
|
),
|
|
moderator: (
|
|
<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 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
|
</svg>
|
|
),
|
|
contributor: (
|
|
<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>
|
|
),
|
|
reader: (
|
|
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
const PERMISSION_GROUPS = [
|
|
{
|
|
label: 'Content',
|
|
permissions: [
|
|
{ key: 'can_publish_articles', label: 'Publish Articles' },
|
|
{ key: 'can_edit_articles', label: 'Edit Articles' },
|
|
{ key: 'can_delete_articles', label: 'Delete Articles' },
|
|
{ key: 'can_manage_comments', label: 'Manage Comments' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Forum',
|
|
permissions: [
|
|
{ key: 'can_moderate_forum', label: 'Moderate Forum' },
|
|
{ key: 'can_pin_threads', label: 'Pin Threads' },
|
|
{ key: 'can_lock_threads', label: 'Lock Threads' },
|
|
{ key: 'can_flag_content', label: 'Flag Content' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Platform',
|
|
permissions: [
|
|
{ key: 'can_view_analytics', label: 'View Analytics' },
|
|
{ key: 'can_manage_newsletter', label: 'Manage Newsletter' },
|
|
{ key: 'can_invite_users', label: 'Invite Users' },
|
|
{ key: 'can_manage_users', label: 'Manage Users' },
|
|
],
|
|
},
|
|
];
|
|
|
|
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',
|
|
role_assigned: 'Role Assigned',
|
|
role_permission_updated: 'Permissions Updated',
|
|
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> = {
|
|
user_role_changed: 'text-orange-400 bg-orange-400/10',
|
|
role_assigned: 'text-blue-400 bg-blue-400/10',
|
|
role_permission_updated: 'text-purple-400 bg-purple-400/10',
|
|
article_published: 'text-green-400 bg-green-400/10',
|
|
article_deleted: 'text-red-400 bg-red-400/10',
|
|
user_invited: 'text-blue-400 bg-blue-400/10',
|
|
user_deactivated: 'text-red-400 bg-red-400/10',
|
|
user_activated: 'text-green-400 bg-green-400/10',
|
|
};
|
|
|
|
// ─── 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 getInitials(name: string): string {
|
|
return name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
}
|
|
|
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
function RoleBadge({ role }: { role: string }) {
|
|
const color = ROLE_COLORS[role] || ROLE_COLORS['reader'];
|
|
const icon = ROLE_ICONS[role] || ROLE_ICONS['reader'];
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-body font-semibold border ${color}`}>
|
|
{icon}
|
|
<span className="capitalize">{role}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
export default function AdminRolesPage() {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>('assign');
|
|
const [permissions, setPermissions] = useState<RolePermission[]>([]);
|
|
const [users, setUsers] = useState<UserProfile[]>([]);
|
|
const [activityLogs, setActivityLogs] = useState<ActivityLog[]>([]);
|
|
const [loadingData, setLoadingData] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedRole, setSelectedRole] = useState('all');
|
|
const [activityRoleFilter, setActivityRoleFilter] = useState('all');
|
|
const [editingRole, setEditingRole] = useState<string | null>(null);
|
|
const [editedPermissions, setEditedPermissions] = useState<Partial<RolePermission>>({});
|
|
const [savingPermissions, setSavingPermissions] = useState(false);
|
|
const [processingUserId, setProcessingUserId] = useState<string | null>(null);
|
|
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) router.replace('/account');
|
|
}, [user, loading, router]);
|
|
|
|
const showNotification = (type: 'success' | 'error', message: string) => {
|
|
setNotification({ type, message });
|
|
setTimeout(() => setNotification(null), 4000);
|
|
};
|
|
|
|
const fetchPermissions = useCallback(async () => {
|
|
const res = await fetch('/api/admin/roles?type=permissions');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPermissions(data.permissions || []);
|
|
}
|
|
}, []);
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
const res = await fetch('/api/admin/roles?type=users');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setUsers(data.users || []);
|
|
}
|
|
}, []);
|
|
|
|
const fetchActivity = useCallback(async () => {
|
|
const res = await fetch(`/api/admin/roles?type=activity&role=${activityRoleFilter}&limit=60`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setActivityLogs(data.logs || []);
|
|
}
|
|
}, [activityRoleFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
setLoadingData(true);
|
|
Promise.all([fetchPermissions(), fetchUsers()]).finally(() => setLoadingData(false));
|
|
}, [user, fetchPermissions, fetchUsers]);
|
|
|
|
useEffect(() => {
|
|
if (user && activeTab === 'activity') fetchActivity();
|
|
}, [user, activeTab, fetchActivity]);
|
|
|
|
const handleAssignRole = async (userId: string, newRole: string) => {
|
|
setProcessingUserId(userId);
|
|
try {
|
|
const res = await fetch('/api/admin/roles', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId, newRole }),
|
|
});
|
|
if (res.ok) {
|
|
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: newRole } : u));
|
|
showNotification('success', 'Role updated successfully');
|
|
} else {
|
|
showNotification('error', 'Failed to update role');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Failed to update role');
|
|
} finally {
|
|
setProcessingUserId(null);
|
|
}
|
|
};
|
|
|
|
const startEditPermissions = (role: string) => {
|
|
const perm = permissions.find((p) => p.role === role);
|
|
if (perm) {
|
|
setEditingRole(role);
|
|
setEditedPermissions({ ...perm });
|
|
}
|
|
};
|
|
|
|
const handleSavePermissions = async () => {
|
|
if (!editingRole) return;
|
|
setSavingPermissions(true);
|
|
try {
|
|
const res = await fetch('/api/admin/roles', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role: editingRole, permissions: editedPermissions }),
|
|
});
|
|
if (res.ok) {
|
|
setPermissions((prev) =>
|
|
prev.map((p) => p.role === editingRole ? { ...p, ...editedPermissions } : p)
|
|
);
|
|
showNotification('success', `Permissions for ${editingRole} updated`);
|
|
setEditingRole(null);
|
|
setEditedPermissions({});
|
|
} else {
|
|
showNotification('error', 'Failed to save permissions');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Failed to save permissions');
|
|
} finally {
|
|
setSavingPermissions(false);
|
|
}
|
|
};
|
|
|
|
const filteredUsers = users.filter((u) => {
|
|
const matchSearch =
|
|
!searchQuery ||
|
|
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
u.full_name.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchRole = selectedRole === 'all' || u.role === selectedRole;
|
|
return matchSearch && matchRole;
|
|
});
|
|
|
|
const roleCounts = ROLES.reduce((acc, r) => {
|
|
acc[r] = users.filter((u) => u.role === r).length;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
if (loading || loadingData) {
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-[#555] text-sm font-body">Loading roles panel…</p>
|
|
</div>
|
|
</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="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/admin" className="text-[#555] hover:text-white transition-colors">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-display font-bold text-white">Roles & Permissions</h1>
|
|
<p className="text-[#555] text-sm font-body mt-0.5">Assign roles, manage permissions, and view role-based activity</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notification */}
|
|
{notification && (
|
|
<div className={`mb-6 rounded-lg px-4 py-3 flex items-center gap-3 border ${
|
|
notification.type === 'success' ?'bg-green-500/10 border-green-500/30 text-green-400' :'bg-red-500/10 border-red-500/30 text-red-400'
|
|
}`}>
|
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
{notification.type === 'success'
|
|
? <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
: <><circle cx="12" cy="12" r="10" /><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01" /></>
|
|
}
|
|
</svg>
|
|
<p className="text-sm font-body">{notification.message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Role Summary Cards */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-8">
|
|
{ROLES.map((role) => {
|
|
const color = ROLE_COLORS[role] || ROLE_COLORS['reader'];
|
|
return (
|
|
<button
|
|
key={role}
|
|
onClick={() => { setSelectedRole(role === selectedRole ? 'all' : role); setActiveTab('assign'); }}
|
|
className={`bg-[#111] border rounded-lg p-4 text-left hover:border-[#333] transition-all ${
|
|
selectedRole === role ? 'border-[#3b82f6] bg-[#0d1a2d]' : 'border-[#252525]'
|
|
}`}
|
|
>
|
|
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-body font-semibold border mb-2 ${color}`}>
|
|
{ROLE_ICONS[role]}
|
|
<span className="capitalize">{role}</span>
|
|
</div>
|
|
<p className="text-xl font-display font-bold text-white">{roleCounts[role] || 0}</p>
|
|
<p className="text-xs text-[#555] font-body">users</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mb-6 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit">
|
|
{([
|
|
{ id: 'assign', label: 'Assign Roles', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
|
{ id: 'permissions', label: 'Permissions', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
|
{ id: 'activity', label: 'Activity Logs', icon: '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' },
|
|
] as { id: Tab; label: string; icon: string }[]).map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-body font-medium transition-all ${
|
|
activeTab === tab.id
|
|
? 'bg-[#3b82f6] text-white'
|
|
: 'text-[#888] hover:text-white'
|
|
}`}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d={tab.icon} />
|
|
</svg>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Tab: Assign Roles ── */}
|
|
{activeTab === 'assign' && (
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
|
{/* Toolbar */}
|
|
<div className="px-5 py-4 border-b border-[#1a1a1a] flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
placeholder="Search users…"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-[#0d0d0d] border border-[#252525] rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder-[#555] font-body focus:outline-none focus:border-[#3b82f6]"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={selectedRole}
|
|
onChange={(e) => setSelectedRole(e.target.value)}
|
|
className="bg-[#0d0d0d] border border-[#252525] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#3b82f6]"
|
|
>
|
|
<option value="all">All Roles</option>
|
|
{ROLES.map((r) => (
|
|
<option key={r} value={r} className="capitalize">{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* User Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-[#1a1a1a]">
|
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">User</th>
|
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">Current Role</th>
|
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider hidden sm:table-cell">Status</th>
|
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider hidden md:table-cell">Joined</th>
|
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">Assign Role</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#1a1a1a]">
|
|
{filteredUsers.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-5 py-10 text-center text-[#555] text-sm font-body">
|
|
No users found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredUsers.map((u) => (
|
|
<tr key={u.id} className="hover:bg-[#0d0d0d] transition-colors">
|
|
<td className="px-5 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0 text-xs font-body font-bold text-[#888]">
|
|
{u.avatar_url
|
|
? <img src={u.avatar_url} alt={u.full_name} className="w-8 h-8 rounded-full object-cover" />
|
|
: getInitials(u.full_name || u.email)
|
|
}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-white font-body font-medium truncate">{u.full_name || '—'}</p>
|
|
<p className="text-xs text-[#555] font-body truncate">{u.email}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<RoleBadge role={u.role} />
|
|
</td>
|
|
<td className="px-5 py-3 hidden sm:table-cell">
|
|
<span className={`text-xs font-body font-semibold px-2 py-0.5 rounded-full ${
|
|
u.is_active ? 'text-green-400 bg-green-400/10' : 'text-[#555] bg-[#1a1a1a]'
|
|
}`}>
|
|
{u.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3 hidden md:table-cell">
|
|
<span className="text-xs text-[#555] font-body">
|
|
{new Date(u.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
defaultValue={u.role}
|
|
onChange={(e) => handleAssignRole(u.id, e.target.value)}
|
|
disabled={processingUserId === u.id}
|
|
className="bg-[#0d0d0d] border border-[#252525] rounded-lg px-2 py-1.5 text-xs text-white font-body focus:outline-none focus:border-[#3b82f6] disabled:opacity-50"
|
|
>
|
|
{ROLES.map((r) => (
|
|
<option key={r} value={r} className="capitalize">{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
{processingUserId === u.id && (
|
|
<div className="w-4 h-4 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filteredUsers.length > 0 && (
|
|
<div className="px-5 py-3 border-t border-[#1a1a1a]">
|
|
<p className="text-xs text-[#555] font-body">{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} shown</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Tab: Permissions ── */}
|
|
{activeTab === 'permissions' && (
|
|
<div className="space-y-4">
|
|
{permissions.map((perm) => {
|
|
const isEditing = editingRole === perm.role;
|
|
const current = isEditing ? editedPermissions : perm;
|
|
const color = ROLE_COLORS[perm.role] || ROLE_COLORS['reader'];
|
|
|
|
return (
|
|
<div key={perm.role} className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
|
{/* Role Header */}
|
|
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-body font-semibold border ${color}`}>
|
|
{ROLE_ICONS[perm.role]}
|
|
<span className="capitalize">{perm.role}</span>
|
|
</div>
|
|
<span className="text-xs text-[#555] font-body">{roleCounts[perm.role] || 0} users</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isEditing ? (
|
|
<>
|
|
<button
|
|
onClick={() => { setEditingRole(null); setEditedPermissions({}); }}
|
|
className="px-3 py-1.5 text-xs font-body text-[#888] hover:text-white border border-[#333] rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSavePermissions}
|
|
disabled={savingPermissions}
|
|
className="px-3 py-1.5 text-xs font-body font-semibold bg-[#3b82f6] hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
|
>
|
|
{savingPermissions && <div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />}
|
|
Save Changes
|
|
</button>
|
|
</>
|
|
) : (
|
|
perm.role !== 'admin' && (
|
|
<button
|
|
onClick={() => startEditPermissions(perm.role)}
|
|
className="px-3 py-1.5 text-xs font-body text-[#888] hover:text-white border border-[#333] hover:border-[#555] rounded-lg transition-colors flex items-center gap-1.5"
|
|
>
|
|
<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>
|
|
Edit
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Permission Groups */}
|
|
<div className="p-5 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
|
{PERMISSION_GROUPS.map((group) => (
|
|
<div key={group.label}>
|
|
<p className="text-xs font-body font-bold text-[#555] uppercase tracking-wider mb-3">{group.label}</p>
|
|
<div className="space-y-2">
|
|
{group.permissions.map(({ key, label }) => {
|
|
const val = (current as Record<string, unknown>)[key] as boolean;
|
|
return (
|
|
<div key={key} className="flex items-center justify-between gap-2">
|
|
<span className="text-sm text-[#aaa] font-body">{label}</span>
|
|
{isEditing ? (
|
|
<button
|
|
onClick={() => setEditedPermissions((prev) => ({ ...prev, [key]: !val }))}
|
|
className={`relative w-9 h-5 rounded-full transition-colors ${val ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}
|
|
>
|
|
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${val ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
) : (
|
|
<span className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${val ? 'bg-green-400/10' : 'bg-[#1a1a1a]'}`}>
|
|
{val ? (
|
|
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" strokeWidth="3" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3 h-3 text-[#444]" fill="none" stroke="currentColor" strokeWidth="3" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{perm.role === 'admin' && (
|
|
<div className="px-5 pb-4">
|
|
<p className="text-xs text-[#555] font-body italic">Admin role has all permissions and cannot be modified.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Tab: Activity Logs ── */}
|
|
{activeTab === 'activity' && (
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
|
{/* Toolbar */}
|
|
<div className="px-5 py-4 border-b border-[#1a1a1a] flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
|
<h2 className="text-sm font-display font-bold text-white">Role-Based Activity</h2>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[#555] font-body">Filter by role:</span>
|
|
<div className="flex gap-1 flex-wrap">
|
|
{['all', ...ROLES].map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => setActivityRoleFilter(r)}
|
|
className={`px-2.5 py-1 rounded-md text-xs font-body font-medium transition-colors capitalize ${
|
|
activityRoleFilter === r
|
|
? 'bg-[#3b82f6] text-white'
|
|
: 'bg-[#1a1a1a] text-[#888] hover:text-white'
|
|
}`}
|
|
>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log List */}
|
|
<div className="divide-y divide-[#1a1a1a]">
|
|
{activityLogs.length === 0 ? (
|
|
<div className="px-5 py-12 text-center">
|
|
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" 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>
|
|
<p className="text-[#555] text-sm font-body">No activity logs found for this filter</p>
|
|
</div>
|
|
) : (
|
|
activityLogs.map((log) => {
|
|
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#1a1a1a]';
|
|
const actionLabel = ACTION_LABELS[log.action] || log.action.replace(/_/g, ' ');
|
|
const performer = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
|
const performerRole = log.user_profiles?.role;
|
|
|
|
return (
|
|
<div key={log.id} className="px-5 py-3.5 flex items-start gap-4 hover:bg-[#0d0d0d] transition-colors">
|
|
{/* Action badge */}
|
|
<div className={`mt-0.5 px-2 py-0.5 rounded text-xs font-body font-semibold flex-shrink-0 ${actionColor}`}>
|
|
{actionLabel}
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm text-white font-body font-medium">{performer}</span>
|
|
{performerRole && <RoleBadge role={performerRole} />}
|
|
</div>
|
|
{log.affected_item_title && (
|
|
<p className="text-xs text-[#555] font-body mt-0.5 truncate">
|
|
{log.affected_item_type && <span className="capitalize">{log.affected_item_type}: </span>}
|
|
{log.affected_item_title}
|
|
</p>
|
|
)}
|
|
{(log.old_value || log.new_value) && (
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
{log.old_value && (
|
|
<span className="text-xs text-red-400 bg-red-400/5 border border-red-400/20 px-1.5 py-0.5 rounded font-mono">
|
|
{Object.entries(log.old_value).map(([k, v]) => `${k}: ${v}`).join(', ')}
|
|
</span>
|
|
)}
|
|
{log.old_value && log.new_value && (
|
|
<svg className="w-3 h-3 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
)}
|
|
{log.new_value && (
|
|
<span className="text-xs text-green-400 bg-green-400/5 border border-green-400/20 px-1.5 py-0.5 rounded font-mono">
|
|
{Object.entries(log.new_value).map(([k, v]) => `${k}: ${v}`).join(', ')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Time */}
|
|
<span className="text-xs text-[#444] font-body flex-shrink-0 mt-0.5">{timeAgo(log.created_at)}</span>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{activityLogs.length > 0 && (
|
|
<div className="px-5 py-3 border-t border-[#1a1a1a]">
|
|
<p className="text-xs text-[#555] font-body">{activityLogs.length} log entries shown</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|