Add configured room creation to administration

Create an explicitly owned new host seat with supported rules/modules and ordered AI seats through one live-authorized transaction. Save exact account-bound retries across refresh, record durable audit metadata, and never restore a revoked or transferred host credential. Connect invitation joining and ordinary voluntary host handover. Preserve normal readiness and Advanced preview gates.

Verified types, lint, 5240 offline tests, 52 HTTP tests and build. Eight authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one room after a lost response and refresh/retry, working host controls and saved continuation. Mobile layout passed; requested desktop viewport override did not take effect. Existing games preserved. Authenticated deployed administrator acceptance remains pending explicit QA access.
This commit is contained in:
Quintine committed 2026-09-22 11:03:12 +10:00
1 parent 8c922b446b
commit e7d4efc3db
21 files changed
+2855 -21

No files matched your search

+7
View File
@@ -38,12 +38,19 @@
.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; }
.admin-room-actions, .admin-created-result { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.admin-created-result h3, .admin-created-result p { flex-basis: 100%; }
.admin-create-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap: 16px; }
.admin-create-bot { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: 12px; margin-bottom: 16px; }
.admin-room-creation fieldset { min-width: 0; border: 1px solid var(--border); padding: 16px; }
.admin-room-link { display: inline-flex; min-height: 44px; align-items: center; padding: 8px 16px; border: 1px solid var(--primary); border-radius: 4px; color: var(--primary); }
@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-create-bot { grid-template-columns: 1fr; }
.admin-room-table { display: block; }
.admin-room-table thead { display: none; }
.admin-room-table tbody { display: block; }
+11 -5
View File
@@ -9,6 +9,7 @@ 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 { AdminRoomCreation } from '@/components/admin-room-creation';
import './admin.css';
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
@@ -27,17 +28,18 @@ export default function Administration() {
const [applied, setApplied] = useState(initialFilters);
const [confirmAll, setConfirmAll] = useState(false);
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [creatingRoom, setCreatingRoom] = useState(false);
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); setSelectedRoom(null);
setBusy(false); setSelectedRoom(null); setCreatingRoom(false);
}, []);
const accessChanged = useCallback(() => {
clearSession();
setNotice('Administrator access changed. Sign in again to confirm any saved room-settings request.');
setNotice('Administrator access changed. Sign in again to confirm any saved room request.');
}, [clearSession]);
const load = useCallback(async (next: Filters, page = 1) => {
const current = ++epoch.current;
@@ -109,7 +111,11 @@ export default function Administration() {
</section> : <>
<section aria-labelledby="admin-rooms-title">
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div>
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedRoom(null); setCreatingRoom(true); }}>Create a room</Button>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
onCreated={code => { const next = { ...initialFilters, q: code }; setFilters(next); void load(next); }} />}
<form className="admin-filters" onSubmit={event => { event.preventDefault(); void load(filters); }}>
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={controlsBusy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
<label>Status<select disabled={controlsBusy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
@@ -149,7 +155,7 @@ export default function Administration() {
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
</TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => setSelectedRoom(room.code)}>Room controls · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
</TableRow>)}</TableBody></Table>}
<nav className="admin-pagination" aria-label="Room pages">
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
@@ -165,7 +171,7 @@ export default function Administration() {
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
</section>
<p className="admin-secondary admin-development">Administration preview: pause/resume and joining locks are available. Room creation, archive/removal, participant support and backup tools are still being implemented.</p>
<p className="admin-secondary admin-development">Administration preview: create a lobby with a new host seat, pause/resume rooms and manage joining locks. Archive/removal, participant support and backup tools are still being implemented.</p>
</>}
</main>;
}