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 (
+ <>
+
{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(); }} + /> + + + > + )} +