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>
);
}