/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard Drives the 6-step post-install setup flow. */ "use strict"; // ── Constants ───────────────────────────────────────────────────── const TOTAL_STEPS = 6; // Domains that may need configuration, with service unit mapping for enabled check const DOMAIN_DEFS = [ { name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true }, { name: "haven", label: "Haven Nostr Relay", unit: "haven-relay.service", needsDdns: true }, { name: "element-calling", label: "Element Video/Audio Calling", unit: "livekit.service", needsDdns: true }, { name: "vaultwarden", label: "Vaultwarden (Password Vault)", unit: "vaultwarden.service", needsDdns: true }, { name: "btcpayserver", label: "BTCPay Server", unit: "btcpayserver.service", needsDdns: true }, { name: "nextcloud", label: "Nextcloud", unit: "phpfpm-nextcloud.service", needsDdns: true }, { name: "wordpress", label: "WordPress", unit: "phpfpm-wordpress.service", needsDdns: true }, ]; const REBUILD_POLL_INTERVAL = 2000; // ── State ───────────────────────────────────────────────────────── var _currentStep = 1; var _servicesData = null; var _domainsData = null; var _featuresData = null; var _rebuildPollTimer = null; var _rebuildLogOffset = 0; var _rebuildFinished = false; // ── Helpers ─────────────────────────────────────────────────────── function escHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } async function apiFetch(path, options) { var res = await fetch(path, options || {}); if (!res.ok) { var detail = res.status + " " + res.statusText; try { var body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} throw new Error(detail); } return res.json(); } function setStatus(elId, msg, type) { var el = document.getElementById(elId); if (!el) return; el.textContent = msg; el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : ""); } // ── Progress / step navigation ──────────────────────────────────── function updateProgress(step) { var fill = document.getElementById("onboarding-progress-fill"); if (fill) { fill.style.width = Math.round(((step - 1) / (TOTAL_STEPS - 1)) * 100) + "%"; } var dots = document.querySelectorAll(".onboarding-step-dot"); dots.forEach(function(dot) { var ds = parseInt(dot.dataset.step, 10); dot.classList.remove("active", "completed"); if (ds < step) dot.classList.add("completed"); if (ds === step) dot.classList.add("active"); }); } function showStep(step) { for (var i = 1; i <= TOTAL_STEPS; i++) { var panel = document.getElementById("step-" + i); if (panel) panel.style.display = (i === step) ? "" : "none"; } _currentStep = step; updateProgress(step); // Lazy-load step content if (step === 2) loadStep2(); if (step === 3) loadStep3(); if (step === 4) loadStep4(); if (step === 5) loadStep5(); } // ── Step 1: Welcome ─────────────────────────────────────────────── async function loadStep1() { try { var cfg = await apiFetch("/api/config"); var badge = document.getElementById("onboarding-role-badge"); if (badge && cfg.role_label) badge.textContent = cfg.role_label; } catch (_) {} } // ── Step 2: Domain Configuration ───────────────────────────────── async function loadStep2() { var body = document.getElementById("step-2-body"); if (!body) return; try { // Fetch services, domains, and network info in parallel var results = await Promise.all([ apiFetch("/api/services"), apiFetch("/api/domains/status"), apiFetch("/api/network"), ]); _servicesData = results[0]; _domainsData = results[1]; var networkData = results[2]; } catch (err) { body.innerHTML = '

⚠ Could not load service data: ' + escHtml(err.message) + '

'; return; } var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)"; // Build set of enabled service units var enabledUnits = new Set(); (_servicesData || []).forEach(function(svc) { if (svc.enabled) enabledUnits.add(svc.unit); }); // Filter domain defs to only those whose service is enabled var relevantDomains = DOMAIN_DEFS.filter(function(d) { return enabledUnits.has(d.unit); }); var html = ""; if (relevantDomains.length === 0) { html += '

No domain-based services are enabled for your role. You can skip this step.

