4 Commits

Author SHA1 Message Date
Adriano Dal Pastro 5cc5123a0c 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) <noreply@anthropic.com>
2026-06-05 08:32:11 +00:00
Adriano Dal Pastro 2eb5c51353 fix(auth): role-aware post-login landing to avoid 403 for non-MeasurementTec
Login always redirected to measure.select_recipe, which is gated by
@role_required("MeasurementTec"). A Maker-only (or Metrologist-only) user
was therefore bounced straight to a 403 right after a successful login.

Add _post_login_landing() which selects the landing endpoint from the user's
roles (MeasurementTec -> measure, Maker -> maker recipes, Metrologist ->
statistics, admin -> users, else profile). Used for both fresh login and the
already-authenticated short-circuit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:24:20 +00:00
Adriano Dal Pastro b325eb3512 fix(deploy): attach prod stack to the real Traefik network
The prod compose joined the external network 'root_default', but the
running Traefik instance is configured with --providers.docker.network=traefik
and 'root_default' is empty. Traefik therefore had no reachable address for
the tmflow-web/tmflow-api routers and served 404 for tieflow.tielogic.xyz.

Point traefik-net at the external network 'traefik'. After redeploying with
docker-compose.yml the site routes correctly (HTTPS 200, valid ACME cert,
/api/ path-prefix router working).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:12:20 +00:00
Adriano Dal Pastro a7254d8932 fix(tasks): eager-load version in reorder endpoint to fix MissingGreenlet
The reorder_tasks endpoint loaded RecipeTask with selectinload(subtasks)
only. Serializing via TaskResponse reads RecipeTask.recipe_id, a @property
that traverses the version relationship, triggering a lazy load outside the
async greenlet context -> MissingGreenlet error.

Add selectinload(RecipeTask.version), matching the pattern already used in
the get-task flow. Fixes the previously order-dependent test_reorder_tasks
failure; full suite now 179 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:50:58 +00:00
5 changed files with 86 additions and 14 deletions
+1 -1
View File
@@ -93,4 +93,4 @@ networks:
driver: bridge
traefik-net:
external: true
name: root_default
name: traefik
+4 -1
View File
@@ -223,7 +223,10 @@ async def reorder_tasks(
result = await db.execute(
select(RecipeTask)
.where(RecipeTask.id.in_(data.task_ids))
.options(selectinload(RecipeTask.subtasks))
.options(
selectinload(RecipeTask.subtasks),
selectinload(RecipeTask.version),
)
)
tasks_map = {t.id: t for t in result.scalars().all()}
+46 -11
View File
@@ -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
@@ -39,15 +39,32 @@ def role_required(*roles):
return decorator
def _post_login_landing():
"""Pick the landing endpoint based on the logged-in user's roles.
A Maker-only user must not be bounced to /measure (MeasurementTec-only),
which would raise a 403 right after login. Order of preference follows the
primary operator flow first, then editing, analysis, admin.
"""
user = session.get("user", {})
roles = user.get("roles", [])
if "MeasurementTec" in roles:
return url_for("measure.select_recipe")
if "Maker" in roles:
return url_for("maker.recipe_list")
if "Metrologist" in roles:
return url_for("statistics.dashboard")
if user.get("is_admin"):
return url_for("admin.user_list")
return url_for("auth.profile")
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
# Se già loggato, redirect a home
# Se già loggato, redirect alla home in base al ruolo
if session.get("api_key"):
try:
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
return redirect(_post_login_landing())
if request.method == "POST":
username = request.form.get("username", "").strip()
@@ -91,11 +108,8 @@ def login():
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
# Redirect dopo login
try:
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
# Redirect dopo login (in base al ruolo)
return redirect(_post_login_landing())
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
@@ -119,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():
@@ -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');
@@ -24,6 +24,9 @@
<!-- Theme CSS Variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
<!-- Authoritative per-user theme from the server session (empty when anonymous) -->
<script>window.__SERVER_THEME = "{{ current_theme if current_user else '' }}";</script>
<!-- Alpine.js Theme Init (before Alpine loads) -->
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>