From 04d282f790614c1fc535a746083e565a3723b5db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:13:26 +0000 Subject: [PATCH] Add first-boot onboarding wizard (backend + frontend) Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d070c508-d5df-43c7-a0a6-a7be4c65fed7 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 29 + app/sovran_systemsos_web/static/app.js | 11 + app/sovran_systemsos_web/static/onboarding.js | 731 ++++++++++++++++++ app/sovran_systemsos_web/static/style.css | 597 ++++++++++++++ .../templates/onboarding.html | 199 +++++ 5 files changed, 1567 insertions(+) create mode 100644 app/sovran_systemsos_web/static/onboarding.js create mode 100644 app/sovran_systemsos_web/templates/onboarding.html diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2708b95..4ceea91 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -50,6 +50,8 @@ ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" REBOOT_COMMAND = ["reboot"] +ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete" + # ── Tech Support constants ──────────────────────────────────────── SUPPORT_KEY_FILE = "/root/.ssh/sovran_support_authorized" @@ -256,6 +258,7 @@ def _file_hash(filename: str) -> str: _APP_JS_HASH = _file_hash("app.js") _STYLE_CSS_HASH = _file_hash("style.css") +_ONBOARDING_JS_HASH = _file_hash("onboarding.js") # ── Update check helpers ────────────────────────────────────────── @@ -819,6 +822,32 @@ async def index(request: Request): }) +@app.get("/onboarding", response_class=HTMLResponse) +async def onboarding(request: Request): + return templates.TemplateResponse("onboarding.html", { + "request": request, + "onboarding_js_hash": _ONBOARDING_JS_HASH, + "style_css_hash": _STYLE_CSS_HASH, + }) + + +@app.get("/api/onboarding/status") +async def api_onboarding_status(): + complete = os.path.exists(ONBOARDING_FLAG) + return {"complete": complete} + + +@app.post("/api/onboarding/complete") +async def api_onboarding_complete(): + os.makedirs(os.path.dirname(ONBOARDING_FLAG), exist_ok=True) + try: + with open(ONBOARDING_FLAG, "w") as f: + f.write("") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write flag file: {exc}") + return {"ok": True} + + @app.get("/api/config") async def api_config(): cfg = load_config() diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3cda230..be55d95 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1475,6 +1475,17 @@ function _renderPortHealthBanner(data) { // ── Init ────────────────────────────────────────────────────────── async function init() { + // Check onboarding status first — redirect to wizard if not complete + try { + var onboardingStatus = await apiFetch("/api/onboarding/status"); + if (!onboardingStatus.complete) { + window.location.href = "/onboarding"; + return; + } + } catch (_) { + // If we can't reach the endpoint, continue to normal dashboard + } + try { var cfg = await apiFetch("/api/config"); if (cfg.category_order) { diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js new file mode 100644 index 0000000..84ccc35 --- /dev/null +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -0,0 +1,731 @@ +/* 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: false }, + { 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: false }, + { name: "btcpayserver", label: "BTCPay Server", unit: "btcpayserver.service", needsDdns: false }, + { name: "nextcloud", label: "Nextcloud", unit: "phpfpm-nextcloud.service",needsDdns: false }, + { name: "wordpress", label: "WordPress", unit: "phpfpm-wordpress.service",needsDdns: false }, +]; + +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 in parallel + var results = await Promise.all([ + apiFetch("/api/services"), + apiFetch("/api/domains/status"), + ]); + _servicesData = results[0]; + _domainsData = results[1]; + } catch (err) { + body.innerHTML = '

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

'; + return; + } + + // 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 += '

Enter a fully-qualified domain name (e.g. matrix.example.com) for each service.

'; + relevantDomains.forEach(function(d) { + var currentVal = (_domainsData && _domainsData[d.name]) || ""; + html += '
'; + html += ''; + html += ''; + if (d.needsDdns) { + html += ''; + html += ''; + } + 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; + var portHealth = null; + + try { + var results = await Promise.all([ + apiFetch("/api/network"), + apiFetch("/api/ports/health"), + ]); + networkData = results[0]; + portHealth = results[1]; + } catch (err) { + body.innerHTML = '

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

