/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard Drives the 5-step post-install setup flow. */ "use strict"; // ── Constants ───────────────────────────────────────────────────── const TOTAL_STEPS = 5; // 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 }, ]; // ── State ───────────────────────────────────────────────────────── var _currentStep = 1; var _servicesData = null; var _domainsData = null; // ── 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(); } // ── 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: Complete ────────────────────────────────────────────── async function completeOnboarding() { var btn = document.getElementById("step-5-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 (Complete) var s4next = document.getElementById("step-4-next"); if (s4next) s4next.addEventListener("click", function() { showStep(5); }); // Step 5: finish var s5finish = document.getElementById("step-5-finish"); if (s5finish) s5finish.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); }); }); } // ── 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(); });