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:
1 parent
36a03bda9c
commit
c2d392e155
27 files changed
+3164
-13
No files matched your search
@@ -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); }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user