Files
nis2-agile/public/admin/users.html
T
DevEnv nis2-agileandClaude Opus 4.8 8799620fba [FIX] Rischi 12 col: titoli gruppo "Rischio Inerente/Residuo" + soglie alert 9-12 (feedback Simon)
Riscontro Simon sulla tabella Gestione Rischi:
- Titoli di gruppo sui tripletti Probabilità/Impatto/Valutazione: da
  "VALUTAZIONE RISCHIO INERENTE/RESIDUO" a "Rischio Inerente"/"Rischio Residuo"
  (risks.html thead + i18n.js grp_inherent/grp_residual IT+EN).
- Ritirata eccezione #3: banda Alert riportata a spec Simon 15-25/9-12/3-8/1-2.
  Simon ha ragione: con P,I in 1-5 i prodotti 7/11/13/14 non esistono → nessun
  buco. alertBand resta >=9 (=9-12 in pratica); aggiornati commento, help.js,
  manuale, KB (9-14 -> 9-12).
- Cache-buster help.js/i18n.js -> 20260730b (i18n va bustato o riscrive il titolo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 07:36:43 +02:00

322 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Utenti · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
/* ── Tabella admin (v3.css non include stili tabella; markup sobrio, no CDN) ── */
.table-container { width:100%; overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:13px; }
thead th { text-align:left; font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:#7a8aa0; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; }
tbody td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:middle; }
tbody tr:last-child td { border-bottom:0; }
tbody tr:hover { background:#F7FAFD; }
td strong { font-weight:700; }
.text-muted { color:var(--muted); }
.visually-hidden { position:absolute !important; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.role-badge { display:inline-flex; padding:2px 7px; border-radius:6px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; white-space:nowrap; }
.role-badge.super_admin { background:#FBE9E9; color:#B23636; }
.role-badge.org_admin { background:#E7F0FB; color:#1F5FA8; }
.role-badge.compliance_manager { background:#E6F4EC; color:#1E7A4E; }
.role-badge.board_member { background:#F0E9FB; color:#7C4DD6; }
.role-badge.auditor { background:#E7F0FB; color:#1F5FA8; }
.role-badge.employee { background:#EEF2F7; color:#5b6b80; }
.active-toggle { position:relative; display:inline-flex; align-items:center; cursor:pointer; }
.active-toggle input { position:absolute; opacity:0; width:0; height:0; }
.toggle-slider { width:36px; height:20px; background:#CBD5E1; border-radius:10px; transition:background .15s; position:relative; }
.toggle-slider::after { content:''; position:absolute; width:16px; height:16px; background:#fff; border-radius:50%; top:2px; left:2px; transition:transform .15s; box-shadow:0 1px 3px rgba(0,0,0,0.2); }
.active-toggle input:checked + .toggle-slider { background:var(--c-green); }
.active-toggle input:checked + .toggle-slider::after { transform:translateX(16px); }
.active-toggle input:focus-visible + .toggle-slider { outline:2px solid var(--gold); outline-offset:2px; }
@media (prefers-reduced-motion:reduce) { .toggle-slider, .toggle-slider::after { transition:none; } }
.org-tags { display:flex; flex-wrap:wrap; gap:4px; }
.org-tag { display:inline-flex; padding:2px 8px; font-size:10.5px; font-weight:600; background:#EEF2F7; color:#5b6b80; border-radius:6px; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pagination { display:flex; align-items:center; justify-content:space-between; padding:14px 14px 2px; border-top:1px solid var(--line); margin-top:6px; }
.pagination-info { font-size:.8125rem; color:var(--muted); }
.pagination-controls { display:flex; gap:8px; }
.btn-ghost[disabled] { opacity:.5; cursor:not-allowed; }
.btn-sm { padding:6px 11px; font-size:11.5px; }
.btn-ghost .ic { color:var(--c-blue); }
.empty-state { text-align:center; padding:60px 24px; color:var(--muted); }
.empty-state .ic { width:48px; height:48px; color:var(--line); margin:0 auto 16px; display:block; }
.empty-state h4 { font-size:1rem; font-weight:700; color:var(--ink); margin:0 0 8px; }
.empty-state p { margin:0; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="/dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><a href="/admin/index.html">Amministrazione</a>
<i data-lucide="chevron-right" class="ic"></i><span>Utenti</span>
</nav>
<div class="page-head">
<div>
<h1>Gestione Utenti</h1>
<p class="sub">Tutti gli utenti registrati sulla piattaforma</p>
</div>
<span class="badge-t red">Super Admin</span>
</div>
<section class="card card-pad">
<div id="users-container" aria-live="polite">
<div class="v3-loading"><span class="v3-spin"></span>Caricamento utenti…</div>
</div>
</section>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide → topnav-v3.js (chrome) → i18n → init pagina -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260730b"></script>
<script src="/js/help.js?v=20260730b"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
let currentPage = 1;
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
return;
}
loadUsers(1);
} catch (e) {
window.location.href = '/dashboard.html';
}
})();
// ── Mappature ────────────────────────────────────────────
const roleLabels = {
super_admin: 'Super Admin',
org_admin: 'Amministratore',
compliance_manager: 'Compliance Manager',
board_member: 'Membro CDA',
auditor: 'Auditor',
employee: 'Dipendente'
};
// ── Caricamento ──────────────────────────────────────────
async function loadUsers(page) {
const container = document.getElementById('users-container');
container.innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento utenti…</div>';
currentPage = page;
try {
const result = await api.request('GET', '/admin/users?page=' + page);
if (result.success) {
// /admin/users risponde paginato: data = {items, total, page, per_page, pages}.
renderUsers((result.data && result.data.items) || (Array.isArray(result.data) ? result.data : []), (result.data && !Array.isArray(result.data)) ? result.data : (result.pagination || {}));
} else {
container.innerHTML = `
<div class="empty-state">
<h4>Errore nel caricamento</h4>
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
</div>`;
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderUsers(users, pagination) {
const container = document.getElementById('users-container');
if (!users || users.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i data-lucide="users" class="ic"></i>
<h4>Nessun utente trovato</h4>
<p>Gli utenti registrati appariranno qui.</p>
</div>`;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
return;
}
let html = `
<div class="table-container">
<table>
<thead>
<tr>
<th scope="col">Nome</th>
<th scope="col">Email</th>
<th scope="col">Ruolo</th>
<th scope="col">Organizzazioni</th>
<th scope="col">Ultimo Login</th>
<th scope="col">Attivo</th>
<th scope="col">Data Creazione</th>
<th scope="col">Azioni</th>
</tr>
</thead>
<tbody>`;
users.forEach(user => {
const role = user.role || 'employee';
const roleLabel = roleLabels[role] || role;
const isActive = user.is_active == 1 || user.is_active === true;
// Organizzazioni
let orgsHtml = '<span class="text-muted">-</span>';
if (user.organizations) {
const orgNames = user.organizations.split(',').filter(n => n.trim());
if (orgNames.length > 0) {
orgsHtml = '<div class="org-tags">';
orgNames.forEach(name => {
orgsHtml += `<span class="org-tag" title="${escapeHtml(name.trim())}">${escapeHtml(name.trim())}</span>`;
});
orgsHtml += '</div>';
}
}
// Ultimo login
const lastLogin = user.last_login_at ? formatDateTime(user.last_login_at) : '<span class="text-muted">Mai</span>';
html += `
<tr>
<td><strong>${escapeHtml(user.full_name || '-')}</strong></td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="role-badge ${role}">${escapeHtml(roleLabel)}</span></td>
<td>${orgsHtml}</td>
<td>${lastLogin}</td>
<td>
<label class="active-toggle" title="${isActive ? 'Attivo' : 'Inattivo'}">
<span class="visually-hidden">Stato attivo utente ${escapeHtml(user.email || '')}</span>
<input type="checkbox" ${isActive ? 'checked' : ''} onchange="toggleUserActive(${user.id}, this.checked)" ${role === 'super_admin' ? 'disabled' : ''} aria-label="${isActive ? 'Disattiva' : 'Attiva'} utente ${escapeHtml(user.email || '')}">
<span class="toggle-slider" aria-hidden="true"></span>
</label>
</td>
<td>${formatDate(user.created_at)}</td>
<td>
${role !== 'super_admin' && isActive
? `<button class="btn-ghost btn-sm" onclick="impersonateUser(${user.id}, '${escapeHtml(user.email)}')" title="Entra come questo utente per 1 ora">
<i data-lucide="repeat" class="ic"></i>
Impersonate
</button>`
: '<span class="text-muted">-</span>'}
</td>
</tr>`;
});
html += '</tbody></table></div>';
// Paginazione
const total = pagination.total || users.length;
const perPage = pagination.per_page || 20;
const page = pagination.page || currentPage;
const totalPages = Math.ceil(total / perPage);
const start = ((page - 1) * perPage) + 1;
const end = Math.min(page * perPage, total);
if (totalPages > 1) {
html += `
<div class="pagination">
<div class="pagination-info">
Visualizzati ${start}-${end} di ${total} utenti
</div>
<div class="pagination-controls">
<button class="btn-ghost btn-sm" onclick="loadUsers(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente
</button>
<button class="btn-ghost btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo;
</button>
</div>
</div>`;
} else if (total > 0) {
html += `
<div class="pagination">
<div class="pagination-info">
Totale: ${total} utent${total === 1 ? 'e' : 'i'}
</div>
<div></div>
</div>`;
}
container.innerHTML = html;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
}
// ── Toggle Attivo/Inattivo ───────────────────────────────
async function toggleUserActive(userId, isActive) {
try {
const result = await api.request('PUT', '/admin/users/' + userId, {
is_active: isActive ? 1 : 0
});
if (result.success) {
showNotification(
isActive ? 'Utente attivato con successo.' : 'Utente disattivato con successo.',
'success'
);
} else {
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
// Ricarica per resettare lo stato
loadUsers(currentPage);
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
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>