P15a: hosting a shared session requires a signed-in GM

- The Host control now checks cloudUsername(): when the GM isn't signed in it shows
  "🔒 Sign in to host" (→ Settings) instead of "📡 Host". Players still join as guests.
- Realtime e2e: the GM marks itself signed in before hosting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:35:54 +02:00
parent d6eda054bf
commit 9b5c63f23c
3 changed files with 15 additions and 2 deletions
+2 -1
View File
@@ -12,8 +12,9 @@ test('GM hosts a session; a player on another device sees live combat', async ({
const gmCtx = await browser.newContext(); const gmCtx = await browser.newContext();
const playerCtx = await browser.newContext(); const playerCtx = await browser.newContext();
// GM: load the sample campaign and host a session. // GM: load the sample campaign and host a session (hosting requires a signed-in GM).
const gm = await fresh(gmCtx); const gm = await fresh(gmCtx);
await gm.evaluate(() => localStorage.setItem('ttrpg-cloud-user', 'GM'));
gm.on('dialog', (d) => d.dismiss()); // dismiss the optional-password prompt gm.on('dialog', (d) => d.dismiss()); // dismiss the optional-password prompt
await gm.getByLabel('Settings').click(); await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click();
+2 -1
View File
@@ -12,8 +12,9 @@ test('a player claims a seat and manages their character; edits round-trip to th
const gmCtx = await browser.newContext(); const gmCtx = await browser.newContext();
const playerCtx = await browser.newContext(); const playerCtx = await browser.newContext();
// GM: sample campaign + host. // GM: sample campaign + host (hosting requires a signed-in GM).
const gm = await fresh(gmCtx); const gm = await fresh(gmCtx);
await gm.evaluate(() => localStorage.setItem('ttrpg-cloud-user', 'GM'));
gm.on('dialog', (d) => d.dismiss()); // optional-password prompt gm.on('dialog', (d) => d.dismiss()); // optional-password prompt
await gm.getByLabel('Settings').click(); await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click();
+11
View File
@@ -1,13 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore'; import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync'; import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { cloudUsername } from '@/lib/cloud/client';
import { useActiveCampaign } from '@/features/campaigns/hooks'; import { useActiveCampaign } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
/** Header control: GM hosts a live session, shares the code, and approves seats. */ /** Header control: GM hosts a live session, shares the code, and approves seats. */
export function SessionControl() { export function SessionControl() {
const navigate = useNavigate();
const campaign = useActiveCampaign(); const campaign = useActiveCampaign();
const role = useSessionStore((s) => s.role); const role = useSessionStore((s) => s.role);
const status = useSessionStore((s) => s.status); const status = useSessionStore((s) => s.status);
@@ -20,6 +23,14 @@ export function SessionControl() {
if (role === 'off') { if (role === 'off') {
if (!campaign) return null; if (!campaign) return null;
// Hosting a shared session requires the GM to be signed in to an account.
if (!cloudUsername()) {
return (
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
🔒 Sign in to host
</Button>
);
}
return ( return (
<Button size="sm" variant="ghost" title="Host a live session for players" <Button size="sm" variant="ghost" title="Host a live session for players"
onClick={() => { onClick={() => {