[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
@@ -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>
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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;">🔒</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
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) + ' · Ultimo accesso ' + lastActivity + ' · 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 { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Settori NIS2 ─────────────────────────────────────────
|
||||
const sectorLabels = {
|
||||
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user