import React, { useState, useEffect, useCallback } from 'react'; import { Users, Wifi, Shield, RefreshCw, Loader2, AlertCircle, UserX, Ban, Clock, CheckCircle, MoreVertical, ChevronRight, ShieldAlert, Search, } from 'lucide-react'; import { useVault } from '../contexts/VaultContext'; import { Button } from './ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card'; import { Badge } from './ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Input } from './ui/input'; import { PlayerContextMenu } from './PlayerContextMenu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuLabel, } from "./ui/dropdown-menu"; import { cn, base64ToUint8Array, uint8ArrayToBase64 } from '../lib/utils'; const apiHeaders = () => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token') ?? ''}`, }); interface Player { id: string; nameHash: string; data: string; joinedAt: string; warningCount: number; } interface DecryptedPlayer { id: string; name: string; ip: string; joinedAt: string; nameHash: string; warningCount: number; } const DURATION_OPTIONS = [ { label: '30 minutes', minutes: 30 }, { label: '1 hour', minutes: 60 }, { label: '6 hours', minutes: 360 }, { label: '24 hours', minutes: 1440 }, { label: '7 days', minutes: 10080 }, { label: 'Permanent', minutes: 0 }, ]; function timeAgo(iso: string) { const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; return `${Math.floor(diff / 3600)}h ago`; } function formatExpiry(iso: string | null) { if (!iso) return 'Permanent'; const d = new Date(iso); const diff = Math.floor((d.getTime() - Date.now()) / 1000 / 60); if (diff <= 0) return 'Expired'; if (diff < 60) return `${diff}m left`; if (diff < 1440) return `${Math.floor(diff / 60)}h left`; return `${Math.floor(diff / 1440)}d left`; } // ─── Kick inline form ──────────────────────────────────────────────────────── const KickForm: React.FC<{ player: DecryptedPlayer; onDone: () => void; onCancel: () => void }> = ({ player, onDone, onCancel, }) => { const [reason, setReason] = useState(''); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const submit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setErr(null); try { const res = await fetch('/api/players/kick', { method: 'POST', headers: apiHeaders(), body: JSON.stringify({ playerNameHash: player.nameHash, reason }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? 'Kick failed'); onDone(); } catch (e) { setErr((e as Error).message); setLoading(false); } }; return (
setReason(e.target.value)} placeholder="Reason (optional)" className="h-9 bg-background/50 border-amber-500/30 focus-visible:ring-amber-500" disabled={loading} /> {err && {err}}
); }; // ─── Ban inline form ───────────────────────────────────────────────────────── const BanForm: React.FC<{ player: DecryptedPlayer; onDone: () => void; onCancel: () => void }> = ({ player, onDone, onCancel, }) => { const [reason, setReason] = useState(''); const [duration, setDuration] = useState(0); // 0 = permanent const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const { encrypt, username } = useVault(); const submit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setErr(null); try { // Encrypt ban details const banData = JSON.stringify({ playerName: player.name, reason: reason || 'Banned by admin', bannedBy: username || 'Admin', }); const encrypted = await encrypt(banData); const dataBase64 = uint8ArrayToBase64(encrypted); const res = await fetch('/api/players/ban', { method: 'POST', headers: apiHeaders(), body: JSON.stringify({ playerNameHash: player.nameHash, data: dataBase64, durationMinutes: duration }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? 'Ban failed'); onDone(); } catch (e) { setErr((e as Error).message); setLoading(false); } }; return (
Ban: {player.name}
setReason(e.target.value)} placeholder="Reason for ban (optional)" className="bg-background/50 border-destructive/30 focus-visible:ring-destructive" disabled={loading} />
{err && (
{err}
)}
); }; // ─── Main component ─────────────────────────────────────────────────────────── interface PlayersProps { onOpenInsights?: (player: { name: string; nameHash: string }) => void; } export const Players: React.FC = ({ onOpenInsights }) => { const [tab, setTab] = useState<'online' | 'history' | 'bans'>('online'); const [players, setPlayers] = useState([]); const [history, setHistory] = useState([]); const [bans, setBans] = useState([]); const [loading, setLoading] = useState(false); const [actionPlayerId, setActionPlayerId] = useState<{ id: string; type: 'kick' | 'ban' } | null>(null); const { decrypt, isLocked } = useVault(); const fetchPlayers = useCallback(async () => { if (isLocked) return; try { const res = await fetch('/api/players', { headers: apiHeaders() }); const data = await res.json(); const rawPlayers = (data.players ?? []) as Player[]; const decrypted = await Promise.all(rawPlayers.map(async p => { try { const decryptedData = await decrypt(base64ToUint8Array(p.data)); const parsed = JSON.parse(decryptedData); return { id: p.id, name: parsed.name, ip: parsed.ip, joinedAt: p.joinedAt, nameHash: p.nameHash }; } catch (e) { return { id: p.id, name: 'Decryption Error', ip: '?.?.?.?', joinedAt: p.joinedAt, nameHash: p.nameHash }; } })); setPlayers(decrypted); } catch {} }, [decrypt, isLocked]); const fetchHistory = useCallback(async () => { if (isLocked) return; try { const res = await fetch('/api/players/search?q=', { headers: apiHeaders() }); const data = await res.json(); const rawPlayers = (data.results ?? []) as Player[]; const decrypted = await Promise.all(rawPlayers.map(async p => { try { const decryptedData = await decrypt(base64ToUint8Array(p.data)); const parsed = JSON.parse(decryptedData); return { id: p.id, name: parsed.name, ip: parsed.ip, joinedAt: p.joinedAt, nameHash: p.nameHash }; } catch (e) { return { id: p.id, name: 'Decryption Error', ip: '?.?.?.?', joinedAt: p.joinedAt, nameHash: p.nameHash }; } })); setHistory(decrypted); } catch {} }, [decrypt, isLocked]); const fetchBans = useCallback(async () => { if (isLocked) return; try { const res = await fetch('/api/bans', { headers: apiHeaders() }); const data = await res.json(); const rawBans = (data.bans ?? []) as BanEntry[]; const decrypted = await Promise.all(rawBans.map(async b => { try { const decryptedData = await decrypt(base64ToUint8Array(b.data)); const parsed = JSON.parse(decryptedData); return { id: b.id, playerName: parsed.playerName, reason: parsed.reason, bannedBy: parsed.bannedBy, bannedAt: b.bannedAt, expiresAt: b.expiresAt, playerNameHash: b.playerNameHash }; } catch (e) { return { id: b.id, playerName: 'Decryption Error', reason: 'N/A', bannedBy: 'N/A', bannedAt: b.bannedAt, expiresAt: b.expiresAt, playerNameHash: b.playerNameHash }; } })); setBans(decrypted); } catch {} }, [decrypt, isLocked]); const refresh = useCallback(async () => { setLoading(true); await Promise.all([fetchPlayers(), fetchHistory(), fetchBans()]); setLoading(false); }, [fetchPlayers, fetchHistory, fetchBans]); useEffect(() => { refresh(); const t = setInterval(fetchPlayers, 5000); return () => clearInterval(t); }, [refresh, fetchPlayers]); const handleKickDone = () => { setActionPlayerId(null); refresh(); }; const handleBanDone = () => { setActionPlayerId(null); refresh(); }; const handleUnban = async (banId: string) => { try { await fetch(`/api/bans?id=${banId}`, { method: 'DELETE', headers: apiHeaders() }); fetchBans(); } catch {} }; return (
{/* Header */}

