diff --git a/backend/main.py b/backend/main.py index e4affe3..4b24332 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Header, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware -import json, os +import json, os, sqlite3, secrets, hashlib +from datetime import datetime, timezone app = FastAPI() @@ -12,11 +13,28 @@ ) _DATA_DIR = os.environ.get("QUESTBOARD_DATA", "/data") +DB_FILE = os.path.join(_DATA_DIR, "questboard.db") +# Legacy single-tenant files, only read once for migration into an account. STATE_FILE = os.path.join(_DATA_DIR, "state.json") CONFIG_FILE = os.path.join(_DATA_DIR, "config.json") -def read_json(path): +def _now(): + return datetime.now(timezone.utc).isoformat() + + +def get_db(): + os.makedirs(_DATA_DIR, exist_ok=True) + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + return conn + + +def _hash_pin(pin, salt): + return hashlib.pbkdf2_hmac("sha256", str(pin).encode(), bytes.fromhex(salt), 100_000).hex() + + +def _read_json(path): if os.path.exists(path): try: with open(path) as f: @@ -26,34 +44,234 @@ def read_json(path): return None -def write_json(path, data): - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: - json.dump(data, f) +def _extract_token(authorization): + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip() + return None -@app.get("/state") -def get_state(): - return read_json(STATE_FILE) or {} +def init_db(): + conn = get_db() + conn.execute( + """CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pin_hash TEXT, + pin_salt TEXT, + config TEXT, + state TEXT, + created_at TEXT + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + created_at TEXT + )""" + ) + conn.commit() + _migrate_legacy(conn) + conn.close() -@app.post("/state") -async def post_state(request: Request): +def _migrate_legacy(conn): + """If there are no accounts yet but legacy JSON files exist, fold them into + a single account so an existing deployment doesn't lose its data.""" + if conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]: + return + old_config = _read_json(CONFIG_FILE) + old_state = _read_json(STATE_FILE) + if old_config is None and old_state is None: + return + name = "My Family" + if isinstance(old_config, dict): + name = old_config.get("familyName") or old_config.get("boardName") or name + conn.execute( + "INSERT INTO accounts (id, name, pin_hash, pin_salt, config, state, created_at) " + "VALUES (?,?,?,?,?,?,?)", + ( + secrets.token_urlsafe(8), + name, + None, + None, + json.dumps(old_config) if old_config is not None else None, + json.dumps(old_state) if old_state is not None else None, + _now(), + ), + ) + conn.commit() + + +init_db() + + +def current_account(authorization: str = Header(None)): + token = _extract_token(authorization) + if not token: + raise HTTPException(status_code=401, detail="Missing token") + conn = get_db() + row = conn.execute( + "SELECT a.* FROM sessions s JOIN accounts a ON a.id = s.account_id WHERE s.token = ?", + (token,), + ).fetchone() + conn.close() + if row is None: + raise HTTPException(status_code=401, detail="Invalid token") + return row + + +# ---- Account / session endpoints ------------------------------------------- + + +@app.get("/accounts") +def list_accounts(): + conn = get_db() + rows = conn.execute( + "SELECT id, name, pin_hash FROM accounts ORDER BY created_at" + ).fetchall() + conn.close() + return [ + {"id": r["id"], "name": r["name"], "has_pin": r["pin_hash"] is not None} + for r in rows + ] + + +@app.post("/accounts") +async def create_account(request: Request): data = await request.json() - write_json(STATE_FILE, data) + name = (data.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Name required") + pin = data.get("pin") + pin_hash = pin_salt = None + if pin: + pin_salt = secrets.token_hex(16) + pin_hash = _hash_pin(pin, pin_salt) + account_id = secrets.token_urlsafe(8) + token = secrets.token_urlsafe(24) + conn = get_db() + conn.execute( + "INSERT INTO accounts (id, name, pin_hash, pin_salt, config, state, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (account_id, name, pin_hash, pin_salt, None, None, _now()), + ) + conn.execute( + "INSERT INTO sessions (token, account_id, created_at) VALUES (?,?,?)", + (token, account_id, _now()), + ) + conn.commit() + conn.close() + return {"id": account_id, "token": token} + + +@app.post("/accounts/{account_id}/login") +async def login(account_id: str, request: Request): + try: + data = await request.json() + except Exception: + data = {} + pin = data.get("pin") + conn = get_db() + acc = conn.execute("SELECT * FROM accounts WHERE id = ?", (account_id,)).fetchone() + if acc is None: + conn.close() + raise HTTPException(status_code=404, detail="No such account") + if acc["pin_hash"]: + if not pin or _hash_pin(pin, acc["pin_salt"]) != acc["pin_hash"]: + conn.close() + raise HTTPException(status_code=401, detail="Incorrect PIN") + token = secrets.token_urlsafe(24) + conn.execute( + "INSERT INTO sessions (token, account_id, created_at) VALUES (?,?,?)", + (token, account_id, _now()), + ) + conn.commit() + conn.close() + return {"token": token} + + +@app.get("/account") +def get_account(account=Depends(current_account)): + return { + "id": account["id"], + "name": account["name"], + "has_pin": account["pin_hash"] is not None, + } + + +@app.post("/account") +async def update_account(request: Request, account=Depends(current_account)): + data = await request.json() + name = (data.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Name required") + conn = get_db() + conn.execute("UPDATE accounts SET name = ? WHERE id = ?", (name, account["id"])) + conn.commit() + conn.close() + return {"ok": True, "name": name} + + +@app.delete("/account") +def delete_account(account=Depends(current_account)): + conn = get_db() + conn.execute("DELETE FROM sessions WHERE account_id = ?", (account["id"],)) + conn.execute("DELETE FROM accounts WHERE id = ?", (account["id"],)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/logout") +def logout(authorization: str = Header(None)): + token = _extract_token(authorization) + if token: + conn = get_db() + conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + conn.close() return {"ok": True} +# ---- Data endpoints (scoped to the caller's account) ----------------------- + + @app.get("/config") -def get_config(): - config = read_json(CONFIG_FILE) - if config is None: +def get_config(account=Depends(current_account)): + if account["config"] is None: return {"needs_setup": True} - return config + return json.loads(account["config"]) @app.post("/config") -async def post_config(request: Request): +async def post_config(request: Request, account=Depends(current_account)): + data = await request.json() + conn = get_db() + conn.execute( + "UPDATE accounts SET config = ? WHERE id = ?", + (json.dumps(data), account["id"]), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/state") +def get_state(account=Depends(current_account)): + if account["state"] is None: + return {} + return json.loads(account["state"]) + + +@app.post("/state") +async def post_state(request: Request, account=Depends(current_account)): data = await request.json() - write_json(CONFIG_FILE, data) + conn = get_db() + conn.execute( + "UPDATE accounts SET state = ? WHERE id = ?", + (json.dumps(data), account["id"]), + ) + conn.commit() + conn.close() return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 35e4751..5089218 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,10 +12,10 @@ import DungeonMap from './components/DungeonMap'; import TileSprite from './components/TileSprite'; import Celebration from './components/Celebration'; import SetupWizard from './components/SetupWizard'; +import AccountGate from './components/AccountGate'; +import { apiFetch, apiPost, getToken, setToken, clearToken, setUnauthorizedHandler } from './api'; import { playHit, playKill, playFanfare, playUndo, playRedeem, playCrit, playKeyPickup, isMuted, setMuted } from './sounds'; -const API = '/api'; - function makeDefaultState(players) { const zeros = Object.fromEntries(players.map(p => [p.id, 0])); return { @@ -173,6 +173,8 @@ function getProjectedOverkillReward(playerId) { } export default function App() { + const [authed, setAuthed] = useState(!!getToken()); + const [accountName, setAccountName] = useState(''); const [config, setConfig] = useState(null); const [needsSetup, setNeedsSetup] = useState(false); const [serverState, setServerState] = useState(null); @@ -223,21 +225,34 @@ export default function App() { const saveState = useCallback(async (state) => { try { - await fetch(`${API}/state`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(state), - }); + await apiPost('/state', state); } catch (e) { console.error('Save failed', e); } }, []); - // Initial load: fetch config, then game state + // Send the user back to the account picker if the session token is rejected. + useEffect(() => { + setUnauthorizedHandler(() => { + setAuthed(false); + setConfig(null); + setServerState(null); + setNeedsSetup(false); + }); + }, []); + + // Initial load: fetch config, then game state (only once authenticated) useEffect(() => { + if (!authed) { setLoading(false); return; } + setLoading(true); async function init() { try { - const cfgRes = await fetch(`${API}/config`); + try { + const accRes = await apiFetch('/account'); + setAccountName((await accRes.json()).name || ''); + } catch (e) { /* non-fatal */ } + + const cfgRes = await apiFetch('/config'); const cfg = await cfgRes.json(); if (cfg.needs_setup) { @@ -248,16 +263,12 @@ export default function App() { setConfig(cfg); - const stateRes = await fetch(`${API}/state`); + const stateRes = await apiFetch('/state'); const fetched = await stateRes.json(); const { state: after, changed, penaltyMsgs } = applyAutoResets(fetched, cfg.players, cfg.weekStartDay ?? 1); if (changed) { - await fetch(`${API}/state`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(after), - }); + await apiPost('/state', after); } setServerState(after); @@ -268,12 +279,12 @@ export default function App() { setLoading(false); } init(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [authed]); // eslint-disable-line react-hooks/exhaustive-deps const loadState = useCallback(async () => { if (Date.now() - lastActionAt.current < 3000) return; try { - const res = await fetch(`${API}/state`); + const res = await apiFetch('/state'); const fetched = await res.json(); const { state: after, changed } = applyAutoResets(fetched, players, config?.weekStartDay ?? 1); if (changed) await saveState(after); @@ -774,16 +785,8 @@ export default function App() { } if (!confirm('This will replace all current data. Continue?')) return; await Promise.all([ - fetch(`${API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(backup.config), - }), - fetch(`${API}/state`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(backup.state), - }), + apiPost('/config', backup.config), + apiPost('/state', backup.state), ]); setConfig(backup.config); setServerState(backup.state); @@ -800,22 +803,72 @@ export default function App() { const freshState = makeDefaultState(wizardConfig.players); const { state: after } = applyAutoResets(freshState, wizardConfig.players, wizardConfig.weekStartDay ?? 1); await Promise.all([ - fetch(`${API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(wizardConfig), - }), - fetch(`${API}/state`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(after), - }), + apiPost('/config', wizardConfig), + apiPost('/state', after), ]); setConfig(wizardConfig); setServerState(after); setNeedsSetup(false); }, []); + const handleAuthenticated = useCallback((token) => { + setToken(token); + setConfig(null); + setServerState(null); + setNeedsSetup(false); + setLoading(true); + setAuthed(true); + }, []); + + const renameAccount = useCallback(async () => { + const next = prompt('Account name:', accountName); + if (next === null) return; + const trimmed = next.trim(); + if (!trimmed || trimmed === accountName) return; + try { + const res = await apiPost('/account', { name: trimmed }); + const data = await res.json(); + setAccountName(data.name || trimmed); + showToast('Account renamed!'); + } catch (e) { + showToast('Rename failed'); + } + }, [accountName, showToast]); + + const switchAccount = useCallback(async () => { + try { await apiPost('/logout'); } catch (e) { /* ignore */ } + clearToken(); + setConfig(null); + setServerState(null); + setNeedsSetup(false); + setSelected(null); + setAuthed(false); + }, []); + + const deleteAccount = useCallback(async () => { + const name = accountName || 'this account'; + // Irreversible: require the user to type the name to confirm. + const typed = prompt(`This permanently deletes "${name}" and ALL its data.\nType the account name to confirm:`); + if (typed === null) return; + if (typed.trim() !== (accountName || '').trim()) { + showToast('Name did not match — not deleted'); + return; + } + try { + await apiFetch('/account', { method: 'DELETE' }); + } catch (e) { + showToast('Delete failed'); + return; + } + clearToken(); + setConfig(null); + setServerState(null); + setNeedsSetup(false); + setSelected(null); + setAccountName(''); + setAuthed(false); + }, [accountName, showToast]); + const handleEditComplete = useCallback(async (wizardConfig) => { const newIds = new Set(wizardConfig.players.map(p => p.id)); const zeros = Object.fromEntries(wizardConfig.players.map(p => [p.id, 0])); @@ -835,16 +888,8 @@ export default function App() { }; await Promise.all([ - fetch(`${API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(wizardConfig), - }), - fetch(`${API}/state`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mergedState), - }), + apiPost('/config', wizardConfig), + apiPost('/state', mergedState), ]); setConfig(wizardConfig); @@ -870,6 +915,15 @@ export default function App() { document.body.classList.toggle('portrait', config?.displayOrientation === 'portrait'); }, [config?.displayOrientation]); + if (!authed) { + return ( + <> + + + + ); + } + if (loading) { return (
@@ -932,6 +986,9 @@ export default function App() { + + +
diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..67ffd7d --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,45 @@ +// Centralized API client: injects the account session token and handles 401s +// by clearing the token and bouncing the user back to the account picker. + +const API = '/api'; +const TOKEN_KEY = 'qb_token'; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token) { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} + +let onUnauthorized = null; + +// App registers a handler so an expired/invalid token sends the user to the gate. +export function setUnauthorizedHandler(fn) { + onUnauthorized = fn; +} + +export async function apiFetch(path, opts = {}) { + const token = getToken(); + const headers = { ...(opts.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${API}${path}`, { ...opts, headers }); + if (res.status === 401) { + clearToken(); + if (onUnauthorized) onUnauthorized(); + throw new Error('Unauthorized'); + } + return res; +} + +export function apiPost(path, body) { + return apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} diff --git a/frontend/src/components/AccountGate.jsx b/frontend/src/components/AccountGate.jsx new file mode 100644 index 0000000..80524d7 --- /dev/null +++ b/frontend/src/components/AccountGate.jsx @@ -0,0 +1,191 @@ +import React, { useState, useEffect } from 'react'; +import TileSprite from './TileSprite'; + +const API = '/api'; + +// Standalone fetch helpers — the gate runs before there is a token, and a 401 +// here (wrong PIN / no such account) must be handled locally, not bounced to +// the global unauthorized handler. +async function listAccounts() { + const res = await fetch(`${API}/accounts`); + return res.ok ? res.json() : []; +} + +async function postJson(path, body) { + const res = await fetch(`${API}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, status: res.status, data }; +} + +const S = { + wrap: { + position: 'relative', zIndex: 2, minHeight: '100vh', + display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, + }, + panel: { + width: '100%', maxWidth: 380, background: 'var(--bg2)', + border: '1px solid var(--border2)', borderRadius: 'var(--radius)', + padding: 24, boxShadow: '0 8px 40px rgba(0,0,0,0.6)', + }, + title: { + color: 'var(--gold2)', fontFamily: 'var(--pixel)', fontSize: 20, + textAlign: 'center', margin: '0 0 4px', + }, + sub: { color: 'var(--text2)', fontSize: 12, textAlign: 'center', margin: '0 0 20px' }, + accountBtn: { + display: 'flex', alignItems: 'center', gap: 8, width: '100%', + background: 'var(--bg4)', color: 'var(--text)', textAlign: 'left', + border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', + padding: '12px 14px', marginBottom: 8, cursor: 'pointer', fontSize: 14, + }, + input: { + width: '100%', background: 'var(--bg)', color: 'var(--text)', + border: '1px solid var(--border2)', borderRadius: 'var(--radius-sm)', + padding: '10px 12px', fontSize: 14, marginBottom: 10, boxSizing: 'border-box', + fontFamily: 'var(--pixel)', + }, + primary: { + width: '100%', background: 'var(--gold-bg)', color: 'var(--gold2)', + border: '1px solid var(--gold-border)', borderRadius: 'var(--radius-sm)', + padding: '12px', fontSize: 14, cursor: 'pointer', fontFamily: 'var(--pixel)', + }, + link: { + display: 'block', width: '100%', background: 'none', color: 'var(--text2)', + border: 'none', padding: '12px 0 0', fontSize: 12, cursor: 'pointer', + textAlign: 'center', + }, + error: { color: 'var(--danger-text)', fontSize: 12, textAlign: 'center', margin: '0 0 10px' }, +}; + +export default function AccountGate({ onAuthenticated }) { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [view, setView] = useState('list'); // 'list' | 'pin' | 'create' + const [active, setActive] = useState(null); // account awaiting a PIN + const [pin, setPin] = useState(''); + const [name, setName] = useState(''); + const [newPin, setNewPin] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + listAccounts().then(a => { setAccounts(a); setLoading(false); }); + }, []); + + function pick(acc) { + setError(''); + if (acc.has_pin) { + setActive(acc); + setPin(''); + setView('pin'); + } else { + doLogin(acc.id, null); + } + } + + async function doLogin(accountId, pinValue) { + setBusy(true); + setError(''); + const { ok, data } = await postJson(`/accounts/${accountId}/login`, pinValue ? { pin: pinValue } : {}); + setBusy(false); + if (ok && data.token) { + onAuthenticated(data.token); + } else { + setError(data.detail || 'Login failed'); + } + } + + async function doCreate() { + if (!name.trim()) { setError('Enter a name'); return; } + setBusy(true); + setError(''); + const { ok, data } = await postJson('/accounts', { name: name.trim(), pin: newPin || undefined }); + setBusy(false); + if (ok && data.token) { + onAuthenticated(data.token); + } else { + setError(data.detail || 'Could not create account'); + } + } + + return ( +
+
+

Questboard

+ + {view === 'list' && ( + <> +

{loading ? 'Loading…' : 'Choose your household'}

+ {error &&

{error}

} + {!loading && accounts.map(acc => ( + + ))} + {!loading && accounts.length === 0 && ( +

No households yet — create the first one.

+ )} + + + )} + + {view === 'pin' && active && ( + <> +

Enter PIN for {active.name}

+ {error &&

{error}

} + setPin(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && !busy) doLogin(active.id, pin); }} + /> + + + + )} + + {view === 'create' && ( + <> +

Create a new household

+ {error &&

{error}

} + setName(e.target.value)} + /> + setNewPin(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && !busy) doCreate(); }} + /> + + + + )} +
+
+ ); +}