From c764cf73c6ba86510f8d97961b47f3c64ce0e8c4 Mon Sep 17 00:00:00 2001
From: "Adriano (daitarn)"
Date: Wed, 7 Oct 2026 12:23:51 +0200
Subject: [PATCH] Login Operatore con nome + PIN: elenco nomi, tastierino,
route login-pin (#59)
Co-Authored-By: Claude Sonnet 5.5
---
src/frontend/flask_app/blueprints/auth.py | 118 +++++++++++++-----
src/frontend/flask_app/static/js/pin-pad.js | 60 +++++++++
.../flask_app/templates/auth/login.html | 63 +++++++++-
src/frontend/flask_app/tests/test_auth.py | 64 ++++++++++
4 files changed, 274 insertions(+), 31 deletions(-)
create mode 100644 src/frontend/flask_app/static/js/pin-pad.js
diff --git a/src/frontend/flask_app/blueprints/auth.py b/src/frontend/flask_app/blueprints/auth.py
index bf43e1b..e07c759 100644
--- a/src/frontend/flask_app/blueprints/auth.py
+++ b/src/frontend/flask_app/blueprints/auth.py
@@ -59,6 +59,45 @@ def _post_login_landing():
return url_for("auth.profile")
+def _complete_login(response, fallback_name):
+ """Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
+
+ Comune al login con password e al login con PIN, cosi' i due restano identici.
+ Ritorna l'utente salvato in sessione.
+ """
+ # Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
+ # (8h, config.py): senza, Flask usa una sessione di durata browser e il
+ # limite configurato non ha mai effetto (#33).
+ session.permanent = True
+ session["api_key"] = response["api_key"]
+ session["user"] = response["user"]
+ session["user_id"] = response["user"]["id"]
+
+ # Applica preferenze utente
+ user = response["user"]
+ if user.get("language_pref"):
+ session["language"] = user["language_pref"]
+ if user.get("theme_pref"):
+ session["theme"] = user["theme_pref"]
+
+ # Carica impostazioni di sistema
+ settings_resp = api_client.get("/api/settings")
+ if not settings_resp.get("error"):
+ logo_path = settings_resp.get("company_logo_path")
+ if logo_path:
+ session["company_logo"] = logo_path
+ auto_logout = settings_resp.get("auto_logout_minutes")
+ if auto_logout:
+ try:
+ session["auto_logout_minutes"] = int(auto_logout)
+ except (ValueError, TypeError):
+ pass
+
+ flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
+
+ return user
+
+
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
@@ -81,35 +120,7 @@ def login():
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
- # Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
- # (8h, config.py): senza, Flask usa una sessione di durata browser e il
- # limite configurato non ha mai effetto (#33).
- session.permanent = True
- session["api_key"] = response["api_key"]
- session["user"] = response["user"]
- session["user_id"] = response["user"]["id"]
-
- # Applica preferenze utente
- user = response["user"]
- if user.get("language_pref"):
- session["language"] = user["language_pref"]
- if user.get("theme_pref"):
- session["theme"] = user["theme_pref"]
-
- # Carica impostazioni di sistema
- settings_resp = api_client.get("/api/settings")
- if not settings_resp.get("error"):
- logo_path = settings_resp.get("company_logo_path")
- if logo_path:
- session["company_logo"] = logo_path
- auto_logout = settings_resp.get("auto_logout_minutes")
- if auto_logout:
- try:
- session["auto_logout_minutes"] = int(auto_logout)
- except (ValueError, TypeError):
- pass
-
- flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
+ user = _complete_login(response, username)
# Redirect dopo login (in base al ruolo)
return redirect(_post_login_landing())
@@ -121,6 +132,55 @@ def login():
return render_template("auth/login.html")
+@auth_bp.route("/pin-users", methods=["GET"])
+def pin_users():
+ """Elenco nomi per la vista Operatore del login (#59): solo id e nome.
+
+ Pubblica come il login; se il backend non risponde o non ha il PIN, lista
+ vuota e la pagina apre il form password.
+ """
+ if session.get("api_key"):
+ return jsonify([])
+ try:
+ resp = api_client.get("/api/auth/pin-users")
+ except Exception:
+ return jsonify([])
+ if not isinstance(resp, list):
+ return jsonify([])
+ return jsonify([
+ {"id": u.get("id"), "display_name": u.get("display_name", "")}
+ for u in resp if isinstance(u, dict)
+ ])
+
+
+@auth_bp.route("/login-pin", methods=["POST"])
+def login_pin():
+ """Login con nome + PIN (#59). JSON in/out; CSRF come il login password
+ (header X-CSRFToken, gestito da CSRFProtect)."""
+ data = request.get_json(silent=True) or {}
+ user_id = data.get("user_id")
+ pin = str(data.get("pin", ""))
+ if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
+ return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
+
+ try:
+ response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
+ except Exception as e:
+ return jsonify({"error": True, "status_code": 502,
+ "detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
+
+ if response.get("error"):
+ status = response.get("status_code") or 401
+ if status == 401:
+ detail = _("PIN errato")
+ else:
+ detail = response.get("detail") or _("Credenziali non valide")
+ return jsonify({"error": True, "status_code": status, "detail": detail}), status
+
+ _complete_login(response, "") # include il flash di benvenuto
+ return jsonify({"ok": True, "redirect": _post_login_landing()})
+
+
@auth_bp.route("/logout", methods=["POST"])
def logout():
"""Clear session and redirect to login."""
diff --git a/src/frontend/flask_app/static/js/pin-pad.js b/src/frontend/flask_app/static/js/pin-pad.js
new file mode 100644
index 0000000..542b467
--- /dev/null
+++ b/src/frontend/flask_app/static/js/pin-pad.js
@@ -0,0 +1,60 @@
+/* Login Operatore: nome + PIN a 4 cifre (#59).
+ * Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
+ * Contratto: GET /auth/pin-users -> [{id, display_name}];
+ * POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
+function pinLogin() {
+ return {
+ mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
+ users: [],
+ selected: null,
+ pin: '',
+ error: '',
+ busy: false,
+
+ init: function () {
+ var self = this;
+ fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : []; })
+ .then(function (list) {
+ self.users = Array.isArray(list) ? list : [];
+ if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
+ })
+ .catch(function () { self.users = []; self.mode = 'password'; });
+ },
+ select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
+ back: function () { this.selected = null; this.pin = ''; this.error = ''; },
+ del: function () { this.pin = this.pin.slice(0, -1); },
+ press: function (d) {
+ if (this.busy || this.pin.length >= 4) return;
+ this.pin += d;
+ this.error = '';
+ if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
+ },
+ submit: function () {
+ var self = this;
+ var csrf = document.querySelector('meta[name=csrf-token]');
+ self.busy = true;
+ fetch(window.TMF_PIN.loginUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-CSRFToken': (csrf && csrf.content) || ''
+ },
+ body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
+ })
+ .then(function (r) { return r.json().catch(function () { return {}; }); })
+ .then(function (d) {
+ if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
+ self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
+ self.pin = '';
+ self.busy = false;
+ })
+ .catch(function () {
+ self.error = window.TMF_PIN.msgNet;
+ self.pin = '';
+ self.busy = false;
+ });
+ }
+ };
+}
diff --git a/src/frontend/flask_app/templates/auth/login.html b/src/frontend/flask_app/templates/auth/login.html
index 08d1423..98775e7 100644
--- a/src/frontend/flask_app/templates/auth/login.html
+++ b/src/frontend/flask_app/templates/auth/login.html
@@ -26,8 +26,50 @@
-
-