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:
co-authored by
Claude Fable 5
parent
253b533d3b
commit
de4db373f2
+46
-1
@@ -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
@@ -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}`),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user