[FEAT] Allineamento NIS2 ↔ TRPG (Fasi 1-5): SSO + Sessions + Reset + Impersonate + Branding

Implementazione completa del progetto allineamento alla suite Evix (TRPG/lg231),
basato sul doc canonico docs/GAP_TRPG_NIS2_ALIGNMENT.md (5 fasi, 18 gap).

Version 1.0.0 → 1.5.0

Fase 1 — SSO Federation (v1.1.0)
- Migration 015_sso_columns: users.sso_identity_id + password_version
- application/services/SsoHelper.php (client SSO dual-mode, cURL nativo, zero deps)
- AuthController::login() + changePassword() conditional SSO (SSO_MODE=local default)

Fase 2 — Multi-device Sessions (v1.2.0)
- Migration 016_active_sessions: tabella + refresh_tokens.session_jti
- BaseController::requireAuth() verifica jti + last_activity throttle + parseDeviceLabel
- login() genera jti, logout/changePassword revoca selettiva
- GET/DELETE /auth/sessions[/{id}]
- UI settings.html tab Sicurezza con lista device + revoca

Fase 3 — Password Reset + Tenant Switcher (v1.3.0)
- Migration 017_password_reset_tokens (TTL 30min, single-use)
- POST /auth/forgot-password (risposta opaca) + reset-password
- Pagine forgot-password.html + reset-password.html (con strength bar)
- EmailService::sendPasswordReset
- POST /auth/switchContext con rotazione JWT + organization_id claim
- Dropdown tenant in sidebar esposto a tutti gli utenti con ≥2 org

Fase 4 — Impersonate + Preferences + Versioning UI (v1.4.0)
- POST /auth/impersonate (super_admin o consulente stesso firm, TTL 1h, audit)
- Migration 018_user_preferences: users.theme/timezone/notif_email/notif_inapp
- GET/PUT /auth/preferences
- Sidebar footer mostra versione + changelog modal su click

Fase 5 — Branding white-label + Auth-gate (v1.5.0)
- Migration 019_firm_branding (logo/colori/brand_name per consulting firm)
- BrandingController GET /branding/current (auth opzionale) + PUT
- common.js auto-applica CSS variables al boot
- public/js/auth-gate.js (gate password client-side per docs riservati, da TRPG)

Skip motivati:
- G15 demo login: simulator esistenti coprono
- G18 refactor controllers: rinviato (~5gg, valore tecnico solo)

Cron sync SSO: AgileHub Ticket #220 aperto a team AGILEHUB per estendere
sso-password-sync.sh al DB nis2_agile_db. Prerequisito per switch SSO_MODE=dual.

