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 += '' + escHtml(d.label) + ' ';
+ html += ' ';
+ if (d.needsDdns) {
+ html += 'DDNS Update URL (optional) ';
+ html += ' ';
+ }
+ html += '
';
+ });
+ }
+
+ // SSL email section
+ var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
+ html += '';
+ html += '
📧 SSL Certificate Email ';
+ 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 += 'Port Protocol Purpose ';
+ html += '';
+ allPorts.forEach(function(p) {
+ html += '';
+ html += '' + escHtml(p.port) + ' ';
+ html += '' + escHtml(p.protocol) + ' ';
+ html += '' + escHtml(p.description || "") + ' ';
+ html += ' ';
+ });
+ html += '
';
+ 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 += ''
+ + '•••••••• '
+ + '' + escHtml(cred.value) + ' '
+ + 'Show '
+ + ' ';
+ } 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 += ' ';
+ 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.
+
+
+
+
+
+
+
+
+
+
Loading service information…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
✅
+
Your Sovran_SystemsOS is Ready!
+
Setup complete
+
+
+
+ All configuration steps are done. Head to the main Hub dashboard to
+ monitor your services, manage credentials, and make changes at any time.
+
+
+ ✅ Domain configuration saved
+ ✅ Port forwarding reviewed
+ ✅ Credentials noted
+ ✅ Features configured
+
+
+
+
+
+
+
+
+
+
+
+
+
+