[FEAT] Simulatore B2B licenze + registrazione ridotta

- simulate-nis2-b2b.php: 6 scenari autonomi (SIM-B1→B6):
  mktg login, invito con recipient data, validazione pubblica,
  registrazione ridotta, provision org, login con org, API Key M2M
- public/simulate-b2b.html: UI terminale dark con flow diagram e SSE streaming
- public/register.html:
  - Registrazione ridotta: con invito che ha recipient data mostra banner
    "Ciao [Nome]!" + campi pre-compilati read-only + solo password richiesta
  - Post-register con inviteToken: chiama provision automaticamente,
    salva nis2_org_id in localStorage, redirect a dashboard.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-10 15:26:23 +01:00
parent 8304da91b4
commit 8b9a617fd5
3 changed files with 752 additions and 0 deletions

View File

@ -222,6 +222,20 @@
<!-- STEP 1: Dati account -->
<div id="step-1">
<!-- Banner accesso B2B ridotto (visibile solo con invito + recipient data) -->
<div id="invite-welcome" style="display:none; background:linear-gradient(135deg,#ecfeff,#cffafe); border:1.5px solid #06B6D4; border-radius:12px; padding:.875rem 1rem; margin-bottom:1rem;">
<div style="display:flex; align-items:center; gap:.75rem; margin-bottom:.4rem;">
<div style="width:34px;height:34px;border-radius:8px;background:#06B6D4;color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="fas fa-ticket-alt" style="font-size:.85rem;"></i>
</div>
<div>
<div id="invite-welcome-name" style="font-weight:700;font-size:.92rem;color:#0e7490;"></div>
<div id="invite-welcome-plan" style="font-size:.73rem;color:#0891b2;margin-top:1px;"></div>
</div>
</div>
<p style="margin:0;font-size:.8rem;color:#0e7490;line-height:1.45;">I tuoi dati sono stati pre-compilati dall'invito. <strong>Scegli solo la password</strong> per accedere alla piattaforma.</p>
</div>
<form id="register-form" novalidate>
<div style="display:flex; gap:.75rem;">
<div class="form-group" style="flex:1;">
@ -331,6 +345,8 @@
let selectedRole = null;
let inviteValid = false;
let inviteToken = null;
let inviteRecipient = null; // dati destinatario dell'invito (pre-fill)
let isReducedReg = false; // true = registrazione ridotta (solo password)
let pivaData = null;
let pivaLookupTimer = null;
let successRedirect = 'onboarding.html';
@ -425,7 +441,10 @@
if (r.last_name) document.getElementById('lastname').value = r.last_name;
if (r.email) document.getElementById('email').value = r.email;
if (r.vat) { document.getElementById('piva').value = r.vat; lookupPiva(r.vat); }
inviteRecipient = r;
isReducedReg = !!(r.first_name && r.last_name && r.email);
}
if (isReducedReg) applyReducedRegMode();
} else {
inviteValid = false;
inviteToken = null;
@ -493,6 +512,29 @@
}, 800);
}
// --- Registrazione Ridotta (invito con dati pre-compilati) ---
function applyReducedRegMode() {
const name = ((inviteRecipient.first_name || '') + ' ' + (inviteRecipient.last_name || '')).trim();
document.getElementById('invite-welcome-name').textContent = 'Ciao ' + name + '!';
document.getElementById('invite-welcome-plan').textContent = 'Accesso B2B — account pre-configurato dall\'invito';
document.getElementById('invite-welcome').style.display = 'block';
// Campi pre-compilati → read-only
['firstname', 'lastname', 'email'].forEach(id => {
const el = document.getElementById(id);
el.readOnly = true;
el.style.background = '#f9fafb';
el.style.color = '#6b7280';
});
// Nascondi P.IVA se già presente nell'invito
if (inviteRecipient.vat) {
document.getElementById('piva-group').style.display = 'none';
}
// Seleziona ruolo default se non già selezionato
if (!selectedRole) selectRole('org_admin');
// Avanza direttamente allo step dati senza richiedere click
goToStep1();
}
// --- Step Navigation ---
function setStep(n) {
[0, 1, 2].forEach(i => {
@ -633,6 +675,38 @@
// Determine redirect
if (inviteToken) {
successRedirect = 'dashboard.html';
// Salva JWT tokens
if (result.data?.access_token) {
api.setTokens(result.data.access_token, result.data.refresh_token);
localStorage.setItem('nis2_user_role', result.data.user?.role || selectedRole || 'org_admin');
}
// Chiama provision per creare l'organizzazione
try {
const companyName = pivaData?.company_name || inviteRecipient?.company || 'Azienda';
const vatNum = (piva || inviteRecipient?.vat || '').replace(/[^0-9]/g, '');
if (vatNum.length === 11) {
btn.textContent = 'Configurazione in corso...';
const provRes = await fetch(api.baseUrl + '/services/provision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
company: {
ragione_sociale: companyName,
partita_iva: vatNum,
sector: pivaData?.sector || 'ict',
nis2_entity_type: 'important'
},
admin: { email, full_name: fullname },
caller: { system: 'self-register' }
})
});
const provData = await provRes.json();
if (provData.success && provData.data?.org_id) {
localStorage.setItem('nis2_org_id', String(provData.data.org_id));
}
}
} catch { /* provision fallita — utente completerà onboarding */ }
} else if (selectedRole === 'consultant') {
successRedirect = 'companies.html';
} else {

255
public/simulate-b2b.html Normal file
View File

@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simulazione B2B — NIS2 Agile</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg:#0f172a;--bg2:#1e293b;--bg3:#0f2d3d;--primary:#06B6D4;--border:#1e3a5f;
--text:#e2e8f0;--muted:#64748b;--ok:#22d3ee;--warn:#fbbf24;--err:#f87171;--phase:#38bdf8; }
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;min-height:100vh}
.header{background:var(--bg2);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;gap:16px}
.header-logo{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1.1rem;color:var(--primary)}
.header-logo svg{width:28px;height:28px}
.header-badge{background:#164e63;color:var(--primary);font-size:.72rem;font-weight:700;padding:3px 10px;border-radius:20px;border:1px solid var(--primary)}
.header-back{margin-left:auto;color:var(--muted);font-size:.82rem;text-decoration:none}
.header-back:hover{color:var(--primary)}
.container{max-width:960px;margin:0 auto;padding:24px 20px}
.flow-diagram{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:24px;flex-wrap:wrap;
background:var(--bg2);border-radius:12px;padding:16px 20px;border:1px solid var(--border)}
.flow-step{display:flex;flex-direction:column;align-items:center;gap:5px;min-width:80px}
.flow-icon{width:40px;height:40px;border-radius:10px;background:var(--bg3);border:1.5px solid var(--border);
display:flex;align-items:center;justify-content:center;font-size:.95rem;color:var(--muted);transition:all .3s}
.flow-icon.active{border-color:var(--primary);color:var(--primary);background:rgba(6,182,212,.1)}
.flow-icon.done{border-color:var(--ok);color:var(--ok);background:rgba(34,211,238,.1)}
.flow-label{font-size:.62rem;color:var(--muted);text-align:center;font-weight:600}
.flow-arrow{color:var(--border);font-size:.75rem;padding:0 4px;margin-bottom:18px}
.btn{padding:10px 20px;border:none;border-radius:8px;font-size:.875rem;font-weight:700;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:8px}
.btn-primary{background:var(--primary);color:#0f172a}
.btn-primary:hover:not(:disabled){background:#0891b2;color:#fff}
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
.btn-danger{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b}
.btn-danger:hover:not(:disabled){background:#991b1b}
.btn-gray{background:var(--bg2);color:#94a3b8;border:1px solid var(--border)}
.btn-gray:hover{background:#334155}
.controls{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.stats-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.stat-pill{display:flex;align-items:center;gap:6px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:.8rem;font-weight:700}
.stat-pass{color:var(--ok)}.stat-warn{color:var(--warn)}.stat-fail{color:var(--err)}.stat-skip{color:var(--muted)}
.progress-wrap{margin-bottom:16px}
.progress-bar{height:5px;background:var(--bg2);border-radius:3px;overflow:hidden}
.progress-fill{height:100%;width:0%;background:linear-gradient(90deg,var(--primary),#0ea5e9);transition:width .4s ease;border-radius:3px}
.progress-label{font-size:.72rem;color:var(--muted);margin-top:4px;text-align:right}
.terminal{background:#080f1a;border:1px solid var(--border);border-radius:12px;height:460px;overflow-y:auto;
font-family:'JetBrains Mono','Fira Code',monospace;font-size:.78rem;line-height:1.6;padding:14px 16px}
.term-header{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--border)}
.term-dot{width:10px;height:10px;border-radius:50%}
.term-title{color:var(--muted);font-size:.72rem;margin-left:auto}
.log-line{padding:1px 0}
.log-ok{color:var(--ok)}.log-warn{color:var(--warn)}.log-error{color:var(--err)}
.log-info{color:#94a3b8}
.log-phase{color:var(--phase);font-weight:700;border-top:1px solid var(--border);padding-top:8px;margin-top:6px}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px}
.info-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
.info-card-title{font-size:.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px}
.info-row{display:flex;justify-content:space-between;font-size:.78rem;padding:3px 0;border-bottom:1px solid rgba(30,58,95,.5)}
.info-row:last-child{border-bottom:none}
.info-key{color:var(--muted)}.info-val{color:var(--text);font-family:monospace;font-size:.75rem}
.done-banner{display:none;background:linear-gradient(135deg,#0a2638,#0c3a50);border:1px solid var(--primary);
border-radius:12px;padding:16px 20px;margin-top:16px;text-align:center}
.done-banner.visible{display:block}
.done-title{font-size:1rem;font-weight:800;color:var(--primary);margin-bottom:6px}
.done-sub{font-size:.82rem;color:#94a3b8}
</style>
</head>
<body>
<div class="header">
<div class="header-logo">
<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>
NIS2 Agile
</div>
<div class="header-badge">B2B SIM</div>
<span style="color:var(--muted);font-size:.82rem;">Simulatore Flusso Licenze B2B</span>
<a href="simulate.html" class="header-back"><i class="fas fa-arrow-left"></i> Sim Standard</a>
<a href="dashboard.html" class="header-back" style="margin-left:8px;"><i class="fas fa-home"></i> Dashboard</a>
</div>
<div class="container">
<div class="flow-diagram">
<div class="flow-step"><div class="flow-icon" id="fi-1"><i class="fas fa-bullhorn"></i></div><div class="flow-label">SIM-B1<br>Mktg Invito</div></div>
<div class="flow-arrow"></div>
<div class="flow-step"><div class="flow-icon" id="fi-2"><i class="fas fa-check-circle"></i></div><div class="flow-label">SIM-B2<br>Valida</div></div>
<div class="flow-arrow"></div>
<div class="flow-step"><div class="flow-icon" id="fi-3"><i class="fas fa-user-plus"></i></div><div class="flow-label">SIM-B3<br>Registrazione</div></div>
<div class="flow-arrow"></div>
<div class="flow-step"><div class="flow-icon" id="fi-4"><i class="fas fa-building"></i></div><div class="flow-label">SIM-B4<br>Provision Org</div></div>
<div class="flow-arrow"></div>
<div class="flow-step"><div class="flow-icon" id="fi-5"><i class="fas fa-sign-in-alt"></i></div><div class="flow-label">SIM-B5<br>Login</div></div>
<div class="flow-arrow"></div>
<div class="flow-step"><div class="flow-icon" id="fi-6"><i class="fas fa-key"></i></div><div class="flow-label">SIM-B6<br>API Key M2M</div></div>
</div>
<div class="controls">
<button id="btnRun" class="btn btn-primary" onclick="runSim()">
<i class="fas fa-play"></i><span id="btnLabel">Avvia Simulazione B2B</span>
</button>
<button id="btnStop" class="btn btn-danger" onclick="stopSim()" disabled>
<i class="fas fa-stop"></i> Interrompi
</button>
<button class="btn btn-gray" onclick="resetSim()">
<i class="fas fa-trash-alt"></i> Reset Dati Demo
</button>
<button class="btn btn-gray" onclick="clearLog()">
<i class="fas fa-eraser"></i> Pulisci
</button>
</div>
<div class="stats-bar">
<div class="stat-pill stat-pass"><i class="fas fa-check-circle"></i> <span id="cnt-pass">0</span> Pass</div>
<div class="stat-pill stat-warn"><i class="fas fa-exclamation-triangle"></i> <span id="cnt-warn">0</span> Warn</div>
<div class="stat-pill stat-fail"><i class="fas fa-times-circle"></i> <span id="cnt-fail">0</span> Fail</div>
<div class="stat-pill stat-skip"><i class="fas fa-arrow-right"></i> <span id="cnt-skip">0</span> Skip</div>
</div>
<div class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="progFill"></div></div>
<div class="progress-label" id="progLabel">Pronto</div>
</div>
<div class="terminal" id="terminal">
<div class="term-header">
<div class="term-dot" style="background:#ef4444;"></div>
<div class="term-dot" style="background:#fbbf24;"></div>
<div class="term-dot" style="background:#22d3ee;"></div>
<span class="term-title">NIS2 B2B Simulator</span>
</div>
<div id="logContainer">
<div class="log-line log-info">Pronto. Clicca "Avvia Simulazione B2B" per testare il ciclo completo Mktg → Invito → Registrazione → Provision → Login → API Key.</div>
</div>
</div>
<div class="info-grid">
<div class="info-card">
<div class="info-card-title"><i class="fas fa-bullhorn"></i> Operatore Mktg</div>
<div class="info-row"><span class="info-key">Email</span><span class="info-val">sim-b2b-mktg@nis2agile.it</span></div>
<div class="info-row"><span class="info-key">Ruolo</span><span class="info-val">super_admin</span></div>
<div class="info-row"><span class="info-key">Canale invito</span><span class="info-val">sim-b2b</span></div>
</div>
<div class="info-card">
<div class="info-card-title"><i class="fas fa-user"></i> Cliente B2B</div>
<div class="info-row"><span class="info-key">Email</span><span class="info-val">laura.bianchi@techstart.b2b.demo</span></div>
<div class="info-row"><span class="info-key">Azienda</span><span class="info-val">TechStart S.r.l.</span></div>
<div class="info-row"><span class="info-key">P.IVA</span><span class="info-val">03456789012</span></div>
</div>
<div class="info-card">
<div class="info-card-title"><i class="fas fa-ticket-alt"></i> Invito B2B</div>
<div class="info-row"><span class="info-key">Piano</span><span class="info-val">professional · 12 mesi</span></div>
<div class="info-row"><span class="info-key">Dati pre-fill</span><span class="info-val">nome, email, P.IVA, azienda</span></div>
<div class="info-row"><span class="info-key">Scadenza</span><span class="info-val">30 giorni</span></div>
</div>
<div class="info-card">
<div class="info-card-title"><i class="fas fa-shield-alt"></i> Registrazione Ridotta</div>
<div class="info-row"><span class="info-key">Campo richiesto</span><span class="info-val">solo password</span></div>
<div class="info-row"><span class="info-key">Provision</span><span class="info-val">automatico post-register</span></div>
<div class="info-row"><span class="info-key">Redirect</span><span class="info-val">dashboard (org pronta)</span></div>
</div>
</div>
<div class="done-banner" id="doneBanner">
<div class="done-title"><i class="fas fa-check-circle"></i> Flusso B2B completato con successo</div>
<div class="done-sub" id="doneSub"></div>
</div>
</div>
<script>
let evtSrc = null;
let phaseN = 0;
const LABELS = ['','Mktg Invito','Valida Invito','Reg. Ridotta','Provision Org','Login + Org','API Key M2M'];
function upProg(pct, lbl) {
document.getElementById('progFill').style.width = pct + '%';
document.getElementById('progLabel').textContent = lbl || pct + '%';
}
function appendLog(msg, type) {
const el = document.createElement('div');
el.className = 'log-line log-' + (type || 'info');
el.textContent = msg;
document.getElementById('logContainer').appendChild(el);
const t = document.getElementById('terminal');
t.scrollTop = t.scrollHeight;
}
function clearLog() {
document.getElementById('logContainer').innerHTML = '';
upProg(0, 'Pronto');
document.getElementById('doneBanner').classList.remove('visible');
for (let i=1;i<=6;i++) { const e=document.getElementById('fi-'+i); e.classList.remove('active','done'); }
['pass','warn','fail','skip'].forEach(k => document.getElementById('cnt-'+k).textContent='0');
}
function runSim() {
if (evtSrc) { evtSrc.close(); evtSrc=null; }
clearLog();
const run=document.getElementById('btnRun'), stop=document.getElementById('btnStop');
run.disabled=true; stop.disabled=false;
document.getElementById('btnLabel').textContent='Simulazione in corso...';
upProg(2,'Avvio...');
evtSrc = new EventSource('simulate-nis2-b2b.php?t=' + Date.now());
phaseN = 0;
evtSrc.onmessage = (e) => {
const d=JSON.parse(e.data), t=d.t||'info', m=d.m||'';
if (t === 'done') {
const st=d.stats||{};
['pass','warn','fail','skip'].forEach(k => document.getElementById('cnt-'+k).textContent=st[k]??0);
upProg(100,'Completato');
for(let i=1;i<=6;i++){const el=document.getElementById('fi-'+i);el.classList.remove('active');el.classList.add('done');}
document.getElementById('doneBanner').classList.add('visible');
document.getElementById('doneSub').textContent=`✓${st.pass||0} pass ⚠${st.warn||0} warn ✗${st.fail||0} fail →${st.skip||0} skip`;
run.disabled=false; stop.disabled=true;
document.getElementById('btnLabel').textContent='Avvia di Nuovo';
evtSrc.close(); evtSrc=null; return;
}
appendLog(m, t);
if (t === 'phase') {
phaseN++;
if (phaseN>=1&&phaseN<=6){
for(let i=1;i<phaseN;i++){const el=document.getElementById('fi-'+i);if(!el.classList.contains('done')){el.classList.add('done');el.classList.remove('active');}}
const cur=document.getElementById('fi-'+phaseN);
if(cur){cur.classList.add('active');cur.classList.remove('done');}
}
upProg(Math.min(5+phaseN*16,93), LABELS[phaseN]||'');
}
};
evtSrc.onerror = () => {
appendLog('Connessione SSE interrotta.','error');
run.disabled=false; stop.disabled=true;
document.getElementById('btnLabel').textContent='Avvia Simulazione B2B';
if(evtSrc){evtSrc.close();evtSrc=null;}
};
}
function stopSim() {
if(evtSrc){evtSrc.close();evtSrc=null;}
appendLog('Simulazione interrotta.','warn');
document.getElementById('btnRun').disabled=false;
document.getElementById('btnStop').disabled=true;
document.getElementById('btnLabel').textContent='Avvia Simulazione B2B';
}
function resetSim() {
if(!confirm('Eliminare tutti i dati demo B2B (utenti *.b2b.demo, inviti sim-b2b)?')) return;
clearLog(); appendLog('Reset B2B in corso...','info');
const src=new EventSource('simulate-nis2-b2b.php?reset=1&t='+Date.now());
src.onmessage=(e)=>{const d=JSON.parse(e.data);appendLog(d.m||'',d.t||'info');if(d.t==='done'){src.close();appendLog('Reset completato.','ok');}};
src.onerror=()=>{src.close();appendLog('Reset completato.','ok');};
}
</script>
</body>
</html>

423
simulate-nis2-b2b.php Normal file
View File

@ -0,0 +1,423 @@
<?php
/**
* NIS2 Agile Simulazione Flusso B2B Licenze
*
* Testa il ciclo completo: Mktg crea invito Cliente registrazione ridotta
* Provision org Login Accesso dashboard + API Key.
*
* 6 Scenari:
* SIM-B1: Mktg login + creazione invito B2B con dati destinatario
* SIM-B2: Validazione invito pubblico verifica dati pre-compilazione
* SIM-B3: Registrazione ridotta cliente (solo password, dati da invito)
* SIM-B4: Provision organizzazione post-register org + API Key
* SIM-B5: Login cliente verifica org caricata + membership
* SIM-B6: API Key da provision dashboard overview + compliance-summary
*
* CLI: php simulate-nis2-b2b.php [--reset]
* Web: public/simulate-b2b.html
*/
declare(strict_types=1);
define('SIM_B2B_VERSION', '1.0.0');
define('MKTG_EMAIL', 'sim-b2b-mktg@nis2agile.it');
define('MKTG_PWD', 'SimB2B2026!');
define('MKTG_NAME', 'Sim B2B Mktg');
define('CLIENTE_EMAIL', 'laura.bianchi@techstart.b2b.demo');
define('CLIENTE_PWD', 'TechStartB2B2026!');
define('CLIENTE_NOME', 'Laura');
define('CLIENTE_COGNOME', 'Bianchi');
define('CLIENTE_FULLNAME','Laura Bianchi');
define('CLIENTE_COMPANY', 'TechStart S.r.l.');
define('CLIENTE_PIVA', '03456789012');
define('CLIENTE_SECTOR', 'ict');
$_sseMode = (bool)getenv('NIS2_SSE');
define('IS_CLI', php_sapi_name() === 'cli' && !$_sseMode);
define('IS_WEB', !IS_CLI);
if (IS_CLI || $_sseMode) {
define('API_BASE', getenv('NIS2_API_BASE') ?: 'https://nis2.agile.software/api');
} else {
$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
define('API_BASE', "{$proto}://{$host}/api");
}
if (IS_WEB) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
ob_implicit_flush(true);
while (ob_get_level()) ob_end_flush();
}
$S = [
'mktg_jwt' => null,
'invite_token'=> null,
'invite_id' => null,
'cliente_jwt' => null,
'org_id' => null,
'api_key' => null,
'stats' => ['pass' => 0, 'skip' => 0, 'fail' => 0, 'warn' => 0],
'sim_start' => microtime(true),
];
// ── Output helpers ──────────────────────────────────────────────────────────
function simLog(string $msg, string $type = 'info'): void
{
static $lastHb = 0;
$ts = date('H:i:s');
if (IS_CLI) {
$p = ['phase'=>"\033[34m══\033[0m",'ok'=>"\033[32m ✓\033[0m",'skip'=>"\033[90m →\033[0m",
'warn'=>"\033[33m ⚠\033[0m",'error'=>"\033[31m ✗\033[0m",'info'=>' '][$type] ?? ' ';
echo "[$ts] {$p} {$msg}\n"; flush();
} else {
$now = time();
if ($now - $lastHb >= 25) { echo ": heartbeat $ts\n\n"; $lastHb = $now; }
echo 'data: ' . json_encode(['t' => $type, 'm' => "[$ts] $msg"]) . "\n\n"; flush();
}
}
function simDone(array $stats): void
{
$e = round(microtime(true) - $GLOBALS['S']['sim_start'], 1);
$msg = "B2B completata in {$e}s — ✓{$stats['pass']}{$stats['skip']}{$stats['warn']}{$stats['fail']}";
simLog($msg, IS_CLI ? 'phase' : 'info');
if (IS_WEB) { echo 'data: ' . json_encode(['t' => 'done', 'stats' => $stats]) . "\n\n"; flush(); }
}
function simPhase(int $n, string $title): void
{
if (IS_CLI) {
$line = str_pad("SIM-B{$n}: {$title}", 60);
echo "\n\033[34m╔════════════════════════════════════════════════════════════╗\033[0m\n";
echo "\033[34m║ {$line}\033[0m\n";
echo "\033[34m╚════════════════════════════════════════════════════════════╝\033[0m\n";
} else { simLog("━━ SIM-B{$n}: {$title}", 'phase'); }
}
function ok(string $m): void { global $S; $S['stats']['pass']++; simLog($m, 'ok'); }
function skip(string $m): void { global $S; $S['stats']['skip']++; simLog($m, 'skip'); }
function fail(string $m): void { global $S; $S['stats']['fail']++; simLog($m, 'error'); }
function warn(string $m): void { global $S; $S['stats']['warn']++; simLog($m, 'warn'); }
function info(string $m): void { simLog($m, 'info'); }
// ── API client ──────────────────────────────────────────────────────────────
function api(string $method, string $path, ?array $body = null,
?string $jwt = null, ?int $orgId = null, ?string $apiKey = null): array
{
$ch = curl_init(API_BASE . $path);
$h = ['Content-Type: application/json', 'Accept: application/json'];
if ($jwt) $h[] = 'Authorization: Bearer ' . $jwt;
if ($orgId) $h[] = 'X-Organization-Id: ' . $orgId;
if ($apiKey) $h[] = 'X-Api-Key: ' . $apiKey;
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>30,
CURLOPT_HTTPHEADER=>$h, CURLOPT_CUSTOMREQUEST=>strtoupper($method),
CURLOPT_SSL_VERIFYPEER=>false, CURLOPT_SSL_VERIFYHOST=>0]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false) return ['success'=>false,'error'=>'cURL fallito','_http'=>0];
$res = json_decode($raw, true) ?? ['success'=>false,'error'=>'JSON invalido','_raw'=>substr($raw,0,200)];
$res['_http'] = $code;
return $res;
}
function apiOk(array $res, string $ctx = ''): bool
{
if (!empty($res['success'])) return true;
warn('API Error' . ($ctx ? " [$ctx]" : '') . ': ' . ($res['error'] ?? $res['message'] ?? 'errore') . " (HTTP {$res['_http']})");
return false;
}
// ── DB helpers ──────────────────────────────────────────────────────────────
function dbConnect(): ?PDO
{
$envFile = __DIR__ . '/.env';
if (!is_file($envFile)) return null;
$env = [];
foreach (file($envFile, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim($v);
}
try {
return new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$env['DB_HOST']??'localhost', $env['DB_PORT']??'3306', $env['DB_NAME']??'nis2_agile_db'),
$env['DB_USER']??'', $env['DB_PASS']??'',
[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]
);
} catch (Throwable) { return null; }
}
function dbSeedMktgUser(): bool
{
$pdo = dbConnect();
if (!$pdo) return false;
$hash = password_hash(MKTG_PWD, PASSWORD_DEFAULT);
$pdo->prepare('INSERT INTO users (email,password_hash,full_name,role,is_active) VALUES (?,?,?,\'super_admin\',1)
ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),role=\'super_admin\',is_active=1')
->execute([MKTG_EMAIL, $hash, MKTG_NAME]);
return true;
}
function dbResetB2B(): void
{
$pdo = dbConnect();
if (!$pdo) { warn('Reset B2B non disponibile (DB non accessibile)'); return; }
$pdo->prepare("DELETE FROM users WHERE email LIKE '%.b2b.demo%' OR email = ?")->execute([MKTG_EMAIL]);
$pdo->exec("DELETE FROM organizations WHERE vat_number = '" . CLIENTE_PIVA . "' OR provisioned_by = 'sim-b2b'");
$pdo->exec("DELETE FROM invites WHERE channel = 'sim-b2b'");
info('Reset B2B completato');
}
// ── Reset ────────────────────────────────────────────────────────────────────
$args = IS_CLI ? array_slice($argv ?? [], 1) : [];
if (in_array('--reset', $args, true) || (IS_WEB && ($_GET['reset'] ?? '') === '1')) {
simPhase(0, 'Reset dati B2B demo');
dbResetB2B();
simDone($S['stats']); exit;
}
// ══════════════════════════════════════════════════════════════════════════════
// SIMULAZIONE
// ══════════════════════════════════════════════════════════════════════════════
simLog('NIS2 Agile B2B Simulator v' . SIM_B2B_VERSION . ' — ' . API_BASE, 'phase');
// ── SIM-B1: Mktg crea invito ─────────────────────────────────────────────────
simPhase(1, 'Mktg crea invito B2B con dati destinatario');
dbSeedMktgUser()
? ok('Operatore mktg seeded: ' . MKTG_EMAIL)
: warn('DB non accessibile — mktg user deve già esistere');
info('Login operatore mktg...');
$res = api('POST', '/auth/login', ['email' => MKTG_EMAIL, 'password' => MKTG_PWD]);
if (!apiOk($res, 'mktg-login')) { fail('Login mktg fallito'); simDone($S['stats']); exit(1); }
$S['mktg_jwt'] = $res['data']['access_token'];
ok('Login mktg OK (user_id: ' . ($res['data']['user']['id'] ?? '?') . ')');
// Cleanup inviti precedenti
$listRes = api('GET', '/invites?channel=sim-b2b&status=pending', null, $S['mktg_jwt']);
if (!empty($listRes['data']['invites'])) {
foreach ($listRes['data']['invites'] as $old) api('DELETE', '/invites/' . $old['id'], null, $S['mktg_jwt']);
info(' Rimossi ' . count($listRes['data']['invites']) . ' inviti pending precedenti');
}
info('Creazione invito B2B piano Professional 12 mesi...');
$res = api('POST', '/invites', [
'plan' => 'professional', 'duration_months' => 12, 'invite_expires_days' => 30,
'max_uses' => 1, 'label' => 'TechStart - Sim B2B', 'channel' => 'sim-b2b',
'notes' => 'Invito simulazione B2B — auto-generato',
'recipient_first_name' => CLIENTE_NOME, 'recipient_last_name' => CLIENTE_COGNOME,
'recipient_email' => CLIENTE_EMAIL, 'recipient_company' => CLIENTE_COMPANY,
'recipient_vat' => CLIENTE_PIVA,
], $S['mktg_jwt']);
if (!apiOk($res, 'create-invite')) { fail('Creazione invito fallita'); simDone($S['stats']); exit(1); }
$invite = $res['data']['invites'][0] ?? null;
if (!$invite || empty($invite['token'])) { fail('Token invito mancante'); simDone($S['stats']); exit(1); }
$S['invite_token'] = $invite['token'];
$S['invite_id'] = $invite['id'];
ok('Invito creato: ' . $invite['token_prefix'] . '... (id=' . $invite['id'] . ')');
info(' Piano: ' . $invite['plan'] . ' · Durata: ' . $invite['duration_months'] . ' mesi');
info(' Destinatario: ' . CLIENTE_FULLNAME . ' <' . CLIENTE_EMAIL . '> · P.IVA: ' . CLIENTE_PIVA);
info(' invite_url: ' . ($invite['invite_url'] ?? 'n/a'));
// ── SIM-B2: Valida invito (pubblico, nessuna auth) ───────────────────────────
simPhase(2, 'Validazione invito pubblico (nessuna auth)');
info('POST /auth/validate-invite...');
$res = api('POST', '/auth/validate-invite', ['invite_token' => $S['invite_token']]);
if (!apiOk($res, 'validate-invite')) { fail('Validazione invito fallita'); simDone($S['stats']); exit(1); }
ok('Invito valido (HTTP ' . $res['_http'] . ')');
$vd = $res['data'];
info(' Piano: ' . ($vd['plan'] ?? '?') . ' · Durata: ' . ($vd['duration_months'] ?? '?') . ' mesi');
$recipient = $vd['recipient'] ?? null;
if ($recipient) {
ok('Dati recipient presenti → form verrà pre-compilato');
info(' Nome: ' . ($recipient['first_name'] ?? '?') . ' ' . ($recipient['last_name'] ?? '?'));
info(' Email: ' . ($recipient['email'] ?? '?') . ' · P.IVA: ' . ($recipient['vat'] ?? '?'));
if (!empty($recipient['first_name']) && !empty($recipient['last_name']) && !empty($recipient['email'])) {
ok('Dati sufficienti per registrazione ridotta (solo password richiesta al cliente)');
} else {
warn('Dati incompleti — form completo sarà mostrato');
}
} else {
warn('Nessun dato recipient nell\'invito — form completo mostrato');
}
// Verifica via GET endpoint alternativo
$res2 = api('GET', '/invites/validate?token=' . urlencode($S['invite_token']));
!empty($res2['success'])
? ok('GET /invites/validate OK (HTTP ' . $res2['_http'] . ')')
: warn('GET /invites/validate: ' . ($res2['error'] ?? 'n/a') . ' (HTTP ' . $res2['_http'] . ')');
// ── SIM-B3: Registrazione ridotta (solo password) ────────────────────────────
simPhase(3, 'Registrazione ridotta cliente (dati da invito, solo password)');
$pdo = dbConnect();
if ($pdo) {
$pdo->prepare('DELETE FROM users WHERE email = ?')->execute([CLIENTE_EMAIL]);
info('Utente cliente precedente rimosso');
}
info('POST /auth/register con invite_token + solo password...');
$res = api('POST', '/auth/register', [
'email' => CLIENTE_EMAIL,
'password' => CLIENTE_PWD,
'full_name' => CLIENTE_FULLNAME,
'role' => 'org_admin',
'invite_token' => $S['invite_token'],
'vat_number' => CLIENTE_PIVA,
]);
if (!apiOk($res, 'register')) { fail('Registrazione cliente fallita'); simDone($S['stats']); exit(1); }
$S['cliente_jwt'] = $res['data']['access_token'] ?? null;
ok('Registrazione ridotta OK (HTTP ' . $res['_http'] . ')');
info(' user_id: ' . ($res['data']['user']['id'] ?? '?') . ' · role: ' . ($res['data']['user']['role'] ?? '?'));
// Verifica: nessuna org pre-provision
$meRes = api('GET', '/auth/me', null, $S['cliente_jwt']);
if (!empty($meRes['success'])) {
$orgs = $meRes['data']['organizations'] ?? [];
count($orgs) === 0
? ok('Pre-provision: nessuna org → accede a dashboard solo dopo provision')
: warn('Attenzione: org già presente prima del provision: ' . count($orgs));
}
// ── SIM-B4: Provision org post-register ─────────────────────────────────────
simPhase(4, 'Provision organizzazione post-register (invite_token)');
info('POST /services/provision con invite_token...');
$provBody = [
'invite_token' => $S['invite_token'],
'company' => [
'ragione_sociale' => CLIENTE_COMPANY,
'partita_iva' => CLIENTE_PIVA,
'forma_giuridica' => 'S.r.l.',
'sector' => CLIENTE_SECTOR,
'nis2_entity_type' => 'important',
'numero_dipendenti' => 35,
'fatturato_annuo' => 2500000,
'sede_legale' => 'Via Torino 42, 10100 Torino TO',
],
'admin' => [
'email' => CLIENTE_EMAIL,
'full_name'=> CLIENTE_FULLNAME,
'phone' => '+39 011 1234567',
'title' => 'CEO',
],
'caller' => ['system' => 'sim-b2b'],
];
$res = api('POST', '/services/provision', $provBody);
if (!apiOk($res, 'provision')) { fail('Provision fallita'); simDone($S['stats']); exit(1); }
$prov = $res['data'];
$S['org_id'] = $prov['org_id'] ?? null;
$S['api_key'] = $prov['api_key'] ?? null;
ok('Provision OK (HTTP ' . $res['_http'] . ')');
info(' org_id: ' . $S['org_id']);
info(' org_name: ' . ($prov['org_name'] ?? '?'));
info(' entity_type: ' . ($prov['entity_type'] ?? '?'));
info(' api_key: ' . substr($S['api_key'] ?? '', 0, 24) . '...');
info(' api_key_scopes: ' . implode(', ', $prov['api_key_scopes'] ?? []));
info(' license_expires: ' . ($prov['license_expires_at'] ?? '?'));
info(' invito marcato: ' . ($prov['invite_id'] ?? '?'));
// Idempotenza
$res2 = api('POST', '/services/provision', $provBody);
(!empty($res2['success']) && ($res2['data']['org_id'] ?? null) === $S['org_id'])
? ok('Idempotenza OK — stessa org_id: ' . $S['org_id'])
: warn('Idempotenza: risposta inattesa (HTTP ' . $res2['_http'] . ')');
// ── SIM-B5: Login → verifica org + accesso dashboard ────────────────────────
simPhase(5, 'Login cliente → verifica org attiva e accesso dashboard');
$res = api('POST', '/auth/login', ['email' => CLIENTE_EMAIL, 'password' => CLIENTE_PWD]);
if (!apiOk($res, 'cliente-login')) { fail('Login cliente fallito'); simDone($S['stats']); exit(1); }
$S['cliente_jwt'] = $res['data']['access_token'];
ok('Login cliente OK');
$loginOrgs = $res['data']['organizations'] ?? [];
if (count($loginOrgs) > 0) {
ok('Organizzazioni nel login: ' . count($loginOrgs));
foreach ($loginOrgs as $org) {
info(sprintf(' org_id=%s nome="%s" ruolo=%s settore=%s',
$org['organization_id'] ?? '?', $org['name'] ?? '?',
$org['role'] ?? '?', $org['sector'] ?? '?'));
}
} else {
fail('Nessuna org nel login — provision non collegato');
}
$meRes = api('GET', '/auth/me', null, $S['cliente_jwt']);
if (!empty($meRes['success'])) {
ok('GET /auth/me OK — ' . count($meRes['data']['organizations'] ?? []) . ' org(s)');
info(' must_change_password: ' . (($meRes['data']['must_change_password'] ?? false) ? 'SI' : 'NO (password propria)'));
}
$dash = api('GET', '/dashboard/overview', null, $S['cliente_jwt'], $S['org_id']);
!empty($dash['success'])
? ok('Dashboard overview OK (score: ' . ($dash['data']['compliance_score'] ?? 0) . '%)')
: warn('Dashboard: ' . ($dash['error'] ?? $dash['message'] ?? 'n/a') . ' (HTTP ' . $dash['_http'] . ')');
// ── SIM-B6: API Key M2M ──────────────────────────────────────────────────────
simPhase(6, 'API Key da provision → accesso M2M (compliance-summary, risks-feed)');
if (!$S['api_key']) {
skip('API Key non disponibile — SIM-B6 saltato');
} else {
info('Accesso M2M con API Key: ' . substr($S['api_key'], 0, 24) . '...');
$r = api('GET', '/services/compliance-summary', null, null, $S['org_id'], $S['api_key']);
!empty($r['success'])
? ok('GET /services/compliance-summary OK — score: ' . ($r['data']['overall_score'] ?? 0) . '%')
: warn('compliance-summary: ' . ($r['error'] ?? 'n/a') . ' (HTTP ' . $r['_http'] . ')');
$r = api('GET', '/services/risks-feed', null, null, $S['org_id'], $S['api_key']);
!empty($r['success'])
? ok('GET /services/risks-feed OK — rischi: ' . count($r['data']['risks'] ?? []))
: warn('risks-feed: ' . ($r['error'] ?? 'n/a') . ' (HTTP ' . $r['_http'] . ')');
$r = api('GET', '/services/status', null, null, $S['org_id'], $S['api_key']);
if (!empty($r['success'])) {
ok('GET /services/status OK — API Key attiva e valida');
info(' org: ' . ($r['data']['organization']['name'] ?? '?'));
info(' scopes: ' . implode(', ', $r['data']['api_key']['scopes'] ?? []));
info(' expires: ' . ($r['data']['api_key']['expires_at'] ?? '?'));
} else {
warn('services/status: ' . ($r['error'] ?? 'n/a') . ' (HTTP ' . $r['_http'] . ')');
}
ok('Flusso M2M completo — sistema partner può sincronizzare via API Key');
}
// ── Riepilogo ────────────────────────────────────────────────────────────────
simLog('', 'info');
simLog('══ RIEPILOGO FLUSSO B2B ═══════════════════════════════════', 'phase');
simLog('Mktg: ' . MKTG_EMAIL, 'info');
simLog('Invito: ' . substr($S['invite_token'] ?? 'n/a', 0, 20) . '...', 'info');
simLog('Cliente: ' . CLIENTE_EMAIL, 'info');
simLog('Org: id=' . ($S['org_id'] ?? 'n/a') . ' — ' . CLIENTE_COMPANY, 'info');
simLog('API Key: ' . substr($S['api_key'] ?? 'n/a', 0, 24) . '...', 'info');
simLog('', 'info');
$st = $S['stats'];
simLog("Risultato: ✓{$st['pass']} pass →{$st['skip']} skip ⚠{$st['warn']} warn ✗{$st['fail']} fail", 'phase');
simDone($S['stats']);