Phase 8: optional accounts, per-user data, shared demo key

Guest-first multi-user mode behind AIDND_MULTI_USER (local installs
unchanged): signed-cookie guest sessions bootstrapped by /api/auth/me,
register upgrades the guest in place, login/logout, per-IP rate limits.
Every router scoped by user_id; Settings become per-user with the API
key Fernet-encrypted at rest and write-only through the API. Users
without a key get a server-funded demo key (OpenRouter free models,
20 turns/day, memory bank disabled on demo turns). Public read-only
demo scenarios (seed_demo.py); debug log restricted to local mode.
Frontend: auth modal + guest nudge, 401 re-establish/retry, demo
banner and key management in Settings.

Migrations 13-23 adopt existing data under a local user and encrypt
stored keys. Verified: migration on a copy of real data.db, two-session
isolation + register/login via curl and Chrome, demo cap 429, live
OpenRouter turn through the encrypted-key path, vite build + oxlint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
parththakkar106
2026-07-06 23:04:03 +05:30
co-authored by Claude Fable 5
parent 253b533d3b
commit de4db373f2
26 changed files with 1247 additions and 189 deletions
+46 -1
View File
@@ -1,6 +1,32 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { api } from './api'
import { AuthModal } from './components'
export default function App() {
// null until /auth/me resolves; in local mode multi_user=false hides all auth UI.
const [me, setMe] = useState(null)
const [authMode, setAuthMode] = useState(null) // 'register' | 'login' | null
useEffect(() => {
api.getMe().then(setMe).catch(() => {})
}, [])
const onAuthed = (newMe, mode) => {
setAuthMode(null)
if (mode === 'login') {
// Different user now — reload so every page refetches its scoped data.
window.location.reload()
} else {
setMe(newMe) // register upgrades the same user in place; data unchanged
}
}
const logout = async () => {
try { await api.logout() } catch { /* already logged out */ }
window.location.reload()
}
return (
<>
<nav className="topnav">
@@ -17,8 +43,27 @@ export default function App() {
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings
</NavLink>
{me?.multi_user && (
<div className="nav-account">
{me.is_guest ? (
<>
<span className="guest-nudge">Playing as guest — sign up to keep your adventures</span>
<button onClick={() => setAuthMode('login')}>Log in</button>
<button className="primary" onClick={() => setAuthMode('register')}>Sign up</button>
</>
) : (
<>
<span className="account-email" title={me.email}>{me.email}</span>
<button onClick={logout}>Log out</button>
</>
)}
</div>
)}
</nav>
<Outlet />
<Outlet context={{ me, setMe }} />
{authMode && (
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed} />
)}
</>
)
}
+25 -2
View File
@@ -1,8 +1,19 @@
async function request(path, options = {}) {
// Multi-user mode: a 401 means our session cookie is missing/stale. Hitting
// /api/auth/me creates a fresh guest session, after which the original call
// is retried once.
async function ensureSession() {
await fetch('/api/auth/me')
}
async function request(path, options = {}, isRetry = false) {
const resp = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (resp.status === 401 && !isRetry && path !== '/auth/me') {
await ensureSession()
return request(path, options, true)
}
if (!resp.ok) {
let detail = resp.statusText
try {
@@ -16,13 +27,17 @@ async function request(path, options = {}) {
}
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal) {
async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
const resp = await fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
if (resp.status === 401 && !isRetry) {
await ensureSession()
return streamSSE(path, payload, onEvent, signal, true)
}
if (!resp.ok) {
let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
@@ -46,6 +61,14 @@ async function streamSSE(path, payload, onEvent, signal) {
}
export const api = {
// Auth (Phase 8 — no-ops in local mode beyond getMe)
getMe: () => request('/auth/me'),
register: (email, password) =>
request('/auth/register', { method: 'POST', body: JSON.stringify({ email, password }) }),
login: (email, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => request('/auth/logout', { method: 'POST' }),
// Scenarios
listScenarios: () => request('/scenarios'),
getScenario: (id) => request(`/scenarios/${id}`),
+62
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { api } from './api'
export function downloadJSON(obj, filename) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
@@ -75,6 +76,67 @@ export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
)
}
// Phase 8: register/login for the hosted multi-user mode. `onAuthed(me)` gets
// the fresh /auth/me payload after success.
export function AuthModal({ mode: initialMode, onClose, onAuthed }) {
const [mode, setMode] = useState(initialMode || 'register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const registering = mode === 'register'
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
const me = registering
? await api.register(email, password)
: await api.login(email, password)
onAuthed(me, mode)
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{registering ? 'Create an account' : 'Log in'}</h2>
<p className="modal-hint">
{registering
? 'Everything you’ve played as a guest stays with your new account, and you can pick it up from any device.'
: 'Welcome back — log in to reach your adventures.'}
</p>
<label className="field">
<span className="label">Email</span>
<input type="email" autoFocus required value={email}
onChange={(e) => setEmail(e.target.value)} />
</label>
<label className="field">
<span className="label">Password{registering ? ' (at least 8 characters)' : ''}</span>
<input type="password" required minLength={registering ? 8 : undefined} value={password}
onChange={(e) => setPassword(e.target.value)} />
</label>
{error && <div className="test-error" style={{ marginTop: 4 }}>{error}</div>}
<div className="modal-buttons" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<button type="button" className="linklike" onClick={() => { setMode(registering ? 'login' : 'register'); setError('') }}>
{registering ? 'Have an account? Log in' : 'New here? Create an account'}
</button>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" onClick={onClose}>Cancel</button>
<button type="submit" className="primary" disabled={busy}>
{busy ? '…' : registering ? 'Sign up' : 'Log in'}
</button>
</div>
</div>
</form>
</div>
)
}
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
return (
<label className="field">
+50
View File
@@ -94,6 +94,56 @@ a:hover { color: var(--accent-bright); }
}
.topnav a.navlink:hover { color: var(--text); }
/* Phase 8: account area (hosted multi-user mode only) */
.nav-account {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.nav-account button { padding: 5px 12px; font-size: 0.82rem; }
.guest-nudge {
color: var(--text-dim);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-email {
color: var(--text-dim);
font-size: 0.82rem;
max-width: 220px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
button.linklike {
background: none;
border: none;
padding: 0;
color: var(--accent);
font-size: 0.82rem;
cursor: pointer;
}
button.linklike:hover { text-decoration: underline; }
@media (max-width: 900px) {
.guest-nudge { display: none; }
}
/* Phase 8: demo-key banner (Settings) */
.demo-banner {
border: 1px solid var(--border-bright);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 18px;
color: var(--text-dim);
font-size: 0.88rem;
line-height: 1.5;
}
.demo-banner strong { color: var(--accent-bright); }
.page {
flex: 1;
width: 100%;
+15 -2
View File
@@ -79,19 +79,31 @@ export default function ScenarioEditor() {
}
if (!scenario) return null
// Shared demo scenarios (Phase 8) are visible to everyone but owned by no
// one; the backend rejects edits, so present them read-only.
const readOnly = !!scenario.is_public
return (
<div className="page">
<div className="page-header">
<h1>Edit Scenario</h1>
<h1>{readOnly ? 'Scenario (read-only)' : 'Edit Scenario'}</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScenario}>Export</button>
<button className="danger" onClick={deleteScenario}>Delete</button>
{!readOnly && <button className="danger" onClick={deleteScenario}>Delete</button>}
<button className="primary" onClick={play}>Play</button>
</div>
</div>
{readOnly && (
<div className="demo-banner">
This is a shared demo scenario — it can’t be edited, but you can hit
<strong> Play</strong> to start your own adventure from it, or <strong>Export</strong> and
re-import it as your own copy.
</div>
)}
<fieldset disabled={readOnly} style={{ border: 'none', padding: 0, margin: 0, minWidth: 0 }}>
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." />
@@ -139,6 +151,7 @@ export default function ScenarioEditor() {
</label>
))
)}
</fieldset>
</div>
)
}
+1
View File
@@ -115,6 +115,7 @@ export default function Scenarios() {
<h3>{sc.title}</h3>
<p>{sc.description || 'No description'}</p>
<div className="meta">
{sc.is_public && <span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>}
{splitTags(sc.tags).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
+54 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { api } from '../api'
function DebugLog() {
@@ -45,7 +46,11 @@ function DebugLog() {
}
export default function Settings() {
const { me, setMe } = useOutletContext() ?? {}
const [settings, setSettings] = useState(null)
// The API key is write-only: the server only reports has_api_key, and this
// holds whatever new key the user has typed (empty = leave unchanged).
const [apiKey, setApiKey] = useState('')
const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('')
@@ -55,9 +60,24 @@ export default function Settings() {
const setField = (field, value) => setSettings({ ...settings, [field]: value })
const buildPayload = () => {
const { has_api_key: _hasKey, ...payload } = settings
if (apiKey.trim()) payload.api_key = apiKey.trim()
return payload
}
const afterSave = async () => {
const fresh = await api.getSettings()
setSettings(fresh)
setApiKey('')
if (me?.multi_user) api.getMe().then(setMe).catch(() => {}) // demo banner state
return fresh
}
const save = async () => {
try {
await api.updateSettings(settings)
await api.updateSettings(buildPayload())
await afterSave()
setSaved('Settings saved')
} catch (err) {
setSaved(`Save failed: ${err.message}`)
@@ -65,10 +85,22 @@ export default function Settings() {
setTimeout(() => setSaved(''), 4000)
}
const clearKey = async () => {
try {
await api.updateSettings({ api_key: '' })
await afterSave()
setSaved('API key removed')
} catch (err) {
setSaved(`Failed: ${err.message}`)
}
setTimeout(() => setSaved(''), 4000)
}
const test = async () => {
setTestResult({ pending: true })
try {
await api.updateSettings(settings)
await api.updateSettings(buildPayload())
await afterSave()
setTestResult(await api.testConnection())
} catch (err) {
setTestResult({ ok: false, detail: err.message })
@@ -76,6 +108,7 @@ export default function Settings() {
}
if (!settings) return null
const demo = me?.demo
return (
<div className="page" style={{ maxWidth: 640 }}>
@@ -84,6 +117,14 @@ export default function Settings() {
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
</div>
{demo?.using_demo && (
<div className="demo-banner">
<strong>Using the shared demo key</strong> — {demo.turns_left} of {demo.turns_per_day} free
turns left today (model: {demo.model}). Add your own API key below for unlimited play,
your choice of models, and the memory bank.
</div>
)}
<label className="field">
<span className="label">Endpoint URL (OpenAI-compatible)</span>
<input type="text" value={settings.endpoint_url}
@@ -91,10 +132,15 @@ export default function Settings() {
onChange={(e) => setField('endpoint_url', e.target.value)} />
</label>
<label className="field">
<span className="label">API Key</span>
<input type="password" value={settings.api_key}
placeholder="Leave empty for local endpoints"
onChange={(e) => setField('api_key', e.target.value)} />
<span className="label">API Key {settings.has_api_key ? '(saved — enter a new one to replace it)' : ''}</span>
<div style={{ display: 'flex', gap: 8 }}>
<input type="password" value={apiKey} style={{ flex: 1 }}
placeholder={settings.has_api_key ? '••••••••••••' : 'Leave empty for local endpoints'}
onChange={(e) => setApiKey(e.target.value)} />
{settings.has_api_key && (
<button type="button" onClick={clearKey}>Remove key</button>
)}
</div>
</label>
<label className="field">
<span className="label">Model</span>
@@ -188,7 +234,8 @@ export default function Settings() {
</div>
)}
<DebugLog />
{/* The provider debug log is a single global buffer — local installs only. */}
{me?.multi_user !== true && <DebugLog />}
</div>
)
}