initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,721 @@
'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<string, string> = {
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<string, string> = {
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<Tab>('users');
const [users, setUsers] = useState<UserProfile[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const [processingIds, setProcessingIds] = useState<Set<string>>(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<UserProfile | null>(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<string, number>);
if (loading) {
return (
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[#0d1117]">
{/* Notification */}
{notification && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg transition-all ${
notification.type === 'success' ? 'bg-green-500/20 text-green-400 border border-green-500/30' :
notification.type === 'warning'? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30' : 'bg-red-500/20 text-red-400 border border-red-500/30'
}`}>
{notification.message}
</div>
)}
{/* Header */}
<div className="bg-[#111827] border-b border-[#1f2937]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<Link href="/admin/articles" className="text-[#555] hover:text-[#888] transition-colors text-sm">
Admin
</Link>
<span className="text-[#333]">/</span>
<h1 className="text-white font-bold text-lg">User Management</h1>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.41 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.96a16 16 0 0 0 6 6l.96-.96a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21.5 16.5z" />
</svg>
Invite Contributor
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{ROLE_OPTIONS.map((r) => (
<div key={r.value} className="bg-[#111827] border border-[#1f2937] rounded-lg p-4">
<p className="text-[#555] text-xs uppercase tracking-wider mb-1">{r.label}s</p>
<p className="text-white text-2xl font-bold">{roleCounts[r.value] || 0}</p>
</div>
))}
</div>
{/* Tabs */}
<div className="flex items-center gap-1 mb-5 border-b border-[#1f2937]">
{(['users', 'invitations'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2.5 text-sm font-semibold capitalize transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'text-[#3b82f6] border-[#3b82f6]'
: 'text-[#555] border-transparent hover:text-[#888]'
}`}
>
{tab === 'invitations' ? 'Invitations' : 'All Users'}
{tab === 'invitations' && invitations.filter((i) => i.status === 'pending').length > 0 && (
<span className="ml-2 bg-yellow-400/20 text-yellow-400 text-xs px-1.5 py-0.5 rounded-full">
{invitations.filter((i) => i.status === 'pending').length}
</span>
)}
</button>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-3 mb-5 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-[#555]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder={activeTab === 'users' ? 'Search users...' : 'Search by email...'}
value={searchQuery}
onChange={(e) => 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]"
/>
</div>
{activeTab === 'users' && (
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="bg-[#111827] border border-[#1f2937] text-[#ccc] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
>
<option value="all">All Roles</option>
{ROLE_OPTIONS.map((r) => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
)}
</div>
{/* Users Table */}
{activeTab === 'users' && (
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
{loadingData ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-16 text-[#555]">
<svg className="mx-auto mb-3 text-[#333]" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<p className="text-sm">No users found</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[#1f2937]">
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-5 py-3 font-semibold">User</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold">Role</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden sm:table-cell">Status</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden md:table-cell">Joined</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-5 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
{u.avatar_url ? (
<img src={u.avatar_url} alt={u.full_name || u.email} className="w-full h-full object-cover" />
) : (
(u.full_name?.[0] || u.email?.[0] || '?').toUpperCase()
)}
</div>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{u.full_name || '—'}</p>
<p className="text-[#555] text-xs truncate">{u.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${ROLE_COLORS[u.role] || 'text-[#888] bg-[#1a1a1a]'}`}>
{u.role}
</span>
</td>
<td className="px-4 py-3.5 hidden sm:table-cell">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${u.is_active ? 'text-green-400' : 'text-[#555]'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${u.is_active ? 'bg-green-400' : 'bg-[#444]'}`} />
{u.is_active ? 'Active' : 'Inactive'}
</span>
{u.suspension_status && (
<span className={`ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold ${
u.suspension_status === 'banned' ?'text-red-400 bg-red-400/10' :'text-orange-400 bg-orange-400/10'
}`}>
{u.suspension_status === 'banned' ? '🚫 Banned' : '⏸ Suspended'}
</span>
)}
</td>
<td className="px-4 py-3.5 hidden md:table-cell text-[#555] text-xs">
{formatDate(u.created_at)}
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditRole(u)}
disabled={processingIds.has(u.id)}
className="text-[#555] hover:text-[#3b82f6] transition-colors text-xs font-medium px-2 py-1 rounded hover:bg-[#1f2937] disabled:opacity-50"
>
Edit Role
</button>
<button
onClick={() => handleToggleActive(u)}
disabled={processingIds.has(u.id)}
className={`text-xs font-medium px-2 py-1 rounded transition-colors disabled:opacity-50 ${
u.is_active
? 'text-[#555] hover:text-red-400 hover:bg-red-400/10'
: 'text-[#555] hover:text-green-400 hover:bg-green-400/10'
}`}
>
{processingIds.has(u.id) ? '...' : u.is_active ? 'Deactivate' : 'Activate'}
</button>
<Link
href={`/admin/suspensions`}
className="text-[#555] hover:text-orange-400 hover:bg-orange-400/10 transition-colors text-xs font-medium px-2 py-1 rounded"
>
Suspend
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Invitations Table */}
{activeTab === 'invitations' && (
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
{loadingData ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
) : filteredInvitations.length === 0 ? (
<div className="text-center py-16 text-[#555]">
<svg className="mx-auto mb-3 text-[#333]" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.41 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.96a16 16 0 0 0 6 6l.96-.96a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21.5 16.5z" />
</svg>
<p className="text-sm">No invitations sent yet</p>
<button
onClick={() => setShowInviteModal(true)}
className="mt-3 text-[#3b82f6] hover:text-[#60a5fa] text-sm transition-colors"
>
Send your first invitation
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[#1f2937]">
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-5 py-3 font-semibold">Email</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold">Role</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold">Status</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden md:table-cell">Expires</th>
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden sm:table-cell">Sent</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-5 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredInvitations.map((inv) => {
const expired = isExpired(inv.expires_at) && inv.status === 'pending';
const displayStatus = expired ? 'expired' : inv.status;
return (
<tr key={inv.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] text-xs font-bold flex-shrink-0">
{inv.email[0].toUpperCase()}
</div>
<span className="text-white text-sm">{inv.email}</span>
</div>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${ROLE_COLORS[inv.role] || 'text-[#888] bg-[#1a1a1a]'}`}>
{inv.role}
</span>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold capitalize ${STATUS_COLORS[displayStatus] || 'text-[#888] bg-[#1a1a1a]'}`}>
{displayStatus.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-3.5 hidden md:table-cell text-[#555] text-xs">
{formatDate(inv.expires_at)}
</td>
<td className="px-4 py-3.5 hidden sm:table-cell text-[#555] text-xs">
{formatDate(inv.created_at)}
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-end gap-2">
{inv.status === 'pending' && !expired && (
<button
onClick={() => handleRevokeInvitation(inv.id)}
disabled={processingIds.has(inv.id)}
className="text-[#555] hover:text-red-400 hover:bg-red-400/10 transition-colors text-xs font-medium px-2 py-1 rounded disabled:opacity-50"
>
{processingIds.has(inv.id) ? '...' : 'Revoke'}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
{/* Invite Modal */}
{showInviteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="bg-[#111827] border border-[#1f2937] rounded-xl w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-[#1f2937]">
<h2 className="text-white font-bold text-base">Invite Contributor</h2>
<button
onClick={() => setShowInviteModal(false)}
className="text-[#555] hover:text-white transition-colors"
aria-label="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSendInvite} className="px-6 py-5 space-y-4">
<div>
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-1.5">
Email Address <span className="text-red-400">*</span>
</label>
<input
type="email"
required
value={inviteEmail}
onChange={(e) => 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]"
/>
</div>
<div>
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-1.5">
Role
</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value="contributor">Contributor can submit articles for review</option>
<option value="editor">Editor can publish and manage articles</option>
<option value="admin">Admin full platform access</option>
</select>
</div>
<div>
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-1.5">
Personal Message <span className="text-[#444]">(optional)</span>
</label>
<textarea
value={inviteMessage}
onChange={(e) => setInviteMessage(e.target.value)}
placeholder="Add a personal note to the invitation email..."
rows={3}
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] resize-none"
/>
</div>
<div className="bg-[#0d1117] border border-[#1f2937] rounded-lg p-3">
<p className="text-[#555] text-xs leading-relaxed">
An invitation email will be sent with a secure link valid for <strong className="text-[#888]">7 days</strong>. The recipient will be prompted to create an account or sign in.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="flex-1 bg-[#1f2937] hover:bg-[#2a3a50] text-[#888] text-sm font-semibold py-2.5 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={inviteSending || !inviteEmail.trim()}
className="flex-1 bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
>
{inviteSending ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Sending...
</>
) : (
'Send Invitation'
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Role Modal */}
{editingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="bg-[#111827] border border-[#1f2937] rounded-xl w-full max-w-sm shadow-2xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-[#1f2937]">
<h2 className="text-white font-bold text-base">Edit Role</h2>
<button
onClick={() => setEditingUser(null)}
className="text-[#555] hover:text-white transition-colors"
aria-label="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-5 space-y-4">
<div className="flex items-center gap-3 bg-[#0d1117] rounded-lg p-3">
<div className="w-9 h-9 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
{editingUser.avatar_url ? (
<img src={editingUser.avatar_url} alt={editingUser.full_name || editingUser.email} className="w-full h-full object-cover" />
) : (
(editingUser.full_name?.[0] || editingUser.email?.[0] || '?').toUpperCase()
)}
</div>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{editingUser.full_name || '—'}</p>
<p className="text-[#555] text-xs truncate">{editingUser.email}</p>
</div>
</div>
<div>
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-2">
Assign Role
</label>
<div className="space-y-2">
{ROLE_OPTIONS.map((r) => (
<label
key={r.value}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
editRole === r.value
? 'border-[#3b82f6] bg-[#3b82f6]/10'
: 'border-[#1f2937] hover:border-[#2a3a50]'
}`}
>
<input
type="radio"
name="role"
value={r.value}
checked={editRole === r.value}
onChange={() => setEditRole(r.value)}
className="sr-only"
/>
<span className={`w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
editRole === r.value ? 'border-[#3b82f6]' : 'border-[#444]'
}`}>
{editRole === r.value && <span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6]" />}
</span>
<span className={`text-xs font-semibold uppercase tracking-wide px-2 py-0.5 rounded ${r.color}`}>
{r.label}
</span>
</label>
))}
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={() => setEditingUser(null)}
className="flex-1 bg-[#1f2937] hover:bg-[#2a3a50] text-[#888] text-sm font-semibold py-2.5 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleEditSave}
disabled={editSaving || editRole === editingUser.role}
className="flex-1 bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
>
{editSaving ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Saving...
</>
) : (
'Save Role'
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}