diff --git a/cortex/main.py b/cortex/main.py index f28718f..90538a2 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -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) diff --git a/cortex/routers/help.py b/cortex/routers/help.py new file mode 100644 index 0000000..0267956 --- /dev/null +++ b/cortex/routers/help.py @@ -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'' + ) + html = html.replace("", f"{config_tag}\n", 1) + return HTMLResponse(html) diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py new file mode 100644 index 0000000..796a738 --- /dev/null +++ b/cortex/routers/settings.py @@ -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'
{success}
') + if error: + html = html.replace("", f'{error}
') + 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.")) diff --git a/cortex/static/app.js b/cortex/static/app.js index 347c37c..92f7fba 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -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'); diff --git a/cortex/static/help.html b/cortex/static/help.html new file mode 100644 index 0000000..7feae07 --- /dev/null +++ b/cortex/static/help.html @@ -0,0 +1,169 @@ + + + + + +