diff --git a/public/register.html b/public/register.html index 2526111..9974baa 100644 --- a/public/register.html +++ b/public/register.html @@ -222,6 +222,20 @@
+ + +
@@ -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 { diff --git a/public/simulate-b2b.html b/public/simulate-b2b.html new file mode 100644 index 0000000..1bd8e3a --- /dev/null +++ b/public/simulate-b2b.html @@ -0,0 +1,255 @@ + + + + + + Simulazione B2B — NIS2 Agile + + + + +
+ +
B2B SIM
+ Simulatore Flusso Licenze B2B + Sim Standard + Dashboard +
+ +
+
+
SIM-B1
Mktg Invito
+
+
SIM-B2
Valida
+
+
SIM-B3
Registrazione
+
+
SIM-B4
Provision Org
+
+
SIM-B5
Login
+
+
SIM-B6
API Key M2M
+
+ +
+ + + + +
+ +
+
0 Pass
+
0 Warn
+
0 Fail
+
0 Skip
+
+ +
+
+
Pronto
+
+ +
+
+
+
+
+ NIS2 B2B Simulator +
+
+
Pronto. Clicca "Avvia Simulazione B2B" per testare il ciclo completo Mktg → Invito → Registrazione → Provision → Login → API Key.
+
+
+ +
+
+
Operatore Mktg
+
Emailsim-b2b-mktg@nis2agile.it
+
Ruolosuper_admin
+
Canale invitosim-b2b
+
+
+
Cliente B2B
+
Emaillaura.bianchi@techstart.b2b.demo
+
AziendaTechStart S.r.l.
+
P.IVA03456789012
+
+
+
Invito B2B
+
Pianoprofessional · 12 mesi
+
Dati pre-fillnome, email, P.IVA, azienda
+
Scadenza30 giorni
+
+
+
Registrazione Ridotta
+
Campo richiestosolo password
+
Provisionautomatico post-register
+
Redirectdashboard (org pronta)
+
+
+ +
+
Flusso B2B completato con successo
+
+
+
+ + + + diff --git a/simulate-nis2-b2b.php b/simulate-nis2-b2b.php new file mode 100644 index 0000000..a6cd96f --- /dev/null +++ b/simulate-nis2-b2b.php @@ -0,0 +1,423 @@ + 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']);