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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user