""" Account settings router. Routes: GET /settings → show account settings page (requires auth) POST /settings/password → change password POST /settings/username → rename the user account (forces re-login) POST /settings/persona/rename → rename a persona directory """ import logging import re 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, _read_auth, _write_auth from persona import list_user_personas from config import settings as app_settings _SLUG_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") 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) # Connected Google account auth_data = _read_auth(username) google_email = auth_data.get("google_email") or "" html = html.replace("{{ google_email }}", google_email) # Gemini API key — show masked hint only, never the full key gemini_key = auth_data.get("gemini_api_key") or "" if gemini_key: hint = f"Saved (…{gemini_key[-4:]})" else: hint = "Using server key" html = html.replace("{{ gemini_key_hint }}", hint) html = html.replace("{{ gemini_key_set }}", "true" if gemini_key else "false") 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.")) @router.post("/settings/username", include_in_schema=False) async def rename_username( request: Request, new_username: str = Form(...), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) new_username = new_username.strip().lower() if not _SLUG_RE.match(new_username): return HTMLResponse(_settings_page( username, personas, error="Invalid username. Use lowercase letters, digits, _ or - only.")) if new_username == username: return RedirectResponse("/settings", status_code=302) home_root = app_settings.home_root() old_dir = home_root / username new_dir = home_root / new_username if new_dir.exists(): return HTMLResponse(_settings_page( username, personas, error=f"Username '{new_username}' is already taken.")) old_dir.rename(new_dir) logger.info("user renamed: %s → %s", username, new_username) # Clear the auth cookie — old JWT now refers to a non-existent user resp = RedirectResponse("/login?msg=username_changed", status_code=302) resp.delete_cookie(COOKIE_NAME) return resp @router.post("/settings/gemini-key", include_in_schema=False) async def save_gemini_key( request: Request, gemini_api_key: str = Form(...), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) gemini_api_key = gemini_api_key.strip() data = _read_auth(username) if gemini_api_key: data["gemini_api_key"] = gemini_api_key msg = "Gemini API key saved." else: data.pop("gemini_api_key", None) msg = "Gemini API key removed — using server key." _write_auth(username, data) logger.info("gemini key updated: %s", username) return HTMLResponse(_settings_page(username, personas, success=msg)) @router.post("/settings/persona/rename", include_in_schema=False) async def rename_persona( request: Request, old_name: str = Form(...), new_name: str = Form(...), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) new_name = new_name.strip().lower() if not _SLUG_RE.match(new_name): return HTMLResponse(_settings_page( username, personas, error="Invalid name. Use lowercase letters, digits, _ or - only.")) if new_name == old_name: return RedirectResponse("/settings", status_code=302) persona_root = app_settings.home_root() / username / "persona" old_dir = persona_root / old_name new_dir = persona_root / new_name if not old_dir.exists(): return HTMLResponse(_settings_page(username, personas, error=f"Persona '{old_name}' not found.")) if new_dir.exists(): return HTMLResponse(_settings_page( username, personas, error=f"A persona named '{new_name}' already exists.")) old_dir.rename(new_dir) logger.info("persona renamed: %s/%s → %s", username, old_name, new_name) return RedirectResponse("/settings", status_code=302)