Files
nis2-agile/public/js/common.js
T

1107 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* NIS2 Agile - Common Utilities
*
* Funzioni condivise tra tutte le pagine del frontend.
*/
// ═══════════════════════════════════════════════════════════════════
// Notifications (Toast)
// ═══════════════════════════════════════════════════════════════════
/**
* Mostra una notifica toast.
* @param {string} message - Testo del messaggio
* @param {string} type - 'success' | 'error' | 'warning' | 'info'
* @param {number} duration - Durata in ms (default 4000)
*/
function showNotification(message, type = 'info', duration = 4000) {
let container = document.querySelector('.notification-container');
if (!container) {
container = document.createElement('div');
container.className = 'notification-container';
document.body.appendChild(container);
}
const icons = {
success: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
error: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>',
warning: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>',
info: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>'
};
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = `
<span class="notification-icon">${icons[type] || icons.info}</span>
<span class="notification-message">${escapeHtml(message)}</span>
<button class="notification-close" onclick="this.parentElement.remove()">&times;</button>
`;
container.appendChild(notification);
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, duration);
}
// ═══════════════════════════════════════════════════════════════════
// Modal
// ═══════════════════════════════════════════════════════════════════
/**
* Mostra un dialogo modale.
* @param {string} title - Titolo del modale
* @param {string} content - Contenuto HTML del body
* @param {object} options - { footer: HTML, size: 'sm'|'md'|'lg' }
*/
function showModal(title, content, options = {}) {
closeModal(); // Chiudi eventuale modale aperto
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'modal-overlay';
const sizeClass = options.size === 'lg' ? 'style="max-width:720px"' : options.size === 'sm' ? 'style="max-width:400px"' : '';
overlay.innerHTML = `
<div class="modal" ${sizeClass}>
<div class="modal-header">
<h3>${escapeHtml(title)}</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">${content}</div>
${options.footer ? `<div class="modal-footer">${options.footer}</div>` : ''}
</div>
`;
// Chiudi cliccando fuori dal modale
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal();
});
document.body.appendChild(overlay);
// Trigger animation
requestAnimationFrame(() => {
overlay.classList.add('active');
});
// Chiudi con ESC
document.addEventListener('keydown', _modalEscHandler);
}
function closeModal() {
const overlay = document.getElementById('modal-overlay');
if (overlay) {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 250);
}
document.removeEventListener('keydown', _modalEscHandler);
}
function _modalEscHandler(e) {
if (e.key === 'Escape') closeModal();
}
// ═══════════════════════════════════════════════════════════════════
// Date Formatting
// ═══════════════════════════════════════════════════════════════════
/**
* Formatta una data in formato italiano (dd/mm/yyyy).
* @param {string|Date} date
* @returns {string}
*/
function formatDate(date) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
/**
* Formatta data e ora in formato italiano.
* @param {string|Date} date
* @returns {string}
*/
function formatDateTime(date) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Restituisce un'etichetta relativa (es. "2 ore fa", "ieri").
* @param {string|Date} date
* @returns {string}
*/
function timeAgo(date) {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const diffMs = now - d;
const diffMin = Math.floor(diffMs / 60000);
const diffHrs = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'Adesso';
if (diffMin < 60) return `${diffMin} min fa`;
if (diffHrs < 24) return `${diffHrs} ore fa`;
if (diffDays === 1) return 'Ieri';
if (diffDays < 7) return `${diffDays} giorni fa`;
return formatDate(date);
}
// ═══════════════════════════════════════════════════════════════════
// Sidebar
// ═══════════════════════════════════════════════════════════════════
/**
* Inietta la sidebar di navigazione nell'elemento #sidebar.
*/
function loadSidebar() {
const container = document.getElementById('sidebar');
if (!container) return;
// Determina la pagina corrente
const currentPage = window.location.pathname.split('/').pop() || 'dashboard.html';
const navItems = [
{
label: 'Principale', i18nKey: 'nav.main',
items: [
{ name: 'Dashboard', href: 'dashboard.html', icon: iconGrid(), i18nKey: 'nav.dashboard' },
{ name: 'Compliance Journey', href: 'workflow.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg>` },
{ name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck(), i18nKey: 'nav.gap_analysis' },
{ name: 'Gap Analysis ACN', href: 'acn-gap.html', icon: iconClipboardCheck(), i18nKey: 'nav.acn_gap' },
{ name: 'Modello SGSI (ISO 27001)', href: 'isms.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.isms' },
]
},
{
label: 'Gestione', i18nKey: 'nav.management',
items: [
{ name: 'Organigramma', href: 'organigramma.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M8 2.5h4a.5.5 0 01.5.5v2a.5.5 0 01-.5.5h-1.25v2.5H15a.5.5 0 01.5.5v1.25H17a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5h1.25V9.5H6v1.25H7.5a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5H4.5V9.5a.5.5 0 01.5-.5h4.75V6H8a.5.5 0 01-.5-.5V3a.5.5 0 01.5-.5z"/></svg>`, i18nKey: 'nav.org_chart' },
{ name: 'Competenze', href: 'competenze.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 11-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 8.134a1 1 0 010 1.732l-3.354.934-1.18 4.455a1 1 0 01-1.933 0L9.854 10.8 6.5 9.866a1 1 0 010-1.732l3.354-.934 1.179-4.456A1 1 0 0112 2z"/></svg>`, i18nKey: 'nav.competences' },
{ name: 'Matrice RACI', href: 'raci.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 6a1 1 0 011-1h5a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zm9 0a1 1 0 011-1h3a1 1 0 011 1v6a1 1 0 01-1 1h-3a1 1 0 01-1-1v-6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.raci' },
{ name: 'Scadenziario', href: 'review-schedule.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.review_schedule' },
{ name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' },
{ name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' },
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' },
{ name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' },
{ name: 'Stakeholder', href: 'stakeholders.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/><path d="M5.5 7.5a2 2 0 11-2.999.001A2 2 0 015.5 7.5zM17.5 7.5a2 2 0 11-2.999.001A2 2 0 0117.5 7.5z"/></svg>`, i18nKey: 'nav.stakeholders' },
{ 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>` },
]
},
{
label: 'Operativo', i18nKey: 'nav.operations',
items: [
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' },
{ name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' },
{ name: 'Audit & Report',href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' },
]
},
{
label: 'Sistema', i18nKey: 'nav.system',
items: [
{ name: 'Guida all\'uso', href: 'guida.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>` },
{ name: 'Impostazioni', href: 'settings.html', icon: iconCog(), i18nKey: 'nav.settings' },
{ name: 'Architettura', href: 'architecture.html', icon: iconCubeTransparent() },
{ name: 'Simulazione Demo', href: 'simulate.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>` },
{ name: 'Integrazioni Esterne', href: 'integrazioniext.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/></svg>` },
]
}
];
let navHTML = '';
// Brand
navHTML += `
<div class="sidebar-brand">
<div class="sidebar-brand-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"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>
</div>
<div>
<h1>NIS2 Agile</h1>
<span>Compliance Platform</span>
</div>
</div>
`;
// Org-switcher area (populated async for consultants)
navHTML += `<div id="sidebar-org-switcher" style="display:none;padding:8px 12px;border-bottom:1px solid var(--gray-100);"></div>`;
// Nav sections
navHTML += '<nav class="sidebar-nav">';
for (const section of navItems) {
const sectionI18n = section.i18nKey ? ` data-i18n="${section.i18nKey}"` : '';
navHTML += `<div class="sidebar-nav-label"${sectionI18n}>${section.label}</div>`;
for (const item of section.items) {
const isActive = currentPage === item.href ? 'active' : '';
const spanI18n = item.i18nKey ? ` data-i18n="${item.i18nKey}"` : '';
navHTML += `<a href="${item.href}" class="${isActive}">${item.icon}<span${spanI18n}>${item.name}</span></a>`;
}
}
navHTML += '</nav>';
// Footer with language toggle + user info
navHTML += `
<div class="sidebar-footer">
<div class="sidebar-lang-toggle" id="sidebar-lang-toggle">
<button class="lang-btn ${_getSavedLang() === 'it' ? 'active' : ''}" onclick="switchLang('it')" title="Italiano" aria-label="Italiano">IT</button>
<button class="lang-btn ${_getSavedLang() === 'en' ? 'active' : ''}" onclick="switchLang('en')" title="English" aria-label="English">EN</button>
</div>
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="sidebar-user-avatar">--</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="sidebar-user-name">Caricamento...</div>
<div class="sidebar-user-role" id="sidebar-user-role">Utente</div>
</div>
<button class="sidebar-logout-btn" onclick="api.logout()" title="Esci">
<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>
`;
container.innerHTML = navHTML;
// Carica info utente (e org-switcher se consulente)
_loadUserInfo();
// Mobile toggle
_setupMobileToggle();
// Version footer (Fase 4 / G13)
_loadVersionFooter();
// Firm branding white-label (Fase 5 / G16) — non bloccante
_loadFirmBranding();
// Impersonate banner (Fase 4 / G11 — UI)
_renderImpersonateBanner();
}
const _roleLabels = {
super_admin: 'Super Admin',
org_admin: 'Admin Org',
compliance_manager: 'Compliance Manager',
board_member: 'Membro CDA',
auditor: 'Auditor',
employee: 'Utente',
consultant: 'Consulente',
};
async function _loadUserInfo() {
try {
const result = await api.getMe();
if (result.success && result.data) {
const user = result.data;
const nameEl = document.getElementById('sidebar-user-name');
const avatarEl = document.getElementById('sidebar-user-avatar');
const roleEl = document.getElementById('sidebar-user-role');
if (nameEl) nameEl.textContent = user.full_name || user.email;
if (avatarEl) {
const initials = (user.full_name || user.email || '--')
.split(' ')
.map(w => w[0])
.slice(0, 2)
.join('')
.toUpperCase();
avatarEl.textContent = initials;
}
if (roleEl) roleEl.textContent = _roleLabels[user.role] || user.role || 'Utente';
// Save role to localStorage (ensures isConsultant() works across pages)
if (user.role) api.setUserRole(user.role);
// Tenant switcher: visibile per consulenti + per chiunque abbia ≥2 org
// (Fase 3 / G10 — esposizione globale del context switch)
_loadConsultantOrgSwitcher();
}
} catch (e) {
// Silenzioso
}
}
async function _loadConsultantOrgSwitcher() {
try {
const result = await api.listOrganizations();
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);
const switcher = document.getElementById('sidebar-org-switcher');
if (!switcher) return;
const dropdownId = 'org-dropdown-' + Date.now();
switcher.style.display = 'block';
switcher.innerHTML = `
<div style="position:relative">
<button onclick="document.getElementById('${dropdownId}').classList.toggle('open')"
style="width:100%;display:flex;align-items:center;gap:8px;padding:6px 10px;
background:var(--gray-50);border:1px solid var(--gray-200);border-radius:6px;
cursor:pointer;font-size:.8rem;font-weight:600;color:var(--gray-700);">
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink:0;color:var(--primary)">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4zm3 1h2v2H7V5zm2 4H7v2h2V9zm2-4h2v2h-2V5zm2 4h-2v2h2V9z" clip-rule="evenodd"/>
</svg>
<span style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${currentOrg ? currentOrg.name : 'Seleziona azienda'}
</span>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink:0">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
<div id="${dropdownId}" class="org-switcher-dropdown"
style="display:none;position:absolute;left:0;right:0;top:calc(100% + 4px);
background:var(--card-bg);border:1px solid var(--gray-200);border-radius:6px;
box-shadow:var(--card-shadow);z-index:200;overflow:hidden;">
${orgs.map(org => {
const orgId = org.id || org.organization_id;
const isActive = orgId === currentOrgId;
return `<button onclick="_switchOrg(${orgId})"
style="width:100%;text-align:left;padding:8px 12px;
background:${isActive ? 'var(--primary-bg)' : 'transparent'};
border:none;cursor:pointer;font-size:.8rem;
color:${isActive ? 'var(--primary)' : 'var(--gray-700)'};
font-weight:${isActive ? '600' : '400'};">
${isActive ? '✓ ' : ''}${org.name}
</button>`;
}).join('')}
<div style="border-top:1px solid var(--gray-100)">
<a href="companies.html"
style="display:block;padding:8px 12px;font-size:.8rem;
color:var(--primary);text-decoration:none;">
← Tutte le aziende
</a>
</div>
</div>
</div>`;
// Close on outside click
document.addEventListener('click', (e) => {
const dd = document.getElementById(dropdownId);
if (dd && !switcher.contains(e.target)) dd.style.display = 'none';
});
// Override toggle to handle display
const ddEl = document.getElementById(dropdownId);
if (ddEl) {
const btn = switcher.querySelector('button');
if (btn) btn.onclick = () => {
ddEl.style.display = ddEl.style.display === 'none' ? 'block' : 'none';
};
}
} catch (e) {
// Silenzioso
}
}
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();
}
// ── Impersonate banner persistente (Fase 4 / G11 — UI) ──────────────────
function _renderImpersonateBanner() {
const originToken = sessionStorage.getItem('nis2_impersonate_origin_token');
const targetEmail = sessionStorage.getItem('nis2_impersonate_target_email');
if (!originToken) return;
if (document.getElementById('impersonate-banner')) return;
const bar = document.createElement('div');
bar.id = 'impersonate-banner';
bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#F59E0B;color:#1F2937;padding:8px 16px;text-align:center;font-size:.85rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);';
bar.innerHTML =
'<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" style="vertical-align:-3px;margin-right:6px"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.94 6.94a.75.75 0 011.06 0L10 6.94l.5.5L10 7.94l-.5-.5L9.5 8h-1V7l.5-.5z"/></svg>'
+ 'Modalità Impersonate — stai usando l\'account di <strong>' + (targetEmail || '?') + '</strong> &nbsp;'
+ '<button onclick="_exitImpersonate()" style="background:#1F2937;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:.8rem;font-weight:600;margin-left:8px;">Esci impersonate</button>';
document.body.insertBefore(bar, document.body.firstChild);
// Sposta il contenuto sotto
document.body.style.paddingTop = (bar.offsetHeight + (parseInt(document.body.style.paddingTop) || 0)) + 'px';
}
function _exitImpersonate() {
const originToken = sessionStorage.getItem('nis2_impersonate_origin_token');
const originRefresh = sessionStorage.getItem('nis2_impersonate_origin_refresh');
if (!originToken) return;
api.setTokens(originToken, originRefresh || '');
sessionStorage.removeItem('nis2_impersonate_origin_token');
sessionStorage.removeItem('nis2_impersonate_origin_refresh');
sessionStorage.removeItem('nis2_impersonate_target_email');
window.location.href = '/admin/users.html';
}
// ── 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')) {
const toggle = document.createElement('button');
toggle.className = 'sidebar-toggle';
toggle.setAttribute('aria-label', 'Apri menu');
toggle.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
toggle.addEventListener('click', () => _toggleSidebar());
document.body.appendChild(toggle);
}
// Crea backdrop per mobile
if (!document.querySelector('.sidebar-backdrop')) {
const backdrop = document.createElement('div');
backdrop.className = 'sidebar-backdrop';
backdrop.addEventListener('click', () => _toggleSidebar(false));
document.body.appendChild(backdrop);
}
}
function _toggleSidebar(forceState) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.querySelector('.sidebar-backdrop');
if (!sidebar) return;
const isOpen = typeof forceState === 'boolean' ? forceState : !sidebar.classList.contains('open');
if (isOpen) {
sidebar.classList.add('open');
if (backdrop) backdrop.classList.add('visible');
} else {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('visible');
}
}
// ═══════════════════════════════════════════════════════════════════
// Auth Check
// ═══════════════════════════════════════════════════════════════════
/**
* Verifica che l'utente sia autenticato. Se non lo e', redirige al login.
* Attiva automaticamente il timeout di sessione per inattivita'.
* @returns {boolean}
*/
function checkAuth() {
if (!api.isAuthenticated()) {
window.location.href = 'login.html';
return false;
}
if (!_idleInitialized) {
_idleInitialized = true;
initIdleTimeout();
}
// Inizializza FAB segnalazioni su tutte le pagine autenticate
if (typeof initFeedbackFab === 'function') {
initFeedbackFab();
}
return true;
}
// ═══════════════════════════════════════════════════════════════════
// Idle Session Timeout
// ═══════════════════════════════════════════════════════════════════
let _idleTimer = null;
let _idleWarningTimer = null;
let _idleCountdownInterval = null;
let _idleInitialized = false;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minuti totali
const IDLE_WARNING_MS = 5 * 60 * 1000; // avviso 5 minuti prima della scadenza
/**
* Avvia il monitoraggio di inattivita' della sessione.
* Mostra un avviso 5 minuti prima del logout automatico.
*/
function initIdleTimeout() {
_resetIdleTimer();
['mousemove', 'keydown', 'click', 'scroll', 'touchstart'].forEach(evt => {
document.addEventListener(evt, _resetIdleTimer, { passive: true });
});
}
function _resetIdleTimer() {
clearTimeout(_idleTimer);
clearTimeout(_idleWarningTimer);
clearInterval(_idleCountdownInterval);
// Chiudi avviso se gia' aperto
const existingWarning = document.getElementById('idle-warning-overlay');
if (existingWarning) existingWarning.remove();
// Timer per mostrare avviso
_idleWarningTimer = setTimeout(_showIdleWarning, IDLE_TIMEOUT_MS - IDLE_WARNING_MS);
// Timer per logout automatico
_idleTimer = setTimeout(() => {
if (typeof api !== 'undefined') api.logout();
}, IDLE_TIMEOUT_MS);
}
function _showIdleWarning() {
const existing = document.getElementById('idle-warning-overlay');
if (existing) existing.remove();
let remaining = Math.floor(IDLE_WARNING_MS / 1000);
const overlay = document.createElement('div');
overlay.id = 'idle-warning-overlay';
overlay.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'right:0', 'bottom:0', 'z-index:9999',
'background:rgba(0,0,0,0.55)', 'display:flex', 'align-items:center', 'justify-content:center'
].join(';');
const _t = (key, fallback) => (typeof I18n !== 'undefined' ? I18n.t(key) : fallback);
overlay.innerHTML = `
<div style="background:var(--card-bg,#fff);border-radius:12px;padding:32px;max-width:400px;width:90%;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="width:56px;height:56px;background:#fff7ed;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<svg width="28" height="28" viewBox="0 0 20 20" fill="#c2410c"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
</div>
<h3 style="font-size:1.1rem;font-weight:700;color:var(--gray-800,#1f2937);margin-bottom:8px;">${_t('session.expiring_title', 'Sessione in scadenza')}</h3>
<p style="font-size:0.875rem;color:var(--gray-500,#6b7280);margin-bottom:20px;">
${_t('session.expiring_msg', 'Per motivi di sicurezza, verrai disconnesso tra')}<br>
<strong id="idle-countdown" style="font-size:1.5rem;color:#c2410c;display:block;margin:8px 0;">5:00</strong>
${_t('session.idle_reason', "a causa di inattivita'.")}
</p>
<div style="display:flex;gap:12px;justify-content:center;">
<button id="idle-stay-btn"
style="padding:10px 24px;background:var(--primary,#2563eb);color:#fff;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:0.875rem;">
${_t('session.stay', 'Rimani connesso')}
</button>
<button onclick="api.logout();"
style="padding:10px 24px;background:var(--gray-100,#f3f4f6);color:var(--gray-700,#374151);border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:0.875rem;">
${_t('session.logout_now', 'Disconnetti')}
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('idle-stay-btn').addEventListener('click', () => {
overlay.remove();
_resetIdleTimer();
});
_idleCountdownInterval = setInterval(() => {
remaining--;
const min = Math.floor(remaining / 60);
const sec = remaining % 60;
const el = document.getElementById('idle-countdown');
if (el) el.textContent = `${min}:${sec.toString().padStart(2, '0')}`;
if (remaining <= 0) clearInterval(_idleCountdownInterval);
}, 1000);
}
// ═══════════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════════
/**
* Escape HTML per prevenire XSS.
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
/**
* Debounce: ritarda l'esecuzione di fn fino a delay ms dopo l'ultima chiamata.
* @param {Function} fn
* @param {number} delay
* @returns {Function}
*/
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
/**
* Restituisce la classe CSS per un punteggio di conformita'.
* @param {number} score - Valore 0-100
* @returns {string}
*/
function getScoreClass(score) {
if (score >= 80) return 'score-excellent';
if (score >= 60) return 'score-good';
if (score >= 40) return 'score-medium';
if (score >= 20) return 'score-low';
return 'score-critical';
}
/**
* Restituisce il colore esadecimale per un punteggio.
* @param {number} score - Valore 0-100
* @returns {string}
*/
function getScoreColor(score) {
if (score >= 80) return '#34a853';
if (score >= 60) return '#84cc16';
if (score >= 40) return '#fbbc04';
if (score >= 20) return '#f97316';
return '#ea4335';
}
/**
* Crea l'SVG per il gauge circolare del punteggio.
* @param {number} score - Valore 0-100
* @param {number} size - Dimensione in px (default 180)
* @returns {string} HTML
*/
function renderScoreGauge(score, size = 180) {
const radius = (size / 2) - 14;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (score / 100) * circumference;
const color = getScoreColor(score);
const cls = getScoreClass(score);
return `
<div class="score-gauge ${cls}" style="width:${size}px;height:${size}px">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle class="score-gauge-circle-bg" cx="${size/2}" cy="${size/2}" r="${radius}"/>
<circle class="score-gauge-circle" cx="${size/2}" cy="${size/2}" r="${radius}"
stroke="${color}"
stroke-dasharray="${circumference}"
stroke-dashoffset="${offset}"
style="--score-color: ${color}"/>
</svg>
<div class="score-gauge-value">
<div class="score-gauge-number">${Math.round(score)}</div>
<div class="score-gauge-label">Compliance</div>
</div>
</div>
`;
}
// ═══════════════════════════════════════════════════════════════════
// SVG Icons (inline, nessuna dipendenza esterna)
// ═══════════════════════════════════════════════════════════════════
function iconGrid() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 8a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zm6-6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zm0 8a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>';
}
function iconClipboardCheck() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>';
}
function iconShieldExclamation() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>';
}
function iconBell() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>';
}
function iconDocumentText() {
return '<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>';
}
function iconLink() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/></svg>';
}
function iconAcademicCap() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>';
}
function iconServer() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>';
}
function iconChartBar() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg>';
}
function iconCubeTransparent() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 3.5a1 1 0 00-.5.134l-5 2.857A1 1 0 004 7.382v5.236a1 1 0 00.5.866l5 2.857a1 1 0 001 0l5-2.857a1 1 0 00.5-.866V7.382a1 1 0 00-.5-.891l-5-2.857A1 1 0 0010 3.5zM6 8.382l4 2.286 4-2.286v3.236l-4 2.286-4-2.286V8.382z"/></svg>';
}
function iconQuestionMarkCircle() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>';
}
function iconCog() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>';
}
/**
* Imposta lo stato di caricamento su un bottone.
* @param {HTMLElement|string} btn - Elemento o ID
* @param {boolean} loading - true per attivare, false per disattivare
* @param {string} [originalText] - Testo originale da ripristinare
*/
function setButtonLoading(btn, loading, originalText) {
if (typeof btn === 'string') btn = document.getElementById(btn);
if (!btn) return;
if (loading) {
btn._originalText = btn.textContent;
btn.classList.add('loading');
btn.disabled = true;
} else {
btn.classList.remove('loading');
btn.disabled = false;
if (originalText) btn.textContent = originalText;
else if (btn._originalText) btn.textContent = btn._originalText;
}
}
function iconPlus() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
}
function iconSparkles() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>';
}
// ═══════════════════════════════════════════════════════════════════
// Language Switcher
// ═══════════════════════════════════════════════════════════════════
function _getSavedLang() {
return localStorage.getItem('nis2_lang') || 'it';
}
function switchLang(lang) {
if (typeof I18n !== 'undefined') {
I18n.setLang(lang);
}
localStorage.setItem('nis2_lang', lang);
// Aggiorna bottoni
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase() === lang);
});
}
// Inject language toggle CSS
(function _injectLangCSS() {
if (document.getElementById('lang-toggle-css')) return;
const style = document.createElement('style');
style.id = 'lang-toggle-css';
style.textContent = `
.sidebar-lang-toggle {
display: flex;
justify-content: center;
gap: 4px;
padding: 8px 16px 4px;
}
.lang-btn {
padding: 4px 14px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--gray-300);
background: var(--gray-50);
color: var(--gray-500);
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.lang-btn:hover {
background: var(--gray-100);
color: var(--gray-700);
}
.lang-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
`;
document.head.appendChild(style);
})();
// ── feedback.js RIMOSSO 2026-06-14 ──────────────────────────────────────────
// Superato dal widget bug-reporter AgileHub (standard suite, come TRPG/ALLTAX):
// niente più doppio sistema di feedback (il pill "Segnala / Feedback" + modale scuro).
// Resta il trittico pallini: campana (notifiche) + supporto (segnala bug) + ARIA.
// (feedback.js e /api/feedback restano nel repo ma non vengono più iniettati.)
/* ════════════════════════════════════════════════════════════════════════
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=20260613';
s.async = true;
s.dataset.product = 'NIS2';
s.dataset.tenantId = '7';
s.dataset.apiUrl = 'https://agilehub.agile.software';
// X-API-Key per il gateway ticket AgileHub. Senza, il widget riceve 401
// "Header X-API-Key o Authorization: Bearer richiesto". Valorizzare con la
// chiave NIS2 emessa da AgileHub (prodotto NIS2, tenant 7). Inietta via
// window.NEXUS_WIDGET_API_KEY oppure hardcoda qui quando disponibile.
s.dataset.apiKey = (window.NEXUS_WIDGET_API_KEY || 'nis2_ak_dev_109b4bb54557c4ab3bdbed45');
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 = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9.5l-4.7 1.9L12 16l-1.8-4.6L5.5 9.5l4.7-1.9L12 3z"/></svg>';
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];
});
}
// Stile TRPG: aprendo il pannello ARIA nascondiamo gli altri pallini (niente
// sovrapposizione); la × / Esc li ripristinano chiudendo.
function _siblingFabs(hidden) {
['nx-bar', 'ai-chat-fab'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.display = hidden ? 'none' : 'flex';
});
}
function closePanel() { panel.style.display = 'none'; _siblingFabs(false); }
function openPanel() { panel.style.display = 'flex'; _siblingFabs(true); render(); }
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && panel.style.display === 'flex') closePanel();
});
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">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M12 3l1.8 4.6L18.5 9.5l-4.7 1.9L12 16l-1.8-4.6L5.5 9.5l4.7-1.9L12 3z"/></svg> 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">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
+ '</button>'
+ '</form>';
document.getElementById('ai-chat-close').onclick = closePanel;
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 () {
if (panel.style.display === 'flex') { closePanel(); } else { openPanel(); }
};
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureFab);
} else {
ensureFab();
}
})();