feat: account settings page + dedicated help page

- Add /settings page with password change form and personas list
- Add /help dedicated page (replaces help modal); renders HELP.md with
  collapsible sections, dark theme, back link to active persona
- Add 👤 account button and convert ? button to link in header
- Remove help modal HTML and ~55 lines of modal JS from main app
- Register settings and help routers in main.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-23 21:41:18 -04:00
parent c01ef663f5
commit 1b425a539f
9 changed files with 588 additions and 74 deletions

View File

@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
from config import settings
from auth_middleware import SessionAuthMiddleware
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
from routers import ui, onboarding
from routers import ui, onboarding, settings, help
@asynccontextmanager
@@ -42,6 +42,12 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
# Onboarding (invite tokens + persona creation — before ui.router)
app.include_router(onboarding.router)
# Account settings
app.include_router(settings.router)
# Help page
app.include_router(help.router)
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
app.include_router(ui.router)

50
cortex/routers/help.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Help page router.
Routes:
GET /help → full-page help viewer (requires auth)
"""
import logging
from pathlib import Path
import jwt
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token
from persona import list_user_personas
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return decode_token(token)
except jwt.InvalidTokenError:
return None
@router.get("/help", include_in_schema=False)
async def help_page(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
back_persona = personas[0] if personas else ""
back_href = f"/{username}/{back_persona}" if back_persona else "/"
html = (_STATIC / "help.html").read_text()
config_tag = (
f'<script>window.HELP_CONFIG = '
f'{{user: "{username}", persona: "{back_persona}", backHref: "{back_href}"}};</script>'
)
html = html.replace("</head>", f"{config_tag}\n</head>", 1)
return HTMLResponse(html)

View File

@@ -0,0 +1,84 @@
"""
Account settings router.
Routes:
GET /settings → show account settings page (requires auth)
POST /settings/password → change password
"""
import logging
from pathlib import Path
import jwt
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password
from persona import list_user_personas
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return decode_token(token)
except jwt.InvalidTokenError:
return None
def _settings_page(username: str, personas: list[str], success: str = "", error: str = "") -> str:
html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username)
persona_items = "\n".join(
f'<li><a href="/{username}/{p}">{p}</a></li>' for p in personas
)
html = html.replace("{{ persona_items }}", persona_items or "<li><em>No personas yet.</em></li>")
back_persona = personas[0] if personas else ""
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
if error:
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
return html
@router.get("/settings", include_in_schema=False)
async def settings_page(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
return HTMLResponse(_settings_page(username, personas))
@router.post("/settings/password", include_in_schema=False)
async def change_password(
request: Request,
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
if not check_credentials(username, current_password):
return HTMLResponse(_settings_page(username, personas, error="Current password is incorrect."))
if len(new_password) < 8:
return HTMLResponse(_settings_page(username, personas, error="New password must be at least 8 characters."))
if new_password != confirm_password:
return HTMLResponse(_settings_page(username, personas, error="New passwords do not match."))
set_password(username, new_password)
logger.info("password changed: %s", username)
return HTMLResponse(_settings_page(username, personas, success="Password updated successfully."))

View File

@@ -912,8 +912,6 @@
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
if (document.getElementById('help-modal')?.classList.contains('open'))
document.getElementById('help-modal').classList.remove('open');
}
// Ctrl+S to save when file modal is open
if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) {
@@ -1121,62 +1119,6 @@
syncHeight();
addMessage('system', 'Session started');
// ── Help modal ────────────────────────────────────────────────
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const helpBody = document.getElementById('help-modal-body');
const helpClose = document.getElementById('help-close-btn');
// Sections open by default — everything after "Notes" starts collapsed
const HELP_OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
function makeCollapsible(container) {
const h2s = Array.from(container.querySelectorAll('h2'));
for (const h2 of h2s) {
const title = h2.textContent.trim();
const details = document.createElement('details');
if (HELP_OPEN_SECTIONS.has(title)) details.open = true;
const summary = document.createElement('summary');
summary.textContent = title;
details.appendChild(summary);
// Collect all following siblings until the next h2
const siblings = [];
let node = h2.nextSibling;
while (node && node.nodeName !== 'H2') {
siblings.push(node);
node = node.nextSibling;
}
for (const sib of siblings) details.appendChild(sib);
h2.parentNode.replaceChild(details, h2);
}
}
async function openHelp() {
helpBody.textContent = 'Loading…';
helpModal.classList.add('open');
try {
const res = await fetch(`/files/HELP.md?${_fileParams}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
helpBody.innerHTML = marked.parse(data.content);
helpBody.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
makeCollapsible(helpBody);
} catch (err) {
helpBody.textContent = `Failed to load help: ${err.message}`;
}
}
helpBtn.addEventListener('click', openHelp);
helpClose.addEventListener('click', () => helpModal.classList.remove('open'));
helpModal.addEventListener('click', (e) => {
if (e.target === helpModal) helpModal.classList.remove('open');
});
// ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg');

169
cortex/static/help.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Help &amp; Reference</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
background: #0f1117;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f0;
padding: 2rem 1.5rem;
}
.page {
max-width: 720px;
margin: 0 auto;
}
.back-link {
display: inline-block;
font-size: 0.8rem;
color: #64748b;
text-decoration: none;
margin-bottom: 1.5rem;
}
.back-link:hover { color: #a78bfa; }
header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #2d3148;
}
header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; }
header p { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }
#help-body { line-height: 1.7; }
/* Collapsible sections */
details {
margin-bottom: 0.75rem;
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 8px;
overflow: hidden;
}
summary {
padding: 0.85rem 1rem;
font-weight: 600;
font-size: 0.95rem;
color: #cbd5e1;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
summary::before {
content: '▶';
font-size: 0.65rem;
color: #64748b;
transition: transform 0.15s;
}
details[open] summary::before { transform: rotate(90deg); }
summary::-webkit-details-marker { display: none; }
details > *:not(summary) {
padding: 0 1rem 1rem;
}
#help-body p { margin: 0.5rem 0; font-size: 0.9rem; color: #cbd5e1; }
#help-body ul { margin: 0.5rem 0 0.5rem 1.25rem; }
#help-body li { font-size: 0.9rem; color: #cbd5e1; margin-bottom: 0.25rem; }
#help-body strong { color: #e2e8f0; }
#help-body code {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
padding: 0.1em 0.4em;
font-size: 0.85em;
color: #a78bfa;
}
#help-body a { color: #a78bfa; }
#help-body h3 {
font-size: 0.8rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.75rem 0 0.25rem;
}
#loading { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
</style>
</head>
<body>
<div class="page">
<a id="back-link" href="/" class="back-link">← Back to Cortex</a>
<header>
<h1>Help &amp; Reference</h1>
<p id="persona-label"></p>
</header>
<div id="help-body"><p id="loading">Loading…</p></div>
</div>
<script>
const cfg = window.HELP_CONFIG || {};
const user = cfg.user || 'scott';
const persona = cfg.persona || 'inara';
const params = `user=${encodeURIComponent(user)}&persona=${encodeURIComponent(persona)}`;
// Wire up back link and persona label
document.getElementById('back-link').href = cfg.backHref || '/';
if (persona) {
document.getElementById('persona-label').textContent =
`${persona.charAt(0).toUpperCase() + persona.slice(1)} · ${user}`;
}
const OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
function makeCollapsible(container) {
const h2s = Array.from(container.querySelectorAll('h2'));
for (const h2 of h2s) {
const title = h2.textContent.trim();
const details = document.createElement('details');
if (OPEN_SECTIONS.has(title)) details.open = true;
const summary = document.createElement('summary');
summary.textContent = title;
details.appendChild(summary);
const siblings = [];
let node = h2.nextSibling;
while (node && node.nodeName !== 'H2') {
siblings.push(node);
node = node.nextSibling;
}
for (const sib of siblings) details.appendChild(sib);
h2.parentNode.replaceChild(details, h2);
}
}
async function loadHelp() {
try {
const res = await fetch(`/files/HELP.md?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const body = document.getElementById('help-body');
body.innerHTML = marked.parse(data.content);
body.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
makeCollapsible(body);
} catch (err) {
document.getElementById('help-body').textContent = `Failed to load help: ${err.message}`;
}
}
loadHelp();
</script>
</body>
</html>

View File

@@ -30,7 +30,8 @@
<button id="sessions-btn" class="hdr-btn">Sessions</button>
<button id="files-btn" class="hdr-btn">Files</button>
<button id="ctx-open-btn" class="hdr-btn" title="Settings"><span class="tier-badge">2</span></button>
<button id="help-btn" class="hdr-btn" title="Help &amp; reference">?</button>
<a href="/help" class="hdr-btn" title="Help &amp; reference" style="text-decoration:none">?</a>
<a id="account-btn" href="/settings" class="hdr-btn" title="Account settings" style="text-decoration:none">👤</a>
<form method="POST" action="/logout" style="margin:0">
<button type="submit" class="hdr-btn" title="Sign out" id="logout-btn"></button>
</form>
@@ -102,17 +103,6 @@
</div>
</div>
<!-- Help modal -->
<div id="help-modal">
<div id="help-modal-inner">
<div id="help-modal-header">
<h2>Cortex — Help &amp; Reference</h2>
<button class="fm-btn" id="help-close-btn"></button>
</div>
<div id="help-modal-body"></div>
</div>
</div>
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
<div id="auth-banner">
<div id="auth-banner-text">

205
cortex/static/settings.html Normal file
View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Account Settings</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1117;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f0;
padding: 1.5rem;
}
.card {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 480px;
}
.back-link {
display: inline-block;
font-size: 0.8rem;
color: #64748b;
text-decoration: none;
margin-bottom: 1.5rem;
}
.back-link:hover { color: #a78bfa; }
.logo {
margin-bottom: 1.75rem;
}
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
.logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
h2 {
font-size: 0.9rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 1rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid #2d3148;
}
.section { margin-bottom: 2rem; }
label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: #94a3b8;
margin-bottom: 0.4rem;
}
input {
width: 100%;
padding: 0.65rem 0.85rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: #7c3aed; }
input[readonly] { color: #64748b; cursor: default; }
.field { margin-bottom: 1rem; }
button[type="submit"] {
width: 100%;
padding: 0.7rem;
margin-top: 0.25rem;
background: #7c3aed;
border: none;
border-radius: 6px;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
button[type="submit"]:hover { background: #6d28d9; }
.error {
color: #f87171;
font-size: 0.85rem;
text-align: center;
margin-bottom: 1rem;
}
.success {
color: #4ade80;
font-size: 0.85rem;
text-align: center;
margin-bottom: 1rem;
}
.persona-list {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.persona-list li a {
display: inline-block;
padding: 0.3rem 0.75rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 20px;
color: #a78bfa;
font-size: 0.85rem;
text-decoration: none;
transition: border-color 0.15s;
}
.persona-list li a:hover { border-color: #7c3aed; }
.persona-list li em { color: #475569; font-size: 0.85rem; }
.add-persona {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.8rem;
color: #64748b;
text-decoration: none;
}
.add-persona:hover { color: #a78bfa; }
</style>
</head>
<body>
<div class="card">
<a href="{{ back_href }}" class="back-link">← Back to Cortex</a>
<div class="logo">
<h1>Account Settings</h1>
<p>Manage your account and personas.</p>
</div>
<!-- SUCCESS -->
<!-- ERROR -->
<!-- Account info -->
<div class="section">
<h2>Account</h2>
<div class="field">
<label>Username</label>
<input type="text" value="{{ username }}" readonly>
</div>
</div>
<!-- Change password -->
<div class="section">
<h2>Change Password</h2>
<form method="POST" action="/settings/password">
<div class="field">
<label for="current_password">Current password</label>
<input type="password" id="current_password" name="current_password"
autocomplete="current-password" required>
</div>
<div class="field">
<label for="new_password">New password</label>
<input type="password" id="new_password" name="new_password"
autocomplete="new-password" required minlength="8">
</div>
<div class="field">
<label for="confirm_password">Confirm new password</label>
<input type="password" id="confirm_password" name="confirm_password"
autocomplete="new-password" required>
</div>
<button type="submit">Update password</button>
</form>
</div>
<!-- Personas -->
<div class="section">
<h2>Personas</h2>
<ul class="persona-list">
{{ persona_items }}
</ul>
<a href="/setup/persona" class="add-persona">+ Add new persona</a>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', e => {
const np = document.getElementById('new_password').value;
const cfm = document.getElementById('confirm_password').value;
if (np !== cfm) {
e.preventDefault();
alert('New passwords do not match.');
}
});
</script>
</body>
</html>