Compare commits

..

1 Commits

Author SHA1 Message Date
Adriano 3c55550d93 Utenti: PIN impostabile/togliibile dall'admin, link Monitor nel menu (#51 #59 #57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:58 +02:00
8 changed files with 348 additions and 370 deletions
@@ -138,6 +138,21 @@ def api_change_password(user_id: int):
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
@login_required
@admin_required
def api_set_pin(user_id: int):
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
data = request.get_json(silent=True) or {}
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@admin_required
+32 -78
View File
@@ -5,7 +5,7 @@ import requests as http_requests
from flask import (
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
g, request, session, url_for,
request, session, url_for,
)
from flask_babel import gettext as _
@@ -654,74 +654,6 @@ def api_pending_authorisation():
return jsonify(resp), 200
def _supervisor_credentials(data: dict):
"""The capoturno's credentials from the request body (#59).
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
from the touch pad) or, as before, ``supervisor_username`` +
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
or ``(None, error_response)`` when neither pair is complete.
"""
pin = str(data.get("supervisor_pin") or "").strip()
user_id = data.get("supervisor_user_id")
if pin and user_id not in (None, ""):
try:
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
except (TypeError, ValueError):
pass # user_id not a number: falls through to the missing-credentials answer
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if username and password:
return {"supervisor_username": username, "supervisor_password": password}, None
return None, (jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400)
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
"""POST an action carrying the capoturno's credentials, forwarding the answer.
api_client reads every 401 as "your API key is dead" and empties the Flask
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
password, and that must not throw the operator out of their session (#59):
when the detail names the supervisor or the PIN, the session is put back and
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
"""
saved = dict(session)
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
if isinstance(resp, dict) and resp.get("error"):
detail = str(resp.get("detail") or "").lower()
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
session.update(saved) # the 401 was about the capoturno, not about this session
g.session_expired = False
resp = {k: v for k, v in resp.items() if k != "session_expired"}
if "supervisor_pin" in creds:
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/pin-users", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_pin_users():
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
Empty list when the backend does not answer, so the modal falls back to the
password form instead of showing a pad nobody can use.
"""
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)
])
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
@login_required
@role_required("MeasurementTec")
@@ -733,10 +665,20 @@ def api_authorise_measurement(measurement_id: int):
written down nowhere and nothing depending on it.
"""
data = request.get_json(silent=True) or {}
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/measurements/{measurement_id}/authorise",
data={"supervisor_username": username, "supervisor_password": password},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ---------------------------------------------------------------------------
@@ -828,12 +770,24 @@ def _supervised_action(run_id: int, action: str):
leave no such record, and is an extra round trip besides.
"""
data = request.get_json(silent=True) or {}
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/production-runs/{run_id}/{action}",
data={
"supervisor_username": username,
"supervisor_password": password,
"note": data.get("note"),
},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
@@ -55,6 +55,10 @@
<template x-if="user.is_admin">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
</template>
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
<template x-if="!user.is_admin && user.has_pin === true">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
</template>
</div>
</div>
</td>
@@ -97,6 +101,10 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
<template x-if="!user.is_admin">
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
</template>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -210,6 +218,20 @@
minlength="6">
</div>
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
<template x-if="!isEditing && !form.is_admin">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
{{ _('PIN') }}
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
</label>
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="1234">
</div>
</template>
<!-- Roles -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
@@ -374,4 +396,59 @@
</div>
</div>
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
<div x-show="showPin" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closePin()">
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
</p>
<template x-if="!pinDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generatePin()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="pinError">
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
</template>
</div>
</template>
<template x-if="pinDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-5">
<div>
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
{{ _('Togli PIN') }}
</button>
</div>
<div class="flex gap-3">
<button @click="closePin()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta PIN') }}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -11,6 +11,13 @@ function userManagement(initialUsers) {
resetPw: '',
resetDone: false,
resetError: '',
// #59: stato del modal PIN
showPin: false,
pinUser: null,
pinValue: '',
pinDone: false,
pinRemoved: false,
pinError: '',
isEditing: false,
editingUserId: null,
toggleUser: null,
@@ -21,6 +28,7 @@ function userManagement(initialUsers) {
display_name: '',
email: '',
password: '',
pin: '', // #59: PIN facoltativo alla creazione
roles: [],
is_admin: false,
language_pref: 'it',
@@ -52,6 +60,7 @@ function userManagement(initialUsers) {
display_name: '',
email: '',
password: '',
pin: '', // #59
roles: [],
is_admin: false,
language_pref: 'it',
@@ -134,6 +143,12 @@ function userManagement(initialUsers) {
return;
}
// #59: PIN facoltativo, 4 cifre, solo per i non admin
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
if (pin && !/^\d{4}$/.test(pin)) {
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
const data = {
username: this.form.username,
password: this.form.password,
@@ -158,6 +173,24 @@ function userManagement(initialUsers) {
}
this.users.push(result);
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
if (pin) {
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
if (!pinResp.ok) {
const pinResult = await pinResp.json().catch(() => ({}));
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
this.isEditing = true;
this.editingUserId = result.id;
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
return;
}
result.has_pin = true;
}
}
this.closeModal();
@@ -218,6 +251,70 @@ function userManagement(initialUsers) {
}
},
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
openPin(user) {
this.pinUser = user;
this.pinValue = '';
this.pinDone = false;
this.pinRemoved = false;
this.pinError = '';
this.showPin = true;
},
closePin() {
this.showPin = false;
this.pinValue = ''; // il PIN generato non resta in memoria
this.pinDone = false;
},
generatePin() {
// 4 cifre da crypto random
const buf = new Uint32Array(4);
crypto.getRandomValues(buf);
this.pinValue = Array.from(buf, n => n % 10).join('');
this.pinError = '';
},
async savePin(pin) {
// pin = stringa di 4 cifre oppure null (toglie il PIN)
this.saving = true;
this.pinError = '';
try {
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
return;
}
// aggiorna l'indicatore locale
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
this.pinRemoved = pin === null;
this.pinDone = true;
} catch (e) {
this.pinError = {{ _('Errore di connessione al server')|tojson }};
} finally {
this.saving = false;
}
},
async confirmPin() {
if (!/^\d{4}$/.test(this.pinValue)) {
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
await this.savePin(this.pinValue);
},
async removePin() {
await this.savePin(null);
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
@@ -8,6 +8,8 @@
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
{% set icon_monitor %}<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>{% endset %}
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
<a href="{{ href }}"
@@ -31,6 +33,10 @@
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %}
{# #57: Monitor produzione, visibile ad admin e capoturno (stessa regola di monitor_required) #}
{% if current_user.get('is_admin') or (current_user.get('roles') and 'Supervisor' in current_user.roles) %}
{{ nav_link(url_for('monitor.index'), _('Monitor'), icon_monitor, ep.startswith('monitor.'), mobile) }}
{% endif %}
{% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
@@ -921,55 +921,7 @@
</div>
</div>
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
<div class="flex gap-2 mb-4" role="tablist">
<button type="button" role="tab" @click="setSupervisorMode('pin')"
:disabled="!supervisorPinUsers.length"
class="btn flex-1 text-sm"
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
<button type="button" role="tab" @click="setSupervisorMode('password')"
class="btn flex-1 text-sm"
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
</div>
<div x-show="supervisorMode === 'pin'" class="mb-5">
{# name grid #}
<div x-show="!supervisorPinUser">
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
<template x-for="u in supervisorPinUsers" :key="u.id">
<button type="button" @click="selectSupervisorUser(u)"
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
</template>
</div>
</div>
{# pad #}
<div x-show="supervisorPinUser">
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
<template x-for="i in 4" :key="i">
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
:class="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
</template>
</div>
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
<button type="button" @click="pressSupervisorPin(d)" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
</template>
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
</div>
</div>
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
</div>
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
<div class="space-y-3 mb-5">
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
@@ -993,13 +945,13 @@
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
</button>
<button @click="submitSupervisor()"
:disabled="!supervisorCanSubmit || supervisorValidating"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
{{ _('Autorizza') }}
</button>
</div>
</div>
@@ -1087,11 +1039,6 @@ function taskExecute() {
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
supervisorUsername: '',
supervisorPassword: '',
// #59: the capoturno can authorise with name + 4-digit PIN instead of the password.
supervisorMode: 'pin', // 'pin' | 'password'
supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users
supervisorPinUser: null,
supervisorPin: '', // never kept past the modal: cleared on open and close
supervisorError: '',
supervisorValidating: false,
/* The out-of-tolerance measurement waiting for the capoturno:
@@ -1885,7 +1832,6 @@ function taskExecute() {
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
this.showSupervisorModal = true;
},
@@ -1894,68 +1840,6 @@ function taskExecute() {
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.supervisorPin = ''; // #59: the PIN does not outlive the modal
this.supervisorPinUser = null;
},
/* #59: PIN mode of the capoturno modal. */
resetSupervisorPin() {
this.supervisorPin = '';
this.supervisorPinUser = null;
this.supervisorMode = 'pin';
this.loadSupervisorPinUsers();
},
// Names with a PIN; an empty list or a failure opens the Password tab instead.
async loadSupervisorPinUsers() {
try {
const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } });
const list = resp.ok ? await resp.json() : [];
this.supervisorPinUsers = Array.isArray(list) ? list : [];
} catch (e) {
this.supervisorPinUsers = [];
}
if (!this.supervisorPinUsers.length) this.supervisorMode = 'password';
},
setSupervisorMode(mode) {
this.supervisorMode = mode;
this.supervisorPin = '';
this.supervisorPinUser = null;
this.supervisorError = '';
},
selectSupervisorUser(u) {
this.supervisorPinUser = u;
this.supervisorPin = '';
this.supervisorError = '';
},
backSupervisorUser() {
this.supervisorPinUser = null;
this.supervisorPin = '';
this.supervisorError = '';
},
// Submits by itself on the fourth digit.
pressSupervisorPin(d) {
if (this.supervisorValidating || this.supervisorPin.length >= 4) return;
this.supervisorPin += d;
this.supervisorError = '';
if (this.supervisorPin.length === 4) this.submitSupervisor();
},
get supervisorCanSubmit() {
if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4;
return !!this.supervisorUsername && !!this.supervisorPassword;
},
// The pair the proxies forward: id + PIN in PIN mode, username + password otherwise.
supervisorCredentials() {
if (this.supervisorMode === 'pin') {
return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin };
}
return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword };
},
/* What kind of task this is, as declared in the recipe.
@@ -2026,15 +1910,16 @@ function taskExecute() {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
body: JSON.stringify({
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
}),
});
const data = await resp.json().catch(() => null);
if (!resp.ok || (data && data.error)) {
this.supervisorError = (data && data.detail)
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared
this.supervisorValidating = false;
return;
}
@@ -2069,7 +1954,10 @@ function taskExecute() {
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
const run = await this.postProduction(base, {
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
});
this.supervisorValidating = false;
if (!run) {
@@ -2077,7 +1965,6 @@ function taskExecute() {
// capoturno sees why the action was refused, rather than a silent no-op.
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
this.productionError = '';
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
return;
}
@@ -0,0 +1,110 @@
"""#59/#51/#57: PIN nella gestione utenti (proxy admin, pannello) e link Monitor nel menu."""
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import api_get_router
USER = {
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
"theme_pref": "light", "active": True, "has_pin": True,
}
@pytest.fixture
def mock_admin_api():
mock = MagicMock()
mock.get.side_effect = api_get_router({
"/api/users": [USER],
"/api/settings": {"auto_logout_minutes": "30"},
})
with patch("blueprints.admin.api_client", mock):
yield mock
def test_pin_proxy_forwards_four_digits(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {"message": "ok"}
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": "1234"})
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": "1234"})
def test_pin_proxy_forwards_null_to_remove(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {"message": "ok"}
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": None})
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": None})
def test_pin_proxy_passes_422_detail(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {
"error": True, "status_code": 422, "detail": "Gli admin non hanno il PIN",
}
resp = logged_in_client.put("/admin/api/users/1/pin", json={"pin": "1234"})
assert resp.status_code == 422
assert resp.get_json()["detail"] == "Gli admin non hanno il PIN"
def test_pin_proxy_requires_admin(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
resp = client.put("/admin/api/users/2/pin", json={"pin": "1234"})
assert resp.status_code != 200
mock_admin_api.put.assert_not_called()
def test_users_panel_has_pin_controls(logged_in_client, mock_admin_api):
html = logged_in_client.get("/admin/settings?tab=utenti").data.decode("utf-8")
assert "openPin(user)" in html # pulsante Imposta/Reset PIN
assert "user.has_pin === true" in html # indicatore PIN impostato
assert "removePin()" in html # Togli PIN
assert "form.pin" in html # campo PIN alla creazione
assert "!user.is_admin" in html # niente PIN per gli admin
def _session_client(client, roles, is_admin):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 5, "username": "u", "display_name": "U", "roles": roles,
"is_admin": is_admin, "language_pref": "it", "theme_pref": "light"}
return client
@pytest.fixture
def mock_monitor_api():
mock = MagicMock()
mock.get.return_value = []
with patch("blueprints.monitor.api_client", mock), \
patch("blueprints.measure.api_client", mock):
yield mock
def test_monitor_link_for_admin(logged_in_client, mock_monitor_api):
html = logged_in_client.get("/monitor").data.decode("utf-8")
assert 'href="/monitor"' in html
def test_monitor_link_for_supervisor(client, mock_monitor_api):
_session_client(client, ["Supervisor"], False)
html = client.get("/monitor").data.decode("utf-8")
assert 'href="/monitor"' in html
def test_monitor_link_absent_for_operator(client, mock_api_client, monkeypatch):
# Stessa preparazione di test_measure.py: la pagina vuole STATION_CODE a config ricaricata
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = []
_session_client(client, ["MeasurementTec"], False)
resp = client.get("/measure/select")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
assert "/measure/select" in html or "Misure" in html or "Measure" in html # menu reso
assert 'href="/monitor"' not in html
@@ -1,168 +0,0 @@
"""Capoturno authorisation by PIN (#59): proxies of the measure blueprint.
The modal in task_execute.html sends either ``supervisor_user_id`` + ``supervisor_pin``
or the old ``supervisor_username`` + ``supervisor_password``; the proxies forward
whichever pair is present and let the PIN/lockout refusals through to the modal.
"""
from unittest.mock import patch
import pytest
from flask import session
RUN = {"id": 7, "status": "paused"}
@pytest.fixture
def measure_mod():
from blueprints import measure
return measure
def test_pin_users_proxy_returns_only_id_and_name(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.return_value = [
{"id": 3, "display_name": "Anna Rossi", "pin_hash": "x"},
{"id": 4, "display_name": "bruno"},
]
resp = logged_in_client.get("/measure/api/pin-users")
assert resp.status_code == 200
assert resp.get_json() == [
{"id": 3, "display_name": "Anna Rossi"},
{"id": 4, "display_name": "bruno"},
]
assert mock_api.get.call_args[0][0] == "/api/auth/pin-users"
def test_pin_users_proxy_empty_on_backend_error(logged_in_client, measure_mod):
"""The modal opens the Password tab when the list is empty."""
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.return_value = {"error": True, "status_code": 502, "detail": "down"}
resp = logged_in_client.get("/measure/api/pin-users")
assert resp.status_code == 200
assert resp.get_json() == []
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
def test_supervised_action_forwards_user_id_and_pin(logged_in_client, measure_mod, action):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
resp = logged_in_client.post(
f"/measure/api/production/7/{action}",
json={"supervisor_user_id": 5, "supervisor_pin": "1234"},
)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == f"/api/production-runs/7/{action}"
assert kwargs["data"]["supervisor_user_id"] == 5
assert kwargs["data"]["supervisor_pin"] == "1234"
assert "supervisor_username" not in kwargs["data"]
assert "supervisor_password" not in kwargs["data"]
def test_supervised_action_password_pair_unchanged(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
resp = logged_in_client.post(
"/measure/api/production/7/pause",
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
)
assert resp.status_code == 200
kwargs = mock_api.post.call_args[1]
assert kwargs["data"]["supervisor_username"] == "capo"
assert kwargs["data"]["supervisor_password"] == "segreta"
assert "supervisor_pin" not in kwargs["data"]
def test_supervised_action_pin_without_user_is_refused(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
resp = logged_in_client.post(
"/measure/api/production/7/pause", json={"supervisor_pin": "1234"},
)
assert resp.status_code == 400
mock_api.post.assert_not_called()
def test_authorise_measurement_forwards_user_id_and_pin(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {"id": 9}
resp = logged_in_client.post(
"/measure/api/measurements/9/authorise",
json={"supervisor_user_id": 5, "supervisor_pin": "4321"},
)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == "/api/measurements/9/authorise"
assert kwargs["data"] == {"supervisor_user_id": 5, "supervisor_pin": "4321"}
def test_wrong_pin_401_stays_in_the_modal_and_keeps_the_session(logged_in_client, measure_mod):
"""api_client empties the session on any 401; a wrong PIN must not log the operator out."""
def refuse(*args, **kwargs):
session.clear() # what api_client._handle_session_expired does
return {
"error": True, "status_code": 401, "session_expired": True,
"detail": "Invalid supervisor credentials",
}
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.side_effect = refuse
resp = logged_in_client.post(
"/measure/api/production/7/close",
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
)
assert resp.status_code == 401
body = resp.get_json()
assert body["detail"] == "PIN errato"
assert "session_expired" not in body
with logged_in_client.session_transaction() as sess:
assert sess.get("api_key") == "test-api-key-12345"
def test_real_expired_session_401_still_clears_the_session(logged_in_client, measure_mod):
"""A 401 that is not about the capoturno keeps its session_expired flag."""
def expired(*args, **kwargs):
session.clear()
return {
"error": True, "status_code": 401, "session_expired": True,
"detail": "Invalid API key",
}
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.side_effect = expired
resp = logged_in_client.post(
"/measure/api/production/7/close",
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
)
assert resp.status_code == 401
assert resp.get_json()["session_expired"] is True
with logged_in_client.session_transaction() as sess:
assert "api_key" not in sess
def test_locked_user_423_passes_the_detail(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 423,
"detail": "Utente bloccato fino alle 10:15",
}
resp = logged_in_client.post(
"/measure/api/production/7/pause",
json={"supervisor_user_id": 5, "supervisor_pin": "1111"},
)
assert resp.status_code == 423
assert resp.get_json()["detail"] == "Utente bloccato fino alle 10:15"
def test_password_401_detail_is_kept(logged_in_client, measure_mod):
"""Password mode: the refusal text is the backend's, as before."""
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 403, "detail": "User is not authorised as supervisor",
}
resp = logged_in_client.post(
"/measure/api/production/7/pause",
json={"supervisor_username": "op", "supervisor_password": "x"},
)
assert resp.status_code == 403
assert resp.get_json()["detail"] == "User is not authorised as supervisor"