'use client'; import React, { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; import { useAuth } from '@/contexts/AuthContext'; import { useRouter } from 'next/navigation'; interface UserProfile { id: string; email: string; full_name: string; avatar_url: string | null; role: string; is_active: boolean; created_at: string; suspension_status: string | null; suspended_until: string | null; } interface Invitation { id: string; email: string; role: string; status: string; message: string | null; expires_at: string; accepted_at: string | null; created_at: string; } type Tab = 'users' | 'invitations'; const ROLE_OPTIONS = [ { value: 'reader', label: 'Reader', color: 'text-[#888] bg-[#1a1a1a]' }, { value: 'contributor', label: 'Contributor', color: 'text-blue-400 bg-blue-400/10' }, { value: 'editor', label: 'Editor', color: 'text-purple-400 bg-purple-400/10' }, { value: 'admin', label: 'Admin', color: 'text-red-400 bg-red-400/10' }, ]; const ROLE_COLORS: Record = { reader: 'text-[#888] bg-[#1a1a1a]', contributor: 'text-blue-400 bg-blue-400/10', editor: 'text-purple-400 bg-purple-400/10', admin: 'text-red-400 bg-red-400/10', }; const STATUS_COLORS: Record = { pending: 'text-yellow-400 bg-yellow-400/10', accepted: 'text-green-400 bg-green-400/10', revoked: 'text-[#555] bg-[#1a1a1a]', expired: 'text-orange-400 bg-orange-400/10', email_failed: 'text-red-400 bg-red-400/10', }; export default function AdminUsersPage() { const { user, loading } = useAuth(); const router = useRouter(); const [activeTab, setActiveTab] = useState('users'); const [users, setUsers] = useState([]); const [invitations, setInvitations] = useState([]); const [loadingData, setLoadingData] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState('all'); const [processingIds, setProcessingIds] = useState>(new Set()); const [notification, setNotification] = useState<{ type: 'success' | 'error' | 'warning'; message: string } | null>(null); // Invite modal state const [showInviteModal, setShowInviteModal] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState('contributor'); const [inviteMessage, setInviteMessage] = useState(''); const [inviteSending, setInviteSending] = useState(false); // Edit role modal state const [editingUser, setEditingUser] = useState(null); const [editRole, setEditRole] = useState(''); const [editSaving, setEditSaving] = useState(false); useEffect(() => { if (!loading && !user) router.replace('/account'); }, [user, loading, router]); const showNotification = (type: 'success' | 'error' | 'warning', message: string) => { setNotification({ type, message }); setTimeout(() => setNotification(null), 4000); }; const fetchData = useCallback(async () => { setLoadingData(true); try { const res = await fetch(`/api/admin/users?tab=${activeTab}`); if (res.ok) { const data = await res.json(); if (activeTab === 'users') setUsers(data.users || []); else setInvitations(data.invitations || []); } } catch { // silent } finally { setLoadingData(false); } }, [activeTab]); useEffect(() => { if (user) fetchData(); }, [user, fetchData]); const filteredUsers = users.filter((u) => { const matchSearch = !searchQuery || u.email.toLowerCase().includes(searchQuery.toLowerCase()) || u.full_name.toLowerCase().includes(searchQuery.toLowerCase()); const matchRole = roleFilter === 'all' || u.role === roleFilter; return matchSearch && matchRole; }); const filteredInvitations = invitations.filter((inv) => { return ( !searchQuery || inv.email.toLowerCase().includes(searchQuery.toLowerCase()) ); }); const handleRoleChange = async (userId: string, newRole: string) => { setProcessingIds((prev) => new Set(prev).add(userId)); try { const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: userId, updates: { role: newRole } }), }); if (res.ok) { setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: newRole } : u)); showNotification('success', 'Role updated successfully'); setEditingUser(null); } else { showNotification('error', 'Failed to update role'); } } catch { showNotification('error', 'Failed to update role'); } finally { setProcessingIds((prev) => { const s = new Set(prev); s.delete(userId); return s; }); } }; const handleToggleActive = async (u: UserProfile) => { setProcessingIds((prev) => new Set(prev).add(u.id)); try { const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: u.id, updates: { is_active: !u.is_active } }), }); if (res.ok) { setUsers((prev) => prev.map((p) => p.id === u.id ? { ...p, is_active: !u.is_active } : p)); showNotification('success', `User ${!u.is_active ? 'activated' : 'deactivated'}`); } else { showNotification('error', 'Failed to update user status'); } } catch { showNotification('error', 'Failed to update user status'); } finally { setProcessingIds((prev) => { const s = new Set(prev); s.delete(u.id); return s; }); } }; const handleSendInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!inviteEmail.trim()) return; setInviteSending(true); try { const res = await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole, message: inviteMessage.trim() || undefined }), }); const data = await res.json(); if (res.ok) { if (data.warning) { showNotification('warning', data.warning); } else { showNotification('success', `Invitation sent to ${inviteEmail}`); } setShowInviteModal(false); setInviteEmail(''); setInviteRole('contributor'); setInviteMessage(''); if (activeTab === 'invitations') fetchData(); else setActiveTab('invitations'); } else { showNotification('error', data.error || 'Failed to send invitation'); } } catch { showNotification('error', 'Failed to send invitation'); } finally { setInviteSending(false); } }; const handleRevokeInvitation = async (invitationId: string) => { if (!confirm('Revoke this invitation? The invite link will no longer work.')) return; setProcessingIds((prev) => new Set(prev).add(invitationId)); try { const res = await fetch('/api/admin/users', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ invitationId }), }); if (res.ok) { setInvitations((prev) => prev.map((inv) => inv.id === invitationId ? { ...inv, status: 'revoked' } : inv)); showNotification('success', 'Invitation revoked'); } else { showNotification('error', 'Failed to revoke invitation'); } } catch { showNotification('error', 'Failed to revoke invitation'); } finally { setProcessingIds((prev) => { const s = new Set(prev); s.delete(invitationId); return s; }); } }; const openEditRole = (u: UserProfile) => { setEditingUser(u); setEditRole(u.role); }; const handleEditSave = async () => { if (!editingUser) return; setEditSaving(true); await handleRoleChange(editingUser.id, editRole); setEditSaving(false); }; const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }; const isExpired = (expiresAt: string) => new Date(expiresAt) < new Date(); const roleCounts = ROLE_OPTIONS.reduce((acc, r) => { acc[r.value] = users.filter((u) => u.role === r.value).length; return acc; }, {} as Record); if (loading) { return (
); } return (
{/* Notification */} {notification && (
{notification.message}
)} {/* Header */}
← Admin /

User Management

{/* Stats */}
{ROLE_OPTIONS.map((r) => (

{r.label}s

{roleCounts[r.value] || 0}

))}
{/* Tabs */}
{(['users', 'invitations'] as Tab[]).map((tab) => ( ))}
{/* Filters */}
setSearchQuery(e.target.value)} className="w-full bg-[#111827] border border-[#1f2937] text-white text-sm rounded-lg pl-9 pr-3 py-2 placeholder-[#444] focus:outline-none focus:border-[#3b82f6]" />
{activeTab === 'users' && ( )}
{/* Users Table */} {activeTab === 'users' && (
{loadingData ? (
) : filteredUsers.length === 0 ? (

No users found

) : (
{filteredUsers.map((u) => ( ))}
User Role Status Joined Actions
{u.avatar_url ? ( {u.full_name ) : ( (u.full_name?.[0] || u.email?.[0] || '?').toUpperCase() )}

{u.full_name || '—'}

{u.email}

{u.role} {u.is_active ? 'Active' : 'Inactive'} {u.suspension_status && ( {u.suspension_status === 'banned' ? '🚫 Banned' : '⏸ Suspended'} )} {formatDate(u.created_at)}
Suspend
)}
)} {/* Invitations Table */} {activeTab === 'invitations' && (
{loadingData ? (
) : filteredInvitations.length === 0 ? (

No invitations sent yet

) : (
{filteredInvitations.map((inv) => { const expired = isExpired(inv.expires_at) && inv.status === 'pending'; const displayStatus = expired ? 'expired' : inv.status; return ( ); })}
Email Role Status Expires Sent Actions
{inv.email[0].toUpperCase()}
{inv.email}
{inv.role} {displayStatus.replace('_', ' ')} {formatDate(inv.expires_at)} {formatDate(inv.created_at)}
{inv.status === 'pending' && !expired && ( )}
)}
)}
{/* Invite Modal */} {showInviteModal && (

Invite Contributor

setInviteEmail(e.target.value)} placeholder="contributor@example.com" className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#3b82f6]" />