Files
Dune/components/admin-room-controls.tsx
T
Quintine 98b1b3261b Add administrative room close and reopen controls
Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped.

Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
2026-09-22 12:31:02 +10:00

163 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import { clearAdminRoomRequest, newAdminRoomRequest, readAdminRoomRequest, retainAdminRoomRequest, saveAdminRoomRequest } from '@/lib/admin-room-control-client';
import type { AdminRoomControlInput, RoomControl } from '@/lib/room-control';
type Props = {
accountId: string;
code: string;
canManage: boolean;
onClose: () => void;
onUpdated: () => void;
onDenied: () => void;
onBusyChange: (busy: boolean) => void;
};
const message = (error: unknown) => error instanceof Error ? error.message : 'The room settings could not be loaded.';
function controlResponse(value: RoomControl): RoomControl {
if (!value || typeof value.paused !== 'boolean' || typeof value.joinLocked !== 'boolean' ||
!Number.isSafeInteger(value.revision) || value.revision < 0 ||
!(value.updatedAt === null || Number.isSafeInteger(value.updatedAt))) throw new Error('The server returned unreadable room settings.');
return value;
}
export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
const [control, setControl] = useState<RoomControl | null>(null);
const [paused, setPaused] = useState(false);
const [joinLocked, setJoinLocked] = useState(false);
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [pending, setPending] = useState<AdminRoomControlInput | null>(null);
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
const inFlight = useRef(false);
const url = `/api/admin/rooms/${code}/control`;
useEffect(() => {
alive.current = true;
title.current?.focus();
let canceled = false;
try {
const saved = readAdminRoomRequest(window.sessionStorage, accountId, code);
// oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR.
setPending(saved);
if (saved) setNotice('A saved request needs confirmation. Review the current settings, then retry the same request.');
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
.then(result => {
const current = controlResponse(result);
if (!canceled) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); }
})
.catch(error => { if (!canceled) {
setNotice(message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
} })
.finally(() => { if (!canceled) setLoading(false); });
return () => { canceled = true; alive.current = false; };
}, [accountId, code, url, onDenied]);
async function refresh() {
if (inFlight.current) return;
inFlight.current = true; setBusy(true); onBusyChange(true);
try {
const current = controlResponse(await requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }));
if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); setConfirmed(false); }
} catch (error) {
if (alive.current) {
setNotice(message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
}
async function submit(saved?: AdminRoomControlInput) {
if (inFlight.current || !control || !canManage || storageProblem || (!saved && !confirmed)) return;
let input: AdminRoomControlInput;
try {
input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason);
saveAdminRoomRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…');
try {
const result = await requestJson<RoomControl & { replayed: boolean }>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
});
const current = controlResponse(result);
if (typeof result.replayed !== 'boolean') throw new Error('The server did not confirm this operation.');
clearAdminRoomRequest(window.sessionStorage, accountId, code);
if (alive.current) {
setPending(null); setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked);
setConfirmed(false); setReason('');
setNotice(result.replayed ? 'The original request was already recorded. Current settings are shown below.' : 'Room settings saved.');
onUpdated();
}
} catch (error) {
const uncertain = requestMayHaveCompleted(error);
let cleared = false;
if (!retainAdminRoomRequest(error)) {
try { clearAdminRoomRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep exact retry after storage failure. */ }
}
if (alive.current) {
if (cleared) setPending(null);
setConfirmed(false);
setNotice(uncertain ? `${message(error)} The change may have been saved. Use Retry saved request to confirm; no new request has been sent.` : message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
else {
try {
const current = controlResponse(await requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }));
if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); }
} catch { /* Preserve the original outcome and retry instructions. */ }
}
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
}
function discardUnreadableRecord() {
if (!confirmed || busy) return;
try {
clearAdminRoomRequest(window.sessionStorage, accountId, code);
setStorageProblem(false); setConfirmed(false);
setNotice('Local retry record removed. This did not undo any server change. Review the current settings before continuing.');
} catch (error) { setNotice(message(error)); }
}
const changed = control && (control.paused !== paused || control.joinLocked !== joinLocked);
return <section className="admin-room-control" aria-labelledby="admin-room-control-title" aria-busy={loading || busy}>
<div className="admin-section-heading"><h2 id="admin-room-control-title" ref={title} tabIndex={-1}>Room {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room controls</Button></div>
{notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading current room settings…</output> : !control ? <Button disabled={busy} onClick={() => void refresh()}>Retry loading settings</Button> : <>
<p>Current status: <strong>{control.closed ? 'Closed' : control.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{control.joinLocked ? 'locked' : 'open'}</strong>.</p>
<p>Pause stops player decisions, automatic effects and AI play. The pending game stays saved. Discussion, seat recovery and taking back control remain available.</p>
<p>A joining lock blocks new players. Existing players can reconnect or recover their seats.</p>
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh current settings</Button>
{!canManage ? <p>Your viewer role can inspect settings. An owner or operator can change them.</p> : storageProblem ? <div className="admin-confirm">
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />I have inspected the current settings and want to remove the local retry record.</label>
<Button disabled={busy || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
</div> : pending ? <div className="admin-confirm">
<p>Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.</p>
<p>Reason: {pending.reason}</p>
<p>The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : control.closed ? <p>Reopen this room before changing pause or joining settings. Existing settings are preserved while closed.</p> : <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label className="admin-check"><input type="checkbox" checked={paused} disabled={busy} onChange={e => { setPaused(e.target.checked); setConfirmed(false); }} />Pause this room</label>
<label className="admin-check"><input type="checkbox" checked={joinLocked} disabled={busy} onChange={e => { setJoinLocked(e.target.checked); setConfirmed(false); }} />Lock new joins</label>
<label htmlFor="admin-room-reason">Reason for the audit history<Input id="admin-room-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => setReason(e.target.value)} /></label>
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
<div className="admin-confirm">
<p>This affects every player in room <strong>{code}</strong>. Warn active players before interrupting their game. Resuming keeps the pending choices and restarts AI pacing.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !changed} onChange={e => setConfirmed(e.target.checked)} />I have checked this room and am ready to apply these settings.</label>
<Button type="submit" disabled={busy || !changed || !confirmed || !reason.trim()}>Apply room settings</Button>
</div>
</form>}
</>}
</section>;
}