diff --git a/README.md b/README.md index 568f78d8..03dbe68b 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ For private NAS hosting and manually applied checkpoint image updates, see the with new games; the requested host port is 33046. The **Administration** link opens `/admin`, with separate administrator sign-in, -session controls and a searchable room directory. See [initial owner setup and -scope](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Room creation, -lifecycle/removal, participant support and backup tools are still being built. +session controls and a searchable room directory. **Room controls** lets owners +and operators pause/resume rooms and lock/unlock new joins. See [room controls](docs/ADMIN_ROOM_CONTROLS.md) +and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). +Room creation, archive/removal, participant support and backup tools are still being built. ## Saved seats and reconnecting diff --git a/app/admin/admin.css b/app/admin/admin.css index 3600eda5..65626c45 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -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; } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 983f4698..20cd9692 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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(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() {
Arrakis Table

{account &&
{account.name} {account.role} -
} + }
{notice && {notice}} {restoring ? Checking administrator access… : !account ? @@ -94,27 +102,33 @@ export default function Administration() {
{ event.preventDefault(); void sessionAction('login'); }}> setKey(event.target.value)} required disabled={busy} /> - + maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={controlsBusy} /> +

First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.

: <>

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

-
+
{ event.preventDefault(); void load(filters); }}> - - + - - + - +
+ {selectedRoom && setSelectedRoom(null)} onDenied={accessChanged} + onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{!directory ? {busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'} : <>

{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match

@@ -123,6 +137,8 @@ export default function Administration() { Room & playersRulesProgressLast game change {directory.rooms.map(room => {room.code} {room.status} + {room.control.paused && Paused} + {room.control.joinLocked && Joins locked}

Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}

{room.players.length} {room.players.length === 1 ? 'player' : 'players'}
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}
  • )}
@@ -133,23 +149,23 @@ export default function Administration() { {room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}

{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}

-

Version {room.version}

+

Version {room.version}

)}
} }

Your access

Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.

- {!confirmAll ? : -

End all your administrator sessions now?

} + {!confirmAll ? : +

End all your administrator sessions now?

}
-

Administration preview: room creation, lifecycle controls, participant support and backup tools are still being implemented.

+

