2026-09-12 12:54:45 +02:00
<!DOCTYPE html>
< html lang = "it" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > IRIDE — Installazione< / title >
< link href = "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Barlow:wght@300;400;500;600;700;800&display=swap" rel = "stylesheet" >
< style >
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080a0e;--bg2:#0e1117;--bg3:#161b24;--bg4:#1c2333;
--border:#1e2840;--border2:#2a3a58;
--text:#e2e8f4;--text2:#8a9ab8;--text3:#445068;
--brand:#8A7CFF;--brand2:#6F5FE6;--brand-dim:#8A7CFF18;
--ok:#10b981;--ok-dim:#10b98118;--err:#ef4444;--err-dim:#ef444418;--warn:#f59e0b;
--mono:'JetBrains Mono',monospace;--sans:'Barlow',sans-serif;--r:10px;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.6}
.shell{min-height:100vh;display:grid;grid-template-rows:auto 1fr auto}
.hdr{background:var(--bg2);border-bottom:1px solid var(--border);padding:16px 32px;display:flex;align-items:center;gap:16px}
.hdr-logo{font-size:22px;font-weight:800;color:var(--brand);letter-spacing:3px}
.hdr-sep{width:1px;height:24px;background:var(--border2)}
.hdr-sub{font-size:12px;color:var(--text3);font-family:var(--mono);text-transform:uppercase;letter-spacing:1.5px}
.hdr-badge{margin-left:auto;font-size:10px;font-family:var(--mono);background:var(--brand-dim);color:var(--brand);padding:4px 10px;border-radius:20px;border:1px solid #8A7CFF33}
.main{display:flex;max-width:1000px;margin:0 auto;width:100%;padding:32px 24px;gap:28px}
.sidebar{width:200px;flex-shrink:0}
.sidebar-title{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;padding:0 12px 12px;font-family:var(--mono)}
.tab-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--r);cursor:pointer;transition:all .15s;color:var(--text2);font-size:13px;font-weight:500;margin-bottom:2px;border:1px solid transparent}
.tab-item:hover{background:var(--bg3);color:var(--text)}
.tab-item.active{background:var(--bg3);color:var(--brand);border-color:var(--border2)}
.tab-item.done .tab-num{background:var(--ok);color:#000}
.tab-item.active .tab-num{background:var(--brand);color:#000}
.tab-num{width:22px;height:22px;border-radius:50%;background:var(--bg4);color:var(--text3);font-size:10px;font-weight:700;font-family:var(--mono);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s}
.content{flex:1;min-width:0}
.tab-panel{display:none}
.tab-panel.active{display:block}
.panel-title{font-size:22px;font-weight:700;margin-bottom:4px}
.panel-sub{font-size:13px;color:var(--text2);margin-bottom:24px}
.progress{display:flex;gap:6px;margin-bottom:28px}
.prog-step{flex:1;height:3px;border-radius:2px;background:var(--border2);transition:background .3s}
.prog-step.done{background:var(--ok)}
.prog-step.active{background:var(--brand)}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.form-full{grid-column:1/-1}
.form-label{display:block;font-size:11px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
.form-label span{color:var(--err);margin-left:2px}
.form-input,.form-select{width:100%;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);padding:10px 14px;color:var(--text);font-family:var(--sans);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}
.form-input:focus,.form-select:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-dim)}
.form-input::placeholder{color:var(--text3)}
.mono-input{font-family:var(--mono);font-size:12px}
.form-hint{font-size:11px;color:var(--text3);margin-top:5px}
.sec-div{grid-column:1/-1;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;font-family:var(--mono);border-top:1px solid var(--border);padding-top:18px;margin-top:4px}
.sec-div:first-child{border-top:none;padding-top:0;margin-top:0}
.ssl-toggle{grid-column:1/-1;display:flex;gap:8px}
.ssl-btn{flex:1;padding:12px 16px;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);cursor:pointer;text-align:center;font-size:13px;font-weight:600;color:var(--text2);transition:all .15s}
.ssl-btn.active{background:var(--brand-dim);border-color:var(--brand);color:var(--brand)}
.ssl-btn small{display:block;font-weight:400;font-size:11px;opacity:.7;margin-top:2px}
.upload-area{grid-column:1/-1;border:2px dashed var(--border2);border-radius:var(--r);padding:20px;text-align:center;cursor:pointer;transition:all .15s}
.upload-area:hover,.upload-area.drag{border-color:var(--brand);background:var(--brand-dim)}
.upload-preview{display:none;align-items:center;justify-content:center;gap:10px;margin-top:10px}
.upload-preview img{max-height:50px;max-width:180px;border-radius:6px}
.alert{padding:12px 16px;border-radius:var(--r);font-size:13px;margin-bottom:0}
.alert-err{background:var(--err-dim);border:1px solid #ef444433;color:var(--err)}
.alert-ok{background:var(--ok-dim);border:1px solid #10b98133;color:var(--ok)}
.alert-warn{background:#f59e0b15;border:1px solid #f59e0b33;color:var(--warn)}
.nav-row{display:flex;justify-content:space-between;align-items:center;margin-top:28px;padding-top:20px;border-top:1px solid var(--border)}
.btn{padding:10px 22px;border-radius:var(--r);border:none;font-family:var(--sans);font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:8px}
.btn-brand{background:var(--brand);color:#000}
.btn-brand:hover{background:var(--brand2)}
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border2)}
.btn-ghost:hover{background:var(--bg3);color:var(--text)}
.btn-ok{background:var(--ok);color:#000;font-size:15px;padding:14px 36px}
.btn-ok:hover{filter:brightness(1.1)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.summary-table{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:16px}
.summary-section{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1px;padding:10px 14px;font-family:var(--mono);background:var(--bg4);border-bottom:1px solid var(--border)}
.summary-row{display:flex;border-bottom:1px solid #1e284044}
.summary-row:last-child{border-bottom:none}
.summary-label{width:160px;padding:8px 14px;font-size:12px;color:var(--text3);flex-shrink:0}
.summary-value{padding:8px 14px;font-size:12px;color:var(--text);font-family:var(--mono);flex:1}
.summary-value.empty{color:var(--text3);font-style:italic;font-family:var(--sans)}
.footer{background:var(--bg2);border-top:1px solid var(--border);padding:12px 32px;text-align:center;font-size:11px;color:var(--text3);font-family:var(--mono)}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes pulse{0%,100%{opacity:.3;transform:scale(.8)}50%{opacity:1;transform:scale(1.2)}}
< / style >
< / head >
< body >
< div class = "shell" >
< header class = "hdr" >
< div class = "hdr-logo" > IRIDE< / div >
< div class = "hdr-sep" > < / div >
< div class = "hdr-sub" > Setup Wizard — Tecnotel Servizi SRL< / div >
2026-09-12 20:59:13 +02:00
< div class = "hdr-badge" > v0.2.1< / div >
2026-09-12 12:54:45 +02:00
< / header >
< div class = "main" >
< aside class = "sidebar" >
< div class = "sidebar-title" > Configurazione< / div >
< div class = "tab-item active" onclick = "goTab(0)" id = "tab-0" > < div class = "tab-num" > 1< / div > Licenza< / div >
< div class = "tab-item" onclick = "goTab(1)" id = "tab-1" > < div class = "tab-num" > 2< / div > Cliente< / div >
< div class = "tab-item" onclick = "goTab(2)" id = "tab-2" > < div class = "tab-num" > 3< / div > Rete & SSL< / div >
< div class = "tab-item" onclick = "goTab(3)" id = "tab-3" > < div class = "tab-num" > 4< / div > Intelligenza artificiale< / div >
< div class = "tab-item" onclick = "goTab(4)" id = "tab-4" > < div class = "tab-num" > 5< / div > Utente admin< / div >
< div class = "tab-item" onclick = "goTab(5)" id = "tab-5" > < div class = "tab-num" > 6< / div > Installa< / div >
< / aside >
< div class = "content" >
< div class = "progress" >
< div class = "prog-step active" id = "ps-0" > < / div >
< div class = "prog-step" id = "ps-1" > < / div >
< div class = "prog-step" id = "ps-2" > < / div >
< div class = "prog-step" id = "ps-3" > < / div >
< div class = "prog-step" id = "ps-4" > < / div >
< div class = "prog-step" id = "ps-5" > < / div >
< / div >
<!-- 1: Licenza -->
< div class = "tab-panel active" id = "panel-0" >
< div class = "panel-title" > 🔑 Licenza IRIDE< / div >
< div class = "panel-sub" > IRIDE richiede una licenza valida emessa da Tecnotel Servizi SRL, vincolata
all'identificativo hardware di questo server. La licenza contiene anche le credenziali di sola
lettura per scaricare il software e i suoi aggiornamenti.< / div >
< div class = "form-grid" >
< div class = "form-full" >
< label class = "form-label" > Machine ID di questo server< / label >
< div style = "display:flex;gap:8px" >
< input class = "form-input mono-input" id = "machine-id-display" value = "Calcolo in corso..." readonly style = "flex:1" >
< button class = "btn btn-ghost" onclick = "copyMachineId()" id = "btn-copy-mid" style = "padding:10px 16px" > 📋 Copia< / button >
< / div >
< div class = "form-hint" > Invia questo identificativo a Tecnotel per ricevere il file < code > license.json< / code > .< / div >
< / div >
< div class = "upload-area form-full" id = "lic-drop"
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
ondrop="handleLicenseDrop(event)" onclick="document.getElementById('lic-input').click()">
< input type = "file" id = "lic-input" accept = ".json,application/json" style = "display:none" onchange = "handleLicenseFile(this)" >
< div style = "font-size:28px;margin-bottom:6px" > 📄< / div >
< div style = "font-weight:600" > Trascina qui < code > license.json< / code > oppure clicca per selezionarlo< / div >
< div class = "form-hint" > Firma Ed25519 verificata sul server prima di procedere< / div >
< / div >
< div id = "lic-error" class = "form-full alert alert-err" style = "display:none" > < / div >
< div id = "lic-summary" class = "form-full" style = "display:none;background:var(--ok-dim);border:1px solid var(--ok);border-radius:var(--r);padding:16px 20px" >
< div style = "font-weight:700;color:var(--ok);margin-bottom:10px" > ✓ Licenza valida< / div >
< div style = "display:grid;grid-template-columns:120px 1fr;gap:6px 12px;font-size:12px;font-family:var(--mono)" >
< div style = "color:var(--text3)" > Cliente:< / div > < div id = "lic-customer" > < / div >
< div style = "color:var(--text3)" > Prodotto:< / div > < div id = "lic-product" > < / div >
< div style = "color:var(--text3)" > Tier:< / div > < div id = "lic-tier" > < / div >
< div style = "color:var(--text3)" > Hostname:< / div > < div id = "lic-hostname" > < / div >
< div style = "color:var(--text3)" > Emessa:< / div > < div id = "lic-issued" > < / div >
< div style = "color:var(--text3)" > Scadenza:< / div > < div id = "lic-expires" > < / div >
< div style = "color:var(--text3)" > Token Gitea:< / div > < div id = "lic-gitea" > < / div >
< / div >
< / div >
< / div >
< div class = "nav-row" >
< div > < / div >
< button class = "btn btn-brand" id = "btn-after-license" onclick = "goTab(1)" disabled style = "opacity:.4;cursor:not-allowed" > Avanti →< / button >
< / div >
< / div >
<!-- 2: Cliente -->
< div class = "tab-panel" id = "panel-1" >
< div class = "panel-title" > 🏢 Informazioni cliente< / div >
< div class = "panel-sub" > Identità dell'istanza: compare nel portale, nei messaggi dell'assistente e nei report.< / div >
< div class = "form-grid" >
< div > < label class = "form-label" > Nome breve < span > *< / span > < / label > < input class = "form-input" id = "cliente_name" placeholder = "es. poliambulatorio-rossi" > < div class = "form-hint" > Identificativo tecnico, minuscolo senza spazi< / div > < / div >
< div > < label class = "form-label" > Tipo organizzazione< / label > < select class = "form-select" id = "cliente_type" > < option value = "healthcare" > Sanità / Healthcare< / option > < option value = "customer_service" > Customer service< / option > < option value = "hospitality" > Hospitality< / option > < option value = "pa" > Pubblica Amministrazione< / option > < option value = "enterprise" > Azienda< / option > < / select > < / div >
< div class = "form-full" > < label class = "form-label" > Nome completo < span > *< / span > < / label > < input class = "form-input" id = "cliente_full" placeholder = "es. Poliambulatorio Rossi S.r.l." > < / div >
< div > < label class = "form-label" > Dominio email< / label > < input class = "form-input" id = "cliente_domain" placeholder = "es. poliambulatoriorossi.it" > < div class = "form-hint" > Dominio degli utenti del portale (opzionale)< / div > < / div >
< div class = "form-full" > < label class = "form-label" > Contesto per l'assistente < span style = "font-weight:400" > (opzionale)< / span > < / label > < textarea class = "form-input" id = "ai_context" rows = "3" placeholder = "es. Poliambulatorio con 3 sedi, prenotazioni e ritiro referti, orari 8-20 nei giorni feriali." > < / textarea > < div class = "form-hint" > Poche frasi sul cliente: entrano nelle istruzioni dell'assistente predefinito, modificabili dal portale.< / div > < / div >
< div class = "sec-div" > Logo (opzionale)< / div >
< div class = "upload-area form-full" id = "logo-drop" ondragover = "event.preventDefault();this.classList.add('drag')" ondragleave = "this.classList.remove('drag')" ondrop = "handleLogoDrop(event)" onclick = "document.getElementById('logo-input').click()" >
< input type = "file" id = "logo-input" accept = "image/*" style = "display:none" onchange = "handleLogoFile(this)" >
< div style = "font-size:24px;margin-bottom:4px" > 🖼️< / div >
< div style = "font-weight:600" > Trascina il logo del cliente oppure clicca< / div >
< div class = "form-hint" > PNG o SVG, max 2 MB — usato nel portale e nel widget< / div >
< div class = "upload-preview" id = "logo-preview" > < img id = "logo-img" src = "" > < div style = "font-size:11px;color:var(--ok);font-family:var(--mono)" id = "logo-fname" > < / div > < / div >
< / div >
< / div >
< div class = "nav-row" > < button class = "btn btn-ghost" onclick = "goTab(0)" > ← Indietro< / button > < button class = "btn btn-brand" onclick = "goTab(2)" > Avanti →< / button > < / div >
< / div >
<!-- 3: Rete & SSL -->
< div class = "tab-panel" id = "panel-2" >
< div class = "panel-title" > 🌐 Rete & SSL< / div >
< div class = "panel-sub" > Nome pubblico dell'istanza e certificato. Per WhatsApp e per i webhook dei canali serve HTTPS pubblico con certificato valido.< / div >
< div class = "form-grid" >
< div class = "form-full" > < label class = "form-label" > Hostname principale < span > *< / span > < / label > < input class = "form-input mono-input" id = "domain" placeholder = "es. iride.cliente.it" > < / div >
< div class = "form-full" > < label class = "form-label" > Nomi alternativi< / label > < input class = "form-input mono-input" id = "aliases" placeholder = "es. assistente.cliente.it" > < div class = "form-hint" > Separati da spazio — opzionale< / div > < / div >
< div class = "sec-div" > Certificato SSL< / div >
< div class = "ssl-toggle" >
< div class = "ssl-btn active" id = "ssl-le" onclick = "setSsl('letsencrypt')" > 🔒 Let's Encrypt< small > Automatico, richiede DNS pubblico< / small > < / div >
< div class = "ssl-btn" id = "ssl-manual" onclick = "setSsl('manual')" > 📄 Certificato esistente< small > Carica .crt e .key< / small > < / div >
< div class = "ssl-btn" id = "ssl-selfsigned" onclick = "setSsl('selfsigned')" > 🔐 Autofirmato< small > LAN / demo — 10 anni< / small > < / div >
< / div >
< div id = "le-block" class = "form-full" > < label class = "form-label" > Email amministratore < span > *< / span > < / label > < input class = "form-input" id = "admin_email" placeholder = "admin@tecnotelsrl.com" > < div class = "form-hint" > Per le notifiche di scadenza del certificato< / div > < / div >
< div id = "manual-block" class = "form-full" style = "display:none" >
< div class = "form-grid" >
< div > < label class = "form-label" > File .crt < span > *< / span > < / label > < input type = "file" class = "form-input" accept = ".crt,.pem,.cer" onchange = "uploadSsl(this,'cert')" > < div class = "form-hint" id = "crt-status" > Nessun file selezionato< / div > < / div >
< div > < label class = "form-label" > File .key < span > *< / span > < / label > < input type = "file" class = "form-input" accept = ".key,.pem" onchange = "uploadSsl(this,'key')" > < div class = "form-hint" id = "key-status" > Nessun file selezionato< / div > < / div >
< / div >
< / div >
< div id = "selfsigned-block" class = "form-full" style = "display:none" >
< div class = "alert alert-warn" > Certificato RSA 4096 autofirmato, validità 10 anni, con hostname, alias e IP del server nel SAN. I browser lo segnaleranno come non attendibile: va bene per LAN e demo, non per WhatsApp.< / div >
< / div >
< / div >
< div class = "nav-row" > < button class = "btn btn-ghost" onclick = "goTab(1)" > ← Indietro< / button > < button class = "btn btn-brand" onclick = "goTab(3)" > Avanti →< / button > < / div >
< / div >
<!-- 4: AI -->
< div class = "tab-panel" id = "panel-3" >
< div class = "panel-title" > 🧠 Intelligenza artificiale< / div >
< div class = "panel-sub" > Provider del modello testuale. "Fake" è deterministico e senza costi: va bene per demo e collaudo; la chiave si può inserire o cambiare dopo dal portale.< / div >
< div class = "form-grid" >
< div > < label class = "form-label" > Provider< / label > < select class = "form-select" id = "ai_provider" onchange = "onProviderChange()" > < option value = "fake" > Fake (demo, nessuna chiave)< / option > < option value = "openai" > OpenAI< / option > < option value = "anthropic" > Anthropic (disponibile nella slice 2)< / option > < / select > < / div >
< div > < label class = "form-label" > Modello< / label > < input class = "form-input mono-input" id = "ai_model" placeholder = "gpt-4.1-mini" value = "gpt-4.1-mini" > < / div >
< div class = "form-full" id = "ai-key-block" style = "display:none" > < label class = "form-label" > API key< / label > < input class = "form-input mono-input" type = "password" id = "ai_api_key" placeholder = "sk-..." > < div class = "form-hint" > Salvata in /opt/iride/config/iride.json (0600). Prima di dati reali: DPA e regione di elaborazione verificati (B-063).< / div > < / div >
< / div >
< div class = "nav-row" > < button class = "btn btn-ghost" onclick = "goTab(2)" > ← Indietro< / button > < button class = "btn btn-brand" onclick = "goTab(4)" > Avanti →< / button > < / div >
< / div >
<!-- 5: Utente admin -->
< div class = "tab-panel" id = "panel-4" >
< div class = "panel-title" > 👤 Utente amministratore< / div >
< div class = "panel-sub" > Primo accesso al portale IRIDE. Gli altri utenti e i ruoli si creano dal portale.< / div >
< div class = "form-grid" >
< div > < label class = "form-label" > Username < span > *< / span > < / label > < input class = "form-input" id = "admin_username" placeholder = "admin" value = "admin" > < / div >
< div > < label class = "form-label" > Email< / label > < input class = "form-input" id = "admin_email_user" placeholder = "admin@cliente.it" > < / div >
< div > < label class = "form-label" > Password < span > *< / span > < / label > < input class = "form-input" type = "password" id = "admin_password" placeholder = "Min. 12 caratteri" > < / div >
< div > < label class = "form-label" > Conferma password < span > *< / span > < / label > < input class = "form-input" type = "password" id = "admin_password2" placeholder = "Ripeti la password" > < / div >
< div class = "form-full" id = "pw-error" style = "display:none" > < div class = "alert alert-err" > Le password non corrispondono o sono più corte di 12 caratteri< / div > < / div >
< / div >
< div class = "nav-row" > < button class = "btn btn-ghost" onclick = "goTab(3)" > ← Indietro< / button > < button class = "btn btn-brand" onclick = "goToInstall()" > Riepilogo →< / button > < / div >
< / div >
<!-- 6: Installa -->
< div class = "tab-panel" id = "panel-5" >
< div class = "panel-title" > 🚀 Riepilogo e installazione< / div >
< div class = "panel-sub" > Controlla i dati. L'installazione clona il repository, crea l'ambiente Python, configura SSL, nginx e i servizi systemd, poi chiude la porta 8888.< / div >
< div id = "summary-box" > < / div >
< div id = "errors-box" style = "display:none" > < / div >
< div id = "spinner-box" style = "display:none;text-align:center;padding:40px 20px" >
< div style = "width:48px;height:48px;border:4px solid var(--border2);border-top-color:var(--brand);border-radius:50%;margin:0 auto 20px;animation:spin 1s linear infinite" > < / div >
< div style = "font-size:15px;font-weight:600;margin-bottom:6px" > Installazione in corso< / div >
< div style = "font-size:12px;color:var(--text3);font-family:var(--mono)" id = "spinner-status" > Avvio installazione...< / div >
< pre id = "log-box" style = "text-align:left;margin-top:20px;max-height:260px;overflow:auto;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:12px;font-family:var(--mono);font-size:11px;color:var(--text2)" > < / pre >
< / div >
< div id = "success-box" style = "display:none;text-align:center;padding:40px 20px" >
< div style = "font-size:48px;margin-bottom:12px" > ✅< / div >
< div style = "font-size:18px;font-weight:700;margin-bottom:6px" > IRIDE installata< / div >
< div style = "font-size:13px;color:var(--text2);margin-bottom:24px" > La porta 8888 è stata chiusa. Configura i canali in < code > /opt/iride/config/channels.json< / code > e < code > credentials.json< / code > .< / div >
< button class = "btn btn-ok" id = "btn-open" onclick = "openIride()" > Apri IRIDE →< / button >
< / div >
< div class = "nav-row" id = "install-nav" >
< button class = "btn btn-ghost" onclick = "goTab(4)" > ← Indietro< / button >
< button class = "btn btn-ok" id = "btn-install" onclick = "avviaInstallazione()" > 🚀 Avvia installazione< / button >
< / div >
< / div >
< / div >
< / div >
2026-09-12 20:59:13 +02:00
< footer class = "footer" > IRIDE — Tecnotel Servizi SRL · Web Installer v0.2.1 · La porta 8888 verrà chiusa al termine< / footer >
2026-09-12 12:54:45 +02:00
< / div >
< script >
let currentTab = 0;
let sslMode = 'letsencrypt';
let certUploaded = false;
let keyUploaded = false;
let installing = false;
let installDomain = '';
let licenseValid = false;
async function loadMachineId() {
const el = document.getElementById('machine-id-display');
try {
const r = await fetch('/api/machine-id');
const d = await r.json();
el.value = (d & & d.machine_id) ? d.machine_id : 'Errore caricamento';
} catch (e) { el.value = 'Errore: ' + e.message; }
}
function copyMachineId() {
const el = document.getElementById('machine-id-display');
el.select();
navigator.clipboard.writeText(el.value).then(() => {
const btn = document.getElementById('btn-copy-mid');
const orig = btn.textContent;
btn.textContent = '✅ Copiato';
setTimeout(() => btn.textContent = orig, 1500);
}).catch(() => {});
}
function handleLicenseDrop(ev) {
ev.preventDefault();
ev.currentTarget.classList.remove('drag');
if (ev.dataTransfer.files.length) uploadLicense(ev.dataTransfer.files[0]);
}
function handleLicenseFile(inp) { if (inp.files.length) uploadLicense(inp.files[0]); }
async function uploadLicense(file) {
const errEl = document.getElementById('lic-error');
const sumEl = document.getElementById('lic-summary');
const btnNext = document.getElementById('btn-after-license');
errEl.style.display = 'none'; sumEl.style.display = 'none';
try {
const text = await file.text();
const r = await fetch('/api/license/upload', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: text });
const d = await r.json();
if (!d.ok) {
errEl.textContent = 'Licenza non valida: ' + (d.error || 'errore sconosciuto');
errEl.style.display = 'block';
licenseValid = false;
btnNext.disabled = true; btnNext.style.opacity = '.4'; btnNext.style.cursor = 'not-allowed';
return;
}
const s = d.summary || {};
document.getElementById('lic-customer').textContent = s.customer || '—';
document.getElementById('lic-product').textContent = s.product || '—';
document.getElementById('lic-tier').textContent = (s.tier || '—').toUpperCase();
document.getElementById('lic-hostname').textContent = s.issued_to || '—';
document.getElementById('lic-issued').textContent = s.issued_at || '—';
document.getElementById('lic-expires').textContent = s.expires_at || '—';
document.getElementById('lic-gitea').textContent = s.has_gitea ? '✓ Incluso' : '— Non incluso';
sumEl.style.display = 'block';
licenseValid = true;
btnNext.disabled = false; btnNext.style.opacity = '1'; btnNext.style.cursor = 'pointer';
if (s.issued_to & & !g('domain')) document.getElementById('domain').value = s.issued_to;
} catch (e) {
errEl.textContent = 'Errore upload: ' + e.message;
errEl.style.display = 'block';
}
}
window.addEventListener('DOMContentLoaded', loadMachineId);
function goTab(n) {
if (n > 0 & & !licenseValid) return;
if (n > currentTab) document.getElementById('tab-' + currentTab).classList.add('done');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.prog-step').forEach((s,i) => {
s.classList.remove('active','done');
if (i < n ) s . classList . add ( ' done ' ) ; else if ( i = == n ) s . classList . add ( ' active ' ) ;
});
document.getElementById('panel-' + n).classList.add('active');
document.getElementById('tab-' + n).classList.add('active');
currentTab = n;
}
function goToInstall() {
const pw1 = g('admin_password'), pw2 = g('admin_password2');
const err = document.getElementById('pw-error');
if (pw1 !== pw2 || pw1.length < 12 ) { err . style . display = 'block' ; return ; }
err.style.display = 'none';
goTab(5);
buildSummary();
}
function setSsl(mode) {
sslMode = mode;
document.getElementById('ssl-le').classList.toggle('active', mode === 'letsencrypt');
document.getElementById('ssl-manual').classList.toggle('active', mode === 'manual');
document.getElementById('ssl-selfsigned').classList.toggle('active', mode === 'selfsigned');
document.getElementById('le-block').style.display = mode === 'letsencrypt' ? 'block' : 'none';
document.getElementById('manual-block').style.display = mode === 'manual' ? 'block' : 'none';
document.getElementById('selfsigned-block').style.display = mode === 'selfsigned' ? 'block' : 'none';
}
function onProviderChange() {
const p = g('ai_provider');
document.getElementById('ai-key-block').style.display = p === 'fake' ? 'none' : 'block';
const m = document.getElementById('ai_model');
if (p === 'openai' & & (!m.value || m.value.startsWith('claude'))) m.value = 'gpt-4.1-mini';
if (p === 'anthropic' & & (!m.value || m.value.startsWith('gpt'))) m.value = 'claude-sonnet-4-6';
}
async function uploadSsl(input, type) {
const file = input.files[0]; if (!file) return;
const sid = type === 'cert' ? 'crt-status' : 'key-status';
try {
const r = await fetch('/api/upload/' + type, { method: 'POST', body: await file.arrayBuffer() });
const d = await r.json();
if (d.ok) {
document.getElementById(sid).textContent = '✓ ' + file.name;
document.getElementById(sid).style.color = 'var(--ok)';
if (type === 'cert') certUploaded = true; else keyUploaded = true;
}
} catch(e) { document.getElementById(sid).textContent = 'Errore: ' + e.message; }
}
function handleLogoDrop(e) { e.preventDefault(); document.getElementById('logo-drop').classList.remove('drag'); processLogo(e.dataTransfer.files[0]); }
function handleLogoFile(input) { processLogo(input.files[0]); }
async function processLogo(file) {
if (!file || file.size > 2*1024*1024) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('logo-img').src = e.target.result;
document.getElementById('logo-fname').textContent = file.name;
document.getElementById('logo-preview').style.display = 'flex';
};
reader.readAsDataURL(file);
fetch('/api/upload/logo', { method: 'POST', body: await file.arrayBuffer() });
}
function g(id) { return (document.getElementById(id)?.value || '').trim(); }
function collectData() {
return {
cliente_name: g('cliente_name').toLowerCase().replace(/[^a-z0-9-]+/g, '-'), cliente_full: g('cliente_full'),
cliente_domain: g('cliente_domain'), cliente_type: g('cliente_type'), ai_context: g('ai_context'),
domain: g('domain'), aliases: g('aliases'), ssl_mode: sslMode, admin_email: g('admin_email'),
ai_provider: g('ai_provider') || 'fake', ai_model: g('ai_model'), ai_api_key: g('ai_api_key'),
admin_username: g('admin_username') || 'admin', admin_email_user: g('admin_email_user'),
admin_password: g('admin_password'),
};
}
function buildSummary() {
const d = collectData();
const errors = [];
if (!d.cliente_name) errors.push('Nome breve del cliente obbligatorio');
if (!d.cliente_full) errors.push('Nome completo del cliente obbligatorio');
if (!d.domain) errors.push('Hostname obbligatorio');
if (!d.admin_password) errors.push('Password admin obbligatoria');
if (d.ai_provider !== 'fake' & & !d.ai_api_key) errors.push('API key del provider AI obbligatoria (oppure scegli Fake)');
if (sslMode === 'letsencrypt' & & !d.admin_email) errors.push("Email per Let's Encrypt obbligatoria");
if (sslMode === 'manual' & & (!certUploaded || !keyUploaded)) errors.push('Certificato SSL (.crt e .key) non caricato');
const row = (l, v) => '< div class = "summary-row" > < div class = "summary-label" > ' + l + '< / div > < div class = "summary-value ' + (v ? '' : 'empty') + '" > ' + (v || 'non configurato') + '< / div > < / div > ';
const sslLabel = sslMode === 'letsencrypt' ? "Let's Encrypt (automatico)" : sslMode === 'manual' ? "Certificato esistente caricato" : "Autofirmato (RSA 4096, 10 anni)";
document.getElementById('summary-box').innerHTML = '< div class = "summary-table" > ' +
'< div class = "summary-section" > Cliente< / div > ' +
row('Nome breve', d.cliente_name) + row('Nome completo', d.cliente_full) + row('Tipo', d.cliente_type) + row('Dominio email', d.cliente_domain) +
(d.ai_context ? row('Contesto', d.ai_context.substring(0, 80) + (d.ai_context.length > 80 ? '…' : '')) : '') +
'< div class = "summary-section" > Rete & SSL< / div > ' + row('Hostname', d.domain) + row('Alias', d.aliases) + row('SSL', sslLabel) +
'< div class = "summary-section" > Intelligenza artificiale< / div > ' + row('Provider', d.ai_provider) + row('Modello', d.ai_provider === 'fake' ? '—' : d.ai_model) + row('API key', d.ai_provider === 'fake' ? '—' : (d.ai_api_key ? '••••' + d.ai_api_key.slice(-4) : '')) +
'< div class = "summary-section" > Utente admin< / div > ' + row('Username', d.admin_username) + row('Email', d.admin_email_user) +
'< / div > ';
const errBox = document.getElementById('errors-box');
if (errors.length) {
errBox.style.display = 'block';
errBox.innerHTML = '< div class = "alert alert-err" style = "margin-bottom:16px" > ⚠️ < strong > Correggere prima di procedere:< / strong > < ul style = "margin-top:8px;padding-left:16px" > ' + errors.map(e => '< li style = "margin-top:4px" > ' + e + '< / li > ').join('') + '< / ul > < / div > ';
document.getElementById('btn-install').disabled = true;
} else {
errBox.style.display = 'none';
document.getElementById('btn-install').disabled = false;
}
}
async function avviaInstallazione() {
if (installing) return;
installing = true;
const data = collectData();
installDomain = data.domain;
document.getElementById('summary-box').style.display = 'none';
document.getElementById('errors-box').style.display = 'none';
document.getElementById('install-nav').style.display = 'none';
document.getElementById('spinner-box').style.display = 'block';
try {
const r = await fetch('/api/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
const res = await r.json();
if (!res.ok) throw new Error(res.error || 'Errore sconosciuto');
pollStatus();
} catch(e) {
document.getElementById('spinner-status').textContent = 'ERRORE: ' + e.message;
document.getElementById('spinner-status').style.color = 'var(--err)';
installing = false;
}
}
function pollStatus() {
let errors = 0;
const statusEl = document.getElementById('spinner-status');
const logEl = document.getElementById('log-box');
const iv = setInterval(async () => {
try {
const r = await fetch('/api/status');
const d = await r.json();
errors = 0;
if (d.log & & d.log.length) {
logEl.textContent = d.log.join('\n');
logEl.scrollTop = logEl.scrollHeight;
const last = d.log[d.log.length-1];
if (last.startsWith('__ERROR__')) statusEl.textContent = last.replace('__ERROR__: ', 'Errore: ');
else if (last.includes('──')) statusEl.textContent = last.replace(/^\[[^\]]+\]\s*/, '').replace(/─/g, '').trim();
}
if (d.done) { clearInterval(iv); showSuccess(); }
if (d.error) { clearInterval(iv); statusEl.style.color = 'var(--err)'; }
} catch(e) {
errors++;
if (errors >= 6) { clearInterval(iv); showSuccess(); }
}
}, 2000);
}
function showSuccess() {
document.getElementById('spinner-box').style.display = 'none';
document.getElementById('success-box').style.display = 'block';
}
function openIride() { window.location.href = 'https://' + installDomain; }
< / script >
< / body >
< / html >