765 lines
37 KiB
TypeScript
765 lines
37 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';
|
|
|
|
interface SuspensionUser {
|
|
id: string;
|
|
email: string;
|
|
full_name: string;
|
|
avatar_url: string | null;
|
|
role: string;
|
|
}
|
|
|
|
interface Suspension {
|
|
id: string;
|
|
user_id: string;
|
|
suspension_type: 'suspended' | 'banned';
|
|
reason: string;
|
|
duration_days: number | null;
|
|
expires_at: string | null;
|
|
is_active: boolean;
|
|
lifted_at: string | null;
|
|
lift_reason: string | null;
|
|
email_sent: boolean;
|
|
created_at: string;
|
|
user: SuspensionUser;
|
|
suspended_by_user: { id: string; full_name: string; email: string };
|
|
}
|
|
|
|
interface UserForSuspend {
|
|
id: string;
|
|
email: string;
|
|
full_name: string;
|
|
avatar_url: string | null;
|
|
role: string;
|
|
suspension_status: string | null;
|
|
suspended_until: string | null;
|
|
}
|
|
|
|
const DURATION_OPTIONS = [
|
|
{ label: '1 day', value: 1 },
|
|
{ label: '3 days', value: 3 },
|
|
{ label: '7 days', value: 7 },
|
|
{ label: '14 days', value: 14 },
|
|
{ label: '30 days', value: 30 },
|
|
{ label: '90 days', value: 90 },
|
|
{ label: 'Permanent (Ban)', value: null },
|
|
];
|
|
|
|
export default function AdminSuspensionsPage() {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [suspensions, setSuspensions] = useState<Suspension[]>([]);
|
|
const [users, setUsers] = useState<UserForSuspend[]>([]);
|
|
const [loadingData, setLoadingData] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'active' | 'history' | 'users'>('active');
|
|
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
|
|
|
|
// Suspend modal state
|
|
const [showSuspendModal, setShowSuspendModal] = useState(false);
|
|
const [selectedUser, setSelectedUser] = useState<UserForSuspend | null>(null);
|
|
const [suspendReason, setSuspendReason] = useState('');
|
|
const [suspendType, setSuspendType] = useState<'suspended' | 'banned'>('suspended');
|
|
const [suspendDuration, setSuspendDuration] = useState<number | null>(7);
|
|
const [suspending, setSuspending] = useState(false);
|
|
|
|
// Lift modal state
|
|
const [showLiftModal, setShowLiftModal] = useState(false);
|
|
const [liftingSuspension, setLiftingSuspension] = useState<Suspension | null>(null);
|
|
const [liftReason, setLiftReason] = useState('');
|
|
const [lifting, setLifting] = useState(false);
|
|
|
|
// User search
|
|
const [userSearch, setUserSearch] = useState('');
|
|
|
|
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 fetchSuspensions = useCallback(async (activeOnly: boolean) => {
|
|
const res = await fetch(`/api/admin/suspensions?active=${activeOnly}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setSuspensions(data.suspensions || []);
|
|
}
|
|
}, []);
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
const res = await fetch('/api/admin/users?tab=users');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setUsers(data.users || []);
|
|
}
|
|
}, []);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoadingData(true);
|
|
try {
|
|
await Promise.all([
|
|
fetchSuspensions(activeTab === 'active'),
|
|
fetchUsers(),
|
|
]);
|
|
} finally {
|
|
setLoadingData(false);
|
|
}
|
|
}, [activeTab, fetchSuspensions, fetchUsers]);
|
|
|
|
useEffect(() => {
|
|
if (user) loadData();
|
|
}, [user, loadData]);
|
|
|
|
const handleSuspend = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedUser || !suspendReason.trim()) return;
|
|
setSuspending(true);
|
|
try {
|
|
const res = await fetch('/api/admin/suspensions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: selectedUser.id,
|
|
suspension_type: suspendType,
|
|
reason: suspendReason.trim(),
|
|
duration_days: suspendType === 'banned' ? null : suspendDuration,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showNotification('success', `User ${suspendType === 'banned' ? 'banned' : 'suspended'} successfully${data.emailSent ? ' — notification email sent' : ''}`);
|
|
setShowSuspendModal(false);
|
|
setSuspendReason('');
|
|
setSuspendType('suspended');
|
|
setSuspendDuration(7);
|
|
setSelectedUser(null);
|
|
loadData();
|
|
} else {
|
|
showNotification('error', data.error || 'Failed to suspend user');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Failed to suspend user');
|
|
} finally {
|
|
setSuspending(false);
|
|
}
|
|
};
|
|
|
|
const handleLiftSuspension = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!liftingSuspension) return;
|
|
setLifting(true);
|
|
try {
|
|
const res = await fetch('/api/admin/suspensions', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
suspension_id: liftingSuspension.id,
|
|
lift_reason: liftReason.trim() || undefined,
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
showNotification('success', 'Suspension lifted successfully');
|
|
setShowLiftModal(false);
|
|
setLiftReason('');
|
|
setLiftingSuspension(null);
|
|
loadData();
|
|
} else {
|
|
const data = await res.json();
|
|
showNotification('error', data.error || 'Failed to lift suspension');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Failed to lift suspension');
|
|
} finally {
|
|
setLifting(false);
|
|
}
|
|
};
|
|
|
|
const openSuspendModal = (u: UserForSuspend) => {
|
|
setSelectedUser(u);
|
|
setSuspendType('suspended');
|
|
setSuspendDuration(7);
|
|
setSuspendReason('');
|
|
setShowSuspendModal(true);
|
|
};
|
|
|
|
const openLiftModal = (s: Suspension) => {
|
|
setLiftingSuspension(s);
|
|
setLiftReason('');
|
|
setShowLiftModal(true);
|
|
};
|
|
|
|
const formatDate = (d: string) =>
|
|
new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
|
|
const formatExpiry = (s: Suspension) => {
|
|
if (s.suspension_type === 'banned') return 'Permanent';
|
|
if (!s.expires_at) return 'Indefinite';
|
|
const exp = new Date(s.expires_at);
|
|
const now = new Date();
|
|
if (exp < now) return 'Expired';
|
|
const days = Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
return `${days}d remaining`;
|
|
};
|
|
|
|
const filteredUsers = users.filter((u) => {
|
|
if (!userSearch) return true;
|
|
return (
|
|
u.email.toLowerCase().includes(userSearch.toLowerCase()) ||
|
|
(u.full_name || '').toLowerCase().includes(userSearch.toLowerCase())
|
|
);
|
|
});
|
|
|
|
const activeSuspensions = suspensions.filter((s) => s.is_active);
|
|
const suspendedUserIds = new Set(activeSuspensions.map((s) => s.user_id));
|
|
|
|
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' :'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" 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">Suspensions & Bans</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-[#555] bg-[#1f2937] px-2 py-1 rounded">
|
|
{activeSuspensions.length} active
|
|
</span>
|
|
</div>
|
|
</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">
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-lg p-4">
|
|
<p className="text-[#555] text-xs uppercase tracking-wider mb-1">Active Suspensions</p>
|
|
<p className="text-white text-2xl font-bold">{activeSuspensions.filter(s => s.suspension_type === 'suspended').length}</p>
|
|
</div>
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-lg p-4">
|
|
<p className="text-[#555] text-xs uppercase tracking-wider mb-1">Active Bans</p>
|
|
<p className="text-red-400 text-2xl font-bold">{activeSuspensions.filter(s => s.suspension_type === 'banned').length}</p>
|
|
</div>
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-lg p-4">
|
|
<p className="text-[#555] text-xs uppercase tracking-wider mb-1">Total Users</p>
|
|
<p className="text-white text-2xl font-bold">{users.length}</p>
|
|
</div>
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-lg p-4">
|
|
<p className="text-[#555] text-xs uppercase tracking-wider mb-1">Restricted Users</p>
|
|
<p className="text-orange-400 text-2xl font-bold">{suspendedUserIds.size}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-1 mb-5 border-b border-[#1f2937]">
|
|
{(['active', 'history', 'users'] as const).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 === 'active' ? 'Active Restrictions' : tab === 'history' ? 'History' : 'Manage Users'}
|
|
{tab === 'active' && activeSuspensions.length > 0 && (
|
|
<span className="ml-2 bg-red-400/20 text-red-400 text-xs px-1.5 py-0.5 rounded-full">
|
|
{activeSuspensions.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{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>
|
|
) : (
|
|
<>
|
|
{/* Active Restrictions Tab */}
|
|
{activeTab === 'active' && (
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
|
{activeSuspensions.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">
|
|
<circle cx="12" cy="12" r="10" /><path d="M4.93 4.93l14.14 14.14" />
|
|
</svg>
|
|
<p className="text-sm">No active suspensions or bans</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">Type</th>
|
|
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden md:table-cell">Reason</th>
|
|
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden sm:table-cell">Expires</th>
|
|
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden lg:table-cell">Email</th>
|
|
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-5 py-3 font-semibold">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activeSuspensions.map((s) => (
|
|
<tr key={s.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">
|
|
{s.user?.avatar_url ? (
|
|
<img src={s.user.avatar_url} alt={s.user.full_name || s.user.email} className="w-full h-full object-cover" />
|
|
) : (
|
|
(s.user?.full_name?.[0] || s.user?.email?.[0] || '?').toUpperCase()
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-white text-sm font-medium truncate">{s.user?.full_name || '—'}</p>
|
|
<p className="text-[#555] text-xs truncate">{s.user?.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 ${
|
|
s.suspension_type === 'banned' ?'text-red-400 bg-red-400/10' :'text-orange-400 bg-orange-400/10'
|
|
}`}>
|
|
{s.suspension_type === 'banned' ? '🚫 Banned' : '⏸ Suspended'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden md:table-cell">
|
|
<p className="text-[#ccc] text-xs max-w-[200px] truncate" title={s.reason}>{s.reason}</p>
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden sm:table-cell">
|
|
<span className={`text-xs font-medium ${
|
|
s.suspension_type === 'banned' ? 'text-red-400' : 'text-orange-400'
|
|
}`}>
|
|
{formatExpiry(s)}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden lg:table-cell">
|
|
<span className={`text-xs ${s.email_sent ? 'text-green-400' : 'text-[#555]'}`}>
|
|
{s.email_sent ? '✓ Sent' : '✗ Not sent'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => openLiftModal(s)}
|
|
disabled={processingIds.has(s.id)}
|
|
className="text-[#555] hover:text-green-400 hover:bg-green-400/10 transition-colors text-xs font-medium px-2 py-1 rounded disabled:opacity-50"
|
|
>
|
|
Lift
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* History Tab */}
|
|
{activeTab === 'history' && (
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
|
{suspensions.length === 0 ? (
|
|
<div className="text-center py-16 text-[#555]">
|
|
<p className="text-sm">No suspension history</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">Type</th>
|
|
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold hidden md:table-cell">Reason</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 lg:table-cell">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{suspensions.map((s) => (
|
|
<tr key={s.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-7 h-7 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] text-xs font-bold flex-shrink-0">
|
|
{(s.user?.full_name?.[0] || s.user?.email?.[0] || '?').toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-white text-sm truncate">{s.user?.full_name || s.user?.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 ${
|
|
s.suspension_type === 'banned' ?'text-red-400 bg-red-400/10' :'text-orange-400 bg-orange-400/10'
|
|
}`}>
|
|
{s.suspension_type}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden md:table-cell">
|
|
<p className="text-[#888] text-xs max-w-[200px] truncate">{s.reason}</p>
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden sm:table-cell">
|
|
<span className={`text-xs font-medium ${
|
|
s.is_active ? 'text-orange-400' : 'text-[#555]'
|
|
}`}>
|
|
{s.is_active ? 'Active' : s.lifted_at ? 'Lifted' : 'Expired'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3.5 hidden lg:table-cell text-[#555] text-xs">
|
|
{formatDate(s.created_at)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Manage Users Tab */}
|
|
{activeTab === 'users' && (
|
|
<div>
|
|
<div className="mb-4">
|
|
<div className="relative 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="Search users..."
|
|
value={userSearch}
|
|
onChange={(e) => setUserSearch(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>
|
|
</div>
|
|
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
|
<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 hidden sm:table-cell">Role</th>
|
|
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-semibold">Status</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) => {
|
|
const isSuspended = suspendedUserIds.has(u.id);
|
|
const activeSusp = activeSuspensions.find(s => s.user_id === u.id);
|
|
return (
|
|
<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 hidden sm:table-cell">
|
|
<span className="text-[#888] text-xs capitalize">{u.role}</span>
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
{isSuspended ? (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold ${
|
|
activeSusp?.suspension_type === 'banned' ?'text-red-400 bg-red-400/10' :'text-orange-400 bg-orange-400/10'
|
|
}`}>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
|
{activeSusp?.suspension_type === 'banned' ? 'Banned' : 'Suspended'}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
|
Active
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{isSuspended && activeSusp ? (
|
|
<button
|
|
onClick={() => openLiftModal(activeSusp)}
|
|
className="text-[#555] hover:text-green-400 hover:bg-green-400/10 transition-colors text-xs font-medium px-2 py-1 rounded"
|
|
>
|
|
Lift Restriction
|
|
</button>
|
|
) : (
|
|
u.role !== 'admin' && (
|
|
<button
|
|
onClick={() => openSuspendModal(u)}
|
|
className="text-[#555] hover:text-orange-400 hover:bg-orange-400/10 transition-colors text-xs font-medium px-2 py-1 rounded"
|
|
>
|
|
Suspend / Ban
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Suspend Modal */}
|
|
{showSuspendModal && selectedUser && (
|
|
<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">Suspend / Ban User</h2>
|
|
<button
|
|
onClick={() => setShowSuspendModal(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={handleSuspend} className="px-6 py-5 space-y-4">
|
|
{/* User info */}
|
|
<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">
|
|
{selectedUser.avatar_url ? (
|
|
<img src={selectedUser.avatar_url} alt={selectedUser.full_name || selectedUser.email} className="w-full h-full object-cover" />
|
|
) : (
|
|
(selectedUser.full_name?.[0] || selectedUser.email?.[0] || '?').toUpperCase()
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-white text-sm font-medium truncate">{selectedUser.full_name || '—'}</p>
|
|
<p className="text-[#555] text-xs truncate">{selectedUser.email}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Type */}
|
|
<div>
|
|
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-2">
|
|
Action Type
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{(['suspended', 'banned'] as const).map((t) => (
|
|
<label
|
|
key={t}
|
|
className={`flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
|
suspendType === t
|
|
? t === 'banned' ? 'border-red-500 bg-red-500/10' : 'border-orange-500 bg-orange-500/10' :'border-[#1f2937] hover:border-[#2a3a50]'
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="suspendType"
|
|
value={t}
|
|
checked={suspendType === t}
|
|
onChange={() => {
|
|
setSuspendType(t);
|
|
if (t === 'banned') setSuspendDuration(null);
|
|
else setSuspendDuration(7);
|
|
}}
|
|
className="sr-only"
|
|
/>
|
|
<span className={`w-3 h-3 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
|
suspendType === t
|
|
? t === 'banned' ? 'border-red-400' : 'border-orange-400' :'border-[#444]'
|
|
}`}>
|
|
{suspendType === t && <span className={`w-1.5 h-1.5 rounded-full ${t === 'banned' ? 'bg-red-400' : 'bg-orange-400'}`} />}
|
|
</span>
|
|
<span className={`text-sm font-semibold capitalize ${
|
|
suspendType === t
|
|
? t === 'banned' ? 'text-red-400' : 'text-orange-400' :'text-[#888]'
|
|
}`}>
|
|
{t === 'banned' ? '🚫 Ban' : '⏸ Suspend'}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Duration (only for suspend) */}
|
|
{suspendType === 'suspended' && (
|
|
<div>
|
|
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-1.5">
|
|
Duration
|
|
</label>
|
|
<select
|
|
value={suspendDuration ?? ''}
|
|
onChange={(e) => setSuspendDuration(e.target.value ? parseInt(e.target.value) : null)}
|
|
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]"
|
|
>
|
|
{DURATION_OPTIONS.filter(d => d.value !== null).map((d) => (
|
|
<option key={d.value} value={d.value!}>{d.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reason */}
|
|
<div>
|
|
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-1.5">
|
|
Reason <span className="text-red-400">*</span>
|
|
</label>
|
|
<textarea
|
|
required
|
|
value={suspendReason}
|
|
onChange={(e) => setSuspendReason(e.target.value)}
|
|
placeholder="Explain the reason for this action..."
|
|
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">
|
|
{suspendType === 'banned' ?'🚫 The user will be permanently banned and cannot post. A notification email will be sent.'
|
|
: `⏸ The user will be suspended for ${suspendDuration} day${suspendDuration !== 1 ? 's' : ''} and cannot post. A notification email will be sent.`}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSuspendModal(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={suspending || !suspendReason.trim()}
|
|
className={`flex-1 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 ${
|
|
suspendType === 'banned' ?'bg-red-600 hover:bg-red-700' :'bg-orange-600 hover:bg-orange-700'
|
|
}`}
|
|
>
|
|
{suspending ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
suspendType === 'banned' ? 'Ban User' : 'Suspend User'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lift Suspension Modal */}
|
|
{showLiftModal && liftingSuspension && (
|
|
<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">Lift Restriction</h2>
|
|
<button
|
|
onClick={() => setShowLiftModal(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={handleLiftSuspension} className="px-6 py-5 space-y-4">
|
|
<div className="bg-[#0d1117] rounded-lg p-3">
|
|
<p className="text-[#888] text-xs mb-1">Lifting restriction for</p>
|
|
<p className="text-white text-sm font-medium">{liftingSuspension.user?.full_name || liftingSuspension.user?.email}</p>
|
|
<p className="text-[#555] text-xs mt-1">Reason: {liftingSuspension.reason}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[#888] text-xs font-semibold uppercase tracking-wider mb-1.5">
|
|
Lift Reason <span className="text-[#444]">(optional)</span>
|
|
</label>
|
|
<textarea
|
|
value={liftReason}
|
|
onChange={(e) => setLiftReason(e.target.value)}
|
|
placeholder="Reason for lifting this restriction..."
|
|
rows={2}
|
|
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="flex items-center gap-3 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowLiftModal(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={lifting}
|
|
className="flex-1 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
{lifting ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
Lifting...
|
|
</>
|
|
) : (
|
|
'Lift Restriction'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|