'; } else { html += '
' + 'Before you continue:' + '
    ' + '
  1. Create an account at https://njal.la
  2. ' + '
  3. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
  4. ' + '
  5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
    ' + '' + escHtml(externalIp) + '
  6. ' + '
  7. Njal.la will give you a curl command like:
    ' + 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
  8. ' + '
  9. Enter the subdomain and paste that curl command below for each service
  10. ' + '
' + '
'; html += '

Enter each fully-qualified subdomain (e.g. matrix.yourdomain.com) and its Njal.la DDNS curl command.

'; relevantDomains.forEach(function(d) { var currentVal = (_domainsData && _domainsData[d.name]) || ""; html += '
'; html += ''; html += ''; html += ''; html += ''; html += '

ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

'; html += '
'; }); } // SSL email section var emailVal = (_domainsData && _domainsData["sslemail"]) || ""; html += '
'; html += ''; html += '

Let\'s Encrypt uses this for certificate expiry notifications.

'; html += ''; html += '
'; body.innerHTML = html; } async function saveStep2() { setStatus("step-2-status", "Saving domains…", "info"); var errors = []; // Save each domain input var domainInputs = document.querySelectorAll("[data-domain]"); for (var i = 0; i < domainInputs.length; i++) { var inp = domainInputs[i]; var domainName = inp.dataset.domain; var domainVal = inp.value.trim(); if (!domainVal) continue; // skip empty — not required var ddnsInput = document.getElementById("ddns-input-" + domainName); var ddnsVal = ddnsInput ? ddnsInput.value.trim() : ""; try { await apiFetch("/api/domains/set", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }), }); } catch (err) { errors.push(domainName + ": " + err.message); } } // Save SSL email var emailInput = document.getElementById("ssl-email-input"); if (emailInput && emailInput.value.trim()) { try { await apiFetch("/api/domains/set-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: emailInput.value.trim() }), }); } catch (err) { errors.push("SSL email: " + err.message); } } if (errors.length > 0) { setStatus("step-2-status", "⚠ Some errors: " + errors.join("; "), "error"); return false; } setStatus("step-2-status", "✓ Saved", "ok"); return true; } // ── Step 3: Port Forwarding ─────────────────────────────────────── async function loadStep3() { var body = document.getElementById("step-3-body"); if (!body) return; body.innerHTML = '

Checking ports…

'; var networkData = null; try { networkData = await apiFetch("/api/network"); } catch (err) { body.innerHTML = '

⚠ Could not load network data: ' + escHtml(err.message) + '

'; return; } var internalIp = (networkData && networkData.internal_ip) || "unknown"; var ip = escHtml(internalIp); var html = '

' + '⚠ Each port only needs to be forwarded once — all services share the same ports.' + '

'; html += '
'; html += ' Forward ports to this machine\'s internal IP:'; html += ' ' + ip + ''; html += '
'; // Required ports table html += '
'; html += '
Required Ports — open these on your router:
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += '
PortProtocolForward toPurpose
80TCP' + ip + 'HTTP
443TCP' + ip + 'HTTPS
22TCP' + ip + 'SSH Remote Access
8448TCP' + ip + 'Matrix Federation
'; html += '
'; // Optional ports table html += '
'; html += '
Optional — Only needed if you enable Element Calling:
'; html += '
These 5 additional port openings are required on top of the 4 required ports above.
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += '
PortProtocolForward toPurpose
7881TCP' + ip + 'LiveKit WebRTC signalling
7882–7894UDP' + ip + 'LiveKit media streams
5349TCP' + ip + 'TURN over TLS
3478UDP' + ip + 'TURN (STUN/relay)
30000–40000TCP/UDP' + ip + 'TURN relay (WebRTC)
'; html += '
'; // Totals html += '
'; html += 'Total port openings: 4 (without Element Calling)
'; html += 'Total port openings: 9 (with Element Calling — 4 required + 5 optional)'; html += '
'; html += '
' + '⚠ Ports 80 and 443 must be forwarded first. ' + 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. ' + 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.' + '
'; html += '
' + 'How to set up port forwarding' + '
    ' + '
  1. Open your router\'s admin panel — usually http://192.168.1.1 or http://192.168.0.1
  2. ' + '
  3. Look for "Port Forwarding", "NAT", or "Virtual Server" in the settings
  4. ' + '
  5. Create a new rule for each port listed above
  6. ' + '
  7. Set the destination/internal IP to ' + ip + '
  8. ' + '
  9. Set both internal and external port to the same number
  10. ' + '
  11. Save and apply changes
  12. ' + '
