From d16fdd2c0260c7090d09ab1a5be8e5cb69443068 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 13 Jun 2026 08:56:36 +0200 Subject: [PATCH] [DEMO] Frontend demo-mode (punto 4): demo-mode.js + demo-selectors.js + demo.css + hook ?demo= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar di prodotto, product-demo-protocol v1.0.1 (NIS2 multi-page): - public/js/demo-mode.js: runtime canale postMessage (inbound navigate/highlight/scroll_to/ trigger_action/open_tab/...; outbound ready/spa_state_changed/step_animation_done/...), verifica origin+HMAC (chiave SHA-256(jwt:seed:demo-control-channel))+idempotency, banner read-only. - public/js/demo-selectors.js: whitelist 17 viste (view->url) + azioni read-only sicure. - public/css/demo.css: highlight pulse-blue/halo + banner. - common-bi.js: hook che inietta gli asset SOLO con ?demo= (no-op per utenti normali). NOTE: (a) HMAC canonical del MESSAGGIO da riconciliare con AgileHub al collaudo (canonicalize()). (b) manca il manifest (steps+selettori) — dipende dagli id stabili in corso. node --check OK su tutti. Co-Authored-By: Claude Opus 4.8 --- public/css/demo.css | 35 ++++++++ public/js/common-bi.js | 15 ++++ public/js/demo-mode.js | 166 ++++++++++++++++++++++++++++++++++++ public/js/demo-selectors.js | 62 ++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 public/css/demo.css create mode 100644 public/js/demo-mode.js create mode 100644 public/js/demo-selectors.js diff --git a/public/css/demo.css b/public/css/demo.css new file mode 100644 index 0000000..9d22c87 --- /dev/null +++ b/public/css/demo.css @@ -0,0 +1,35 @@ +/* NIS2 Agile — Avatar di prodotto: stili demo-mode (highlight + banner). + Caricato solo in demo-mode (?demo=). product-demo-protocol v1.0.1. */ + +/* Banner "Modalità dimostrazione" */ +#nis2-demo-banner { + position: fixed; top: 0; left: 0; right: 0; z-index: 99998; + background: #0066CC; color: #fff; font: 600 13px/1 "Titillium Web", system-ui, sans-serif; + padding: 8px 16px; display: flex; align-items: center; gap: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,.25); +} +#nis2-demo-banner .nis2-demo-dot { width: 8px; height: 8px; border-radius: 50%; background: #7ee787; animation: nis2-demo-pulse 1.4s infinite; } +#nis2-demo-banner .nis2-demo-spacer { flex: 1; } +body.nis2-demo-on { padding-top: 34px; } /* lascia spazio al banner */ + +/* Overlay highlight (spotlight) — applicato a un elemento target */ +.nis2-demo-hl { position: relative; z-index: 99990; } +.nis2-demo-hl.pulse-blue { box-shadow: 0 0 0 3px #0066CC, 0 0 0 9px rgba(0,102,204,.25); border-radius: 6px; animation: nis2-demo-pulse 1.4s ease-in-out infinite; } +.nis2-demo-hl.halo { box-shadow: 0 0 0 3px #f59e0b, 0 0 18px 4px rgba(245,158,11,.45); border-radius: 6px; } +.nis2-demo-hl.outline { outline: 3px solid #0066CC; outline-offset: 2px; border-radius: 6px; } + +@keyframes nis2-demo-pulse { + 0%,100% { box-shadow: 0 0 0 3px #0066CC, 0 0 0 6px rgba(0,102,204,.30); } + 50% { box-shadow: 0 0 0 3px #0066CC, 0 0 0 12px rgba(0,102,204,.06); } +} + +/* In demo-mode (read-only) attenua gli elementi di scrittura per chiarezza visiva. + La sicurezza vera è server-side (guard DEMO_READ_ONLY); questo è solo UX. */ +body.nis2-demo-readonly [data-demo-action]:not([data-demo-readonly-safe]) { /* azioni non-safe: nessun blocco CSS, solo hook JS */ } + +/* Toast errore demo */ +#nis2-demo-toast { + position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); z-index: 99999; + background: #17324d; color: #fff; padding: 10px 16px; border-radius: 8px; + font: 500 13px/1.3 "Titillium Web", system-ui, sans-serif; max-width: 80vw; display: none; +} diff --git a/public/js/common-bi.js b/public/js/common-bi.js index 2b73f74..ca2528d 100644 --- a/public/js/common-bi.js +++ b/public/js/common-bi.js @@ -170,3 +170,18 @@ // Override globale: le pagine -bi chiamano loadSidebar() come le originali. window.loadSidebar = loadSidebarBI; })(); + +// ── Avatar di prodotto: hook demo-mode (product-demo-protocol v1.0.1) ── +// Solo in presenza di ?demo= inietta gli asset demo (no-op per utenti normali). +(function () { + try { + if (!new URLSearchParams(window.location.search).get('demo')) return; + var css = document.createElement('link'); css.rel = 'stylesheet'; css.href = '/css/demo.css'; document.head.appendChild(css); + var inject = function () { + var s1 = document.createElement('script'); s1.src = '/js/demo-selectors.js'; + s1.onload = function () { var s2 = document.createElement('script'); s2.src = '/js/demo-mode.js'; document.body.appendChild(s2); }; + document.body.appendChild(s1); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', inject); else inject(); + } catch (e) { /* noop */ } +})(); diff --git a/public/js/demo-mode.js b/public/js/demo-mode.js new file mode 100644 index 0000000..1a5e263 --- /dev/null +++ b/public/js/demo-mode.js @@ -0,0 +1,166 @@ +/* + * NIS2 Agile — Avatar di prodotto: demo-mode runtime (product-demo-protocol v1.0.1). + * + * Si attiva SOLO con ?demo= nell'URL. NIS2 è multi-page: la navigazione tra + * viste = cambio pagina con ?demo preservato; ad ogni pagina il runtime si re-inizializza + * e ristabilisce il canale postMessage col widget AgileHub (iframe_origin). + * + * Canale: parent (widget AgileHub @ dimostrazione.agile.software) ⇄ questa SPA (iframe). + * - inbound (widget→SPA): navigate, highlight, scroll_to, trigger_action, open_tab, + * set_period, show_overlay_text, reset_demo_state, end_tour + * - outbound (SPA→widget): ready, spa_state_changed, step_animation_done, user_clicked, + * error, requested_help, cta_clicked + * Sicurezza: verifica ev.origin, protocol, HMAC (chiave canale), idempotency message_id (~30s), + * whitelist azioni (demo-selectors.js). Read-only: le scritture sono bloccate server-side + * dal guard DEMO_READ_ONLY (BaseController::applyDemoGuard). + * + * ⚠️ HMAC CANONICAL: la derivazione della CHIAVE canale è da contratto + * SHA-256(demo_jwt + ":" + seed + ":demo-control-channel"); la canonicalizzazione del + * MESSAGGIO per l'HMAC NON è fissata nello standard → vedi canonicalize() (TO-RECONCILE + * con AgileHub al collaudo §8.6). Se enforce e il canonical non combacia, i messaggi + * vengono scartati: in tal caso allinea canonicalize() alla forma del parser AgileHub. + */ +(function (w, d) { + 'use strict'; + + var IFRAME_ORIGIN = 'https://dimostrazione.agile.software'; + var PROTOCOL = 'agilehub.product-demo.v1'; + var HMAC_ENFORCE = true; // §2.3: scarta i messaggi non validi + var SEL = w.NIS2_DEMO_SELECTORS || null; + + var qs = new URLSearchParams(w.location.search); + var sessionId = qs.get('demo'); + if (!sessionId) return; // non in demo-mode → no-op + + var state = { jwt: null, seed: null, channelKeyRaw: null, seen: {}, ready: false }; + + // ── util crypto ────────────────────────────────────────────────────────── + function enc(s) { return new TextEncoder().encode(s); } + function toHex(buf) { return Array.prototype.map.call(new Uint8Array(buf), function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); } + function sha256(str) { return crypto.subtle.digest('SHA-256', enc(str)); } + + // chiave canale (raw 32 byte) = SHA-256(demo_jwt + ":" + seed + ":demo-control-channel") + function deriveKey() { + return sha256(state.jwt + ':' + state.seed + ':demo-control-channel').then(function (buf) { state.channelKeyRaw = buf; }); + } + + // canonicalizzazione del messaggio per l'HMAC — TO-RECONCILE con AgileHub. + function canonicalize(msg) { + return [msg.protocol, msg.type, msg.message_id, msg.ts, JSON.stringify(msg.payload || {})].join('|'); + } + function verifyHmac(msg) { + if (!HMAC_ENFORCE) return Promise.resolve(true); + if (!msg.hmac || !state.channelKeyRaw) return Promise.resolve(false); + return crypto.subtle.importKey('raw', state.channelKeyRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + .then(function (key) { return crypto.subtle.sign('HMAC', key, enc(canonicalize(msg))); }) + .then(function (sig) { return toHex(sig).toLowerCase() === String(msg.hmac).toLowerCase(); }) + .catch(function () { return false; }); + } + + // ── outbound ───────────────────────────────────────────────────────────── + function send(type, payload) { + try { w.parent.postMessage({ protocol: PROTOCOL, type: type, payload: payload || {}, message_id: 'spa-' + Date.now() + '-' + Math.floor(performance.now()), ts: new Date().toISOString() }, IFRAME_ORIGIN); } catch (e) { /* noop */ } + } + function currentView() { + var p = w.location.pathname.replace(/^\//, '').replace(/\.html$/, ''); + return p || 'index'; + } + + // ── UI helpers ─────────────────────────────────────────────────────────── + function banner() { + if (d.getElementById('nis2-demo-banner')) return; + var b = d.createElement('div'); b.id = 'nis2-demo-banner'; + b.innerHTML = 'Modalità dimostrazione — sola lettura · NIS2 AgileARIA ti guida'; + d.body.appendChild(b); d.body.classList.add('nis2-demo-on', 'nis2-demo-readonly'); + } + function toast(t) { + var el = d.getElementById('nis2-demo-toast'); + if (!el) { el = d.createElement('div'); el.id = 'nis2-demo-toast'; d.body.appendChild(el); } + el.textContent = t; el.style.display = 'block'; setTimeout(function () { el.style.display = 'none'; }, 3500); + } + var hlEls = []; + function clearHl() { hlEls.forEach(function (e) { e.classList.remove('nis2-demo-hl', 'pulse-blue', 'halo', 'outline'); }); hlEls = []; } + function highlight(selector, style, duration) { + clearHl(); + var el; try { el = d.querySelector(selector); } catch (e) { el = null; } + if (!el) { send('error', { code: 'SELECTOR_NOT_FOUND', selector: selector }); return; } + el.classList.add('nis2-demo-hl', style || 'pulse-blue'); hlEls.push(el); + try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} + setTimeout(function () { clearHl(); send('step_animation_done', { selector: selector }); }, duration || 4000); + } + function withDemo(url) { return url + (url.indexOf('?') < 0 ? '?' : '&') + 'demo=' + encodeURIComponent(sessionId); } + function gotoView(view) { + var url = SEL && SEL.viewUrl(view); + if (!url) { send('error', { code: 'UNKNOWN_VIEW', view: view }); return; } + if (currentView() === view) { send('spa_state_changed', { view: view }); return; } + w.location.href = withDemo(url); // ?demo preservato → re-init sulla nuova pagina + } + function runAction(action) { + if (!SEL || !SEL.isAllowedAction(action)) { send('error', { code: 'ACTION_NOT_WHITELISTED', action: action }); return; } + var a = SEL.action(action); + var el = null; + if (a.type === 'anchor') { try { el = d.querySelector('[data-demo-action="' + action + '"]'); } catch (e) {} } + else if (a.selector) { try { el = d.querySelector(a.selector); } catch (e) {} } + if (!el) { send('error', { code: 'ACTION_ANCHOR_MISSING', action: action }); return; } + el.click(); send('user_clicked', { action: action }); + } + + // ── inbound dispatch ───────────────────────────────────────────────────── + function dispatch(msg) { + var p = msg.payload || {}; + switch (msg.type) { + case 'navigate': gotoView(p.view); break; + case 'highlight': highlight(p.selector, p.style, p.duration_ms); break; + case 'scroll_to': try { var e = d.querySelector(p.selector); if (e) e.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (x) {} break; + case 'trigger_action': runAction(p.action); break; + case 'open_tab': runAction('open-tab-' + p.tab); break; + case 'show_overlay_text':toast(p.text || ''); break; + case 'set_period': /* NIS2 non ha periodi globali: no-op informativo */ send('spa_state_changed', { view: currentView(), note: 'set_period non applicabile' }); break; + case 'reset_demo_state': w.location.href = withDemo(SEL.viewUrl('dashboard')); break; + case 'end_tour': clearHl(); toast('Demo terminata.'); break; + default: /* tipi non gestiti: ignora */ break; + } + } + + function onMessage(ev) { + if (ev.origin !== IFRAME_ORIGIN) return; + var msg = ev.data; + if (!msg || msg.protocol !== PROTOCOL || !msg.type) return; + verifyHmac(msg).then(function (ok) { + if (!ok) { send('error', { code: 'HMAC_INVALID', type: msg.type }); return; } + if (msg.message_id) { if (state.seen[msg.message_id]) return; state.seen[msg.message_id] = Date.now(); } + dispatch(msg); + }); + } + + // ── init ───────────────────────────────────────────────────────────────── + function loadCredentials() { + state.seed = sessionStorage.getItem('demo_hmac_seed'); + state.jwt = sessionStorage.getItem('demo_jwt'); + if (state.jwt && state.seed) return Promise.resolve(); + // fallback: recupera dal backend (il seed resta quello scritto dal widget) + return fetch('/api/demo/credentials?session_id=' + encodeURIComponent(sessionId)) + .then(function (r) { return r.json(); }) + .then(function (j) { var x = (j && j.data) || j || {}; state.jwt = state.jwt || x.demo_jwt; if (x.hmac_key_seed && !state.seed) state.seed = x.hmac_key_seed; }); + } + + function boot() { + loadCredentials() + .then(function () { if (!state.jwt || !state.seed) throw new Error('demo creds mancanti'); return deriveKey(); }) + .then(function () { + banner(); + w.addEventListener('message', onMessage, false); + state.ready = true; + send('ready', { view: currentView(), session_id: sessionId, ua: navigator.userAgent }); + send('spa_state_changed', { view: currentView() }); + // segnala i click utente su CTA marcate + d.addEventListener('click', function (e) { + var cta = e.target.closest && e.target.closest('[data-demo-cta]'); + if (cta) send('cta_clicked', { cta: cta.getAttribute('data-demo-cta') }); + }, true); + }) + .catch(function (err) { send('error', { code: 'DEMO_INIT_FAILED', message: String(err && err.message || err) }); }); + } + + if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', boot); else boot(); +})(window, document); diff --git a/public/js/demo-selectors.js b/public/js/demo-selectors.js new file mode 100644 index 0000000..55c28c5 --- /dev/null +++ b/public/js/demo-selectors.js @@ -0,0 +1,62 @@ +/* + * NIS2 Agile — Avatar di prodotto: WHITELIST selettori/azioni demo (product-demo-protocol v1.0.1). + * + * Fonte di verità (regola 3-way): DOM ↔ questo file ↔ docs/mappa-logica/SELETTORI_DEMO.md + * devono restare allineati. Se cambi un selettore qui o nel DOM → avvisa AgileHub per il re-ingest RAG. + * + * - VIEWS: nomi-vista del manifest → URL pagina reale (NIS2 è multi-page: navigate = cambio pagina, + * con ?demo= preservato da demo-mode.js). + * - ACTIONS: SOLO azioni read-only sicure lanciabili dal tour (trigger_action). Ogni azione mappa a un + * anchor data-demo-action="" nel DOM. In demo:read-only le scritture sono già bloccate server-side + * (guard DEMO_READ_ONLY); qui restano solo azioni di consultazione (apri tab/dettaglio/pannello). + */ +(function (w) { + 'use strict'; + + // vista (manifest view_target) → pagina reale + var VIEWS = { + 'dashboard': '/dashboard.html', + 'assessment': '/assessment.html', + 'acn-gap': '/acn-gap.html', + 'risks': '/risks.html', + 'incidents': '/incidents.html', + 'policies': '/policies.html', + 'supply-chain': '/supply-chain.html', + 'assets': '/assets.html', + 'training': '/training.html', + 'reports': '/reports.html', + 'isms': '/isms.html', + 'normative': '/normative.html', + 'whistleblowing': '/whistleblowing.html', + 'kb': '/kb.html', + 'cross-analysis': '/cross-analysis.html', + 'settings': '/settings.html', + 'companies': '/companies.html' + }; + + // azioni read-only sicure (whitelist). value = come eseguirle in pagina. + // { type:'click', selector } | { type:'tab', selector } | { type:'anchor', action } + // NB: anchor = clicca l'elemento [data-demo-action=""] (da cablare nel DOM). + var ACTIONS = { + // navigazione interna sicura (apertura tab di una vista) + 'open-tab-matrix': { type: 'anchor', action: 'open-tab-matrix' }, // risks: vista Matrice 5x5 + 'open-tab-fair': { type: 'anchor', action: 'open-tab-fair' }, // risks: vista FAIR + 'open-tab-controls': { type: 'anchor', action: 'open-tab-controls' }, // reports: tab Controlli + 'open-tab-iso': { type: 'anchor', action: 'open-tab-iso' }, // reports: tab ISO 27001 + 'open-tab-audit': { type: 'anchor', action: 'open-tab-audit' }, // reports: tab Audit Log + 'open-tab-acn': { type: 'anchor', action: 'open-tab-acn' }, // reports: tab Requisiti ACN + // pannelli/consultazione (read-only) + 'open-ai-panel': { type: 'anchor', action: 'open-ai-panel' }, // cross-analysis: chat ARIA + 'open-first-detail': { type: 'anchor', action: 'open-first-detail' } // apre il primo dettaglio di una lista + }; + + w.NIS2_DEMO_SELECTORS = { + version: '1.0', + // protocollo: queste sono le UNICHE viste/azioni eseguibili dal canale demo + views: VIEWS, + actions: ACTIONS, + viewUrl: function (view) { return VIEWS[view] || null; }, + isAllowedAction: function (name) { return Object.prototype.hasOwnProperty.call(ACTIONS, name); }, + action: function (name) { return ACTIONS[name] || null; } + }; +})(window);