[FEAT] UI Fasi 4+5: Impersonate banner + Preferenze + Branding white-label (v1.6.0)

Completamento UI per gli endpoint backend già attivi (commit e4f9e91):

- admin/users.html: colonna Azioni con pulsante "Impersonate" per utenti non-super_admin
  attivi → salva token originale in sessionStorage, sostituisce con quello impersonate,
  redirige a dashboard
- js/common.js: banner persistente arancione "Modalità Impersonate" in tutte le
  pagine quando sessionStorage ha impersonate origin → pulsante "Esci impersonate"
  ripristina token originale e torna ad admin/users
- settings.html: nuovo tab "Preferenze" (lingua/tema/timezone/notifiche email+in-app)
  con form salva via PUT /auth/preferences
- settings.html: nuovo tab "Branding" (solo super_admin / consulente) con
  brand_name/logo_url/primary_color/secondary_color, PUT /branding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-29 13:28:57 +02:00
parent e4f9e9179e
commit a7bd37a797
4 changed files with 251 additions and 3 deletions

View File

@ -231,6 +231,7 @@
<th>Ultimo Login</th>
<th>Attivo</th>
<th>Data Creazione</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
@ -270,6 +271,14 @@
</label>
</td>
<td>${formatDate(user.created_at)}</td>
<td>
${role !== 'super_admin' && isActive
? `<button class="btn btn-secondary btn-sm" onclick="impersonateUser(${user.id}, '${escapeHtml(user.email)}')" title="Entra come questo utente per 1 ora">
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" style="vertical-align:-2px"><path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/></svg>
Impersonate
</button>`
: '<span class="text-muted">-</span>'}
</td>
</tr>`;
});
@ -333,6 +342,27 @@
loadUsers(currentPage);
}
}
// ── Impersonate (Fase 4 / G11 — UI) ──
async function impersonateUser(userId, email) {
if (!confirm('Entrare come ' + email + ' per 1 ora?\n\nSarai loggato come quell\'utente. Tornerai alla tua sessione facendo logout.')) return;
try {
const res = await api.post('/auth/impersonate', { user_id: userId });
if (res.success && res.data && res.data.access_token) {
// Salva sessione originale per "Exit impersonate"
sessionStorage.setItem('nis2_impersonate_origin_token', api.token);
sessionStorage.setItem('nis2_impersonate_origin_refresh', api.refreshToken || '');
sessionStorage.setItem('nis2_impersonate_target_email', email);
// Sostituisce token con quello impersonate (no refresh per default)
api.setTokens(res.data.access_token, '');
window.location.href = '/dashboard.html';
} else {
showNotification(res.message || 'Impersonate fallito.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
</script>
</body>
</html>

View File

@ -291,6 +291,9 @@ function loadSidebar() {
// Firm branding white-label (Fase 5 / G16) — non bloccante
_loadFirmBranding();
// Impersonate banner (Fase 4 / G11 — UI)
_renderImpersonateBanner();
}
const _roleLabels = {
@ -431,6 +434,36 @@ async function _switchOrg(orgId) {
window.location.reload();
}
// ── Impersonate banner persistente (Fase 4 / G11 — UI) ──────────────────
function _renderImpersonateBanner() {
const originToken = sessionStorage.getItem('nis2_impersonate_origin_token');
const targetEmail = sessionStorage.getItem('nis2_impersonate_target_email');
if (!originToken) return;
if (document.getElementById('impersonate-banner')) return;
const bar = document.createElement('div');
bar.id = 'impersonate-banner';
bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#F59E0B;color:#1F2937;padding:8px 16px;text-align:center;font-size:.85rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);';
bar.innerHTML =
'<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" style="vertical-align:-3px;margin-right:6px"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.94 6.94a.75.75 0 011.06 0L10 6.94l.5.5L10 7.94l-.5-.5L9.5 8h-1V7l.5-.5z"/></svg>'
+ 'Modalità Impersonate — stai usando l\'account di <strong>' + (targetEmail || '?') + '</strong> &nbsp;'
+ '<button onclick="_exitImpersonate()" style="background:#1F2937;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:.8rem;font-weight:600;margin-left:8px;">Esci impersonate</button>';
document.body.insertBefore(bar, document.body.firstChild);
// Sposta il contenuto sotto
document.body.style.paddingTop = (bar.offsetHeight + (parseInt(document.body.style.paddingTop) || 0)) + 'px';
}
function _exitImpersonate() {
const originToken = sessionStorage.getItem('nis2_impersonate_origin_token');
const originRefresh = sessionStorage.getItem('nis2_impersonate_origin_refresh');
if (!originToken) return;
api.setTokens(originToken, originRefresh || '');
sessionStorage.removeItem('nis2_impersonate_origin_token');
sessionStorage.removeItem('nis2_impersonate_origin_refresh');
sessionStorage.removeItem('nis2_impersonate_target_email');
window.location.href = '/admin/users.html';
}
// ── Firm branding white-label (Fase 5 / G16) ────────────────────────────
async function _loadFirmBranding() {
try {

View File

@ -210,10 +210,12 @@
<div class="settings-tabs">
<button class="settings-tab active" onclick="switchTab('org')">Organizzazione</button>
<button class="settings-tab" onclick="switchTab('profile')">Profilo</button>
<button class="settings-tab" onclick="switchTab('preferences')">Preferenze</button>
<button class="settings-tab" onclick="switchTab('members')">Membri</button>
<button class="settings-tab" onclick="switchTab('security')">Sicurezza</button>
<button class="settings-tab" onclick="switchTab('apikeys')">API Keys</button>
<button class="settings-tab" onclick="switchTab('webhooks')">Webhook</button>
<button class="settings-tab" id="tab-btn-branding" onclick="switchTab('branding')" style="display:none;">Branding</button>
</div>
<!-- ══════════════ TAB: Organizzazione ══════════════ -->
@ -420,6 +422,71 @@
</div>
<!-- ══════════════ TAB: Membri ══════════════ -->
<!-- ══════════════ TAB: Preferenze (Fase 4 / G12) ══════════════ -->
<div class="tab-panel" id="tab-preferences">
<div class="card">
<div class="card-header">
<h3>Preferenze utente</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Personalizzazione interfaccia e notifiche.</p>
</div>
<div class="card-body">
<form id="preferences-form" style="max-width:560px;">
<div class="form-group">
<label class="form-label" for="pref-lang">Lingua</label>
<select id="pref-lang" class="form-input">
<option value="it">Italiano</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="pref-theme">Tema</label>
<select id="pref-theme" class="form-input">
<option value="auto">Auto (segue sistema)</option>
<option value="light">Chiaro</option>
<option value="dark">Scuro</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="pref-tz">Timezone</label>
<select id="pref-tz" class="form-input">
<option value="Europe/Rome">Europe/Rome (CET/CEST)</option>
<option value="Europe/London">Europe/London (GMT/BST)</option>
<option value="Europe/Paris">Europe/Paris (CET/CEST)</option>
<option value="Europe/Berlin">Europe/Berlin (CET/CEST)</option>
<option value="Europe/Madrid">Europe/Madrid (CET/CEST)</option>
<option value="America/New_York">America/New_York (EST/EDT)</option>
<option value="America/Los_Angeles">America/Los_Angeles (PST/PDT)</option>
<option value="UTC">UTC</option>
</select>
</div>
<div class="form-group">
<label style="display:flex; gap:10px; align-items:center; font-size:.9rem;">
<input type="checkbox" id="pref-notif-email">
<span>Ricevi notifiche via email (incidenti, deadline, training)</span>
</label>
</div>
<div class="form-group">
<label style="display:flex; gap:10px; align-items:center; font-size:.9rem;">
<input type="checkbox" id="pref-notif-inapp">
<span>Mostra notifiche in-app (badge sidebar)</span>
</label>
</div>
<div class="form-group" style="margin-top:24px;">
<button type="submit" class="btn btn-primary" id="pref-save-btn">Salva preferenze</button>
<span id="pref-saved-msg" style="margin-left:12px; color:var(--success); display:none; font-size:.85rem;">✓ Salvato</span>
</div>
</form>
</div>
</div>
</div>
<div class="tab-panel" id="tab-members">
<div class="card">
<div class="card-header">
@ -546,6 +613,44 @@
</div>
</div>
<!-- ══════════════ TAB: Branding (Fase 5 / G16) ══════════════ -->
<div class="tab-panel" id="tab-branding">
<div class="card">
<div class="card-header">
<h3>Branding white-label</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Personalizza colori, logo e nome prodotto per il tuo studio. Visibile a tutti i tuoi clienti loggati.</p>
</div>
<div class="card-body">
<form id="branding-form" style="max-width:560px;">
<div class="form-group">
<label class="form-label" for="brand-name">Nome prodotto custom (opzionale)</label>
<input type="text" id="brand-name" class="form-input" maxlength="120" placeholder="es. Studio Rossi NIS2 Suite">
</div>
<div class="form-group">
<label class="form-label" for="brand-logo">URL Logo (opzionale)</label>
<input type="url" id="brand-logo" class="form-input" maxlength="512" placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label" for="brand-pri">Colore primario</label>
<input type="color" id="brand-pri" class="form-input" style="height:42px; padding:4px;" value="#1e40af">
</div>
<div class="form-group">
<label class="form-label" for="brand-sec">Colore secondario</label>
<input type="color" id="brand-sec" class="form-input" style="height:42px; padding:4px;" value="#06b6d4">
</div>
<div class="form-group" style="margin-top:24px;">
<button type="submit" class="btn btn-primary" id="brand-save-btn">Salva branding</button>
<span id="brand-saved-msg" style="margin-left:12px; color:var(--success); display:none; font-size:.85rem;">✓ Salvato — ricarica per vedere</span>
</div>
</form>
</div>
</div>
</div>
</div>
</main>
</div>
@ -570,24 +675,104 @@
// Carica dati iniziali
loadOrgData();
loadProfileData();
// Mostra tab Branding solo a super_admin / consulente
(async () => {
try {
const me = await api.getMe();
if (me && me.success && me.data && (me.data.role === 'super_admin' || me.data.role === 'consultant')) {
const btn = document.getElementById('tab-btn-branding');
if (btn) btn.style.display = '';
}
} catch (e) { /* silenzioso */ }
})();
// ── Tab Navigation ───────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const tabMap = { org: 0, profile: 1, members: 2, security: 3, apikeys: 4, webhooks: 5 };
const panelMap = { org: 'tab-org', profile: 'tab-profile', members: 'tab-members', security: 'tab-security', apikeys: 'tab-apikeys', webhooks: 'tab-webhooks' };
const tabMap = { org: 0, profile: 1, preferences: 2, members: 3, security: 4, apikeys: 5, webhooks: 6, branding: 7 };
const panelMap = { org: 'tab-org', profile: 'tab-profile', preferences: 'tab-preferences', members: 'tab-members', security: 'tab-security', apikeys: 'tab-apikeys', webhooks: 'tab-webhooks', branding: 'tab-branding' };
document.querySelectorAll('.settings-tab')[tabMap[tab]].classList.add('active');
document.getElementById(panelMap[tab]).classList.add('active');
if (tab === 'preferences') loadPreferences();
if (tab === 'members') loadMembers();
if (tab === 'security') { loadSessions(); loadAuditLog(); }
if (tab === 'apikeys') loadApiKeys();
if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); }
if (tab === 'branding') loadBranding();
}
// ── Preferenze utente (Fase 4 / G12 — UI) ─────────────────
async function loadPreferences() {
try {
const res = await api.get('/auth/preferences');
if (!res.success) return;
const p = res.data;
document.getElementById('pref-lang').value = p.preferred_language || 'it';
document.getElementById('pref-theme').value = p.theme || 'auto';
document.getElementById('pref-tz').value = p.timezone || 'Europe/Rome';
document.getElementById('pref-notif-email').checked = !!p.notif_email;
document.getElementById('pref-notif-inapp').checked = !!p.notif_inapp;
} catch (e) { /* silenzioso */ }
}
// ── Branding white-label (Fase 5 / G16 — UI) ─────────────
async function loadBranding() {
try {
const res = await api.get('/branding/current');
if (!res.success) return;
const b = res.data;
document.getElementById('brand-name').value = b.custom_brand_name || '';
document.getElementById('brand-logo').value = b.logo_url || '';
document.getElementById('brand-pri').value = b.primary_color || '#1e40af';
document.getElementById('brand-sec').value = b.secondary_color || '#06b6d4';
} catch (e) { /* silenzioso */ }
}
document.getElementById('branding-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('brand-save-btn');
const msg = document.getElementById('brand-saved-msg');
btn.disabled = true; btn.textContent = 'Salvataggio...';
try {
await api.put('/branding', {
custom_brand_name: document.getElementById('brand-name').value.trim() || null,
logo_url: document.getElementById('brand-logo').value.trim() || null,
primary_color: document.getElementById('brand-pri').value,
secondary_color: document.getElementById('brand-sec').value,
});
msg.style.display = 'inline';
} catch (e) {
alert('Errore: ' + (e.message || 'impossibile salvare'));
} finally {
btn.disabled = false; btn.textContent = 'Salva branding';
}
});
document.getElementById('preferences-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('pref-save-btn');
const msg = document.getElementById('pref-saved-msg');
btn.disabled = true; btn.textContent = 'Salvataggio...';
try {
await api.put('/auth/preferences', {
preferred_language: document.getElementById('pref-lang').value,
theme: document.getElementById('pref-theme').value,
timezone: document.getElementById('pref-tz').value,
notif_email: document.getElementById('pref-notif-email').checked,
notif_inapp: document.getElementById('pref-notif-inapp').checked,
});
msg.style.display = 'inline'; setTimeout(function(){ msg.style.display = 'none'; }, 3000);
} catch (e) {
alert('Errore: ' + (e.message || 'impossibile salvare'));
} finally {
btn.disabled = false; btn.textContent = 'Salva preferenze';
}
});
// ── Sessioni Multi-Device (Fase 2 / G07) ─────────────────
async function loadSessions() {
const container = document.getElementById('sessions-container');

View File

@ -1 +1 @@
{"version":"1.5.0","build":"20260529e","date":"2026-05-29T14:00:00+02:00","changelog":"Fase 5 Polishing: BrandingController + migration 019_firm_branding (white-label per consulenti), auth-gate.js per documenti riservati. Skip G15 demo (coperto dai simulator esistenti) e G18 refactor (rinviato). Progetto allineamento NIS2↔TRPG COMPLETATO (Fasi 1-5). Vedi docs/GAP_TRPG_NIS2_ALIGNMENT.md"}
{"version":"1.6.0","build":"20260529f","date":"2026-05-29T14:30:00+02:00","changelog":"UI Fasi 4+5: pulsante Impersonate in admin/users.html con banner persistente arancione, tab Preferenze in settings (lingua/tema/timezone/notifiche), tab Branding white-label per super_admin/consulenti (colori + logo + brand name). Vedi docs/GAP_TRPG_NIS2_ALIGNMENT.md"}