'; + return; + } + + var internalIp = (networkData && networkData.internal_ip) || "unknown"; + + var html = '
'; + html += ' Forward ports to this machine\'s internal IP:'; + html += ' ' + escHtml(internalIp) + ''; + html += '
'; + + var status = (portHealth && portHealth.status) || "ok"; + var totalPorts = (portHealth && portHealth.total_ports) || 0; + var closedPorts = (portHealth && portHealth.closed_ports) || 0; + + if (totalPorts === 0) { + html += '

No port requirements detected for your current role.

'; + } else if (status === "ok") { + html += '

✅ All ' + totalPorts + ' required ports are open and ready.

'; + } else { + html += '
'; + html += '⚠ ' + closedPorts + ' of ' + totalPorts + ' ports appear closed. '; + html += 'You can continue, but affected services may not work until ports are forwarded.'; + html += '
'; + } + + // Show per-service breakdown + var affectedSvcs = (portHealth && portHealth.affected_services) || []; + if (affectedSvcs.length > 0) { + html += '
'; + html += '
Affected Services
'; + affectedSvcs.forEach(function(svc) { + html += '
'; + html += '
' + escHtml(svc.name) + '
'; + (svc.closed_ports || []).forEach(function(p) { + html += '
'; + html += ' 🔴'; + html += ' ' + escHtml(p.port) + '/' + escHtml(p.protocol) + ''; + if (p.description) html += ' ' + escHtml(p.description) + ''; + html += '
'; + }); + html += '
'; + }); + html += '
'; + } + + // Full port table from services + if (_servicesData) { + // Collect all unique port requirements + var allPorts = []; + var seen = new Set(); + (_servicesData || []).forEach(function(svc) { + (svc.port_requirements || []).forEach(function(p) { + var key = p.port + "/" + p.protocol; + if (!seen.has(key)) { + seen.add(key); + allPorts.push(p); + } + }); + }); + + if (allPorts.length > 0) { + html += '
'; + html += 'View All Required Ports'; + html += ''; + html += ''; + html += ''; + allPorts.forEach(function(p) { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
PortProtocolPurpose
' + escHtml(p.port) + '' + escHtml(p.protocol) + '' + escHtml(p.description || "") + '
'; + html += '
'; + } + } + + 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) { + 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(); +}); diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 424b959..2726875 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1684,3 +1684,600 @@ button.btn-reboot:hover:not(:disabled) { .sidebar .section-header { font-size: 0.75rem; } + +/* ── Onboarding Wizard ────────────────────────────────────────── */ + +.onboarding-body { + overflow: auto; + display: flex; + align-items: flex-start; + justify-content: center; + background-color: var(--bg-color); + min-height: 100vh; + padding: 32px 16px 64px; +} + +.onboarding-shell { + width: 100%; + max-width: 680px; +} + +/* Progress bar */ + +.onboarding-progress-bar { + height: 4px; + background-color: var(--border-color); + border-radius: 2px; + margin-bottom: 24px; + overflow: hidden; +} + +.onboarding-progress-fill { + height: 100%; + background-color: var(--accent-color); + border-radius: 2px; + transition: width 0.4s ease; + width: 0%; +} + +/* Step dots */ + +.onboarding-steps-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + margin-bottom: 32px; +} + +.onboarding-step-dot { + width: 28px; + height: 28px; + border-radius: 50%; + background-color: var(--card-color); + border: 2px solid var(--border-color); + color: var(--text-dim); + font-size: 0.72rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background-color 0.2s, border-color 0.2s, color 0.2s; +} + +.onboarding-step-dot.active { + background-color: var(--accent-color); + border-color: var(--accent-color); + color: #1e1e2e; +} + +.onboarding-step-dot.completed { + background-color: #2ec27e; + border-color: #2ec27e; + color: #fff; +} + +.onboarding-step-connector { + flex: 1; + height: 2px; + background-color: var(--border-color); + max-width: 60px; + min-width: 16px; +} + +/* Panel */ + +.onboarding-panel-wrap { + position: relative; +} + +.onboarding-panel { + animation: ob-fadein 0.25s ease; +} + +@keyframes ob-fadein { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Hero (steps 1 and 6) */ + +.onboarding-hero { + text-align: center; + margin-bottom: 28px; +} + +.onboarding-logo { + font-size: 3.5rem; + line-height: 1; + margin-bottom: 14px; +} + +.onboarding-title { + font-size: 1.7rem; + font-weight: 800; + color: var(--text-primary); + margin-bottom: 8px; +} + +.onboarding-subtitle { + font-size: 1rem; + color: var(--accent-color); + font-weight: 600; + letter-spacing: 0.04em; +} + +/* Step header (steps 2-5) */ + +.onboarding-step-header { + margin-bottom: 20px; +} + +.onboarding-step-icon { + font-size: 2rem; + display: block; + margin-bottom: 10px; +} + +.onboarding-step-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.onboarding-step-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; +} + +/* Cards */ + +.onboarding-card { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-card); + padding: 24px; + margin-bottom: 20px; +} + +.onboarding-card--scroll { + max-height: 440px; + overflow-y: auto; +} + +.onboarding-body-text { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 16px; +} + +.onboarding-body-text--dim { + color: var(--text-dim); + font-size: 0.85rem; +} + +.onboarding-body-text:last-child { margin-bottom: 0; } + +/* Role badge */ + +.onboarding-role-row { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0; +} + +.onboarding-role-label { + font-size: 0.88rem; + color: var(--text-secondary); + font-weight: 600; +} + +.onboarding-role-badge { + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.75rem; + font-weight: 700; + padding: 3px 12px; + border-radius: 20px; + letter-spacing: 0.04em; +} + +/* Checklist */ + +.onboarding-checklist { + list-style: none; + margin: 16px 0 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.onboarding-checklist li { + font-size: 0.9rem; + color: var(--text-secondary); +} + +/* Footer navigation */ + +.onboarding-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; +} + +.onboarding-btn-next, +.onboarding-btn-back { + min-width: 140px; +} + +/* Status messages */ + +.onboarding-save-status { + font-size: 0.85rem; + min-height: 1.4em; + margin-bottom: 8px; + padding: 0 4px; +} + +.onboarding-save-status--ok { + color: #a6e3a1; +} + +.onboarding-save-status--error { + color: #f38ba8; +} + +.onboarding-save-status--info { + color: var(--text-secondary); +} + +/* Loading / error states */ + +.onboarding-loading { + font-size: 0.88rem; + color: var(--text-dim); + text-align: center; + padding: 24px 0; +} + +.onboarding-error { + font-size: 0.88rem; + color: #f38ba8; + padding: 12px 0; +} + +/* Hints */ + +.onboarding-hint { + font-size: 0.82rem; + color: var(--text-dim); + margin-bottom: 14px; + line-height: 1.5; +} + +.onboarding-hint code { + font-family: 'JetBrains Mono', monospace; + background: rgba(137, 180, 250, 0.1); + padding: 1px 5px; + border-radius: 3px; + color: var(--accent-color); +} + +.onboarding-hint--inline { + margin-bottom: 6px; + margin-top: 2px; +} + +.onboarding-optional { + font-size: 0.78rem; + color: var(--text-dim); +} + +/* Domain inputs */ + +.onboarding-domain-group { + margin-bottom: 18px; +} + +.onboarding-domain-group--email { + border-top: 1px solid var(--border-color); + padding-top: 18px; + margin-top: 6px; +} + +.onboarding-domain-label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 5px; +} + +.onboarding-domain-label--sub { + font-weight: 500; + font-size: 0.8rem; + margin-top: 8px; +} + +.onboarding-domain-input { + width: 100%; + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + color: var(--text-primary); + font-size: 0.88rem; + padding: 8px 12px; + outline: none; + transition: border-color 0.15s; + font-family: inherit; +} + +.onboarding-domain-input:focus { + border-color: var(--accent-color); +} + +/* Port forwarding */ + +.onboarding-port-ip { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + background: rgba(137, 180, 250, 0.06); + border: 1px solid rgba(137, 180, 250, 0.2); + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 16px; + font-size: 0.88rem; +} + +.onboarding-port-ip-label { + color: var(--text-secondary); +} + +.onboarding-port-all-ok { + color: #a6e3a1; + font-size: 0.92rem; + font-weight: 600; + margin-bottom: 12px; +} + +.onboarding-port-warn { + background: rgba(229, 165, 10, 0.08); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 8px; + padding: 12px 16px; + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 14px; + line-height: 1.5; +} + +.onboarding-port-breakdown { + margin-bottom: 14px; +} + +.onboarding-port-breakdown-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + margin-bottom: 8px; +} + +.onboarding-port-svc { + margin-bottom: 10px; +} + +.onboarding-port-svc-name { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.onboarding-port-row { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0; + font-size: 0.85rem; +} + +.onboarding-port-details { + margin-top: 12px; +} + +.onboarding-port-details-summary { + font-size: 0.82rem; + color: var(--accent-color); + cursor: pointer; + font-weight: 600; + padding: 4px 0; +} + +.onboarding-port-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; + font-size: 0.82rem; +} + +.onboarding-port-table th { + text-align: left; + padding: 4px 8px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + border-bottom: 1px solid var(--border-color); +} + +.onboarding-port-table td { + padding: 4px 8px; + vertical-align: top; +} + +/* Credentials */ + +.onboarding-creds-notice { + background: rgba(137, 180, 250, 0.06); + border: 1px solid rgba(137, 180, 250, 0.2); + border-radius: 8px; + padding: 10px 14px; + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 16px; + line-height: 1.5; +} + +.onboarding-creds-category { + margin-bottom: 20px; +} + +.onboarding-creds-category-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color); +} + +.onboarding-creds-service { + background: var(--surface-color); + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 10px; +} + +.onboarding-creds-service-name { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.onboarding-cred-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 3px 0; + font-size: 0.85rem; + flex-wrap: wrap; +} + +.onboarding-cred-label { + color: var(--text-dim); + font-weight: 600; + min-width: 100px; + flex-shrink: 0; +} + +.onboarding-cred-value { + color: var(--text-primary); + word-break: break-all; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; +} + +.onboarding-cred-secret { + display: flex; + align-items: center; + gap: 8px; +} + +.onboarding-cred-hidden { + color: var(--text-dim); + letter-spacing: 0.15em; +} + +.onboarding-cred-reveal-btn { + background: none; + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--accent-color); + font-size: 0.72rem; + padding: 1px 6px; + cursor: pointer; + font-family: inherit; +} + +.onboarding-cred-reveal-btn:hover { + background-color: rgba(137, 180, 250, 0.1); +} + +/* Feature step */ + +.onboarding-feat-group { + margin-bottom: 20px; +} + +.onboarding-feat-group-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color); +} + +.onboarding-feat-card { + background: var(--surface-color); + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 10px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.onboarding-feat-info { + flex: 1; + min-width: 0; +} + +.onboarding-feat-name { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 3px; +} + +.onboarding-feat-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.4; + margin-bottom: 5px; +} + +.onboarding-feat-domain { + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + display: inline-block; +} + +.onboarding-feat-domain--ok { + background: rgba(46, 194, 126, 0.12); + color: #a6e3a1; +} + +.onboarding-feat-domain--missing { + background: rgba(229, 165, 10, 0.1); + color: #f9e2af; +} + diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html new file mode 100644 index 0000000..65049a3 --- /dev/null +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -0,0 +1,199 @@ + + + + + + Sovran_SystemsOS — First-Boot Setup + + + + + +
+ + +
+
+
+ + +
+ 1 + + 2 + + 3 + + 4 + + 5 + + 6 +
+ + +
+ + +
+
+ +

Welcome to Sovran_SystemsOS!

+

Be Digitally Sovereign

+
+
+

+ Your system is installed and ready to configure. This wizard will guide + you through the final setup steps so everything works perfectly. +

+
+ Your Role: + Loading… +
+

+ This setup only takes a few minutes. You can always revisit these + settings from the main Hub dashboard. +

+
+ +
+ + + + + + + + + + + + + + + + +
+
+ + + + + + +