'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Shield, RefreshCw, LogOut } from 'lucide-react'; import { ClientRequestError, requestJson } from '@/lib/client-request'; import type { AdminDirectory } from '@/lib/admin-directory'; import { FACTIONS } from '@/game/catalog'; import { AdminRoomControls } from '@/components/admin-room-controls'; import { AdminRoomCreation } from '@/components/admin-room-creation'; import { AdminLobbyControls } from '@/components/admin-lobby-controls'; import { AdminDiscussion } from '@/components/admin-discussion'; import { AdminSeatAi } from '@/components/admin-seat-ai'; import { AdminRoomArchive } from '@/components/admin-room-archive'; import { AdminRoomClosure } from '@/components/admin-room-closure'; import { AdminRoomRemoval } from '@/components/admin-room-removal'; import './admin.css'; type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' }; type Filters = { q: string; status: string; rules: string; sort: string; availability: string; removal: string; archive: string }; const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active', archive: 'unarchived' }; const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.'; export default function Administration() { const [account, setAccount] = useState(null); const [restoring, setRestoring] = useState(true); const [key, setKey] = useState(''); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(''); const [directory, setDirectory] = useState(null); const [filters, setFilters] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); const [confirmAll, setConfirmAll] = useState(false); const [selectedDiscussion, setSelectedDiscussion] = useState(null); const [selectedSeatAi, setSelectedSeatAi] = useState(null); const [selectedRoom, setSelectedRoom] = useState(null); const [selectedLobby, setSelectedLobby] = useState(null); const [selectedArchive, setSelectedArchive] = useState(null); const [selectedClosure, setSelectedClosure] = useState(null); const [selectedRemoval, setSelectedRemoval] = useState(null); const [creatingRoom, setCreatingRoom] = useState(false); const [operationBusy, setOperationBusy] = useState(false); const controlsBusy = busy || operationBusy; const epoch = useRef(0); const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); setBusy(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); setNotice('Administrator access changed. Sign in again to confirm any saved room request.'); }, [clearSession]); const load = useCallback(async (next: Filters, page = 1) => { const current = ++epoch.current; setBusy(true); setNotice(''); try { const query = new URLSearchParams({ ...next, page: String(page) }); const result = await requestJson('/api/admin/rooms?' + query, { cache: 'no-store' }); if (current === epoch.current) { setDirectory(result); setApplied(next); } } catch (error) { if (current === epoch.current) { if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) clearSession(); setNotice(errorMessage(error)); } } finally { if (current === epoch.current) setBusy(false); } }, [clearSession]); useEffect(() => { let canceled = false; requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' }) .then(async result => { if (!canceled) { setAccount(result.admin); await load(initialFilters); } }) .catch(error => { if (!canceled && !(error instanceof ClientRequestError && error.status === 401)) setNotice(errorMessage(error)); }) .finally(() => { if (!canceled) setRestoring(false); }); return () => { canceled = true; }; }, [load]); async function sessionAction(action: 'login' | 'logout' | 'logoutAll') { if (controlsBusy) return; setBusy(true); setNotice(''); const submittedKey = key.trim(); setKey(''); try { const result = await requestJson<{ admin?: Account }>('/api/admin/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...(action === 'login' ? { key: submittedKey } : {}) }), }); if (result.admin) { setAccount(result.admin); await load(initialFilters); } else { clearSession(); setNotice(action === 'logoutAll' ? 'All your administrator sessions are signed out.' : 'Signed out.'); } } catch (error) { // Reconcile an uncertain response with a read; never repeat a mutation automatically. try { const result = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' }); setAccount(result.admin); await load(initialFilters); setNotice(action === 'login' ? '' : 'You are still signed in. Retry the sign-out action.'); } catch (readError) { if (readError instanceof ClientRequestError && readError.status === 401) clearSession(); setNotice(errorMessage(error)); } } finally { setBusy(false); } } return
Arrakis Table

{account &&
{account.name} {account.role}
}
{notice && {notice}} {restoring ? Checking administrator access… : !account ?

Administrator sign-in

Use your personal administrator access key. Room invitations and saved-seat recovery kits cannot sign in here.

{ event.preventDefault(); void sessionAction('login'); }}> setKey(event.target.value)} required disabled={controlsBusy} />

First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.

: <>

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

Action history{account.role !== 'viewer' && } {account.role === 'owner' && Room backups} {account.role === 'owner' && Operations} {account.role === 'owner' && Accounts}
{creatingRoom && account.role !== 'viewer' && setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy} onCreated={code => { const next = { ...initialFilters, q: code }; setFilters(next); void load(next); }} />}
{ event.preventDefault(); void load(filters); }}>
{selectedDiscussion && setSelectedDiscussion(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedSeatAi && setSelectedSeatAi(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedRoom && setSelectedRoom(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedLobby && setSelectedLobby(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedArchive && setSelectedArchive(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedClosure && setSelectedClosure(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedRemoval && setSelectedRemoval(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{!directory ? {busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'} : <>

{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match

{directory.rooms.length === 0 ?

No rooms on this page. Change the filters or return to the first page.

: Room & playersRulesProgressLast game change {directory.rooms.map(room => {room.code} {room.status} {room.archived && Archived} {room.removed && Removed} {room.control.closed && Closed} {room.control.paused && Paused} {room.control.joinLocked && Joins locked}

Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}

{room.players.length} {room.players.length === 1 ? 'player' : 'players'}
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}
  • )}
{room.advanced ? room.preview ? 'Advanced preview' : 'Advanced development' : 'Basic'} {room.expansions.length > 0 &&

Expansions: {room.expansions.map(id => id === 'ix' ? 'Ixians & Tleilaxu' : id === 'choam' ? 'CHOAM & Richese' : 'Ecaz & Moritani').join(', ')}

}

{room.modules.join(', ') || 'No optional modules'}

{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}

{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}

Version {room.version}

)}
} }

Your access

Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.

{!confirmAll ? :

End all your administrator sessions now?

}

Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, archive/unarchive closed rooms, and recoverably remove or restore rooms. Participant AI can support human seats in paused games; discussion controls mute or unmute new messages from a seat. Permanent removal, further participant support and backup tools are still being implemented.

}
; }