Phase 10: accounts + cloud sync + Pathbuilder import

- Lean file-backed accounts (server/src/accounts.ts): scrypt-hashed passwords,
  hashed bearer tokens, per-user backup blob; persisted under DATA_DIR. /api routes
  (register/login/logout, PUT/GET /save) with per-IP throttle + 48MB body limit.
- Client cloud lib + Settings "Cloud sync": sign up / sign in, back up this device,
  restore from cloud (whole-backup push/pull, last-write-wins). Local-first default;
  the assistant key (localStorage) is never part of the synced Dexie backup.
- Pathbuilder 2e import: the existing character import now detects a Pathbuilder
  build JSON and converts it to a PF2e character (abilities/HP/saves/skills).
- Dockerfile creates a node-owned /data; compose mounts a named ttrpg-data volume.
- (Spectator links = existing /play?room=CODE; PDF export = existing Print.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:04:28 +02:00
parent f01e8ffe32
commit 76efc459bb
10 changed files with 448 additions and 5 deletions
+55
View File
@@ -4,10 +4,12 @@ import { useUiStore, type Theme } from '@/stores/uiStore';
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample';
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
import { AssistantSettings } from './AssistantSettings';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
export function SettingsPage() {
@@ -67,6 +69,8 @@ export function SettingsPage() {
<AssistantSettings />
<CloudSync />
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2>
<ul className="space-y-1 text-sm text-muted">
@@ -112,3 +116,54 @@ export function SettingsPage() {
</Page>
);
}
function CloudSync() {
const [user, setUser] = useState<string | null>(cloudUsername());
const [u, setU] = useState('');
const [p, setP] = useState('');
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirmPull, setConfirmPull] = useState(false);
const run = async (fn: () => Promise<void>) => {
setBusy(true); setMsg(null);
try { await fn(); } catch (e) { setMsg(e instanceof CloudError ? e.message : 'Something went wrong.'); } finally { setBusy(false); }
};
const auth = (which: 'in' | 'up') => run(async () => {
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
});
const push = () => run(async () => { const n = await pushBackup(); setMsg(`Backed up to cloud (${Math.round(n / 1024)} KB).`); });
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
return (
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2>
<p className="mb-3 text-sm text-muted">Sign in to back up your campaigns and sync them across devices. Local-first stays the default nothing leaves this device until you push.</p>
{user ? (
<div className="space-y-2">
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<Input className="max-w-40" placeholder="Username" value={u} onChange={(e) => setU(e.target.value)} aria-label="Cloud username" />
<Input className="max-w-40" type="password" placeholder="Password" value={p} onChange={(e) => setP(e.target.value)} aria-label="Cloud password" />
<Button variant="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
</div>
)}
{msg && <p className="mt-2 text-sm text-accent">{msg}</p>}
<Modal open={confirmPull} onClose={() => setConfirmPull(false)} title="Restore from cloud?"
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
</Modal>
</section>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { buildBackup, restoreBackup } from '@/lib/io/backup';
/**
* Optional cloud account + backup sync against the same-origin server (/api).
* Local-first stays the default: this only does anything when the user signs in.
* Sync is a whole-backup push/pull (last-write-wins), not a live merge.
*/
const TOKEN_KEY = 'ttrpg-cloud-token';
const USER_KEY = 'ttrpg-cloud-user';
const base = () => `${location.origin}/api`;
export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); }
function token(): string | null { return localStorage.getItem(TOKEN_KEY); }
function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); }
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); }
export class CloudError extends Error {}
async function authPost(path: string, body: unknown): Promise<{ token: string; username: string }> {
let res: Response;
try {
res = await fetch(`${base()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
} catch {
throw new CloudError('Could not reach the server.');
}
const data = (await res.json().catch(() => ({}))) as { token?: string; username?: string; message?: string };
if (!res.ok || !data.token) throw new CloudError(data.message ?? 'Request failed.');
setSession(data.token, data.username ?? '');
return { token: data.token, username: data.username ?? '' };
}
export function register(username: string, password: string) { return authPost('/register', { username, password }); }
export function login(username: string, password: string) { return authPost('/login', { username, password }); }
export async function logout(): Promise<void> {
const t = token();
if (t) { try { await fetch(`${base()}/logout`, { method: 'POST', headers: { authorization: `Bearer ${t}` } }); } catch { /* ignore */ } }
clearSession();
}
/** Upload this device's full backup to the cloud. */
export async function pushBackup(): Promise<number> {
const t = token();
if (!t) throw new CloudError('Not signed in.');
const blob = JSON.stringify(await buildBackup());
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob }) });
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (!res.ok) throw new CloudError('Upload failed.');
return blob.length;
}
/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */
export async function pullBackup(): Promise<boolean> {
const t = token();
if (!t) throw new CloudError('Not signed in.');
const res = await fetch(`${base()}/save`, { headers: { authorization: `Bearer ${t}` } });
if (res.status === 404) return false;
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (!res.ok) throw new CloudError('Download failed.');
await restoreBackup(await res.text());
return true;
}
+13 -2
View File
@@ -1,6 +1,7 @@
import { characterSchema, type Character } from '@/lib/schemas';
import { newId } from '@/lib/ids';
import { downloadJson, safeFilename } from './file';
import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder';
const FORMAT = 'ttrpg-manager:character';
const VERSION = 1;
@@ -33,6 +34,18 @@ export function parseCharacterImport(text: string, campaignId: string): Characte
throw new CharacterImportError('That file is not valid JSON.');
}
const now = new Date().toISOString();
// Pathbuilder 2e export → convert to our PF2e character shape.
if (isPathbuilder(raw)) {
const result = characterSchema.safeParse({
...pathbuilderToCharacterFields(raw.build),
id: newId(), campaignId, createdAt: now, updatedAt: now,
});
if (!result.success) throw new CharacterImportError(`Pathbuilder import failed: ${result.error.issues[0]?.message ?? 'unknown error'}`);
return result.data;
}
const candidate =
raw && typeof raw === 'object' && 'character' in raw
? (raw as { character: unknown }).character
@@ -41,8 +54,6 @@ export function parseCharacterImport(text: string, campaignId: string): Characte
if (!candidate || typeof candidate !== 'object') {
throw new CharacterImportError('No character data found in that file.');
}
const now = new Date().toISOString();
const result = characterSchema.safeParse({
...(candidate as Record<string, unknown>),
id: newId(),
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder';
import { parseCharacterImport } from './character';
const file = {
build: {
name: 'Valeros', class: 'Fighter', level: 3, ancestry: 'Human', background: 'Soldier',
abilities: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 },
attributes: { ancestryhp: 8, classhp: 10, bonushp: 0, bonushpPerLevel: 0 },
proficiencies: { perception: 4, fortitude: 4, reflex: 4, will: 2, athletics: 4, intimidation: 2 },
},
};
describe('pathbuilder import', () => {
it('detects and converts a build', () => {
expect(isPathbuilder(file)).toBe(true);
expect(isPathbuilder({ build: {} })).toBe(false);
const f = pathbuilderToCharacterFields(file.build);
expect(f.system).toBe('pf2e');
expect(f.name).toBe('Valeros');
expect(f.className).toBe('Fighter');
expect(f.level).toBe(3);
expect(f.abilities!.str).toBe(18);
expect(f.hp!.max).toBe(44); // 8 ancestry + (10 class + 2 con)*3
expect(f.saveRanks!.con).toBe('expert');
expect(f.saveRanks!.wis).toBe('trained');
expect(f.skillRanks!.athletics).toBe('expert');
expect(f.perceptionRank).toBe('expert');
});
it('parseCharacterImport handles a Pathbuilder file end-to-end', () => {
const c = parseCharacterImport(JSON.stringify(file), 'cmp1');
expect(c.name).toBe('Valeros');
expect(c.system).toBe('pf2e');
expect(c.campaignId).toBe('cmp1');
expect(c.hp.max).toBe(44);
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { Character } from '@/lib/schemas';
/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */
const RANK: Record<number, Character['perceptionRank']> = { 0: 'untrained', 2: 'trained', 4: 'expert', 6: 'master', 8: 'legendary' };
const PF2E_SKILLS = ['acrobatics', 'arcana', 'athletics', 'crafting', 'deception', 'diplomacy', 'intimidation', 'medicine', 'nature', 'occultism', 'performance', 'religion', 'society', 'stealth', 'survival', 'thievery'];
export interface PathbuilderBuild {
name?: string; class?: string; level?: number; ancestry?: string; heritage?: string; background?: string;
abilities?: Record<string, number>;
attributes?: { ancestryhp?: number; classhp?: number; bonushp?: number; bonushpPerLevel?: number };
proficiencies?: Record<string, number>;
}
export function isPathbuilder(raw: unknown): raw is { build: PathbuilderBuild } {
return !!raw && typeof raw === 'object' && 'build' in raw && !!(raw as { build?: { class?: unknown } }).build?.class;
}
const rank = (v: number | undefined): Character['perceptionRank'] => RANK[v ?? 0] ?? 'untrained';
export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial<Character> {
const a = b.abilities ?? {};
const abilities = { str: a.str ?? 10, dex: a.dex ?? 10, con: a.con ?? 10, int: a.int ?? 10, wis: a.wis ?? 10, cha: a.cha ?? 10 };
const level = Math.max(1, Math.min(20, b.level ?? 1));
const conMod = Math.floor((abilities.con - 10) / 2);
const at = b.attributes ?? {};
const hpMax = Math.max(1, (at.ancestryhp ?? 0) + (at.bonushp ?? 0) + ((at.classhp ?? 8) + (at.bonushpPerLevel ?? 0) + conMod) * level);
const prof = b.proficiencies ?? {};
const saveRanks: Record<string, Character['perceptionRank']> = {};
if (prof.fortitude) saveRanks.con = rank(prof.fortitude);
if (prof.reflex) saveRanks.dex = rank(prof.reflex);
if (prof.will) saveRanks.wis = rank(prof.will);
const skillRanks: Record<string, Character['perceptionRank']> = {};
for (const s of PF2E_SKILLS) if (prof[s]) skillRanks[s] = rank(prof[s]);
return {
system: 'pf2e',
kind: 'pc',
name: b.name?.trim() || 'Imported Character',
className: b.class ?? '',
ancestry: b.ancestry ?? '',
level,
abilities,
hp: { current: hpMax, max: hpMax, temp: 0 },
saveRanks: saveRanks as Character['saveRanks'],
skillRanks: skillRanks as Character['skillRanks'],
perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception),
...(b.background ? { notes: `Background: ${b.background}${b.heritage ? `\nHeritage: ${b.heritage}` : ''}` } : {}),
};
}