feat: timer intervallo misura su ricetta + auto-logout per inattività

- Recipe: nuovo campo measurement_interval_minutes (migration 003) con UI nel recipe editor
- Auto-logout: impostazione di sistema configurabile da admin/settings, timer inattività JS
  con warning modale 60s prima del logout, tracking eventi utente throttled
- Navbar: aggiunto link "Impostazioni" per admin (desktop + mobile)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-23 14:36:02 +00:00
parent b9c767fb0c
commit 0748ce9b1e
13 changed files with 387 additions and 4 deletions
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="systemSettings()">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
</div>
<!-- Settings Card -->
<div class="tmf-card">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Sessione e Sicurezza') }}
</div>
<div class="tmf-card-body space-y-5">
<!-- Auto-logout -->
<div>
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
<div class="flex items-center gap-3">
<input id="auto-logout"
type="number"
x-model.number="autoLogoutMinutes"
class="tmf-input w-32"
min="1"
max="480"
placeholder="—">
<button @click="autoLogoutMinutes = null"
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
x-show="autoLogoutMinutes">
{{ _('Disabilita') }}
</button>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
</p>
</div>
</div>
<div class="tmf-card-footer flex items-center justify-between">
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
<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="M5 13l4 4L19 7"/>
</svg>
{{ _('Salvato') }}
</span>
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
<div class="flex-1"></div>
<button @click="save()"
:disabled="saving"
class="btn btn-primary gap-1.5">
<svg x-show="!saving" 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="M5 13l4 4L19 7"/>
</svg>
<svg x-show="saving" 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>
{{ _('Salva impostazioni') }}
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function systemSettings() {
return {
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
saving: false,
saved: false,
errorMessage: '',
async save() {
this.saving = true;
this.saved = false;
this.errorMessage = '';
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
var payload = {
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
};
try {
var resp = await fetch('/admin/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify(payload)
});
var data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || data.error;
} else {
this.saved = true;
setTimeout(() => { this.saved = false; }, 3000);
}
} catch (err) {
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
}
this.saving = false;
}
};
}
</script>
{% endblock %}
@@ -115,6 +115,74 @@
{% block extra_js %}{% endblock %}
{% if current_user and auto_logout_minutes %}
<script>
function inactivityTimer() {
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
var warningMs = 60 * 1000;
return {
lastActivity: Date.now(),
showWarning: false,
remainingSeconds: 60,
_interval: null,
init() {
var self = this;
this._interval = setInterval(function() { self._check(); }, 5000);
var lastUpdate = 0;
var handler = function() {
var now = Date.now();
if (now - lastUpdate > 3000) {
lastUpdate = now;
self.lastActivity = now;
self.showWarning = false;
}
};
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
window.addEventListener(evt, handler, { passive: true });
});
},
resetTimer() {
this.lastActivity = Date.now();
this.showWarning = false;
},
_check() {
var elapsed = Date.now() - this.lastActivity;
var remaining = timeoutMs - elapsed;
if (remaining <= 0) {
clearInterval(this._interval);
window.location.href = '{{ url_for("auth.logout") }}';
} else if (remaining <= warningMs) {
this.showWarning = true;
this.remainingSeconds = Math.ceil(remaining / 1000);
}
}
};
}
</script>
<div x-data="inactivityTimer()" class="contents">
<template x-if="showWarning">
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-5">
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
</p>
<button @click="resetTimer()" class="btn btn-primary w-full">
{{ _('Continua a lavorare') }}
</button>
</div>
</div>
</template>
</div>
{% endif %}
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body>
@@ -102,6 +102,20 @@
</svg>
<span>{{ _('Stazioni') }}</span>
</a>
<a href="{{ url_for('admin.settings_page') }}"
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 == 'admin.settings_page' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<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="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"/>
</svg>
<span>{{ _('Impostazioni') }}</span>
</a>
{% endif %}
</div>
@@ -299,6 +313,17 @@
</svg>
{{ _('Stazioni') }}
</a>
<a href="{{ url_for('admin.settings_page') }}"
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="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"/>
</svg>
{{ _('Impostazioni') }}
</a>
{% endif %}
</div>
@@ -231,6 +231,23 @@
rows="3"
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
</div>
<!-- Intervallo di misura -->
<div>
<label class="tmf-label" for="recipe-interval">
{{ _('Intervallo misura (minuti)') }}
</label>
<input id="recipe-interval"
type="number"
x-model.number="measurementIntervalMinutes"
class="tmf-input"
min="1"
max="1440"
placeholder="{{ _('Es. 30') }}">
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
</p>
</div>
</div>
</div>
</div>
@@ -466,6 +483,9 @@ function recipeEditor() {
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ----
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
// ---- File upload (preview image) ----
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
uploadingFile: false,
@@ -487,6 +507,11 @@ function recipeEditor() {
description: this.description.trim(),
};
// Measurement interval
if (this.measurementIntervalMinutes) {
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
}
// Include image_path for preview thumbnail
if (this.currentFilePath) {
payload.image_path = this.currentFilePath;