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
@@ -0,0 +1,162 @@
|
||||
'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.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> : <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>;
|
||||
}
|
||||
+15
-13
@@ -86,6 +86,7 @@ import {
|
||||
} from './richese-allied-no-field';
|
||||
import { presenceByLocation } from '@/game/force-presence';
|
||||
import { AutomaticActionNotice } from './automatic-action-notice';
|
||||
import { RoomControlNotice } from './room-control-notice';
|
||||
import { TableSounds } from './table-sounds';
|
||||
import { EcazSpice } from './ecaz-spice';
|
||||
import { VictoryProgress } from './victory-progress';
|
||||
@@ -285,7 +286,7 @@ export function GameTable({
|
||||
: [],
|
||||
);
|
||||
const busy =
|
||||
transportBusy || !!me.autopilot || g.automaticContinuationPending;
|
||||
transportBusy || !!g.roomControl?.paused || !!me.autopilot || g.automaticContinuationPending;
|
||||
const traitorBattle = g.battle;
|
||||
const traitorBeneficiary =
|
||||
traitorBattle &&
|
||||
@@ -954,6 +955,7 @@ export function GameTable({
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<RoomControlNotice control={g.roomControl} />
|
||||
<div className="game-banner">
|
||||
<span className="eyebrow">
|
||||
{g.status === 'lobby'
|
||||
@@ -1573,14 +1575,14 @@ export function GameTable({
|
||||
<PrivateBattlePlan game={g} />
|
||||
<NexusCards game={g} act={act} busy={busy} />
|
||||
<Recruits game={g} act={act} busy={busy} />
|
||||
<NexusTraitors game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusTraitors game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<RihaniHistory game={g} />
|
||||
<MentatHistory game={g} />
|
||||
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<NexusSuboids game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<NexusAdvisors game={g} act={act} busy={busy} />
|
||||
<NexusSardaukar game={g} act={act} busy={busy} />
|
||||
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
|
||||
@@ -3035,9 +3037,9 @@ export function GameTable({
|
||||
))}
|
||||
</>
|
||||
) : g.decision.kind === 'sukRescue' ? (
|
||||
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
) : g.decision.kind === 'rihani' ? (
|
||||
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
) : g.decision.kind === 'battleLosses' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
@@ -3142,13 +3144,13 @@ export function GameTable({
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'discoveryEntry' ? (
|
||||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
) : g.decision.kind === 'ecologicalStorm' ? (
|
||||
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
) : g.decision.kind === 'discoveryDiscard' ? (
|
||||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
|
||||
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
) : g.decision.kind === 'wormProtection' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RoomControl } from '@/lib/room-control';
|
||||
|
||||
/** Public operational status only; administrator reasons and identity stay private. */
|
||||
export function RoomControlNotice({ control }: { control?: RoomControl }) {
|
||||
if (!control?.paused && !control?.joinLocked) return null;
|
||||
return <section className="notice" aria-label="Room availability" aria-live="polite">
|
||||
{control.paused ? <>
|
||||
<h2 className="text-lg font-semibold">Room paused by an administrator</h2>
|
||||
<p>Player decisions and automatic play are paused. Your game and pending choices are saved. An administrator can resume this room.</p>
|
||||
<p>You can still read the table, use discussion, protect or recover your seat, and take back control from AI.</p>
|
||||
</> : <h2 className="text-lg font-semibold">New joins are locked</h2>}
|
||||
{control.joinLocked && <p>New players cannot join. Existing players can reconnect and recover their saved seats.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export function SeatAiDelegation({
|
||||
const received = grants.filter((grant) => grant.delegateId === game.me);
|
||||
const started = ['setup', 'playing'].includes(game.status);
|
||||
const locked = disabled || busy || !loaded;
|
||||
const paused = !!game.roomControl?.paused;
|
||||
if (!own || own.bot) return null;
|
||||
|
||||
function store(next: SeatAiDelegateRequest | null) {
|
||||
@@ -254,7 +255,8 @@ export function SeatAiDelegation({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{started && !own.autopilot && peers.length > 0 && (
|
||||
{paused && <p>The room is paused. New AI permissions and activation will be available after it resumes. Revoking permission and confirming saved requests remain available.</p>}
|
||||
{started && !paused && !own.autopilot && peers.length > 0 && (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<label htmlFor={`${id}-player`}>
|
||||
Player allowed to start your AI
|
||||
@@ -316,7 +318,7 @@ export function SeatAiDelegation({
|
||||
{!started && <p>Permission can be granted after the game starts.</p>}
|
||||
{own.autopilot && (
|
||||
<p>
|
||||
Your AI is already running. Use Take back control at the table
|
||||
Your seat is assigned to AI{paused ? ', currently paused with the room' : ''}. Use Take back control at the table
|
||||
before granting another permission.
|
||||
</p>
|
||||
)}
|
||||
@@ -341,6 +343,7 @@ export function SeatAiDelegation({
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={
|
||||
locked ||
|
||||
paused ||
|
||||
!started ||
|
||||
!!game.players.find((player) => player.id === grant.ownerId)
|
||||
?.autopilot
|
||||
|
||||
@@ -36,10 +36,10 @@ export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
|
||||
AI autopilot · {own.autopilot}
|
||||
</output>
|
||||
<p className="m-0 text-sm leading-6 text-[#e0e4d8]">
|
||||
AI is making this seat’s decisions using only its available
|
||||
{game.roomControl?.paused ? 'AI decisions are paused with the room. On resume, AI uses only this seat’s available information, with about 1.5 seconds between decisions. You can take back control now.' : <>AI is making this seat’s decisions using only its available
|
||||
information, with about 1.5 seconds between decisions. You keep the
|
||||
seat and its recovery setup. Taking back control stops future AI
|
||||
decisions; completed decisions remain in the game.
|
||||
decisions; completed decisions remain in the game.</>}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -56,6 +56,9 @@ export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
|
||||
</section>
|
||||
);
|
||||
|
||||
if (game.roomControl?.paused)
|
||||
return <p className="notice">The room is paused. AI can be enabled after an administrator resumes play.</p>;
|
||||
|
||||
return (
|
||||
<details className="notice min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3 text-base font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77]">
|
||||
|
||||
Reference in new issue
Block a user