feat: add admin user management page with CRUD and i18n

- New admin blueprint with CRUD proxy endpoints for users
- Admin user management template with search, create, edit, toggle active
- Navbar: add admin link for is_admin users (desktop + mobile)
- Register admin blueprint in app factory
- Add IT/EN translations for all admin UI strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-20 11:58:47 +01:00
parent 5cc576cec9
commit 13986f05d7
6 changed files with 873 additions and 0 deletions
+2
View File
@@ -37,11 +37,13 @@ def create_app() -> Flask:
from blueprints.measure import measure_bp from blueprints.measure import measure_bp
from blueprints.maker import maker_bp from blueprints.maker import maker_bp
from blueprints.statistics import statistics_bp from blueprints.statistics import statistics_bp
from blueprints.admin import admin_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(measure_bp, url_prefix="/measure") app.register_blueprint(measure_bp, url_prefix="/measure")
app.register_blueprint(maker_bp, url_prefix="/maker") app.register_blueprint(maker_bp, url_prefix="/maker")
app.register_blueprint(statistics_bp, url_prefix="/statistics") app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
@app.route("/") @app.route("/")
def index(): def index():
+104
View File
@@ -0,0 +1,104 @@
"""Admin blueprint - user management."""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required
from services.api_client import api_client
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def admin_required(f):
"""Decorator to require admin privileges."""
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not user.get("is_admin"):
flash(_("Accesso non autorizzato"), "error")
return redirect(url_for("measure.select_recipe"))
return f(*args, **kwargs)
return decorated
# ============================================================================
# PAGINE (GET)
# ============================================================================
@admin_bp.route("/users")
@login_required
@admin_required
def user_list():
"""User management page."""
resp = api_client.get("/api/users")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
users = []
elif isinstance(resp, list):
users = resp
else:
users = []
return render_template("admin/users.html", users=users)
# ============================================================================
# API PROXY AJAX (JSON)
# ============================================================================
@admin_bp.route("/api/users", methods=["POST"])
@login_required
@admin_required
def api_create_user():
"""Proxy: Create new user."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/users", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/users/<int:user_id>", methods=["PUT"])
@login_required
@admin_required
def api_update_user(user_id: int):
"""Proxy: Update user."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/users/{user_id}", data=data)
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>/password", methods=["PUT"])
@login_required
@admin_required
def api_change_password(user_id: int):
"""Proxy: Change user password."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/users/{user_id}/password", data=data)
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
def api_toggle_active(user_id: int):
"""Proxy: Toggle user active status."""
data = request.get_json(silent=True) or {}
active = data.get("active", True)
resp = api_client.put(f"/api/users/{user_id}", data={"active": active})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
+535
View File
@@ -0,0 +1,535 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__users = {{ users|tojson }};</script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="userManagement(window.__users)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
</div>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<!-- Search -->
<div class="relative w-full sm:w-80">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" x-model="search"
placeholder="{{ _('Cerca utente...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] placeholder-[var(--text-secondary)]
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<!-- New User Button -->
<button @click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuovo Utente') }}
</button>
</div>
<!-- Users Table -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Username') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden sm:table-cell">{{ _('Nome Visualizzato') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Email') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Ruoli') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-color)]">
<template x-for="user in filteredUsers" :key="user.id">
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
@click="openEditModal(user)">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-semibold text-xs"
x-text="user.display_name ? user.display_name[0].toUpperCase() : user.username[0].toUpperCase()"></div>
<div>
<span class="text-sm font-medium text-[var(--text-primary)]" x-text="user.username"></span>
<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>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden sm:table-cell" x-text="user.display_name"></td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="user.email || '-'"></td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="role in user.roles" :key="role">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="{
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist'
}"
x-text="role"></span>
</template>
<template x-if="!user.roles || user.roles.length === 0">
<span class="text-xs text-[var(--text-secondary)]">-</span>
</template>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="user.active
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
x-text="user.active ? '{{ _('Attivo') }}' : '{{ _('Disattivato') }}'"></span>
</td>
<td class="px-4 py-3 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button @click="openEditModal(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="'{{ _('Modifica') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<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'
: 'text-[var(--text-secondary)] hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'"
:title="user.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'">
<svg x-show="user.active" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
<svg x-show="!user.active" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Empty State -->
<template x-if="filteredUsers.length === 0">
<div class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessun utente trovato') }}</p>
</div>
</template>
</div>
<!-- Count -->
<div class="mt-3 text-xs text-[var(--text-secondary)]">
<span x-text="filteredUsers.length"></span> {{ _('utenti') }}
</div>
<!-- Modal: Create / Edit User -->
<div x-show="showModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeModal()">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
<!-- Modal Content -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
x-text="isEditing ? '{{ _('Modifica Utente') }}' : '{{ _('Nuovo Utente') }}'"></h2>
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4 space-y-4">
<!-- Username -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Username') }}</label>
<input type="text" x-model="form.username"
:disabled="isEditing"
:class="isEditing ? 'opacity-50 cursor-not-allowed' : ''"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Username') }}">
<template x-if="isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il nome utente non può essere modificato') }}</p>
</template>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome Visualizzato') }}</label>
<input type="text" x-model="form.display_name"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Nome Visualizzato') }}">
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Email') }}</label>
<input type="email" x-model="form.email"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="email@example.com">
</div>
<!-- Password -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
{{ _('Password') }}
<template x-if="isEditing">
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('lascia vuoto per non modificare') }})</span>
</template>
</label>
<input type="password" x-model="form.password"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
:placeholder="isEditing ? '{{ _('Nuova password (opzionale)') }}' : '{{ _('Password') }}'"
minlength="6">
</div>
<!-- Roles -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
<div class="flex flex-wrap gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="Maker"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">Maker</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="MeasurementTec"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">MeasurementTec</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="Metrologist"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
</label>
</div>
</div>
<!-- Admin + Preferences Row -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Is Admin -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.is_admin"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Amministratore') }}</span>
</label>
</div>
<!-- Language -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Lingua') }}</label>
<select x-model="form.language_pref"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="it">{{ _('Italiano') }}</option>
<option value="en">{{ _('English') }}</option>
</select>
</div>
<!-- Theme -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Tema') }}</label>
<select x-model="form.theme_pref"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="light">{{ _('Chiaro') }}</option>
<option value="dark">{{ _('Scuro') }}</option>
</select>
</div>
</div>
<!-- Error Message -->
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="saveUser()"
:disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Utente') }}'"></span>
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
</button>
</div>
</div>
</div>
<!-- Confirm Toggle Modal -->
<div x-show="showToggleConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="showToggleConfirm = false"></div>
<div x-show="showToggleConfirm"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2"
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
<span x-text="toggleUser?.active
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
<strong x-text="toggleUser?.username"></strong>?
</p>
<div class="flex justify-end gap-3">
<button @click="showToggleConfirm = false"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="confirmToggle()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm disabled:opacity-50 transition-colors"
:class="toggleUser?.active ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'">
<span x-text="toggleUser?.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
{% endblock %}
+28
View File
@@ -73,6 +73,22 @@
</a> </a>
{% endif %} {% endif %}
{# Admin: Utenti #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('admin.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Users Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<span>{{ _('Utenti') }}</span>
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -248,6 +264,18 @@
</a> </a>
{% endif %} {% endif %}
{# Admin: Utenti #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
{{ _('Utenti') }}
</a>
{% endif %}
</div> </div>
</div> </div>
@@ -1605,6 +1605,108 @@ msgstr "Select a measurement point for the histogram"
msgid "Errore nella generazione del report" msgid "Errore nella generazione del report"
msgstr "Error generating report" msgstr "Error generating report"
# Admin - User Management
#: blueprints/admin.py:18
msgid "Accesso non autorizzato"
msgstr "Unauthorized access"
#: blueprints/admin.py:36
#, python-format
msgid "Errore nel caricamento degli utenti: %(error)s"
msgstr "Error loading users: %(error)s"
#: templates/admin/users.html:3 templates/admin/users.html:13
msgid "Gestione Utenti"
msgstr "User Management"
#: templates/admin/users.html:14
msgid "Crea, modifica e gestisci gli utenti del sistema"
msgstr "Create, edit and manage system users"
#: templates/admin/users.html:25
msgid "Cerca utente..."
msgstr "Search user..."
#: templates/admin/users.html:38
msgid "Nuovo Utente"
msgstr "New User"
#: templates/admin/users.html:50
msgid "Email"
msgstr "Email"
#: templates/admin/users.html:95
msgid "Attivo"
msgstr "Active"
#: templates/admin/users.html:95 templates/admin/users.html:323
msgid "Disattivato"
msgstr "Deactivated"
#: templates/admin/users.html:108
msgid "Disattiva"
msgstr "Deactivate"
#: templates/admin/users.html:108 templates/admin/users.html:323
msgid "Riattiva"
msgstr "Reactivate"
#: templates/admin/users.html:130
msgid "Nessun utente trovato"
msgstr "No users found"
#: templates/admin/users.html:137
msgid "utenti"
msgstr "users"
#: templates/admin/users.html:160
msgid "Modifica Utente"
msgstr "Edit User"
#: templates/admin/users.html:292
msgid "Crea Utente"
msgstr "Create User"
#: templates/admin/users.html:207
msgid "lascia vuoto per non modificare"
msgstr "leave empty to keep unchanged"
#: templates/admin/users.html:213
msgid "Nuova password (opzionale)"
msgstr "New password (optional)"
#: templates/admin/users.html:246
msgid "Amministratore"
msgstr "Administrator"
#: templates/admin/users.html:252
msgid "Lingua"
msgstr "Language"
#: templates/admin/users.html:263
msgid "Tema"
msgstr "Theme"
#: templates/admin/users.html:307
msgid "Conferma Disattivazione"
msgstr "Confirm Deactivation"
#: templates/admin/users.html:307
msgid "Conferma Riattivazione"
msgstr "Confirm Reactivation"
#: templates/admin/users.html:310
msgid "Sei sicuro di voler disattivare l'utente"
msgstr "Are you sure you want to deactivate user"
#: templates/admin/users.html:311
msgid "Sei sicuro di voler riattivare l'utente"
msgstr "Are you sure you want to reactivate user"
#: templates/admin/users.html:459
msgid "Username, nome visualizzato e password sono obbligatori"
msgstr "Username, display name and password are required"
#~ msgid "Esci" #~ msgid "Esci"
#~ msgstr "Logout" #~ msgstr "Logout"
@@ -1635,6 +1635,108 @@ msgstr "Seleziona un punto di misura per l'istogramma"
msgid "Errore nella generazione del report" msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report" msgstr "Errore nella generazione del report"
# Admin - User Management
#: blueprints/admin.py:18
msgid "Accesso non autorizzato"
msgstr "Accesso non autorizzato"
#: blueprints/admin.py:36
#, python-format
msgid "Errore nel caricamento degli utenti: %(error)s"
msgstr "Errore nel caricamento degli utenti: %(error)s"
#: templates/admin/users.html:3 templates/admin/users.html:13
msgid "Gestione Utenti"
msgstr "Gestione Utenti"
#: templates/admin/users.html:14
msgid "Crea, modifica e gestisci gli utenti del sistema"
msgstr "Crea, modifica e gestisci gli utenti del sistema"
#: templates/admin/users.html:25
msgid "Cerca utente..."
msgstr "Cerca utente..."
#: templates/admin/users.html:38
msgid "Nuovo Utente"
msgstr "Nuovo Utente"
#: templates/admin/users.html:50
msgid "Email"
msgstr "Email"
#: templates/admin/users.html:95
msgid "Attivo"
msgstr "Attivo"
#: templates/admin/users.html:95 templates/admin/users.html:323
msgid "Disattivato"
msgstr "Disattivato"
#: templates/admin/users.html:108
msgid "Disattiva"
msgstr "Disattiva"
#: templates/admin/users.html:108 templates/admin/users.html:323
msgid "Riattiva"
msgstr "Riattiva"
#: templates/admin/users.html:130
msgid "Nessun utente trovato"
msgstr "Nessun utente trovato"
#: templates/admin/users.html:137
msgid "utenti"
msgstr "utenti"
#: templates/admin/users.html:160
msgid "Modifica Utente"
msgstr "Modifica Utente"
#: templates/admin/users.html:292
msgid "Crea Utente"
msgstr "Crea Utente"
#: templates/admin/users.html:207
msgid "lascia vuoto per non modificare"
msgstr "lascia vuoto per non modificare"
#: templates/admin/users.html:213
msgid "Nuova password (opzionale)"
msgstr "Nuova password (opzionale)"
#: templates/admin/users.html:246
msgid "Amministratore"
msgstr "Amministratore"
#: templates/admin/users.html:252
msgid "Lingua"
msgstr "Lingua"
#: templates/admin/users.html:263
msgid "Tema"
msgstr "Tema"
#: templates/admin/users.html:307
msgid "Conferma Disattivazione"
msgstr "Conferma Disattivazione"
#: templates/admin/users.html:307
msgid "Conferma Riattivazione"
msgstr "Conferma Riattivazione"
#: templates/admin/users.html:310
msgid "Sei sicuro di voler disattivare l'utente"
msgstr "Sei sicuro di voler disattivare l'utente"
#: templates/admin/users.html:311
msgid "Sei sicuro di voler riattivare l'utente"
msgstr "Sei sicuro di voler riattivare l'utente"
#: templates/admin/users.html:459
msgid "Username, nome visualizzato e password sono obbligatori"
msgstr "Username, nome visualizzato e password sono obbligatori"
#~ msgid "Esci" #~ msgid "Esci"
#~ msgstr "Esci" #~ msgstr "Esci"