initial commit: rocket.new export of broadcastbeat
This commit is contained in:
721
src/app/admin/users/page.tsx
Normal file
721
src/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user