Backup files: tutti i file modificati hanno .bak.pre-{fase}-{ts} sia in DEV
sia in /var/www/nis2-agile/.backups/ su Hetzner (rollback ready).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-05-29 13:18:35 +02:00
co-authored by Claude Opus 4.7
parent c37423f900
commit e4f9e9179e
21 changed files with 2636 additions and 152 deletions
+37
View File
@@ -0,0 +1,37 @@
/**
* Auth gate per documenti tecnici NIS2 Agile (adattato da TRPG, Fase 5 / G17).
* Protezione lato client (non crittografica) — scopo: evitare visualizzazione casuale.
* Caricare nel <head> PRIMA di qualsiasi altro script o CSS.
*
* Password di default: Nis2Agile2026!@
* Override per-pagina: aggiungere data-pw="..." allo script tag, es:
* <script src="/js/auth-gate.js" data-pw="Angelo@2026!"></script>
* La session key è derivata dalla pw, quindi pagine con pw diverse non condividono sessione.
*/
(function(){
var DEFAULT_PW = 'Nis2Agile2026!@';
var scriptTag = document.currentScript || (function(){
var all = document.getElementsByTagName('script');
for (var i = 0; i < all.length; i++) if (all[i].src && all[i].src.indexOf('auth-gate.js') !== -1) return all[i];
return null;
})();
var EXPECTED = (scriptTag && scriptTag.getAttribute('data-pw')) || DEFAULT_PW;
var keySuffix = '';
try { keySuffix = btoa(EXPECTED).replace(/=/g, '').slice(0, 10); } catch (e) { keySuffix = String(EXPECTED.length); }
var KEY = 'nis2_tech_auth_' + keySuffix;
if (sessionStorage.getItem(KEY) === 'ok') return;
var pwd = prompt('Documento riservato — inserisci password:');
if (pwd === EXPECTED) {
sessionStorage.setItem(KEY, 'ok');
return;
}
try { window.stop(); } catch (e) {}
document.documentElement.innerHTML = '<head><meta charset="UTF-8"><title>Accesso riservato</title></head>'
+ '<body style="background:#0F172A;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">'
+ '<div style="text-align:center;padding:40px;max-width:420px;">'
+ '<div style="font-size:3rem;margin-bottom:20px;">&#128274;</div>'
+ '<h1 style="font-size:1.5rem;margin:0 0 12px;">Accesso riservato</h1>'
+ '<p style="color:#94A3B8;font-size:.95rem;line-height:1.6;margin:0 0 24px;">Questo documento richiede autenticazione. Contatta il team Agile Software per ottenere la password.</p>'
+ '<button onclick="location.reload()" style="padding:10px 24px;background:#3B82F6;color:#fff;border:none;border-radius:6px;font-size:.9rem;cursor:pointer;">Riprova</button>'
+ '</div></body>';
})();
+260 -6
View File
@@ -200,6 +200,7 @@ function loadSidebar() {
{ name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` },
{ name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` },
{ name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` },
{ name: 'Knowledge Base', href: 'kb.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/></svg>` },
]
},
{
@@ -269,6 +270,11 @@ function loadSidebar() {
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h6a1 1 0 100-2H4V5h5a1 1 0 100-2H3zm11.707 3.293a1 1 0 010 1.414L12.414 10l2.293 2.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M16 10a1 1 0 00-1-1H8a1 1 0 100 2h7a1 1 0 001-1z" clip-rule="evenodd"/></svg>
</button>
</div>
<div class="sidebar-version" id="sidebar-version" title="Versione applicazione"
style="text-align:center; padding:6px 12px; font-size:.68rem; color:var(--gray-400); cursor:pointer; border-top:1px solid var(--gray-100); margin-top:4px;"
onclick="_showVersionChangelog()">
v—
</div>
</div>
`;
@@ -279,6 +285,12 @@ function loadSidebar() {
// Mobile toggle
_setupMobileToggle();
// Version footer (Fase 4 / G13)
_loadVersionFooter();
// Firm branding white-label (Fase 5 / G16) — non bloccante
_loadFirmBranding();
}
const _roleLabels = {
@@ -314,10 +326,9 @@ async function _loadUserInfo() {
// Save role to localStorage (ensures isConsultant() works across pages)
if (user.role) api.setUserRole(user.role);
// For consultants: render org-switcher
if (user.role === 'consultant') {
_loadConsultantOrgSwitcher();
}
// Tenant switcher: visibile per consulenti + per chiunque abbia ≥2 org
// (Fase 3 / G10 — esposizione globale del context switch)
_loadConsultantOrgSwitcher();
}
} catch (e) {
// Silenzioso
@@ -327,9 +338,12 @@ async function _loadUserInfo() {
async function _loadConsultantOrgSwitcher() {
try {
const result = await api.listOrganizations();
if (!result.success || !result.data || result.data.length === 0) return;
if (!result.success || !result.data) return;
const orgs = result.data;
// Mostra switcher solo se utente ha ≥2 organizzazioni
// (single-tenant users non hanno bisogno di scegliere)
if (orgs.length < 2) return;
const currentOrgId = parseInt(api.orgId);
const currentOrg = orgs.find(o => (o.id || o.organization_id) === currentOrgId);
@@ -398,11 +412,72 @@ async function _loadConsultantOrgSwitcher() {
}
}
function _switchOrg(orgId) {
async function _switchOrg(orgId) {
// Fase 3 / G09: chiama switchContext per rotare JWT con organization_id come claim.
// Fallback: se l'endpoint non risponde 200, applica solo il vecchio comportamento
// (set localStorage + reload) per backward-compat con versioni precedenti.
try {
const result = await api.post('/auth/switchContext', { organization_id: orgId });
if (result.success && result.data && result.data.access_token) {
api.setTokens(result.data.access_token, result.data.refresh_token);
api.setOrganization(orgId);
window.location.reload();
return;
}
} catch (e) {
// continua col fallback
}
api.setOrganization(orgId);
window.location.reload();
}
// ── Firm branding white-label (Fase 5 / G16) ────────────────────────────
async function _loadFirmBranding() {
try {
const r = await fetch('/api/branding/current', {
headers: api.token ? { 'Authorization': 'Bearer ' + api.token } : {}
});
if (!r.ok) return;
const resp = await r.json();
if (!resp.success || !resp.data) return;
const b = resp.data;
const root = document.documentElement;
if (b.primary_color) root.style.setProperty('--primary', b.primary_color);
if (b.secondary_color) root.style.setProperty('--secondary', b.secondary_color);
if (b.custom_brand_name) {
document.querySelectorAll('.sidebar-logo-text, .auth-logo-text').forEach(function(el) {
el.textContent = b.custom_brand_name;
});
}
if (b.logo_url) {
document.querySelectorAll('.sidebar-logo-icon img, .auth-logo-icon img').forEach(function(el) {
el.src = b.logo_url;
});
}
} catch (e) { /* silenzioso */ }
}
// ── Versioning live (Fase 4 / G13) ──────────────────────────────────────
let _versionInfo = null;
async function _loadVersionFooter() {
try {
const r = await fetch('/version.json?_=' + Date.now());
if (!r.ok) return;
_versionInfo = await r.json();
const el = document.getElementById('sidebar-version');
if (el && _versionInfo.version) {
el.textContent = 'v' + _versionInfo.version + (_versionInfo.build ? ' · ' + _versionInfo.build : '');
}
} catch (e) { /* silenzioso */ }
}
function _showVersionChangelog() {
if (!_versionInfo) return;
const v = _versionInfo;
alert('NIS2 Agile v' + v.version + '\nBuild: ' + (v.build || '—') + '\nData: ' + (v.date || '—') + '\n\n' + (v.changelog || 'Nessun changelog disponibile'));
}
function _setupMobileToggle() {
// Crea pulsante toggle se non esiste
if (!document.querySelector('.sidebar-toggle')) {
@@ -799,3 +874,182 @@ function switchLang(lang) {
s.src = 'js/feedback.js';
document.body.appendChild(s);
})();
/* ════════════════════════════════════════════════════════════════════════
AgileHub / Nexus integration (NIS2)
─────────────────────────────────────────────────────────────────────────
- bug-reporter.js viene iniettato dinamicamente DOPO il login
(richiede i data-user-* del profilo corrente). Idempotente.
- FAB AI viola "ARIA" creato via JS (niente modifiche alle 18 pagine HTML).
- Si collega all'AI nativa NIS2 per il grounding KB Multi-Livello.
════════════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
// Salta nelle pagine pubbliche (login/register/landing/marketing).
var publicPages = ['/login.html', '/register.html', '/index.html', '/presentation.html', '/'];
var path = location.pathname.replace(/^.*\//, '/');
if (publicPages.indexOf(path) !== -1) return;
var token = localStorage.getItem('nis2_access_token');
if (!token) return; // utente non loggato → skip
// Mini JWT decoder (base64url → JSON payload)
function decodeJwt(t) {
try {
var b64 = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(decodeURIComponent(escape(atob(b64))));
} catch (e) { return {}; }
}
var claims = decodeJwt(token);
var userEmail = claims.email || '';
var userName = claims.name || claims.full_name || claims.email || '';
var userRole = localStorage.getItem('nis2_user_role') || claims.role || '';
// ── 1. Inietta il bug-reporter Nexus ────────────────────────────────────
if (!window.__nexusWidgetLoaded) {
window.__nexusWidgetLoaded = true;
var s = document.createElement('script');
s.src = 'js/bug-reporter.js?v=20260411';
s.async = true;
s.dataset.product = 'NIS2';
s.dataset.tenantId = '7';
s.dataset.apiUrl = 'https://agilehub.agile.software';
s.dataset.userName = userName;
s.dataset.userEmail = userEmail;
s.dataset.userRole = userRole;
s.dataset.lang = 'it';
document.body.appendChild(s);
}
// ── 2. AI Chat FAB viola "ARIA" ─────────────────────────────────────────
function ensureFab() {
if (document.getElementById('ai-chat-fab')) return;
var fab = document.createElement('button');
fab.id = 'ai-chat-fab';
fab.setAttribute('aria-label', 'Chiedi ad ARIA');
fab.style.cssText = 'position:fixed;bottom:24px;right:24px;width:56px;height:56px;'
+ 'border-radius:50%;border:none;cursor:pointer;z-index:9998;'
+ 'background:linear-gradient(135deg,#7C3AED,#3B82F6);color:#fff;'
+ 'box-shadow:0 6px 20px rgba(124,58,237,.4);font-size:22px;'
+ 'display:flex;align-items:center;justify-content:center;';
fab.innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i>';
document.body.appendChild(fab);
var panel = document.createElement('div');
panel.id = 'ai-chat-panel';
panel.style.cssText = 'position:fixed;bottom:90px;right:24px;width:360px;'
+ 'max-height:520px;background:#fff;border-radius:16px;'
+ 'box-shadow:0 12px 32px rgba(0,0,0,.2);display:none;z-index:9999;'
+ 'overflow:hidden;flex-direction:column;'
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;";
document.body.appendChild(panel);
var STORAGE_KEY = 'nis2_ai_chat';
var history = [];
try { history = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '[]'); } catch (e) {}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
});
}
function render() {
var list = history.map(function (m) {
var isUser = m.role === 'user';
return '<div style="margin-bottom:10px;display:flex;justify-content:'
+ (isUser ? 'flex-end' : 'flex-start') + '">'
+ '<div style="max-width:78%;padding:8px 12px;border-radius:14px;'
+ 'font-size:13px;line-height:1.45;background:'
+ (isUser ? 'linear-gradient(135deg,#7C3AED,#3B82F6)' : '#f3f4f6')
+ ';color:' + (isUser ? '#fff' : '#111827')
+ ';white-space:pre-wrap">' + escHtml(m.content) + '</div></div>';
}).join('');
panel.innerHTML =
'<div style="padding:14px 16px;background:linear-gradient(135deg,#7C3AED,#3B82F6);'
+ 'color:#fff;display:flex;justify-content:space-between;align-items:center">'
+ '<div style="display:flex;align-items:center;gap:8px;font-weight:600">'
+ '<i class="fa-solid fa-wand-magic-sparkles"></i> ARIA — Assistente NIS2'
+ '</div>'
+ '<button id="ai-chat-close" aria-label="Chiudi" style="background:none;'
+ 'border:none;color:#fff;font-size:18px;cursor:pointer">&times;</button>'
+ '</div>'
+ '<div id="ai-chat-msgs" style="flex:1;overflow-y:auto;padding:14px 16px;'
+ 'background:#fafafa">'
+ (list || '<div style="color:#9ca3af;font-size:13px;text-align:center;'
+ 'margin-top:24px">Ciao! Chiedimi qualcosa su NIS2: misure di '
+ 'sicurezza, audit, fornitori, incident response…</div>')
+ '</div>'
+ '<form id="ai-chat-form" style="display:flex;gap:6px;padding:10px;'
+ 'border-top:1px solid #e5e7eb;background:#fff">'
+ '<input id="ai-chat-input" type="text" placeholder="Scrivi…" '
+ 'autocomplete="off" style="flex:1;padding:10px 12px;border:1px solid '
+ '#d1d5db;border-radius:10px;font-size:14px;font-family:inherit">'
+ '<button type="submit" style="padding:10px 14px;border:none;'
+ 'border-radius:10px;background:linear-gradient(135deg,#7C3AED,#3B82F6);'
+ 'color:#fff;font-weight:600;cursor:pointer">'
+ '<i class="fa-solid fa-paper-plane"></i>'
+ '</button>'
+ '</form>';
document.getElementById('ai-chat-close').onclick = function () {
panel.style.display = 'none';
};
document.getElementById('ai-chat-form').onsubmit = onSubmit;
var msgs = document.getElementById('ai-chat-msgs');
msgs.scrollTop = msgs.scrollHeight;
}
async function onSubmit(ev) {
ev.preventDefault();
var input = document.getElementById('ai-chat-input');
var text = (input.value || '').trim();
if (!text) return;
history.push({ role: 'user', content: text });
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
render();
try {
// AI nativa NIS2 (RAG con KB Multi-Livello già attivo)
var t = localStorage.getItem('nis2_access_token') || '';
var orgId = localStorage.getItem('nis2_org_id') || '';
var r = await fetch('/api/ai/ask', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': t ? ('Bearer ' + t) : '',
'X-Organization-Id': orgId
},
body: JSON.stringify({ question: text, history: history })
});
var resp = await r.json();
var answer = (resp && resp.success && resp.data
&& (resp.data.answer || resp.data.message || resp.data.text))
|| (resp && (resp.answer || resp.message || resp.text))
|| 'ARIA non ha risposto. Riprova tra poco.';
history.push({ role: 'assistant', content: answer });
} catch (e) {
history.push({
role: 'assistant',
content: '⚠️ ARIA non risponde in questo momento. Riprova tra poco.'
});
}
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
render();
}
fab.onclick = function () {
var visible = panel.style.display === 'flex';
panel.style.display = visible ? 'none' : 'flex';
if (!visible) render();
};
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureFab);
} else {
ensureFab();
}
})();