fix(login): render the profile's active theme on the login page

The pre-auth login page cannot call /api/prefs/theme (401) and a freshly
opened device has nothing in localStorage, so it always fell back to the
built-in default palette instead of the theme the user set in-app.

Inject the profile's active theme into the login page at render time so it
matches the in-app theme on any device, including a first visit. Scoped to
single-user instances (no way to know whose theme to show before sign-in);
localStorage and the default :root remain fallbacks. The injected JSON is
escaped so it cannot break out of the surrounding <script>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sunny 2026-07-24 12:42:10 -04:00
parent d8a2059df8
commit cf0cc4dc0f
2 changed files with 42 additions and 1 deletions

View file

@ -1,5 +1,6 @@
# src/app_helpers.py # src/app_helpers.py
import base64 import base64
import json
import logging import logging
import os import os
@ -9,6 +10,37 @@ from starlette.requests import Request
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _login_theme_json() -> str:
"""Active profile theme as a script-safe JSON literal for the login page.
The pre-auth login page can't fetch /api/prefs/theme (401) and a fresh
device has nothing in localStorage, so without this it always renders the
built-in default. We inject the profile's active theme so login matches
the theme set in-app. Only a single-user instance is handled with more
than one user there's no way to know whose theme to show before sign-in,
so we return "null" and let the client fall back to localStorage/default.
Returns the string "null" (a valid JS literal) on any miss or error.
"""
try:
from src.constants import USER_PREFS_FILE
with open(USER_PREFS_FILE, "r", encoding="utf-8") as f:
users = (json.load(f) or {}).get("_users", {})
if len(users) != 1:
return "null"
theme = next(iter(users.values())).get("theme")
if not isinstance(theme, dict) or not theme.get("colors"):
return "null"
# Escape so the JSON can't break out of the surrounding <script>.
return (
json.dumps(theme)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
except Exception:
return "null"
def read_if_exists(path: str) -> str: def read_if_exists(path: str) -> str:
"""Read file if it exists, return empty string otherwise.""" """Read file if it exists, return empty string otherwise."""
try: try:
@ -46,6 +78,10 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
raise HTTPException(500, "Internal server error") raise HTTPException(500, "Internal server error")
nonce = getattr(request.state, "csp_nonce", "") nonce = getattr(request.state, "csp_nonce", "")
html = html.replace("{{CSP_NONCE}}", nonce) html = html.replace("{{CSP_NONCE}}", nonce)
# Only the login page carries this placeholder; skip the prefs read for
# every other template (index, backgrounds) that doesn't need it.
if "{{LOGIN_THEME}}" in html:
html = html.replace("{{LOGIN_THEME}}", _login_theme_json())
return HTMLResponse(html) return HTMLResponse(html)

View file

@ -23,7 +23,12 @@
}; };
var THEME_DEFAULT_INTENSITY = { midnight:0.5, terminal:0.8, organs:0.65 }; var THEME_DEFAULT_INTENSITY = { midnight:0.5, terminal:0.8, organs:0.65 };
try { try {
var t = JSON.parse(localStorage.getItem('odysseus-theme')); // Prefer the profile's active theme injected by the server (so the login
// page matches the in-app theme on ANY device, even a first visit with an
// empty localStorage). Falls back to this device's localStorage, then to
// the default :root palette below. The server substitutes a theme object
// or the literal null in the parenthesized slot on the next line.
var t = ({{LOGIN_THEME}}) || JSON.parse(localStorage.getItem('odysseus-theme'));
var s = document.documentElement.style; var s = document.documentElement.style;
if (t && t.colors) { if (t && t.colors) {
var c = t.colors; var c = t.colors;