Add administrator pause and joining-lock controls
Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices. Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
This commit is contained in:
1 parent
f29d05620c
commit
8c922b446b
39 files changed
+3196
-104
No files matched your search
@@ -30,11 +30,20 @@
|
||||
.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; }
|
||||
.admin-room-control { margin: 24px 0; padding: 24px; border: 1px solid var(--primary); border-radius: 8px; background: #171c16; max-width: 880px; }
|
||||
.admin-room-control form { display: grid; gap: 16px; margin-top: 24px; }
|
||||
.admin-room-control button { min-height: 44px; }
|
||||
.admin-room-control label { display: grid; gap: 6px; }
|
||||
.admin-room-control .admin-check { display: flex; align-items: center; gap: 12px; min-height: 44px; }
|
||||
.admin-room-control .admin-check input { width: 22px; height: 22px; min-height: 22px; flex-shrink: 0; accent-color: var(--primary); }
|
||||
.admin-room-control .admin-confirm { margin-top: 16px; }
|
||||
.admin-room-control h2:focus-visible { outline: 2px solid var(--primary); outline-offset: 5px; }
|
||||
@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-control { padding: 18px; }
|
||||
.admin-room-table { display: block; }
|
||||
.admin-room-table thead { display: none; }
|
||||
.admin-room-table tbody { display: block; }
|
||||
|
||||
+36
-20
@@ -8,11 +8,12 @@ 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 './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' };
|
||||
type Filters = { q: string; status: string; rules: string; sort: string; availability: string };
|
||||
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all' };
|
||||
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
|
||||
|
||||
export default function Administration() {
|
||||
@@ -25,12 +26,19 @@ export default function Administration() {
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
const [applied, setApplied] = useState(initialFilters);
|
||||
const [confirmAll, setConfirmAll] = useState(false);
|
||||
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
|
||||
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);
|
||||
setBusy(false); setSelectedRoom(null);
|
||||
}, []);
|
||||
const accessChanged = useCallback(() => {
|
||||
clearSession();
|
||||
setNotice('Administrator access changed. Sign in again to confirm any saved room-settings request.');
|
||||
}, [clearSession]);
|
||||
const load = useCallback(async (next: Filters, page = 1) => {
|
||||
const current = ++epoch.current;
|
||||
setBusy(true); setNotice('');
|
||||
@@ -55,7 +63,7 @@ export default function Administration() {
|
||||
}, [load]);
|
||||
|
||||
async function sessionAction(action: 'login' | 'logout' | 'logoutAll') {
|
||||
if (busy) return;
|
||||
if (controlsBusy) return;
|
||||
setBusy(true); setNotice('');
|
||||
const submittedKey = key.trim();
|
||||
setKey('');
|
||||
@@ -84,7 +92,7 @@ export default function Administration() {
|
||||
<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>}
|
||||
<Button variant="outline" disabled={controlsBusy} 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 ?
|
||||
@@ -94,27 +102,33 @@ export default function Administration() {
|
||||
<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>
|
||||
maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={controlsBusy} />
|
||||
<Button type="submit" disabled={controlsBusy || !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>
|
||||
<Button variant="outline" disabled={controlsBusy} 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 })}>
|
||||
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={controlsBusy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
|
||||
<label>Status<select disabled={controlsBusy} 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 })}>
|
||||
<label>Rules<select disabled={controlsBusy} 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 })}>
|
||||
<label>Availability<select disabled={controlsBusy} value={filters.availability} onChange={e => setFilters({ ...filters, availability: e.target.value })}>
|
||||
<option value="all">All rooms</option><option value="running">Running</option><option value="paused">Paused</option><option value="locked">New joins locked</option>
|
||||
</select></label>
|
||||
<label>Sort<select disabled={controlsBusy} 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>
|
||||
<Button type="submit" disabled={controlsBusy}>Apply filters</Button>
|
||||
</form>
|
||||
{selectedRoom && <AdminRoomControls key={`${account.id}:${selectedRoom}`} accountId={account.id} code={selectedRoom}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedRoom(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
<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>
|
||||
@@ -123,6 +137,8 @@ export default function Administration() {
|
||||
<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>
|
||||
{room.control.paused && <span className="admin-tag">Paused</span>}
|
||||
{room.control.joinLocked && <span className="admin-tag">Joins locked</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>
|
||||
@@ -133,23 +149,23 @@ export default function Administration() {
|
||||
<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>
|
||||
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => setSelectedRoom(room.code)}>Room controls · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><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>
|
||||
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
|
||||
<Button variant="outline" disabled={controlsBusy || 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>
|
||||
<Button variant="outline" disabled={controlsBusy || 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>}
|
||||
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
|
||||
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} 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>
|
||||
<p className="admin-secondary admin-development">Administration preview: pause/resume and joining locks are available. Room creation, archive/removal, participant support and backup tools are still being implemented.</p>
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
Reference in new issue
Block a user