[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
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password dimenticata - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.back-link { display:block; text-align:center; margin-top:14px; font-size:.85rem; color:#6B7280; text-decoration:none; }
.back-link:hover { color: var(--color-primary, #2563eb); }
.auth-success { background:#ECFDF5; color:#065F46; border:1px solid #A7F3D0; padding:12px 16px; border-radius:6px; font-size:.9rem; margin-bottom:16px; display:none; }
.auth-success.visible { display:block; }
.auth-helper { font-size:.85rem; color:#6B7280; margin-bottom:18px; line-height:1.5; }
</style>
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
</svg>
</div>
<span class="auth-logo-text">NIS2 <span>Agile</span></span>
</div>
<p class="auth-subtitle">Reimposta la tua password</p>
</div>
<div class="auth-body">
<div class="auth-error" id="err"></div>
<div class="auth-success" id="ok"></div>
<p class="auth-helper">Inserisci l'indirizzo email associato al tuo account. Ti invieremo un link valido 30 minuti per impostare una nuova password.</p>
<form id="forgot-form" novalidate>
<div class="form-group">
<label class="form-label" for="email">Indirizzo Email</label>
<input type="email" id="email" name="email" class="form-input"
placeholder="nome@azienda.it" autocomplete="email" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-full" id="submit-btn">
Invia link
</button>
</form>
</div>
<div class="auth-footer">
<a href="login.html" class="back-link"><i class="fas fa-arrow-left"></i> Torna al login</a>
</div>
</div>
</div>
<script src="js/api.js"></script>
<script>
const form = document.getElementById('forgot-form');
const err = document.getElementById('err');
const ok = document.getElementById('ok');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async function(e) {
e.preventDefault();
err.classList.remove('visible');
ok.classList.remove('visible');
const email = document.getElementById('email').value.trim();
if (!email) {
err.textContent = 'Inserisci l\'indirizzo email.';
err.classList.add('visible');
return;
}
btn.disabled = true;
btn.textContent = 'Invio in corso...';
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
const data = await res.json();
if (res.status === 429) {
err.textContent = data.message || 'Troppe richieste. Riprova più tardi.';
err.classList.add('visible');
} else if (data.success) {
ok.textContent = data.message;
ok.classList.add('visible');
form.style.display = 'none';
} else {
err.textContent = data.message || 'Errore. Riprova.';
err.classList.add('visible');
}
} catch (e) {
err.textContent = 'Errore di connessione al server.';
err.classList.add('visible');
} finally {
btn.disabled = false;
btn.textContent = 'Invia link';
}
});
</script>
</body>
</html>
+31
View File
@@ -107,6 +107,8 @@ $controllerMap = [
'contact' => 'ContactController', // legacy
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014)
'branding' => 'BrandingController', // White-label firm (Fase 5 / G16)
];
if (!isset($controllerMap[$controllerName])) {
@@ -154,6 +156,20 @@ $actionMap = [
'PUT:profile' => 'updateProfile',
'POST:changePassword' => 'changePassword',
'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth)
// Multi-device sessions (Fase 2 / G06)
'GET:sessions' => 'listSessions',
'DELETE:sessions/{id}' => 'revokeSession',
'DELETE:sessions' => 'revokeAllSessions',
// Password reset (Fase 3 / G08, no auth)
'POST:forgotPassword' => 'forgotPassword',
'POST:resetPassword' => 'resetPassword',
// Context switch (Fase 3 / G09)
'POST:switchContext' => 'switchContext',
// Impersonate (Fase 4 / G11)
'POST:impersonate' => 'impersonate',
// Preferences (Fase 4 / G12)
'GET:preferences' => 'getPreferences',
'PUT:preferences' => 'updatePreferences',
],
// ── OrganizationController ──────────────────────
@@ -399,6 +415,21 @@ $actionMap = [
'PUT:{id}' => 'update',
'POST:{id}/resolve' => 'resolve',
],
// ── KnowledgeBaseController — KB multi-livello (Migration 012-014) ──
'knowledgebase' => [
'POST:ingest' => 'ingest',
'GET:list' => 'list',
'GET:firmOrgs' => 'firmOrgs',
'POST:search' => 'search',
'DELETE:{id}' => 'delete',
],
// ── BrandingController — White-label firm (Fase 5 / G16) ──
'branding' => [
'GET:current' => 'getCurrent',
'PUT:index' => 'update',
],
];
// ═══════════════════════════════════════════════════════════════════════════
+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();
}
})();
+1 -1
View File
@@ -69,7 +69,7 @@
<button type="submit" class="btn btn-primary btn-lg w-full" id="login-btn">
Accedi
</button>
<a href="#" class="forgot-link" onclick="alert('Contatta presidenza@agile.software per il reset password.');return false;">
<a href="forgot-password.html" class="forgot-link">
Password dimenticata?
</a>
</form>
+171
View File
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imposta nuova password - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.pw-wrap { position: relative; }
.pw-wrap .form-input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer;
color: #9CA3AF; font-size: 15px; padding: 0;
}
.strength-bar { display:flex; gap:3px; margin-top:6px; }
.strength-bar span { flex:1; height:4px; border-radius:2px; background:#E0E4E8; transition:background .2s; }
.strength-text { font-size:.78rem; color:#6B7280; margin-top:6px; min-height:16px; }
.auth-success { background:#ECFDF5; color:#065F46; border:1px solid #A7F3D0; padding:12px 16px; border-radius:6px; font-size:.9rem; margin-bottom:16px; display:none; }
.auth-success.visible { display:block; }
.pw-rules { font-size:.78rem; color:#6B7280; margin-top:8px; line-height:1.6; }
.back-link { display:block; text-align:center; margin-top:14px; font-size:.85rem; color:#6B7280; text-decoration:none; }
.back-link:hover { color: var(--color-primary, #2563eb); }
</style>
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
</svg>
</div>
<span class="auth-logo-text">NIS2 <span>Agile</span></span>
</div>
<p class="auth-subtitle">Imposta una nuova password</p>
</div>
<div class="auth-body">
<div class="auth-error" id="err"></div>
<div class="auth-success" id="ok"></div>
<form id="reset-form" novalidate>
<div class="form-group">
<label class="form-label" for="pw">Nuova password</label>
<div class="pw-wrap">
<input type="password" id="pw" class="form-input"
placeholder="Almeno 8 caratteri" autocomplete="new-password" required>
<button type="button" class="pw-toggle" onclick="togglePw('pw',this)" aria-label="Mostra/nascondi password">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="strength-bar"><span></span><span></span><span></span><span></span></div>
<div class="strength-text" id="strength-text"></div>
</div>
<div class="form-group">
<label class="form-label" for="pw2">Conferma password</label>
<div class="pw-wrap">
<input type="password" id="pw2" class="form-input"
placeholder="Ripeti la password" autocomplete="new-password" required>
<button type="button" class="pw-toggle" onclick="togglePw('pw2',this)" aria-label="Mostra/nascondi password">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<p class="pw-rules">Minimo 8 caratteri, con almeno una maiuscola, una minuscola e un numero.</p>
<button type="submit" class="btn btn-primary btn-lg w-full" id="submit-btn" style="margin-top:12px;">
Imposta password
</button>
</form>
</div>
<div class="auth-footer">
<a href="login.html" class="back-link"><i class="fas fa-arrow-left"></i> Torna al login</a>
</div>
</div>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token') || '';
const err = document.getElementById('err');
const ok = document.getElementById('ok');
const form = document.getElementById('reset-form');
const btn = document.getElementById('submit-btn');
const pwInput = document.getElementById('pw');
const pwBars = document.querySelectorAll('.strength-bar span');
const pwText = document.getElementById('strength-text');
if (!token) {
err.textContent = 'Token mancante. Richiedi un nuovo link di reset.';
err.classList.add('visible');
form.style.display = 'none';
}
function togglePw(id, button) {
const i = document.getElementById(id);
if (i.type === 'password') { i.type = 'text'; button.innerHTML = '<i class="fas fa-eye-slash"></i>'; }
else { i.type = 'password'; button.innerHTML = '<i class="fas fa-eye"></i>'; }
}
function computeStrength(pw) {
let s = 0;
if (pw.length >= 8) s++;
if (pw.length >= 12) s++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
if (/\d/.test(pw) && /[^A-Za-z0-9]/.test(pw)) s++;
return s;
}
pwInput.addEventListener('input', function() {
const s = computeStrength(pwInput.value);
const colors = ['#E0E4E8', '#EF4444', '#F59E0B', '#3B82F6', '#10B981'];
const labels = ['', 'Debole', 'Media', 'Buona', 'Ottima'];
pwBars.forEach(function(bar, i) { bar.style.background = (i < s) ? colors[s] : '#E0E4E8'; });
pwText.textContent = pwInput.value.length > 0 ? 'Sicurezza: ' + labels[s] : '';
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
err.classList.remove('visible');
ok.classList.remove('visible');
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
if (pw !== pw2) {
err.textContent = 'Le due password non coincidono.';
err.classList.add('visible');
return;
}
if (pw.length < 8) {
err.textContent = 'La password deve essere di almeno 8 caratteri.';
err.classList.add('visible');
return;
}
btn.disabled = true;
btn.textContent = 'Salvataggio...';
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token, new_password: pw })
});
const data = await res.json();
if (data.success) {
ok.textContent = data.message + ' Sarai reindirizzato al login.';
ok.classList.add('visible');
form.style.display = 'none';
setTimeout(function() { window.location.href = 'login.html'; }, 2500);
} else {
err.textContent = data.message || 'Errore. Token non valido o scaduto.';
err.classList.add('visible');
}
} catch (e) {
err.textContent = 'Errore di connessione al server.';
err.classList.add('visible');
} finally {
btn.disabled = false;
btn.textContent = 'Imposta password';
}
});
</script>
</body>
</html>
+102 -10
View File
@@ -444,14 +444,6 @@
<h3>Sicurezza Account</h3>
</div>
<div class="card-body">
<div class="security-item">
<div class="security-item-header">
<span class="security-item-title">Sessione Corrente</span>
<span class="badge badge-success">Attiva</span>
</div>
<p class="security-item-desc" id="session-info">Sessione autenticata tramite token JWT. L'accesso e' protetto da crittografia.</p>
</div>
<div class="security-item">
<div class="security-item-header">
<span class="security-item-title">Autenticazione a Due Fattori (2FA)</span>
@@ -465,7 +457,22 @@
<span class="security-item-title">Accesso API</span>
<span class="badge badge-info">JWT</span>
</div>
<p class="security-item-desc">L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh. Tutti i token vengono invalidati al cambio password.</p>
<p class="security-item-desc">L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh.</p>
</div>
</div>
</div>
<div class="card mb-24" id="card-sessions">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>Sessioni Attive</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Dispositivi attualmente loggati al tuo account. Puoi disconnetterli singolarmente o tutti tranne questo.</p>
</div>
<button class="btn btn-outline btn-danger" onclick="revokeAllOtherSessions()" id="revoke-all-btn" style="display:none;">Disconnetti gli altri</button>
</div>
<div class="card-body" style="padding:0;">
<div id="sessions-container">
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div>
</div>
@@ -576,11 +583,96 @@
document.getElementById(panelMap[tab]).classList.add('active');
if (tab === 'members') loadMembers();
if (tab === 'security') loadAuditLog();
if (tab === 'security') { loadSessions(); loadAuditLog(); }
if (tab === 'apikeys') loadApiKeys();
if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); }
}
// ── Sessioni Multi-Device (Fase 2 / G07) ─────────────────
async function loadSessions() {
const container = document.getElementById('sessions-container');
container.innerHTML = '<div class="spinner" style="margin:40px auto;"></div>';
try {
const res = await api.get('/auth/sessions');
const sessions = res.data.sessions || [];
renderSessions(sessions);
} catch (e) {
container.innerHTML = '<div style="padding:20px; color:var(--gray-500);">Impossibile caricare le sessioni.</div>';
}
}
function renderSessions(sessions) {
const container = document.getElementById('sessions-container');
const revokeAllBtn = document.getElementById('revoke-all-btn');
if (!sessions.length) {
container.innerHTML = '<div style="padding:20px; color:var(--gray-500);">Nessuna sessione attiva.</div>';
revokeAllBtn.style.display = 'none';
return;
}
revokeAllBtn.style.display = sessions.length > 1 ? 'inline-block' : 'none';
const html = sessions.map(function(s) {
const lastActivity = fmtRelativeTime(s.last_activity_at);
const created = fmtDate(s.created_at);
const isCurrent = s.is_current;
const badge = isCurrent
? '<span class="badge badge-success" style="margin-left:8px;">Questo dispositivo</span>'
: '';
const actionBtn = isCurrent
? ''
: '<button class="btn btn-sm btn-outline btn-danger" onclick="revokeSession(\'' + s.id + '\')">Disconnetti</button>';
return '<div style="display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100);">'
+ '<div>'
+ '<div style="font-weight:600;">' + escapeHtml(s.device_label) + badge + '</div>'
+ '<div style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">'
+ 'IP ' + escapeHtml(s.ip_address) + ' &middot; Ultimo accesso ' + lastActivity + ' &middot; Login ' + created
+ '</div>'
+ '</div>'
+ actionBtn
+ '</div>';
}).join('');
container.innerHTML = html;
}
async function revokeSession(sessionId) {
if (!confirm('Disconnettere questo dispositivo? Sara\' necessario un nuovo login per riaccedere.')) return;
try {
await api.del('/auth/sessions/' + sessionId);
loadSessions();
} catch (e) {
alert('Errore: ' + (e.message || 'impossibile revocare la sessione'));
}
}
async function revokeAllOtherSessions() {
if (!confirm('Disconnettere tutti gli altri dispositivi? La sessione corrente non sara\' interrotta.')) return;
try {
await api.del('/auth/sessions');
loadSessions();
} catch (e) {
alert('Errore: ' + (e.message || 'impossibile revocare le sessioni'));
}
}
function fmtRelativeTime(iso) {
if (!iso) return '—';
const d = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z'));
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
if (diffSec < 60) return 'pochi secondi fa';
if (diffSec < 3600) return Math.floor(diffSec / 60) + ' min fa';
if (diffSec < 86400) return Math.floor(diffSec / 3600) + ' ore fa';
return Math.floor(diffSec / 86400) + ' giorni fa';
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z'));
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
});
}
// ── Settori NIS2 ─────────────────────────────────────────
const sectorLabels = {
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
+1
View File
@@ -0,0 +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"}