[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:
co-authored by
Claude Opus 4.7
parent
c37423f900
commit
e4f9e9179e
+260
-6
@@ -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 ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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">×</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();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user