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.
This commit is contained in:
1 parent
238de3860d
commit
98b1b3261b
49 files changed
+4255
-86
No files matched your search
@@ -0,0 +1,147 @@
|
||||
'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 { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminClosureResponse, adminClosureConfirmation, clearAdminClosureRequest, newAdminClosureRequest, readAdminClosureRequest, saveAdminClosureRequest } from '@/lib/admin-closure-client';
|
||||
import type { AdminClosureInput, AdminClosureView } from '@/lib/admin-closure';
|
||||
|
||||
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 : 'Room availability could not be loaded.';
|
||||
|
||||
export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminClosureView | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminClosureInput | 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), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/closure`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminClosureRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved closure or reopening request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminClosureResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.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 = adminClosureResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setConfirmed(false); setConfirmationCode(''); }
|
||||
} 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?: AdminClosureInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room || room.removed || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return;
|
||||
let input: AdminClosureInput;
|
||||
try {
|
||||
input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason);
|
||||
saveAdminClosureRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…');
|
||||
try {
|
||||
const result = adminClosureConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminClosureRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current room availability is shown below.' :
|
||||
input.closed ? 'Room closed to further play. Existing players can still read their saved table and discussion.' : 'Room reopened with its saved game, seats and previous pause and joining settings.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminClosureRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminClosureResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminClosureRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current room availability before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-closure-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-closure-title" ref={title} tabIndex={-1}>Close or reopen · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room availability controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading room availability…</output> : <>
|
||||
{room ? <>
|
||||
<p>Current availability: <strong>{room.closed ? 'Closed' : 'Open'}</strong>. Saved pause setting: <strong>{room.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{room.joinLocked ? 'locked' : 'open'}</strong>.</p>
|
||||
{room.closed && <p>The table and discussion history remain readable to existing players. New play, messages and seat changes are stopped.</p>}
|
||||
{room.closedAt !== null && <p className="admin-secondary">Closed at: <time dateTime={new Date(room.closedAt).toISOString()}>{new Date(room.closedAt).toLocaleString()}</time>.</p>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room availability</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading room availability</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect room availability. An owner or operator can close or reopen rooms.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.closed ? 'Close' : 'Reopen'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
|
||||
<p>After refreshing the page or signing in again, choose All rooms in the Directory filter to find this room and reopen these controls.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room?.removed ? <p>Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-closure-reason">Reason for the audit history<Input id="admin-closure-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
{room.closed ? <>
|
||||
<p>Reopen play in room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
|
||||
<p>Reopening preserves the previous pause and joining settings. {room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
|
||||
</> : <>
|
||||
<p>Close room <strong>{code}</strong> to further play. This stops joining, game decisions, AI, new messages and seat security changes. Existing players can still read their own table and discussion history.</p>
|
||||
<p>Warn active players before interrupting their game. Closing preserves the exact pending game and seats. It does not award a winner. An administrator can reopen it later.</p>
|
||||
<label htmlFor="admin-closure-code">Type {code} to confirm this room<Input id="admin-closure-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
|
||||
</>}
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{room.closed ? `I have reviewed room ${code} and its saved pause setting and am ready to reopen it.` : `I have checked room ${code}, warned active players, and am ready to close it.`}</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.closed && confirmationCode !== code)}>{room.closed ? 'Reopen room' : 'Close room'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
|
||||
<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>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>
|
||||
@@ -146,7 +146,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
|
||||
<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(); }}>
|
||||
</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>
|
||||
|
||||
@@ -132,7 +132,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
|
||||
<div className="admin-confirm">
|
||||
{room.removed ? <>
|
||||
<p>Restore ordinary access to room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
|
||||
<p>Restoration preserves the previous pause and joining settings. {room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
|
||||
<p>Restoration preserves the previous pause and joining settings. {room.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
|
||||
</> : <>
|
||||
<p>Remove room <strong>{code}</strong> from ordinary access. This interrupts every player: private room access, joining, game actions, AI play, seat security changes and messages stop until restoration.</p>
|
||||
<p>Warn active players before interrupting their game. This recoverable removal keeps the saved game and seats for restoration.</p>
|
||||
|
||||
+14
-13
@@ -286,7 +286,7 @@ export function GameTable({
|
||||
: [],
|
||||
);
|
||||
const busy =
|
||||
transportBusy || !!g.roomControl?.paused || !!me.autopilot || g.automaticContinuationPending;
|
||||
transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot || g.automaticContinuationPending;
|
||||
const traitorBattle = g.battle;
|
||||
const traitorBeneficiary =
|
||||
traitorBattle &&
|
||||
@@ -1575,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 || !!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} />
|
||||
<NexusTraitors game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<DiscoveryPanel game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<RihaniHistory game={g} />
|
||||
<MentatHistory game={g} />
|
||||
<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} />
|
||||
<NexusChoamTrade game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<NexusTleilaxu game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
<NexusSuboids game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!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} />}
|
||||
@@ -1633,6 +1633,7 @@ export function GameTable({
|
||||
Your faction
|
||||
<select
|
||||
value={me.faction}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
act({ type: 'faction', faction: e.target.value })
|
||||
}
|
||||
@@ -3037,9 +3038,9 @@ export function GameTable({
|
||||
))}
|
||||
</>
|
||||
) : g.decision.kind === 'sukRescue' ? (
|
||||
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'rihani' ? (
|
||||
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'battleLosses' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
@@ -3144,13 +3145,13 @@ export function GameTable({
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'discoveryEntry' ? (
|
||||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'ecologicalStorm' ? (
|
||||
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'discoveryDiscard' ? (
|
||||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
|
||||
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
|
||||
<GreatMakerDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'wormProtection' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
|
||||
@@ -2,6 +2,11 @@ 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?.closed) return <section className="notice" aria-label="Room availability" aria-live="polite">
|
||||
<h2 className="text-lg font-semibold">Room closed by an administrator</h2>
|
||||
<p>This room is closed to further play. Your table and discussion history remain readable. The saved position and pending choices are preserved; closing does not award a winner.</p>
|
||||
<p>New joins, game decisions, AI, messages and seat changes are stopped. Keep any unconfirmed request and recovery kit. Exact saved requests can still confirm an earlier completed action. An administrator can reopen the room.</p>
|
||||
</section>;
|
||||
if (!control?.paused && !control?.joinLocked) return null;
|
||||
return <section className="notice" aria-label="Room availability" aria-live="polite">
|
||||
{control.paused ? <>
|
||||
|
||||
@@ -73,7 +73,8 @@ 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;
|
||||
const closed = !!game.roomControl?.closed;
|
||||
const paused = !!game.roomControl?.paused || closed;
|
||||
if (!own || own.bot) return null;
|
||||
|
||||
function store(next: SeatAiDelegateRequest | null) {
|
||||
@@ -82,7 +83,7 @@ export function SeatAiDelegation({
|
||||
setStorageProblem(false);
|
||||
}
|
||||
async function submit(request: SeatAiDelegateRequest) {
|
||||
if (locked || pending.current) return;
|
||||
if (locked || pending.current || (closed && !record)) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setExpanded(true);
|
||||
@@ -241,7 +242,7 @@ export function SeatAiDelegation({
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
variant="outline"
|
||||
disabled={locked}
|
||||
disabled={locked || closed}
|
||||
onClick={() =>
|
||||
void submit({
|
||||
type: 'revokeSeatAiDelegate',
|
||||
@@ -255,7 +256,7 @@ export function SeatAiDelegation({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{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>}
|
||||
{closed ? <p>The room is closed. New AI permission changes are stopped; exact saved requests can still confirm earlier completed actions.</p> : 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`}>
|
||||
|
||||
@@ -24,6 +24,8 @@ export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
|
||||
if (!own || own.bot || !['setup', 'playing'].includes(game.status))
|
||||
return null;
|
||||
|
||||
if (game.roomControl?.closed) return <p className="notice">The room is closed. AI play and changes to its control are stopped until an administrator reopens it.</p>;
|
||||
|
||||
if (own.autopilot)
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -50,6 +50,7 @@ export function SeatHandoverSetup({
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [replaceAcknowledged, setReplaceAcknowledged] = useState(false);
|
||||
const closed = !!game.roomControl?.closed;
|
||||
const own = game.players.find((player) => player.id === game.me);
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -96,7 +97,7 @@ export function SeatHandoverSetup({
|
||||
setRecord(next);
|
||||
}
|
||||
async function submit(operation: 'create' | 'revoke', kit: SeatHandoverKit) {
|
||||
if (disabled || pending.current) return;
|
||||
if (disabled || pending.current || (closed && !record?.operation)) return;
|
||||
pending.current = true;
|
||||
setReplaceAcknowledged(false);
|
||||
setBusy(true);
|
||||
@@ -170,10 +171,11 @@ export function SeatHandoverSetup({
|
||||
accepted. Recovering your seat invalidates its pending handover. Only
|
||||
share the kit with the intended recipient.
|
||||
</p>
|
||||
{closed && <p>The room is closed. New handovers and cancellations are stopped; exact saved requests can still confirm a completed change.</p>}
|
||||
{!record ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !loaded}
|
||||
disabled={disabled || closed || busy || !loaded}
|
||||
onClick={() => {
|
||||
try {
|
||||
void submit('create', createHandoverKit(game.code, game.me));
|
||||
@@ -238,7 +240,7 @@ export function SeatHandoverSetup({
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
disabled={disabled || closed || busy}
|
||||
onClick={() => void submit('revoke', record.kit)}
|
||||
>
|
||||
Cancel handover
|
||||
@@ -263,7 +265,7 @@ export function SeatHandoverSetup({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
disabled || busy || (!!record.operation && !replaceAcknowledged)
|
||||
disabled || closed || busy || (!!record.operation && !replaceAcknowledged)
|
||||
}
|
||||
onClick={() => {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { isRoomRemoved, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { isRoomClosed, isRoomRemoved, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { clearHandoverOwner } from '@/lib/seat-handover';
|
||||
import {
|
||||
createRecoveryAttempt,
|
||||
@@ -41,6 +41,7 @@ export function SeatRecoverySetup({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [uncertain, setUncertain] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const closed = !!game.roomControl?.closed;
|
||||
const own = game.players.find((player) => player.id === game.me);
|
||||
const kit =
|
||||
draft?.roomCode === game.code && draft.playerId === game.me ? draft : null;
|
||||
@@ -61,7 +62,7 @@ export function SeatRecoverySetup({
|
||||
}
|
||||
|
||||
async function saveKey() {
|
||||
if (!kit || !saved || confirmed || disabled || pending.current) return;
|
||||
if (!kit || !saved || confirmed || disabled || closed || pending.current) return;
|
||||
let confirmedView: GameView | undefined;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
@@ -96,7 +97,7 @@ export function SeatRecoverySetup({
|
||||
const mayHaveSaved = requestMayHaveCompleted(error);
|
||||
setUncertain(mayHaveSaved);
|
||||
setMessage(
|
||||
isRoomRemoved(error)
|
||||
isRoomClosed(error) ? `${(error as Error).message} Keep this kit and request; retry after reopening.` : isRoomRemoved(error)
|
||||
? `${(error as Error).message} Keep this same recovery kit and this page open. Retry saving after the room is restored.`
|
||||
: mayHaveSaved
|
||||
? 'Saving was not confirmed and may have completed. Keep this same kit. Retry saving to confirm it; no retry has been sent automatically.'
|
||||
@@ -126,10 +127,11 @@ export function SeatRecoverySetup({
|
||||
invalidates the previous kit. The kit stays in memory here; save it
|
||||
before refreshing or closing this page.
|
||||
</p>
|
||||
{closed && <p>The room is closed. Keep any existing kit; new recovery keys can be enabled after reopening.</p>}
|
||||
{!kit ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
disabled={disabled || busy || closed}
|
||||
onClick={() => {
|
||||
try {
|
||||
setDraft(createRecoveryKit(game.code, game.me));
|
||||
@@ -173,14 +175,14 @@ export function SeatRecoverySetup({
|
||||
id={`${id}-saved`}
|
||||
type="checkbox"
|
||||
checked={saved}
|
||||
disabled={disabled || busy}
|
||||
disabled={disabled || busy || closed}
|
||||
onChange={(event) => setSaved(event.target.checked)}
|
||||
/>
|
||||
I have saved the complete private kit outside this page.
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={disabled || busy || !saved || confirmed}
|
||||
disabled={disabled || busy || closed || !saved || confirmed}
|
||||
onClick={() => void saveKey()}
|
||||
>
|
||||
{confirmed
|
||||
@@ -309,7 +311,7 @@ export function SeatRecoveryClaim({
|
||||
setUncertain(mayHaveRecovered);
|
||||
onUncertain?.(mayHaveRecovered);
|
||||
setMessage(
|
||||
isRoomRemoved(error)
|
||||
isRoomClosed(error) ? `${(error as Error).message} Keep this kit and request; retry after reopening.` : isRoomRemoved(error)
|
||||
? `${(error as Error).message} Your exact recovery request is retained. Keep this page open and retry it after restoration.`
|
||||
: mayHaveRecovered
|
||||
? 'Recovery was not confirmed and may have completed. Retry this same request below. Keep this page open to preserve its exact retry details; no retry is automatic.'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
/** Remount on room/seat changes. Messages belong to seats, never browser identities. */
|
||||
export function TableTalk({ game }: { game: GameView }) {
|
||||
const field = useId();
|
||||
const closed = !!game.roomControl?.closed;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
@@ -151,7 +152,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
if (sending.current || !ready) return;
|
||||
if (sending.current || !ready || (closed && !record)) return;
|
||||
const draft = record ?? {
|
||||
id: randomId(),
|
||||
recipientId: recipient || null,
|
||||
@@ -314,6 +315,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
{shownPage.messages.length === 0 && (
|
||||
<p className="muted">No messages loaded in this conversation.</p>
|
||||
)}
|
||||
{closed && <p className="notice">This room is closed. Discussion history remains available; an exact saved message can still be confirmed.</p>}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -330,7 +332,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
rows={3}
|
||||
maxLength={TALK_LIMIT}
|
||||
value={text}
|
||||
disabled={busy || !!record}
|
||||
disabled={busy || !!record || closed}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
aria-describedby={`${field}-count`}
|
||||
/>
|
||||
@@ -341,6 +343,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
type="submit"
|
||||
disabled={
|
||||
!ready ||
|
||||
(closed && !record) ||
|
||||
busy ||
|
||||
!text.trim() ||
|
||||
(!record && !!recipient && !target)
|
||||
|
||||
Reference in new issue
Block a user