Keep admin and seat recovery notices readable

Show progress during initial admin and seat-control submissions; retain exact recovery instructions for uncertain requests and explicit retries. Keep seat recovery and handover confirmations above the table until Continue without blocking gameplay. Use readable 16px text and 44px controls.

Verified isolated checkpoint: types, lint, 5381 offline tests, 52 HTTP tests and build. Local delayed/lost-response browser QA covered admin creation, durable retry, handover and seat recovery, mobile readability and saved-seat restoration. Existing 2098 rooms and 4455 seats preserved. Deployment acceptance follows separately.
This commit is contained in:
Quintine committed 2026-09-26 13:36:05 +10:00
1 parent e918e2e0c0
commit 137f9223f1
15 files changed
+134 -31

No files matched your search

+4 -3
View File
@@ -31,6 +31,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false), inFlight = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
@@ -101,7 +102,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
input = saved ?? newAdminLobbyRequest(lobby, action, reason);
saveAdminLobbyRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving lobby change…');
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving lobby change…');
try {
const result = adminLobbyConfirmation(await requestJson<unknown>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
@@ -127,7 +128,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
if (alive.current) setLobby(current);
} catch { /* Preserve the original outcome and retry instructions. */ }
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
}
function discardUnreadableRecord() {
if (!confirmed || busy) return;
@@ -152,7 +153,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
<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 lobby 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">
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
<p>Saved request for {code}: {describeAdminLobbyAction(pending.action, lobby)}.</p><p>Reason: {pending.reason}</p>
<p>The exact request stays in this tab across refresh. Retrying confirms an already completed change without applying it again, including after the game has started.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved lobby request</Button>
+4 -3
View File
@@ -23,6 +23,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false), inFlight = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
@@ -69,7 +70,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
input = saved ?? newAdminArchiveRequest(room!, !room!.archived, reason);
saveAdminArchiveRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…');
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…');
try {
const result = adminArchiveConfirmation(await requestJson<unknown>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
@@ -96,7 +97,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
if (alive.current) setRoom(current);
} catch { /* Preserve the original outcome and saved retry. */ }
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
}
function discardUnreadableRecord() {
@@ -121,7 +122,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</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">
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
<p>Saved request: <strong>{pending.archived ? 'Archive' : 'Unarchive'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
<p>The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again; the current archive status may differ after another administrator’s action.</p>
<p>After refresh or sign-in, choose All rooms in both the Directory and Archive filters to find the room and reopen these controls.</p>
+4 -3
View File
@@ -23,6 +23,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false), inFlight = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
@@ -69,7 +70,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
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…');
inFlight.current = true; setInitialRequestPending(!saved); 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),
@@ -96,7 +97,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
if (alive.current) setRoom(current);
} catch { /* Preserve the original outcome and saved retry. */ }
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
}
function discardUnreadableRecord() {
@@ -121,7 +122,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
<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">
</div> : initialRequestPending ? null : 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 both the Directory and Archive filters to find this room and reopen these controls.</p>
+4 -3
View File
@@ -34,6 +34,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
@@ -84,7 +85,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
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…');
inFlight.current = true; setInitialRequestPending(!saved); 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),
@@ -116,7 +117,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
} catch { /* Preserve the original outcome and retry instructions. */ }
}
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
}
function discardUnreadableRecord() {
@@ -141,7 +142,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
<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">
</div> : initialRequestPending ? null : 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>
+4 -3
View File
@@ -28,6 +28,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
const [notice, setNotice] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const alive = useRef(false), inFlight = useRef(false);
const heading = useRef<HTMLHeadingElement>(null);
@@ -51,7 +52,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
input = saved ?? newAdminRoomCreationRequest({ name, faction, advanced, techTokens, strongholdCards, bots, reason });
saveAdminRoomCreation(window.sessionStorage, accountId, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true);
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true);
setRecord({ kind: 'pending', input }); setNotice('Creating your room…');
try {
const result = await requestJson<AdminRoomCreationResult>('/api/admin/rooms', {
@@ -82,7 +83,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The room may already exist. Retry the saved request to confirm it.` : message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
}
function clearRecord() {
@@ -107,7 +108,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
<p>A room may already have been created. Check the directory before removing this unreadable record. Removing it cannot cancel creation and may lose this tab’s host-seat retry proof.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I checked the directory and want to discard this local record.</label>
<Button disabled={!confirmed} onClick={clearRecord}>Discard unreadable local record</Button>
</div> : record?.kind === 'pending' ? <div className="admin-confirm">
</div> : initialRequestPending ? null : record?.kind === 'pending' ? <div className="admin-confirm">
<p>Unconfirmed room: host <strong>{record.input.name}</strong> · {record.input.advanced ? 'Advanced preview' : 'Basic'} · {record.input.bots.length} AI players.</p>
<p>The exact request is kept privately in this tab across refresh. Keep the tab and its storage until the result is confirmed. Retrying finds the same room.</p>
<Button disabled={busy} onClick={() => void submit(record.input)}>Retry saved room creation</Button>
+4 -3
View File
@@ -23,6 +23,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false), inFlight = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
@@ -69,7 +70,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
input = saved ?? newAdminRemovalRequest(room!, !room!.removed, reason);
saveAdminRemovalRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…');
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…');
try {
const result = adminRemovalConfirmation(await requestJson<unknown>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
@@ -96,7 +97,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
if (alive.current) setRoom(current);
} catch { /* Preserve the original outcome and saved retry. */ }
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
}
function discardUnreadableRecord() {
@@ -121,7 +122,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing 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">
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
<p>Saved request: <strong>{pending.removed ? 'Remove' : 'Restore'} 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 both the Directory and Archive filters to find this room and reopen these controls.</p>
+4 -1
View File
@@ -35,6 +35,7 @@ export function SeatAiDelegation({
const [record, setRecord] = useState<SeatAiDelegateRequest | null>(null);
const [storageProblem, setStorageProblem] = useState(false);
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [message, setMessage] = useState('');
const [abandon, setAbandon] = useState(false);
const [delegateId, setDelegateId] = useState('');
@@ -86,6 +87,7 @@ export function SeatAiDelegation({
if (locked || pending.current || (closed && !record)) return;
pending.current = true;
setBusy(true);
setInitialRequestPending(!record);
setExpanded(true);
setAbandon(false);
onPending(true);
@@ -148,6 +150,7 @@ export function SeatAiDelegation({
);
} finally {
pending.current = false;
setInitialRequestPending(false);
setBusy(false);
onPending(false);
}
@@ -175,7 +178,7 @@ export function SeatAiDelegation({
your own AI control, or recovering or transferring either seat
invalidates unused permission.
</p>
{record || storageProblem ? (
{initialRequestPending ? <output className="block">Saving AI permission…</output> : record || storageProblem ? (
<section
aria-label="Unconfirmed AI permission request"
className="flex flex-col gap-3"
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { Button } from './ui/button';
export type SeatConfirmationKind = 'recovery' | 'handover';
/** Local acknowledgement only; reconnects and game actions never dismiss it. */
export function SeatConfirmation({ kind, onContinue }: { kind: SeatConfirmationKind; onContinue: () => void }) {
return <section className="notice" style={{ fontSize: '1rem', lineHeight: 1.5 }} aria-label="Seat confirmation">
<h2>{kind === 'recovery' ? 'Seat recovered' : 'Seat handover complete'}</h2>
<output className="block">{kind === 'recovery'
? 'Your seat is recovered in this browser. Previous browser sessions for this seat have been revoked.'
: 'You now control this seat and its saved progress. The previous owner’s access has been revoked. Use Protect your saved seat to create your own recovery kit.'}</output>
<Button type="button" variant="outline" className="mt-3 min-h-11" style={{ fontSize: '1rem' }} onClick={onContinue}>Continue</Button>
</section>;
}
+8 -2
View File
@@ -49,6 +49,7 @@ export function SeatHandoverSetup({
const [loaded, setLoaded] = useState(false);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [replaceAcknowledged, setReplaceAcknowledged] = useState(false);
const closed = !!game.roomControl?.closed;
const own = game.players.find((player) => player.id === game.me);
@@ -101,6 +102,7 @@ export function SeatHandoverSetup({
pending.current = true;
setReplaceAcknowledged(false);
setBusy(true);
setInitialRequestPending(!record?.operation);
onPending(true);
try {
store({ kit, expiresAt: record?.expiresAt ?? null, operation });
@@ -151,6 +153,7 @@ export function SeatHandoverSetup({
);
} finally {
pending.current = false;
setInitialRequestPending(false);
setBusy(false);
onPending(false);
}
@@ -172,7 +175,7 @@ export function SeatHandoverSetup({
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 ? (
{initialRequestPending ? <output className="block">Saving handover…</output> : !record ? (
<Button
variant="outline"
disabled={disabled || closed || busy || !loaded}
@@ -308,6 +311,7 @@ export function SeatHandoverClaim({
const [stored, setStored] = useState(false);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [initialRequestPending, setInitialRequestPending] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
@@ -333,6 +337,7 @@ export function SeatHandoverClaim({
if (!claim || pending.current) return;
pending.current = true;
setBusy(true);
setInitialRequestPending(!stored);
try {
saveHandoverClaim(sessionStorage, claim);
setStored(true);
@@ -366,6 +371,7 @@ export function SeatHandoverClaim({
);
} finally {
pending.current = false;
setInitialRequestPending(false);
setBusy(false);
}
}
@@ -434,7 +440,7 @@ export function SeatHandoverClaim({
)}
</>
)}
{stored && (
{stored && !initialRequestPending && (
<>
<p>
Abandoning removes this tab’s exact retry proof. It cannot undo a