Merge lavoro/daitarn-trafilo-utenti-pin

This commit is contained in:
2026-10-07 12:39:58 +02:00
5 changed files with 305 additions and 0 deletions
@@ -138,6 +138,21 @@ def api_change_password(user_id: int):
return jsonify(resp), 200 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"]) @admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required @login_required
@admin_required @admin_required
@@ -55,6 +55,10 @@
<template x-if="user.is_admin"> <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> <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> </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>
</div> </div>
</td> </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"/> <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> </svg>
</button> </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" <button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active :class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20' ? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -210,6 +218,20 @@
minlength="6"> minlength="6">
</div> </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 --> <!-- Roles -->
<div> <div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label> <label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
@@ -374,4 +396,59 @@
</div> </div>
</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> </div>
@@ -11,6 +11,13 @@ function userManagement(initialUsers) {
resetPw: '', resetPw: '',
resetDone: false, resetDone: false,
resetError: '', resetError: '',
// #59: stato del modal PIN
showPin: false,
pinUser: null,
pinValue: '',
pinDone: false,
pinRemoved: false,
pinError: '',
isEditing: false, isEditing: false,
editingUserId: null, editingUserId: null,
toggleUser: null, toggleUser: null,
@@ -21,6 +28,7 @@ function userManagement(initialUsers) {
display_name: '', display_name: '',
email: '', email: '',
password: '', password: '',
pin: '', // #59: PIN facoltativo alla creazione
roles: [], roles: [],
is_admin: false, is_admin: false,
language_pref: 'it', language_pref: 'it',
@@ -52,6 +60,7 @@ function userManagement(initialUsers) {
display_name: '', display_name: '',
email: '', email: '',
password: '', password: '',
pin: '', // #59
roles: [], roles: [],
is_admin: false, is_admin: false,
language_pref: 'it', language_pref: 'it',
@@ -134,6 +143,12 @@ function userManagement(initialUsers) {
return; 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 = { const data = {
username: this.form.username, username: this.form.username,
password: this.form.password, password: this.form.password,
@@ -158,6 +173,24 @@ function userManagement(initialUsers) {
} }
this.users.push(result); 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(); 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) { toggleActive(user) {
this.toggleUser = user; this.toggleUser = user;
this.showToggleConfirm = true; 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_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_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. #} {# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
{% macro nav_link(href, label, icon, active=false, mobile=false) -%} {% macro nav_link(href, label, icon, active=false, mobile=false) -%}
<a href="{{ href }}" <a href="{{ href }}"
@@ -31,6 +33,10 @@
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %} {% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }} {{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %} {% 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') %} {% 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.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) }} {{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
@@ -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