Players

See who's online, browse history, and manage bans.

setTab(v as any)} className="w-full"> Online ({players.length}) History ({history.length}) Bans ({bans.length}) {players.length === 0 ? (

No players online

No players are currently connected to your servers.

) : (
{players.map(player => { const isActioning = actionPlayerId?.id === player.id; return ( {})} onKick={(p) => setActionPlayerId({ id: p.id, type: 'kick' })} onBan={(p) => setActionPlayerId({ id: p.id, type: 'ban' })} >
{player.name.slice(0, 1).toUpperCase()}

{player.name}

{player.warningCount > 0 && ( {player.warningCount} Warnings )} Active_Link
{player.ip}
Joined {timeAgo(player.joinedAt)}
Actions onOpenInsights?.(player)} className="font-bold"> View History setActionPlayerId({ id: player.id, type: 'kick' })} className="text-amber-500 focus:text-amber-500 font-black"> Kick setActionPlayerId({ id: player.id, type: 'ban' })} className="text-destructive focus:text-destructive font-black"> Ban
{isActioning && actionPlayerId?.type === 'kick' && ( setActionPlayerId(null)} /> )} {isActioning && actionPlayerId?.type === 'ban' && ( setActionPlayerId(null)} /> )} ); })}
)} {history.length === 0 ? (

No history yet

) : (
{history.map(player => { const isActioning = actionPlayerId?.id === player.id; return ( {})} onBan={(p) => setActionPlayerId({ id: p.id, type: 'ban' })} >
{player.name.slice(0, 1).toUpperCase()}

{player.name}

{player.warningCount > 0 && ( {player.warningCount}W )}

ID: {player.nameHash.slice(0, 12)}

Actions onOpenInsights?.(player)} className="font-bold"> View History setActionPlayerId({ id: player.id, type: 'ban' })} className="text-destructive focus:text-destructive font-black"> Ban Archived {new Date(player.joinedAt).toLocaleDateString()}
{isActioning && actionPlayerId?.type === 'ban' && ( setActionPlayerId(null)} /> )}
); })}
)}
{bans.length === 0 ? (

No active bans

) : (
{bans.map(ban => (
{ban.playerName} {formatExpiry(ban.expiresAt)}

Reason "{ban.reason}"

Banned by

{ban.bannedBy}

))}
)}
); };