From 5cc5123a0c84271c68bd05f477416118790631b0 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 5 Jun 2026 08:32:11 +0000 Subject: [PATCH] fix(theme): apply per-user theme_pref on login, persist toggle to server alpine-init.js resolved the theme purely from localStorage, so it never reflected the logged-in user's theme_pref: switching user kept the previous user's theme because localStorage persists per-browser. - base.html injects window.__SERVER_THEME from the session theme (empty when anonymous, preserving OS-preference behaviour on the login page). - alpine-init.js now treats the server value as authoritative for logged-in users (server > localStorage > OS > light) and syncs localStorage to it. - The navbar toggle now persists the choice via POST /auth/set-theme, which updates the session and the user's theme_pref, so it survives navigation and matches on the next login. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/flask_app/blueprints/auth.py | 23 ++++++++++++- .../flask_app/static/js/alpine-init.js | 33 ++++++++++++++++++- src/frontend/flask_app/templates/base.html | 3 ++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/frontend/flask_app/blueprints/auth.py b/src/frontend/flask_app/blueprints/auth.py index 387d38c..e0abfd6 100644 --- a/src/frontend/flask_app/blueprints/auth.py +++ b/src/frontend/flask_app/blueprints/auth.py @@ -1,7 +1,7 @@ """Authentication blueprint - login, logout, profile.""" from functools import wraps -from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for +from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for from flask_babel import gettext as _ from services.api_client import api_client @@ -133,6 +133,27 @@ def logout(): return redirect(url_for("auth.login")) +@auth_bp.route("/set-theme", methods=["POST"]) +@login_required +def set_theme(): + """Persist the user's theme toggle (light/dark) to session and profile. + + Called via AJAX from the navbar theme switch so the choice survives + navigation and matches on the next login. Best-effort on the API side: + the session is updated regardless so the current browsing stays correct. + """ + theme = (request.get_json(silent=True) or {}).get("theme", "") + if theme not in ("light", "dark"): + return jsonify({"error": True, "detail": "invalid theme"}), 400 + + session["theme"] = theme + try: + api_client.put("/api/auth/me", data={"theme_pref": theme}) + except Exception: + pass # Non-fatal: session theme is already updated for this session. + return jsonify({"ok": True, "theme": theme}) + + @auth_bp.route("/profile", methods=["GET", "POST"]) @login_required def profile(): diff --git a/src/frontend/flask_app/static/js/alpine-init.js b/src/frontend/flask_app/static/js/alpine-init.js index e5c0e46..926941b 100644 --- a/src/frontend/flask_app/static/js/alpine-init.js +++ b/src/frontend/flask_app/static/js/alpine-init.js @@ -16,9 +16,21 @@ /** * Resolve the initial dark-mode state. - * Priority: localStorage > system preference > light (default). + * Priority: server (per-user theme_pref) > localStorage > system preference > light. + * + * The server value (window.__SERVER_THEME) is authoritative for logged-in + * users: it reflects the theme_pref of whoever is currently authenticated, + * so switching user actually switches theme instead of inheriting the + * previous user's localStorage value. It is empty for anonymous pages. */ function getInitialDark() { + var server = (typeof window !== 'undefined' && window.__SERVER_THEME) || ''; + if (server === 'dark' || server === 'light') { + // Keep localStorage in sync so the rest of the app stays consistent. + try { localStorage.setItem(STORAGE_KEY, server); } catch (_) {} + return server === 'dark'; + } + var stored = null; try { stored = localStorage.getItem(STORAGE_KEY); @@ -70,6 +82,7 @@ toggle: function () { this.dark = !this.dark; this._apply(); + this._persist(); }, set: function (dark) { @@ -77,6 +90,24 @@ this._apply(); }, + // Persist an explicit user choice to the server (session + theme_pref), + // so it survives navigation and matches on the next login. Best-effort. + _persist: function () { + try { + var csrf = document.querySelector('meta[name=csrf-token]'); + fetch('/auth/set-theme', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': (csrf && csrf.content) || '' + }, + body: JSON.stringify({ theme: this.dark ? 'dark' : 'light' }) + }).catch(function () {}); + } catch (_) { + // Non-fatal: localStorage already holds the choice for this browser. + } + }, + _apply: function () { if (this.dark) { document.documentElement.classList.add('dark'); diff --git a/src/frontend/flask_app/templates/base.html b/src/frontend/flask_app/templates/base.html index 10b0a96..86eaae4 100644 --- a/src/frontend/flask_app/templates/base.html +++ b/src/frontend/flask_app/templates/base.html @@ -24,6 +24,9 @@ + + +