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 @@

- -
+
+ +
+ +
+

{{ _('Chi sei?') }}

+
+ +
+
+ +
+

+

{{ _('Inserisci il PIN') }}

+ + +
+ + + + +
+
+

+ {{ _('Accedi con password') }} +

+
+ + +
@@ -98,6 +140,11 @@
+

+ {{ _('Accedi con PIN') }} +

+
+

@@ -115,3 +162,15 @@

{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/src/frontend/flask_app/tests/test_auth.py b/src/frontend/flask_app/tests/test_auth.py index d0ae802..edcb130 100644 --- a/src/frontend/flask_app/tests/test_auth.py +++ b/src/frontend/flask_app/tests/test_auth.py @@ -136,3 +136,67 @@ class TestLanguageSwitch: with client.session_transaction() as sess: # Invalid language should NOT be stored assert sess.get("language") != "xx" + + +class TestPinLogin: + """Login Operatore con nome + PIN (#59).""" + + USER = { + "id": 7, "username": "mario", "display_name": "Mario Rossi", + "roles": ["MeasurementTec"], "is_admin": False, + "language_pref": "it", "theme_pref": "light", "active": True, + } + + def test_login_page_has_pin_view_and_password_link(self, client): + resp = client.get("/auth/login") + assert b"pin-pad.js" in resp.data + assert b"pin-pad" in resp.data # tastierino presente nel markup + assert b'name="password"' in resp.data # form password ancora presente + + def test_pin_users_listed(self, client, mock_api_client): + mock_api_client.get.return_value = [ + {"id": 7, "display_name": "Mario Rossi", "secret": "x"}, + ] + resp = client.get("/auth/pin-users") + assert resp.status_code == 200 + assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}] + + def test_pin_users_empty_on_error(self, client, mock_api_client): + mock_api_client.get.return_value = {"error": True, "detail": "boom"} + assert client.get("/auth/pin-users").get_json() == [] + + def test_pin_users_empty_list_falls_back(self, client, mock_api_client): + mock_api_client.get.return_value = [] + assert client.get("/auth/pin-users").get_json() == [] + + def test_login_pin_success_sets_session(self, client, mock_api_client): + mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER} + mock_api_client.get.return_value = {} + resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"}) + assert resp.status_code == 200 + assert resp.get_json()["ok"] is True + assert resp.get_json()["redirect"] + mock_api_client.post.assert_called_once_with( + "/api/auth/login-pin", data={"user_id": 7, "pin": "1234"}) + with client.session_transaction() as sess: + assert sess["api_key"] == "pin-key" + assert sess["user_id"] == 7 + assert sess.permanent is True + + def test_login_pin_wrong_pin_401(self, client, mock_api_client): + mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"} + resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"}) + assert resp.status_code == 401 + assert resp.get_json()["detail"] == "PIN errato" + + def test_login_pin_locked_423_shows_detail(self, client, mock_api_client): + mock_api_client.post.return_value = { + "error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"} + resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"}) + assert resp.status_code == 423 + assert "10:15" in resp.get_json()["detail"] + + def test_login_pin_invalid_pin_422(self, client, mock_api_client): + resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"}) + assert resp.status_code == 422 + mock_api_client.post.assert_not_called()