From b9c8c20347d1b4d985a2ecc6c913ef2d1ec9a710 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:24:17 +0000 Subject: [PATCH] feat: add Feature Manager to Sovran Hub dashboard - flake.nix: import /etc/nixos/hub-overrides.nix alongside custom.nix - sovran-hub.nix: add hub-overrides-init service (seeds file if missing), sovran-hub-rebuild service (nixos-rebuild switch only), and feature_manager=true in generated config.json - server.py: add FEATURE_REGISTRY with 6 features (rdp, haven, element-calling, mempool, bip110, bitcoin-core); add hub-overrides.nix read/write helpers; add /api/features, /api/features/toggle, /api/rebuild/status, /api/domains/set, /api/domains/set-email, /api/domains/status endpoints; update /api/config to expose feature_manager - index.html: add domain setup modal, SSL email modal, feature confirm modal, and rebuild modal HTML - app.js: add Feature Manager rendering with sub-category layout, feature toggle cards with sliding toggles, domain setup flow, SSL email collection, conflict confirmation, rebuild polling - style.css: add Feature Manager styles (feature cards, toggle switch, domain badge, conflict warning, domain input fields)" Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 389 +++++++++++++- app/sovran_systemsos_web/static/app.js | 503 +++++++++++++++++- app/sovran_systemsos_web/static/style.css | 217 ++++++++ app/sovran_systemsos_web/templates/index.html | 66 +++ flake.nix | 1 + modules/core/sovran-hub.nix | 60 +++ 6 files changed, 1228 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5b79a79..f4de31c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -13,10 +13,11 @@ import subprocess import urllib.request from fastapi import FastAPI, HTTPException -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request +from pydantic import BaseModel from .config import load_config from . import systemctl as sysctl @@ -31,6 +32,15 @@ UPDATE_LOG = "/var/log/sovran-hub-update.log" UPDATE_STATUS = "/var/log/sovran-hub-update.status" UPDATE_UNIT = "sovran-hub-update.service" +REBUILD_LOG = "/var/log/sovran-hub-rebuild.log" +REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status" +REBUILD_UNIT = "sovran-hub-rebuild.service" + +HUB_OVERRIDES_NIX = "/etc/nixos/hub-overrides.nix" +DOMAINS_DIR = "/var/lib/domains" +NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub" +NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh" + INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" @@ -55,6 +65,85 @@ CATEGORY_ORDER = [ ("apps", "Self-Hosted Apps"), ("nostr", "Nostr"), ("support", "Support"), + ("feature-manager", "Feature Manager"), +] + +FEATURE_REGISTRY = [ + { + "id": "rdp", + "name": "Remote Desktop (RDP)", + "description": "Access your desktop remotely via RDP client", + "category": "infrastructure", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + }, + { + "id": "haven", + "name": "Haven NOSTR Relay", + "description": "Run your own private Nostr relay", + "category": "nostr", + "needs_domain": True, + "domain_name": "haven", + "needs_ddns": True, + "extra_fields": [ + { + "id": "nostr_npub", + "label": "Nostr Public Key (npub1...)", + "type": "text", + "required": True, + "current_value": "", + }, + ], + "conflicts_with": [], + }, + { + "id": "element-calling", + "name": "Element Video & Audio Calling", + "description": "Add video/audio calling to Matrix via LiveKit", + "category": "communication", + "needs_domain": True, + "domain_name": "element-calling", + "needs_ddns": True, + "extra_fields": [], + "conflicts_with": [], + "requires": ["matrix_domain"], + }, + { + "id": "mempool", + "name": "Mempool Explorer", + "description": "Bitcoin mempool visualization and explorer", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + }, + { + "id": "bip110", + "name": "BIP-110 (Bitcoin Better Money)", + "description": "Bitcoin Knots with BIP-110 consensus changes", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": ["bitcoin-core"], + }, + { + "id": "bitcoin-core", + "name": "Bitcoin Core", + "description": "Use Bitcoin Core instead of Bitcoin Knots", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": ["bip110"], + }, ] ROLE_LABELS = { @@ -303,6 +392,77 @@ def _resolve_credential(cred: dict) -> dict | None: return result +# ── Rebuild helpers (file-based, no systemctl) ─────────────────── + +def _read_rebuild_status() -> str: + """Read the rebuild status file. Returns RUNNING, SUCCESS, FAILED, or IDLE.""" + try: + with open(REBUILD_STATUS, "r") as f: + return f.read().strip() + except FileNotFoundError: + return "IDLE" + + +def _read_rebuild_log(offset: int = 0) -> tuple[str, int]: + """Read the rebuild log file from the given byte offset.""" + try: + with open(REBUILD_LOG, "rb") as f: + f.seek(0, 2) + size = f.tell() + if offset > size: + offset = 0 + f.seek(offset) + chunk = f.read() + return chunk.decode(errors="replace"), offset + len(chunk) + except FileNotFoundError: + return "", 0 + + +# ── hub-overrides.nix helpers ───────────────────────────────────── + +def _read_hub_overrides() -> tuple[dict, str | None]: + """Parse hub-overrides.nix. Returns (features_dict, nostr_npub_or_none).""" + features: dict[str, bool] = {} + nostr_npub = None + try: + with open(HUB_OVERRIDES_NIX, "r") as f: + content = f.read() + for m in re.finditer( + r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(true|false)\s*;', + content, + ): + features[m.group(1)] = m.group(2) == "true" + m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*"([^"]*)"', content) + if m2: + nostr_npub = m2.group(1) + except FileNotFoundError: + pass + return features, nostr_npub + + +def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: + """Write a complete hub-overrides.nix from the given state.""" + lines = [] + for feat_id, enabled in features.items(): + val = "true" if enabled else "false" + lines.append(f" sovran_systemsOS.features.{feat_id} = {val};") + if nostr_npub: + lines.append(f' sovran_systemsOS.nostr_npub = "{nostr_npub}";') + body = "\n".join(lines) + "\n" if lines else "" + content = ( + "# Auto-generated by Sovran Hub — do not edit manually\n" + "{ ... }:\n" + "{\n" + + body + + "}\n" + ) + nix_dir = os.path.dirname(HUB_OVERRIDES_NIX) + if nix_dir: + os.makedirs(nix_dir, exist_ok=True) + with open(HUB_OVERRIDES_NIX, "w") as f: + f.write(content) + + # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: @@ -422,6 +582,7 @@ async def api_config(): "role": role, "role_label": ROLE_LABELS.get(role, role), "category_order": CATEGORY_ORDER, + "feature_manager": cfg.get("feature_manager", False), } @@ -611,6 +772,232 @@ async def api_support_disable(): return {"ok": True, "verified": verified, "message": "Support access removed and verified"} +# ── Feature Manager endpoints ───────────────────────────────────── + +@app.get("/api/features") +async def api_features(): + """Return all toggleable features with current state and domain requirements.""" + loop = asyncio.get_event_loop() + overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + + ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail") + ssl_email_configured = os.path.exists(ssl_email_path) + + features = [] + for feat in FEATURE_REGISTRY: + feat_id = feat["id"] + enabled = overrides.get(feat_id, False) + + domain_name = feat.get("domain_name") + domain_configured = True + if domain_name: + domain_configured = os.path.exists(os.path.join(DOMAINS_DIR, domain_name)) + + extra_fields = [] + for ef in feat.get("extra_fields", []): + ef_copy = dict(ef) + if ef["id"] == "nostr_npub": + ef_copy["current_value"] = nostr_npub or "" + extra_fields.append(ef_copy) + + entry: dict = { + "id": feat_id, + "name": feat["name"], + "description": feat["description"], + "category": feat["category"], + "enabled": enabled, + "needs_domain": feat.get("needs_domain", False), + "domain_configured": domain_configured, + "domain_name": domain_name, + "needs_ddns": feat.get("needs_ddns", False), + "extra_fields": extra_fields, + "conflicts_with": feat.get("conflicts_with", []), + } + if "requires" in feat: + entry["requires"] = feat["requires"] + features.append(entry) + + return {"features": features, "ssl_email_configured": ssl_email_configured} + + +class FeatureToggleRequest(BaseModel): + feature: str + enabled: bool + extra: dict = {} + + +@app.post("/api/features/toggle") +async def api_features_toggle(req: FeatureToggleRequest): + """Enable or disable a feature and trigger a system rebuild.""" + feat_meta = next((f for f in FEATURE_REGISTRY if f["id"] == req.feature), None) + if not feat_meta: + raise HTTPException(status_code=404, detail="Feature not found") + + loop = asyncio.get_event_loop() + features, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + + if req.enabled: + # Element-calling requires matrix domain + if req.feature == "element-calling": + if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")): + raise HTTPException( + status_code=400, + detail=( + "Element Calling requires a Matrix domain to be configured. " + "Please run `sovran-setup-domains` first or configure the Matrix domain." + ), + ) + + # Domain requirement check + if feat_meta.get("needs_domain") and feat_meta.get("domain_name"): + domain_path = os.path.join(DOMAINS_DIR, feat_meta["domain_name"]) + if not os.path.exists(domain_path): + return JSONResponse( + status_code=400, + content={ + "error": "domain_required", + "domain_name": feat_meta["domain_name"], + }, + ) + + # Haven requires nostr_npub + if req.feature == "haven": + npub = (req.extra or {}).get("nostr_npub", "").strip() + if npub: + nostr_npub = npub + elif not nostr_npub: + raise HTTPException(status_code=400, detail="nostr_npub is required for Haven") + + # Auto-disable conflicting features + for conflict_id in feat_meta.get("conflicts_with", []): + features[conflict_id] = False + + features[req.feature] = True + else: + features[req.feature] = False + + # Persist any extra fields (nostr_npub) + new_npub = (req.extra or {}).get("nostr_npub", "").strip() + if new_npub: + nostr_npub = new_npub + try: + os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True) + with open(NOSTR_NPUB_FILE, "w") as f: + f.write(nostr_npub) + except OSError: + pass + + await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub) + + # Start the rebuild service + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "rebuilding"} + + +@app.get("/api/rebuild/status") +async def api_rebuild_status(offset: int = 0): + """Poll endpoint for rebuild progress.""" + loop = asyncio.get_event_loop() + status = await loop.run_in_executor(None, _read_rebuild_status) + new_log, new_offset = await loop.run_in_executor(None, _read_rebuild_log, offset) + running = status == "RUNNING" + result = "pending" if running else status.lower() + return { + "running": running, + "result": result, + "log": new_log, + "offset": new_offset, + } + + +# ── Domain endpoints ────────────────────────────────────────────── + +class DomainSetRequest(BaseModel): + domain_name: str + domain: str + ddns_url: str = "" + + +@app.post("/api/domains/set") +async def api_domains_set(req: DomainSetRequest): + """Save a domain and optionally register a DDNS URL.""" + os.makedirs(DOMAINS_DIR, exist_ok=True) + domain_path = os.path.join(DOMAINS_DIR, req.domain_name) + with open(domain_path, "w") as f: + f.write(req.domain.strip()) + + if req.ddns_url: + ddns_url = req.ddns_url.strip() + # Strip leading "curl " if present + if ddns_url.lower().startswith("curl "): + ddns_url = ddns_url[5:].strip() + # Strip surrounding quotes + if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]: + ddns_url = ddns_url[1:-1] + # Replace trailing &auto with &a=${IP} + if ddns_url.endswith("&auto"): + ddns_url = ddns_url[:-5] + "&a=${IP}" + # Append curl line to njalla.sh + njalla_dir = os.path.dirname(NJALLA_SCRIPT) + if njalla_dir: + os.makedirs(njalla_dir, exist_ok=True) + with open(NJALLA_SCRIPT, "a") as f: + f.write(f'curl "{ddns_url}"\n') + try: + os.chmod(NJALLA_SCRIPT, 0o755) + except OSError: + pass + # Run njalla.sh immediately to update DNS + try: + subprocess.run([NJALLA_SCRIPT], timeout=30, check=False) + except Exception: + pass + + return {"ok": True} + + +class DomainSetEmailRequest(BaseModel): + email: str + + +@app.post("/api/domains/set-email") +async def api_domains_set_email(req: DomainSetEmailRequest): + """Save the SSL certificate email address.""" + os.makedirs(DOMAINS_DIR, exist_ok=True) + with open(os.path.join(DOMAINS_DIR, "sslemail"), "w") as f: + f.write(req.email.strip()) + return {"ok": True} + + +@app.get("/api/domains/status") +async def api_domains_status(): + """Return the value of each known domain file (or null if missing).""" + known = [ + "matrix", "haven", "element-calling", "sslemail", + "vaultwarden", "btcpayserver", "nextcloud", "wordpress", + ] + domains: dict[str, str | None] = {} + for name in known: + path = os.path.join(DOMAINS_DIR, name) + try: + with open(path, "r") as f: + domains[name] = f.read().strip() + except FileNotFoundError: + domains[name] = None + return {"domains": domains} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3f39249..179c0ed 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,5 +1,5 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v7 — Status-only dashboard + Tech Support */ + v7 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; @@ -16,8 +16,18 @@ const CATEGORY_ORDER = [ "apps", "nostr", "support", + "feature-manager", ]; +const FEATURE_SUBCATEGORY_LABELS = { + "infrastructure": "🔧 Infrastructure", + "bitcoin": "₿ Bitcoin", + "communication": "💬 Communication", + "nostr": "📡 Nostr", +}; + +const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; + const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); @@ -35,6 +45,15 @@ let _supportTimerInt = null; let _supportEnabledAt = null; let _cachedExternalIp = null; +// Feature Manager state +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm + // ── DOM refs ────────────────────────────────────────────────────── const $tilesArea = document.getElementById("tiles-area"); @@ -63,6 +82,35 @@ const $supportModal = document.getElementById("support-modal"); const $supportBody = document.getElementById("support-body"); const $supportCloseBtn = document.getElementById("support-close-btn"); +// Feature Manager — rebuild modal +const $rebuildModal = document.getElementById("rebuild-modal"); +const $rebuildSpinner = document.getElementById("rebuild-spinner"); +const $rebuildStatus = document.getElementById("rebuild-status"); +const $rebuildLog = document.getElementById("rebuild-log"); +const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +const $rebuildSave = document.getElementById("rebuild-save-report"); +const $rebuildClose = document.getElementById("rebuild-close-btn"); + +// Feature Manager — domain setup modal +const $domainSetupModal = document.getElementById("domain-setup-modal"); +const $domainSetupTitle = document.getElementById("domain-setup-title"); +const $domainSetupBody = document.getElementById("domain-setup-body"); +const $domainSetupClose = document.getElementById("domain-setup-close-btn"); + +// Feature Manager — SSL email modal +const $sslEmailModal = document.getElementById("ssl-email-modal"); +const $sslEmailInput = document.getElementById("ssl-email-input"); +const $sslEmailSave = document.getElementById("ssl-email-save-btn"); +const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +const $sslEmailClose = document.getElementById("ssl-email-close-btn"); + +// Feature Manager — confirm modal +const $featureConfirmModal = document.getElementById("feature-confirm-modal"); +const $featureConfirmMsg = document.getElementById("feature-confirm-message"); +const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } @@ -480,6 +528,417 @@ function waitForServerReboot() { .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); } +// ── Rebuild modal ───────────────────────────────────────────────── + +function openRebuildModal() { + if (!$rebuildModal) return; + _rebuildLog = ""; + _rebuildLogOffset = 0; + _rebuildServerDown = false; + _rebuildFinished = false; + if ($rebuildLog) $rebuildLog.textContent = ""; + if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; + if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); + if ($rebuildReboot) $rebuildReboot.style.display = "none"; + if ($rebuildSave) $rebuildSave.style.display = "none"; + if ($rebuildClose) $rebuildClose.disabled = true; + $rebuildModal.classList.add("open"); + startRebuildPoll(); +} + +function closeRebuildModal() { + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopRebuildPoll(); +} + +function appendRebuildLog(text) { + if (!text) return; + _rebuildLog += text; + if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } +} + +function startRebuildPoll() { + pollRebuildStatus(); + _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); +} + +function stopRebuildPoll() { + if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } +} + +async function pollRebuildStatus() { + if (_rebuildFinished) return; + try { + var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); + if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } + if (data.log) appendRebuildLog(data.log); + _rebuildLogOffset = data.offset; + if (data.running) return; + _rebuildFinished = true; + stopRebuildPoll(); + onRebuildDone(data.result === "success"); + } catch (err) { + if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } + } +} + +function onRebuildDone(success) { + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (success) { + if ($rebuildStatus) $rebuildStatus.textContent = "✓ Rebuild complete"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + // Refresh feature states + loadFeatureManager(); + } else { + if ($rebuildStatus) $rebuildStatus.textContent = "✗ Rebuild failed"; + if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + } +} + +function saveRebuildErrorReport() { + var blob = new Blob([_rebuildLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Feature confirm modal ───────────────────────────────────────── + +function openFeatureConfirm(message, onConfirm) { + if (!$featureConfirmModal) return; + if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; + $featureConfirmModal.classList.add("open"); + // Replace ok handler + var newOk = $featureConfirmOk.cloneNode(true); + $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); + newOk.addEventListener("click", function() { + closeFeatureConfirm(); + onConfirm(); + }); +} + +function closeFeatureConfirm() { + if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); +} + +// ── SSL Email modal ─────────────────────────────────────────────── + +function openSslEmailModal(onSaved) { + if (!$sslEmailModal) return; + if ($sslEmailInput) $sslEmailInput.value = ""; + $sslEmailModal.classList.add("open"); + // Replace save handler + var newSave = $sslEmailSave.cloneNode(true); + $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); + newSave.addEventListener("click", async function() { + var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; + if (!email) { alert("Please enter an email address."); return; } + newSave.disabled = true; + newSave.textContent = "Saving…"; + try { + await apiFetch("/api/domains/set-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }), + }); + closeSslEmailModal(); + onSaved(); + } catch (err) { + newSave.disabled = false; + newSave.textContent = "Save"; + alert("Failed to save email. Please try again."); + } + }); +} + +function closeSslEmailModal() { + if ($sslEmailModal) $sslEmailModal.classList.remove("open"); +} + +// ── Domain Setup modal ──────────────────────────────────────────── + +function openDomainSetupModal(feat, onSaved) { + if (!$domainSetupModal) return; + if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + + var npubField = ""; + if (feat.id === "haven") { + var currentNpub = ""; + if (feat.extra_fields && feat.extra_fields.length > 0) { + for (var i = 0; i < feat.extra_fields.length; i++) { + if (feat.extra_fields[i].id === "nostr_npub") { + currentNpub = feat.extra_fields[i].current_value || ""; + break; + } + } + } + npubField = '
'; + } + + $domainSetupBody.innerHTML = + 'Before continuing, you need:
ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record