' + '
'; body.innerHTML = html; } // ── Step 4: Credentials ─────────────────────────────────────────── async function loadStep4() { var body = document.getElementById("step-4-body"); if (!body) return; body.innerHTML = '

Loading credentials…

'; if (!_servicesData) { try { _servicesData = await apiFetch("/api/services"); } catch (err) { body.innerHTML = '

⚠ Could not load services: ' + escHtml(err.message) + '

'; return; } } // Find services with credentials that are enabled var credsServices = (_servicesData || []).filter(function(svc) { return svc.has_credentials && svc.enabled; }); if (credsServices.length === 0) { body.innerHTML = '

No credentials found for your current configuration.

'; return; } body.innerHTML = '

Loading credentials…

'; // Fetch all credentials in parallel var fetches = credsServices.map(function(svc) { return apiFetch("/api/credentials/" + encodeURIComponent(svc.unit)) .then(function(data) { return { svc: svc, data: data, error: null }; }) .catch(function(err) { return { svc: svc, data: null, error: err.message }; }); }); var allCreds = await Promise.all(fetches); // Group by category var CATEGORY_ORDER_LOCAL = [ ["infrastructure", "🔧 Infrastructure"], ["bitcoin-base", "₿ Bitcoin Base"], ["bitcoin-apps", "₿ Bitcoin Apps"], ["communication", "💬 Communication"], ["apps", "📦 Self-Hosted Apps"], ["nostr", "📡 Nostr"], ]; var grouped = {}; allCreds.forEach(function(item) { var cat = item.svc.category || "other"; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(item); }); var html = '
💡 Save these credentials somewhere safe. You can always view them again from the Hub dashboard.
'; CATEGORY_ORDER_LOCAL.forEach(function(pair) { var catKey = pair[0]; var catLabel = pair[1]; if (!grouped[catKey] || grouped[catKey].length === 0) return; html += '
'; html += '
' + escHtml(catLabel) + '
'; grouped[catKey].forEach(function(item) { html += '
'; html += '
' + escHtml(item.svc.name) + '
'; if (item.error) { html += '

⚠ ' + escHtml(item.error) + '

'; } else if (item.data && item.data.credentials) { item.data.credentials.forEach(function(cred) { html += '
'; html += '' + escHtml(cred.label || "") + ''; if (cred.value) { var isSecret = /password|secret|key|token/i.test(cred.label || ""); if (isSecret) { html += '' + '••••••••' + '' + '' + ''; } else { html += '' + escHtml(cred.value) + ''; } } html += '
'; }); } html += '
'; }); html += '
'; }); // Remaining categories not in the order Object.keys(grouped).forEach(function(catKey) { var inOrder = CATEGORY_ORDER_LOCAL.some(function(p) { return p[0] === catKey; }); if (inOrder || !grouped[catKey] || grouped[catKey].length === 0) return; html += '
'; html += '
' + escHtml(catKey) + '
'; grouped[catKey].forEach(function(item) { html += '
'; html += '
' + escHtml(item.svc.name) + '
'; if (item.error) { html += '

⚠ ' + escHtml(item.error) + '

'; } else if (item.data && item.data.credentials) { item.data.credentials.forEach(function(cred) { if (cred.value) { html += '
'; html += '' + escHtml(cred.label || "") + ''; html += '' + escHtml(cred.value) + ''; html += '
'; } }); } html += '
'; }); html += '
'; }); body.innerHTML = html; // Wire up reveal buttons body.querySelectorAll(".onboarding-cred-secret").forEach(function(el) { var btn = el.querySelector(".onboarding-cred-reveal-btn"); var hidden = el.querySelector(".onboarding-cred-hidden"); var real = el.querySelector(".onboarding-cred-real"); if (!btn || !hidden || !real) return; btn.addEventListener("click", function() { if (real.style.display === "none") { real.style.display = ""; hidden.style.display = "none"; btn.textContent = "Hide"; } else { real.style.display = "none"; hidden.style.display = ""; btn.textContent = "Show"; } }); }); } // ── Step 5: Feature Manager ─────────────────────────────────────── async function loadStep5() { var body = document.getElementById("step-5-body"); if (!body) return; body.innerHTML = '

