Add administrator sign-in and private-safe room directory

Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit.

Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
This commit is contained in:
Quintine committed 2026-09-22 09:36:11 +10:00
1 parent 36a03bda9c
commit c2d392e155
27 files changed
+3164 -13

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
.admin-shell { max-width: 1420px; margin: auto; padding: 32px; line-height: 1.5; }
.admin-header, .admin-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.admin-header { padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--border); }
.admin-return { color: var(--primary); font-size: 1rem; }
.admin-header h1 { display: flex; align-items: center; gap: 12px; margin: 12px 0 0; }
.admin-session { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; }
.admin-shell h2 { margin-top: 0; }
.admin-secondary { color: #b7bbaf; }
.admin-login { max-width: 580px; margin: 48px auto; padding: 30px; border: 1px solid var(--border); background: #171c16; }
.admin-login form { display: grid; gap: 12px; margin: 24px 0; }
.admin-shell input, .admin-shell select, .admin-shell button { min-height: 42px; font-size: 1rem; }
.admin-shell input, .admin-shell select { width: 100%; border: 1px solid var(--border); border-radius: 4px; background: #151a14; color: var(--foreground); padding: 8px 10px; }
.admin-shell button { white-space: normal; }
.admin-notice { display: block; padding: 14px 18px; border: 1px solid var(--primary); background: #25291e; }
.admin-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 16px; padding: 22px 0; border-bottom: 1px solid var(--border); }
.admin-filters label { display: grid; gap: 6px; font-size: 0.9375rem; flex: 1 1 145px; }
.admin-filters .admin-search { flex: 2 1 240px; }
.admin-results { margin: 20px 0 12px; }
.admin-room-table { table-layout: fixed; font-size: 0.9375rem; }
.admin-room-table th, .admin-room-table td { padding: 16px 12px; white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
.admin-room-table th:first-child { width: 30%; }
.admin-room-table td p { margin: 8px 0; }
.admin-room-table details { margin-top: 8px; }
.admin-room-table summary { cursor: pointer; color: var(--primary); }
.admin-room-table ul { padding-left: 20px; }
.admin-code { font-family: monospace; font-size: 1.125rem; letter-spacing: 1px; }
.admin-tag { display: inline-block; font-size: 0.875rem; border: 1px solid #626951; border-radius: 4px; padding: 2px 7px; margin-left: 5px; }
.admin-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
.admin-session-control { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; max-width: 820px; }
.admin-confirm { border: 1px solid var(--border); padding: 16px; }
.admin-confirm button { margin-right: 10px; }
.admin-development { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 20px; }
@media (max-width: 700px) {
.admin-shell { padding: 20px 16px; }
.admin-header, .admin-section-heading { align-items: start; flex-direction: column; gap: 16px; }
.admin-header h1 { font-size: 1.8rem; }
.admin-login { padding: 20px; margin: 24px 0; }
.admin-room-table { display: block; }
.admin-room-table thead { display: none; }
.admin-room-table tbody { display: block; }
.admin-room-table tr { display: grid; grid-template-columns: 1fr; padding: 14px 0; }
.admin-room-table td { display: block; padding: 6px 0; }
.admin-room-table td:nth-child(2)::before { content: 'Rules: '; color: var(--primary); }
.admin-room-table td:nth-child(3)::before { content: 'Progress: '; color: var(--primary); }
.admin-room-table td:nth-child(4)::before { content: 'Last game change: '; color: var(--primary); }
}
+155
View File
@@ -0,0 +1,155 @@
'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 './admin.css';
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
type Filters = { q: string; status: string; rules: string; sort: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent' };
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
export default function Administration() {
const [account, setAccount] = useState<Account | null>(null);
const [restoring, setRestoring] = useState(true);
const [key, setKey] = useState('');
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
const [directory, setDirectory] = useState<AdminDirectory | null>(null);
const [filters, setFilters] = useState(initialFilters);
const [applied, setApplied] = useState(initialFilters);
const [confirmAll, setConfirmAll] = useState(false);
const epoch = useRef(0);
const clearSession = useCallback(() => {
epoch.current++;
setAccount(null); setDirectory(null); setConfirmAll(false);
setBusy(false);
}, []);
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<AdminDirectory>('/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 (busy) 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 <main className="admin-shell">
<header className="admin-header">
<div><a className="admin-return" href="/">Arrakis Table</a><h1><Shield aria-hidden="true" /> Administration</h1></div>
{account && <div className="admin-session"><span>{account.name} <span className="admin-tag">{account.role}</span></span>
<Button variant="outline" disabled={busy} onClick={() => void sessionAction('logout')}><LogOut aria-hidden="true" /> Sign out</Button></div>}
</header>
{notice && <output className="admin-notice">{notice}</output>}
{restoring ? <output>Checking administrator access…</output> : !account ?
<section className="admin-login" aria-labelledby="admin-sign-in">
<h2 id="admin-sign-in">Administrator sign-in</h2>
<p>Use your personal administrator access key. Room invitations and saved-seat recovery kits cannot sign in here.</p>
<form onSubmit={event => { event.preventDefault(); void sessionAction('login'); }}>
<label htmlFor="admin-key">Access key</label>
<Input id="admin-key" type="password" autoComplete="current-password" autoCapitalize="none" spellCheck={false}
maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={busy} />
<Button type="submit" disabled={busy || !key.trim()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
</form>
<p className="admin-secondary">First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.</p>
</section> : <>
<section aria-labelledby="admin-rooms-title">
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
<Button variant="outline" disabled={busy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div>
<form className="admin-filters" onSubmit={event => { event.preventDefault(); void load(filters); }}>
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={busy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
<label>Status<select disabled={busy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
<option value="all">All statuses</option><option value="lobby">Lobby</option><option value="setup">Setup</option><option value="playing">Playing</option><option value="finished">Finished</option>
</select></label>
<label>Rules<select disabled={busy} value={filters.rules} onChange={e => setFilters({ ...filters, rules: e.target.value })}>
<option value="all">All rules</option><option value="basic">Basic</option><option value="advanced">Advanced</option>
</select></label>
<label>Sort<select disabled={busy} value={filters.sort} onChange={e => setFilters({ ...filters, sort: e.target.value })}>
<option value="recent">Recently changed</option><option value="oldest">Oldest change</option><option value="code">Room code</option>
</select></label>
<Button type="submit" disabled={busy}>Apply filters</Button>
</form>
<div aria-busy={busy}>
{!directory ? <output>{busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'}</output> : <>
<p className="admin-results">{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match</p>
{directory.rooms.length === 0 ? <p>No rooms on this page. Change the filters or return to the first page.</p> :
<Table className="admin-room-table"><TableHeader><TableRow>
<TableHead>Room & players</TableHead><TableHead>Rules</TableHead><TableHead>Progress</TableHead><TableHead>Last game change</TableHead>
</TableRow></TableHeader><TableBody>{directory.rooms.map(room => <TableRow key={room.code}>
<TableCell><strong className="admin-code">{room.code}</strong> <span className="admin-tag">{room.status}</span>
<p>Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}</p>
<details><summary>{room.players.length} {room.players.length === 1 ? 'player' : 'players'}</summary><ul>{room.players.map(player => <li key={player.id}>{player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}</li>)}</ul></details>
</TableCell>
<TableCell>{room.advanced ? room.preview ? 'Advanced preview' : 'Advanced development' : 'Basic'}
{room.expansions.length > 0 && <p>Expansions: {room.expansions.map(id => id === 'ix' ? 'Ixians & Tleilaxu' : id === 'choam' ? 'CHOAM & Richese' : 'Ecaz & Moritani').join(', ')}</p>}
<p className="admin-secondary">{room.modules.join(', ') || 'No optional modules'}</p>
</TableCell>
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
</TableCell>
<TableCell><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time><p className="admin-secondary">Version {room.version}</p></TableCell>
</TableRow>)}</TableBody></Table>}
<nav className="admin-pagination" aria-label="Room pages">
<Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
<Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void load(applied, directory.page - 1)}>Previous</Button>
<span>Page {directory.page} of {Math.max(1, Math.ceil(directory.total / directory.pageSize))}</span>
<Button variant="outline" disabled={busy || directory.page * directory.pageSize >= directory.total} onClick={() => void load(applied, directory.page + 1)}>Next</Button>
</nav>
</>}
</div>
</section>
<section className="admin-session-control" aria-labelledby="admin-access-title"><h2 id="admin-access-title">Your access</h2>
<p>Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.</p>
{!confirmAll ? <Button variant="outline" disabled={busy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={busy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={busy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
</section>
<p className="admin-secondary admin-development">Administration preview: room creation, lifecycle controls, participant support and backup tools are still being implemented.</p>
</>}
</main>;
}