Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user