Loading features…

'; try { _featuresData = await apiFetch("/api/features"); } catch (err) { body.innerHTML = '

⚠ Could not load features: ' + escHtml(err.message) + '

'; return; } renderFeaturesStep(_featuresData); } function renderFeaturesStep(data) { var body = document.getElementById("step-5-body"); if (!body) return; var SUBCATEGORY_LABELS = { "infrastructure": "🔧 Infrastructure", "bitcoin": "₿ Bitcoin", "communication": "💬 Communication", "nostr": "📡 Nostr", }; var SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; var grouped = {}; (data.features || []).forEach(function(f) { var cat = f.category || "other"; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(f); }); var html = ""; var orderedCats = SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); Object.keys(grouped).forEach(function(k) { if (orderedCats.indexOf(k) === -1) orderedCats.push(k); }); orderedCats.forEach(function(catKey) { var feats = grouped[catKey]; if (!feats || feats.length === 0) return; var catLabel = SUBCATEGORY_LABELS[catKey] || catKey; html += '
'; html += '
' + escHtml(catLabel) + '
'; feats.forEach(function(feat) { var domainHtml = ""; if (feat.needs_domain) { if (feat.domain_configured) { domainHtml = '🌐 Domain configured'; } else { domainHtml = '🌐 Domain not set'; } } html += '
'; html += '
'; html += '
' + escHtml(feat.name) + '
'; html += '
' + escHtml(feat.description) + '
'; html += domainHtml; html += '
'; html += ''; html += '
'; }); html += '
'; }); body.innerHTML = html; // Wire up toggles body.querySelectorAll(".feature-toggle-input").forEach(function(input) { var featId = input.dataset.featId; var label = input.closest(".feature-toggle"); var feat = (data.features || []).find(function(f) { return f.id === featId; }); if (!feat) return; input.addEventListener("change", function() { var newEnabled = input.checked; // Revert UI until confirmed/done input.checked = feat.enabled; if (newEnabled) { if (label) label.classList.remove("active"); } else { if (label) label.classList.add("active"); } handleFeatureToggleStep5(feat, newEnabled, input, label); }); }); } async function handleFeatureToggleStep5(feat, newEnabled, inputEl, labelEl) { // For Bitcoin features being enabled, show a clear mutual-exclusivity confirmation if (newEnabled && (feat.id === "bip110" || feat.id === "bitcoin-core")) { var confirmMsg; if (feat.id === "bip110") { confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; } else { confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; } if (!confirm(confirmMsg)) { if (inputEl) inputEl.checked = feat.enabled; return; } } setStatus("step-5-rebuild-status", "Saving…", "info"); // Collect nostr_npub if needed var extra = {}; if (newEnabled && feat.id === "haven") { var npub = prompt("Enter your Nostr public key (npub1…):"); if (!npub || !npub.trim()) { setStatus("step-5-rebuild-status", "⚠ npub required for Haven", "error"); return; } extra.nostr_npub = npub.trim(); } try { await apiFetch("/api/features/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feature: feat.id, enabled: newEnabled, extra: extra }), }); } catch (err) { setStatus("step-5-rebuild-status", "⚠ " + err.message, "error"); return; } // Update local state feat.enabled = newEnabled; if (inputEl) inputEl.checked = newEnabled; if (labelEl) { if (newEnabled) labelEl.classList.add("active"); else labelEl.classList.remove("active"); } setStatus("step-5-rebuild-status", "✓ Feature updated — system rebuild started", "ok"); startRebuildPoll(); } // ── Rebuild progress polling ────────────────────────────────────── function startRebuildPoll() { _rebuildLogOffset = 0; _rebuildFinished = false; var modal = document.getElementById("ob-rebuild-modal"); var statusEl = document.getElementById("ob-rebuild-status"); var logEl = document.getElementById("ob-rebuild-log"); var closeBtn = document.getElementById("ob-rebuild-close"); var spinner = document.getElementById("ob-rebuild-spinner"); if (modal) modal.style.display = "flex"; if (statusEl) statusEl.textContent = "Rebuilding…"; if (logEl) logEl.textContent = ""; if (closeBtn) closeBtn.disabled = true; if (spinner) spinner.style.display = ""; if (_rebuildPollTimer) clearInterval(_rebuildPollTimer); _rebuildPollTimer = setInterval(pollRebuild, REBUILD_POLL_INTERVAL); } async function pollRebuild() { if (_rebuildFinished) { clearInterval(_rebuildPollTimer); return; } try { var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); var logEl = document.getElementById("ob-rebuild-log"); var statusEl = document.getElementById("ob-rebuild-status"); var closeBtn = document.getElementById("ob-rebuild-close"); var spinner = document.getElementById("ob-rebuild-spinner"); if (data.log && logEl) { logEl.textContent += data.log; logEl.scrollTop = logEl.scrollHeight; _rebuildLogOffset = data.offset || _rebuildLogOffset; } if (!data.running) { _rebuildFinished = true; clearInterval(_rebuildPollTimer); if (data.result === "success" || data.result === "ok") { if (statusEl) statusEl.textContent = "✓ Rebuild complete"; if (spinner) spinner.style.display = "none"; setStatus("step-5-rebuild-status", "✓ Rebuild complete", "ok"); } else { if (statusEl) statusEl.textContent = "⚠ Rebuild finished with issues"; if (spinner) spinner.style.display = "none"; setStatus("step-5-rebuild-status", "⚠ Rebuild finished with issues — see log", "error"); } if (closeBtn) closeBtn.disabled = false; } } catch (_) {} } // ── Step 6: Complete ────────────────────────────────────────────── async function completeOnboarding() { var btn = document.getElementById("step-6-finish"); if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; } try { await apiFetch("/api/onboarding/complete", { method: "POST" }); } catch (_) { // Even if this fails, navigate to dashboard } window.location.href = "/"; } // ── Event wiring ────────────────────────────────────────────────── function wireNavButtons() { // Step 1 → 2 var s1next = document.getElementById("step-1-next"); if (s1next) s1next.addEventListener("click", function() { showStep(2); }); // Step 2 → 3 (save first) var s2next = document.getElementById("step-2-next"); if (s2next) s2next.addEventListener("click", async function() { s2next.disabled = true; s2next.textContent = "Saving…"; await saveStep2(); s2next.disabled = false; s2next.textContent = "Save & Continue →"; showStep(3); }); // Step 3 → 4 var s3next = document.getElementById("step-3-next"); if (s3next) s3next.addEventListener("click", function() { showStep(4); }); // Step 4 → 5 var s4next = document.getElementById("step-4-next"); if (s4next) s4next.addEventListener("click", function() { showStep(5); }); // Step 5 → 6 var s5next = document.getElementById("step-5-next"); if (s5next) s5next.addEventListener("click", function() { showStep(6); }); // Step 6: finish var s6finish = document.getElementById("step-6-finish"); if (s6finish) s6finish.addEventListener("click", completeOnboarding); // Back buttons document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { var prev = parseInt(btn.dataset.prev, 10); btn.addEventListener("click", function() { showStep(prev); }); }); // Rebuild modal close var rebuildClose = document.getElementById("ob-rebuild-close"); if (rebuildClose) { rebuildClose.addEventListener("click", function() { var modal = document.getElementById("ob-rebuild-modal"); if (modal) modal.style.display = "none"; }); } } // ── Init ────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", async function() { // If onboarding is already complete, go to dashboard try { var status = await apiFetch("/api/onboarding/status"); if (status.complete) { window.location.href = "/"; return; } } catch (_) {} wireNavButtons(); updateProgress(1); loadStep1(); });