- Punto 1: la tabella 'Rischi dai requisiti del framework di sicurezza' e ora un riquadro a scorrimento verticale (max-height 60vh, header sticky) invece di allungare la pagina. - Punto 2: elenca SOLO i requisiti applicabili alla classe del soggetto (importante -> Allegato 1, essenziale -> Allegato 2, Det. ACN 164179/2025); le righe non pertinenti non sono piu mostrate (prima solo attenuate). - help.js allineato + cache-buster help.js?v=20260729b su 35 pagine; version.json 1.25.10. - Punto 3 (rimozione sezione all-hazard) NON applicato: contraddice la decisione documentata nel #500 (copertura multirischio art.24 D.Lgs.138/2024) -> escalato. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
17 KiB
HTML
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=20260729"></script>
|
|
<script src="/js/common.js?v=20260627t"></script>
|
|
<script src="/vendor/lucide/lucide.min.js"></script>
|
|
<script src="/js/topnav-v3.js?v=20260729"></script>
|
|
<script src="/js/i18n.js?v=20260729"></script>
|
|
<script src="/js/help.js?v=20260729b"></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' : ''}>
|
|
« Precedente
|
|
</button>
|
|
<button class="btn-ghost btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
|
Successivo »
|
|
</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>
|