Administration preview: pause/resume and joining locks are available. Room creation, archive/removal, participant support and backup tools are still being implemented.

} ; } diff --git a/app/api/admin/rooms/[code]/control/route.ts b/app/api/admin/rooms/[code]/control/route.ts new file mode 100644 index 00000000..4092113f --- /dev/null +++ b/app/api/admin/rooms/[code]/control/route.ts @@ -0,0 +1,35 @@ +import { env, waitUntil } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { applyAdminRoomControl, readAdminRoomControl } from '@/db/admin-lifecycle'; +import { resumeRoom } from '@/db/room-continuation'; +import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; +import { validAdminRoomControlInput } from '@/lib/room-control'; + +function roomCode(request: Request) { + const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? ''; + if (!/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400); + return code; +} + +export async function GET(request: Request) { + try { + const identity = await requireAdmin(env.DB, adminToken(request)); + if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id) + throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401); + return adminResponse(await readAdminRoomControl(env.DB, identity, roomCode(request))); + } catch (error) { return adminFailure(error); } +} + +export async function POST(request: Request) { + try { + const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN); + const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']); + if (request.headers.get('X-Dune-Admin-Id') !== identity.id) + throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401); + if (!validAdminRoomControlInput(body)) throw new AdminError('Provide room settings and a reason of 1–300 characters.', 400); + const code = roomCode(request); + const result = await applyAdminRoomControl(env.DB, identity, code, body); + if (!result.paused) waitUntil(resumeRoom(code)); + return adminResponse(result); + } catch (error) { return adminFailure(error); } +} diff --git a/app/api/rooms/[code]/control/route.ts b/app/api/rooms/[code]/control/route.ts index 5a3657d6..0cbf8689 100644 --- a/app/api/rooms/[code]/control/route.ts +++ b/app/api/rooms/[code]/control/route.ts @@ -85,7 +85,7 @@ export async function POST(req: Request) { ownerId: input.ownerId, grantId: input.grantId, }); - if (result.view.botsPending || needsAutomaticRoomRecovery(result.view)) + if (!result.view.roomControl?.paused && (result.view.botsPending || needsAutomaticRoomRecovery(result.view))) waitUntil(resumeRoom(code)); return Response.json(result, { headers: noStore }); } diff --git a/app/api/rooms/[code]/route.ts b/app/api/rooms/[code]/route.ts index 100e1dbb..2d457002 100644 --- a/app/api/rooms/[code]/route.ts +++ b/app/api/rooms/[code]/route.ts @@ -56,7 +56,7 @@ export async function GET(req: Request) { const view = await readSeatView(code, id); // Reconnect resumes persisted automatic effects/acknowledgements and AI work // after an interruption. Actual human choices remain engine-owned decisions. - if (view.botsPending || needsAutomaticRoomRecovery(view)) + if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view))) waitUntil(resumeRoom(code)); return Response.json(view, { headers: { 'Cache-Control': 'no-store' }, @@ -132,7 +132,7 @@ export async function POST(req: Request) { ) throw new RuleError('The action is invalid.'); const view = await act(code, id, body.version, body.action); - if (view.botsPending || needsAutomaticRoomRecovery(view)) + if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view))) waitUntil(resumeRoom(code)); return Response.json(view, { headers: { 'Cache-Control': 'no-store' }, diff --git a/app/page.tsx b/app/page.tsx index 13318c95..b6a2d443 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -170,6 +170,7 @@ export default function Home() { const room = data.code; if ( !data.botsPending || + data.roomControl?.paused || activeRoom.current !== room || data.me !== activeSeat.current || data.version < (knownVersions.current.get(room) ?? -1) || @@ -501,6 +502,10 @@ export default function Home() { async function send(action: Action) { if (!game || mutationPending.current || recovery.current) return; + if (game.roomControl?.paused && !(action.type === 'setAutopilot' && action.difficulty === null)) { + setNotice('An administrator paused this room. Game decisions will be available when the room resumes.'); + return; + } const room = game.code, seat = game.me, requestEpoch = epoch.current; diff --git a/components/admin-room-controls.tsx b/components/admin-room-controls.tsx new file mode 100644 index 00000000..ed5e1f55 --- /dev/null +++ b/components/admin-room-controls.tsx @@ -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(null); + const [paused, setPaused] = useState(false); + const [joinLocked, setJoinLocked] = useState(false); + const [reason, setReason] = useState(''); + const [confirmed, setConfirmed] = useState(false); + const [pending, setPending] = useState(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(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(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(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(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(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
+

Room {code}

+ {notice && {notice}} + {loading ? Loading current room settings… : !control ? : <> +

Current status: {control.paused ? 'Paused' : 'Running'} · New joins {control.joinLocked ? 'locked' : 'open'}.

+

Pause stops player decisions, automatic effects and AI play. The pending game stays saved. Discussion, seat recovery and taking back control remain available.

+

A joining lock blocks new players. Existing players can reconnect or recover their seats.

+ + {!canManage ?

Your viewer role can inspect settings. An owner or operator can change them.

: storageProblem ?
+

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.

+ + +
: pending ?
+

Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.

+

Reason: {pending.reason}

+

The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.

+ +
:
{ event.preventDefault(); void submit(); }}> + + + +

Use a short operational reason. Do not include credentials or private game information.

+
+

This affects every player in room {code}. Warn active players before interrupting their game. Resuming keeps the pending choices and restarts AI pacing.

+ + +
+
} + } +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 7775549f..aef008cf 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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({ +
{g.status === 'lobby' @@ -1573,14 +1575,14 @@ export function GameTable({ - - - + + + - - - + + + {!g.nexusCards?.waiting.length && } @@ -3035,9 +3037,9 @@ export function GameTable({ ))} ) : g.decision.kind === 'sukRescue' ? ( - + ) : g.decision.kind === 'rihani' ? ( - + ) : g.decision.kind === 'battleLosses' ? ( <>

@@ -3142,13 +3144,13 @@ export function GameTable({ })} ) : g.decision.kind === 'discoveryEntry' ? ( - + ) : g.decision.kind === 'ecologicalStorm' ? ( - + ) : g.decision.kind === 'discoveryDiscard' ? ( - + ) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? ( - + ) : g.decision.kind === 'wormProtection' ? ( <>

diff --git a/components/room-control-notice.tsx b/components/room-control-notice.tsx new file mode 100644 index 00000000..3c729723 --- /dev/null +++ b/components/room-control-notice.tsx @@ -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

+ {control.paused ? <> +

Room paused by an administrator

+

Player decisions and automatic play are paused. Your game and pending choices are saved. An administrator can resume this room.

+

You can still read the table, use discussion, protect or recover your seat, and take back control from AI.

+ :

New joins are locked

} + {control.joinLocked &&

New players cannot join. Existing players can reconnect and recover their saved seats.

} +
; +} diff --git a/components/seat-ai-delegation.tsx b/components/seat-ai-delegation.tsx index e2301394..43688f22 100644 --- a/components/seat-ai-delegation.tsx +++ b/components/seat-ai-delegation.tsx @@ -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({ )} )} - {started && !own.autopilot && peers.length > 0 && ( + {paused &&

The room is paused. New AI permissions and activation will be available after it resumes. Revoking permission and confirming saved requests remain available.

} + {started && !paused && !own.autopilot && peers.length > 0 && (