"use strict"; // ── Service detail modal ────────────────────────────────────────── function _renderCredsHtml(credentials, unit) { var html = ""; for (var i = 0; i < credentials.length; i++) { var cred = credentials[i]; var id = "cred-" + Math.random().toString(36).substring(2, 8); var qrBlock = ""; if (cred.qrcode) { qrBlock = '
QR Code for ' + escHtml(cred.label) + '
Scan with Zeus app on your phone
'; } // If qronly, render the label + QR block only — skip value and copy button if (cred.qronly) { html += '
' + escHtml(cred.label) + '
' + qrBlock + '
'; continue; } var displayValue = linkify(cred.value); html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
'; } return html; } function _attachCopyHandlers(container) { container.querySelectorAll(".creds-copy-btn").forEach(function(btn) { btn.addEventListener("click", function() { var target = document.getElementById(btn.dataset.target); if (!target) return; var text = target.textContent; function onSuccess() { btn.textContent = "Copied!"; btn.classList.add("copied"); setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); } function fallbackCopy() { var ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); onSuccess(); } catch (e) {} document.body.removeChild(ta); } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); } else { fallbackCopy(); } }); }); } var _nwcModalState = null; function _isNwcServiceUnit(unit) { // The dashboard tile is backed by Alby Hub, while older drafts referred to // the logical feature as nwc-wallets.service. Support both so the Wallet // Connections manager appears from the actual tile and any legacy configs. return unit === "albyhub.service" || unit === "nwc-wallets.service"; } function _nwcStateMessageHtml() { if (!_nwcModalState || !_nwcModalState.message) return ""; var msgClass = _nwcModalState.messageKind === "success" ? "success" : "error"; return '
' + escHtml(_nwcModalState.message) + '
'; } function _nwcSetMessage(kind, text) { if (!_nwcModalState) return; _nwcModalState.messageKind = kind || "error"; _nwcModalState.message = text || ""; } function _nwcClearMessage() { if (!_nwcModalState) return; _nwcModalState.messageKind = ""; _nwcModalState.message = ""; } function _nwcWalletSectionEl() { return document.getElementById("nwc-wallets-body"); } function _nwcRenderWalletState() { var host = _nwcWalletSectionEl(); if (!host || !_nwcModalState) return; var state = _nwcModalState; var html = ""; html += _nwcStateMessageHtml(); if (state.view === "create") { var selectedLimited = state.createForm.access_preset === "send_receive_limited"; html += '

Create a new isolated wallet connection for your app.

' + '
' + '
' + '
' + '' + '
Lowercase letters, numbers, "_" and "-" only.
' + '
' + '
' + '
' + '
" + '
' + '' + '' + '
'; host.innerHTML = html; var presetSel = document.getElementById("nwc-wallet-preset"); if (presetSel) { presetSel.addEventListener("change", function() { state.createForm.access_preset = presetSel.value; _nwcRenderWalletState(); }); } var cancelBtn = document.getElementById("nwc-create-cancel-btn"); if (cancelBtn) { cancelBtn.addEventListener("click", function() { if (state.busy) return; state.view = state.wallets.length > 0 ? "list" : "empty"; _nwcClearMessage(); _nwcRenderWalletState(); }); } var submitBtn = document.getElementById("nwc-create-submit-btn"); if (submitBtn) submitBtn.addEventListener("click", _nwcCreateWallet); return; } if (state.view === "created" && state.lastCreated) { var created = state.lastCreated; var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8); html += '
⚠ One-time pairing secret. Save it now — it will not be shown again.
'; if (created.pairing_qrcode) { html += '
QR code for Wallet Connections pairing secret
Scan now in Zeus or copy the URI below.
'; } html += '
Pairing URI
' + '
' + escHtml(created.pairing_uri || "Unavailable") + '
'; if (created.wallet && created.wallet.lightning_address) { html += '
Lightning Address
' + '
' + escHtml(created.wallet.lightning_address) + '
'; } html += '
' + '' + '' + '
'; host.innerHTML = html; _attachCopyHandlers(host); var continueBtn = document.getElementById("nwc-created-continue-btn"); if (continueBtn) { continueBtn.addEventListener("click", async function() { if (state.busy) return; state.lastCreated = null; state.view = "list"; await _nwcRefreshWallets(); }); } var anotherBtn = document.getElementById("nwc-created-another-btn"); if (anotherBtn) { anotherBtn.addEventListener("click", function() { if (state.busy) return; state.view = "create"; _nwcClearMessage(); _nwcRenderWalletState(); }); } return; } if (state.view === "share" && state.shareWallet) { var sw = state.shareWallet; var swId = encodeURIComponent(String(sw.id || sw.pubkey || "")); var swAddress = sw.lightning_address || ((sw.alias && state.domain) ? sw.alias + "@" + state.domain : ""); var pngUrl = "/api/nwc/wallets/" + swId + "/lnurl-qr.png"; var shareAddrId = "nwc-share-addr-" + Math.random().toString(36).substring(2, 8); var shareLnurlId = "nwc-share-lnurl-" + Math.random().toString(36).substring(2, 8); html += '

Share the Lightning Address for ' + escHtml(sw.name || "Wallet") + '. This QR never expires — anyone can scan it with any Lightning wallet to send sats, as many times as they like.

' + '
' + 'LNURL QR code for ' + escHtml(swAddress) + '' + '
Scan with any Lightning wallet to pay ' + escHtml(swAddress) + '
' + '
' + (swAddress ? '
Lightning Address
' + escHtml(swAddress) + '
' : "") + '
LNURL
Loading…
' + '
' + '' + '' + '' + '' + '
' + '
PNG is ideal for websites and social posts. SVG stays sharp at any print size.
'; host.innerHTML = html; _attachCopyHandlers(host); apiFetch("/api/nwc/wallets/" + swId + "/lnurl").then(function(payload) { var el = document.getElementById(shareLnurlId); if (el) el.textContent = payload.lnurl || "Unavailable"; }).catch(function() { var el = document.getElementById(shareLnurlId); if (el) el.textContent = "Unavailable"; }); function _nwcShareDownload(url) { var a = document.createElement("a"); a.href = url; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); } var dlPngBtn = document.getElementById("nwc-share-dl-png-btn"); if (dlPngBtn) dlPngBtn.addEventListener("click", function() { _nwcShareDownload(pngUrl + "?download=1"); }); var dlSvgBtn = document.getElementById("nwc-share-dl-svg-btn"); if (dlSvgBtn) dlSvgBtn.addEventListener("click", function() { _nwcShareDownload("/api/nwc/wallets/" + swId + "/lnurl-qr.svg?download=1"); }); var printBtn = document.getElementById("nwc-share-print-btn"); if (printBtn) printBtn.addEventListener("click", function() { window.open("/api/nwc/wallets/" + swId + "/lnurl-qr/print", "_blank", "noopener"); }); var shareBackBtn = document.getElementById("nwc-share-back-btn"); if (shareBackBtn) { shareBackBtn.addEventListener("click", function() { state.view = "list"; state.shareWallet = null; _nwcClearMessage(); _nwcRenderWalletState(); }); } return; } var canCreate = !!state.domain && !state.busy; html += '
' + '' + '' + '
'; if (state.domain) { html += '

Lightning Address domain: ' + escHtml(state.domain) + '

'; } else { html += '
Domain required
Configure the Lightning Address domain in the Domain Diagnostic Checklist above before creating Wallet Connections. Use a unique hostname such as lightning.yourdomain.com.
'; } if (!state.wallets || state.wallets.length === 0) { html += '

No wallet connections yet. Create your first wallet connection to generate a one-time pairing secret.

'; host.innerHTML = html; var openCreateBtn = document.getElementById("nwc-open-create-btn"); if (openCreateBtn) { openCreateBtn.addEventListener("click", function() { if (state.busy) return; _nwcClearMessage(); state.view = "create"; _nwcRenderWalletState(); }); } var refreshBtnEmpty = document.getElementById("nwc-refresh-btn"); if (refreshBtnEmpty) refreshBtnEmpty.addEventListener("click", _nwcRefreshWallets); return; } html += '
'; state.wallets.forEach(function(wallet) { var id = wallet.id || wallet.pubkey || ""; var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8); html += '
' + '
' + escHtml(wallet.name || "Wallet") + '
' + '
Alias
' + escHtml(wallet.alias || "") + '
' + (wallet.lightning_address ? '
Lightning Address
' + escHtml(wallet.lightning_address) + '
' : "") + '
Balance
' + escHtml(String(wallet.balance_sats || 0)) + ' sats
' + '
Pending TX
' + escHtml(String(wallet.pending_transactions || 0)) + '
' + '
' + '' + '' + '' + '' + '
' + '
'; }); html += "
"; host.innerHTML = html; _attachCopyHandlers(host); var createBtn = document.getElementById("nwc-open-create-btn"); if (createBtn) { createBtn.addEventListener("click", function() { if (state.busy) return; _nwcClearMessage(); state.view = "create"; _nwcRenderWalletState(); }); } var refreshBtn = document.getElementById("nwc-refresh-btn"); if (refreshBtn) refreshBtn.addEventListener("click", _nwcRefreshWallets); host.querySelectorAll(".nwc-wallet-action-btn").forEach(function(btn) { btn.addEventListener("click", function() { var action = btn.getAttribute("data-action"); if (action === "share") _nwcOpenShare(btn.getAttribute("data-wallet-id")); else if (action === "test") _nwcVerifyWallet(btn.getAttribute("data-wallet-alias")); else if (action === "drain") _nwcDrainWallet(btn.getAttribute("data-wallet-id")); else if (action === "delete") _nwcDeleteWallet(btn.getAttribute("data-wallet-id")); }); }); } function _nwcOpenShare(walletId) { if (!_nwcModalState || _nwcModalState.busy || !walletId) return; var wallet = null; for (var i = 0; i < _nwcModalState.wallets.length; i++) { var w = _nwcModalState.wallets[i]; if (String(w.id || w.pubkey || "") === String(walletId)) { wallet = w; break; } } if (!wallet) { _nwcSetMessage("error", "Wallet connection not found."); _nwcRenderWalletState(); return; } _nwcClearMessage(); _nwcModalState.shareWallet = wallet; _nwcModalState.view = "share"; _nwcRenderWalletState(); } async function _nwcRefreshWallets() { if (!_nwcModalState) return; var host = _nwcWalletSectionEl(); if (host) host.innerHTML = '

Loading wallet connections…

'; try { var payload = await apiFetch("/api/nwc/wallets"); _nwcModalState.wallets = Array.isArray(payload.wallets) ? payload.wallets : []; _nwcModalState.domain = payload.domain || null; if (_nwcModalState.view !== "create" && _nwcModalState.view !== "created" && _nwcModalState.view !== "share") { _nwcModalState.view = _nwcModalState.wallets.length > 0 ? "list" : "empty"; } _nwcRenderWalletState(); } catch (err) { _nwcSetMessage("error", (err && err.message) ? err.message : "Could not load wallet connections."); _nwcRenderWalletState(); } } function _nwcBusy(on) { if (!_nwcModalState) return; _nwcModalState.busy = !!on; } async function _nwcCreateWallet() { if (!_nwcModalState || _nwcModalState.busy) return; var nameEl = document.getElementById("nwc-wallet-name"); var aliasEl = document.getElementById("nwc-wallet-alias"); var presetEl = document.getElementById("nwc-wallet-preset"); var limitEl = document.getElementById("nwc-wallet-limit"); if (!nameEl || !aliasEl || !presetEl || !limitEl) return; var name = (nameEl.value || "").trim(); var alias = (aliasEl.value || "").trim().toLowerCase(); var preset = presetEl.value || "receive_only"; var limitRaw = (limitEl.value || "").trim(); var limit = null; _nwcModalState.createForm = { name: name, alias: alias, access_preset: preset, spending_limit_sats: limitRaw }; if (!name) { _nwcSetMessage("error", "Wallet Connection name is required."); _nwcRenderWalletState(); return; } if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(alias)) { _nwcSetMessage("error", 'Alias must start with a letter or number and use only lowercase letters, numbers, "_" or "-".'); _nwcRenderWalletState(); return; } if (preset === "send_receive_limited") { limit = parseInt(limitRaw, 10); if (!Number.isFinite(limit) || limit <= 0) { _nwcSetMessage("error", "A positive spending limit is required for limited send access."); _nwcRenderWalletState(); return; } } _nwcBusy(true); _nwcClearMessage(); _nwcRenderWalletState(); try { var payload = await apiFetch("/api/nwc/wallets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, alias: alias, access_preset: preset, spending_limit_sats: preset === "send_receive_limited" ? limit : null }) }); _nwcModalState.lastCreated = { wallet: payload.wallet || null, pairing_uri: payload.pairing_uri || "", pairing_qrcode: payload.pairing_qrcode || "", lightning_address: payload.lightning_address || null }; _nwcModalState.view = "created"; _nwcSetMessage("success", "Wallet created. Save the one-time secret before you continue."); _nwcBusy(false); _nwcRenderWalletState(); } catch (err) { _nwcBusy(false); _nwcSetMessage("error", (err && err.message) ? err.message : "Failed to create wallet connection."); _nwcRenderWalletState(); } } async function _nwcVerifyWallet(alias) { if (!_nwcModalState || _nwcModalState.busy || !alias) return; _nwcBusy(true); _nwcClearMessage(); _nwcRenderWalletState(); try { await apiFetch("/api/nwc/addresses/" + encodeURIComponent(alias) + "/test", { method: "POST" }); _nwcSetMessage("success", "Lightning Address verification succeeded for " + alias + "."); } catch (err) { _nwcSetMessage("error", (err && err.message) ? err.message : "Lightning Address verification failed."); } _nwcBusy(false); _nwcRenderWalletState(); } async function _nwcDrainWallet(walletId) { if (!_nwcModalState || _nwcModalState.busy || !walletId) return; if (!window.confirm("Drain this wallet connection now? This cannot be undone.")) return; _nwcBusy(true); _nwcClearMessage(); _nwcRenderWalletState(); try { var resp = await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId) + "/drain", { method: "POST" }); _nwcSetMessage("success", "Wallet drained (" + String(resp.drained_sats || 0) + " sats)."); _nwcBusy(false); await _nwcRefreshWallets(); } catch (err) { _nwcBusy(false); _nwcSetMessage("error", (err && err.message) ? err.message : "Failed to drain wallet."); _nwcRenderWalletState(); } } async function _nwcDeleteWallet(walletId) { if (!_nwcModalState || _nwcModalState.busy || !walletId) return; if (!window.confirm("Delete this wallet connection? This removes the wallet alias from Wallet Connections.")) return; _nwcBusy(true); _nwcClearMessage(); _nwcRenderWalletState(); try { await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId), { method: "DELETE" }); _nwcSetMessage("success", "Wallet connection deleted."); _nwcBusy(false); await _nwcRefreshWallets(); } catch (err) { _nwcBusy(false); _nwcSetMessage("error", (err && err.message) ? err.message : "Failed to delete wallet connection."); _nwcRenderWalletState(); } } async function _nwcInitWalletFlow(unit, name, icon) { _nwcModalState = { unit: unit, name: name, icon: icon, view: "empty", wallets: [], domain: null, busy: false, message: "", messageKind: "", lastCreated: null, shareWallet: null, createForm: { name: "", alias: "", access_preset: "receive_only", spending_limit_sats: "" } }; await _nwcRefreshWallets(); } async function openServiceDetailModal(unit, name, icon) { if (!$credsModal) return; if ($credsTitle) { $credsTitle.innerHTML = ''; if (icon) { var iconImg = document.createElement("img"); iconImg.className = "creds-title-icon"; iconImg.src = "/static/icons/" + escHtml(icon) + ".svg"; iconImg.alt = name; iconImg.onerror = function() { this.style.display = "none"; }; $credsTitle.appendChild(iconImg); } var nameSpan = document.createElement("span"); nameSpan.textContent = name; $credsTitle.appendChild(nameSpan); } if ($credsBody) $credsBody.innerHTML = '

Loading…

'; $credsModal.classList.add("open"); try { var url = "/api/service-detail/" + encodeURIComponent(unit); if (icon) url += "?icon=" + encodeURIComponent(icon); var data = await apiFetch(url); var html = ""; // Section A: Description if (data.description) { html += '
' + '

' + escHtml(data.description) + '

' + '
'; } // Section B: Status // When a feature override is present, use the feature's enabled state so the // modal matches what the dashboard tile shows (feature toggle is authoritative). var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled; var effectiveHealth = data.feature && !data.feature.enabled ? "disabled" : (data.health || data.status); var sc = statusClass(effectiveHealth); var st = statusText(effectiveHealth, effectiveEnabled); html += '
' + '
Status
' + '
' + '' + '' + escHtml(st) + '' + '
' + '
'; // Section B2: BIP-110 live status (bip110 tile only) if (icon === 'bip110' && data.bip110) { var bip110 = data.bip110; var bip110State = bip110.state || 'unknown'; var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown; var bip110Source = bip110.source ? ' (source: ' + escHtml(bip110.source) + ')' : ''; html += '
' + '
BIP-110 Deployment Status
' + '
' + '' + escHtml(bip110Cfg.label) + '' + bip110Source + '
' + '
'; } // Section C: Domain diagnostics (domain services) if (data.needs_domain) { var steps = data.domain_check_steps || []; var stepsHtml = ""; steps.forEach(function(step) { var iconLabel = "—"; if (step.status === "ok") iconLabel = "✅"; else if (step.status === "error") iconLabel = "❌"; else if (step.status === "warning") iconLabel = "⚠️"; else if (step.status === "skipped") iconLabel = "⏭️"; var detail = escHtml(step.detail || "").replace(/\n/g, "
"); stepsHtml += '
' + '' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '' + (detail ? '
' + detail + '
' : '') + '
'; }); var domainActionHtml = ""; var ds = data.domain_status || {}; if (!data.domain && data.domain_name) { domainActionHtml = ''; } else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) { domainActionHtml = ''; } html += '
' + '
Domain Diagnostic Checklist
' + stepsHtml + domainActionHtml + '
'; if (data.router_ports && data.router_ports.length > 0) { var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; html += '
' + '
Ports to Forward in Your Router
' + renderPortForwardGuideHtml(data.router_ports, { internalIp: trimmedInternalIp || null, tableClass: "svc-detail-port-table", introClass: "svc-detail-port-note", noteClass: "svc-detail-port-note", }) + '
'; } } else if (data.port_statuses && data.port_statuses.length > 0) { // Non-domain services (e.g. SSH): show what to forward, plus a short // note if the service isn't actually listening on this computer yet. var localInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; var notListening = data.port_statuses.filter(function(p) { return p.status === "closed"; }); var localNote = notListening.length ? '
' + '⚠ Port ' + escHtml(notListening.map(function(p) { return p.port; }).join(", ")) + ' is not open on this computer yet — enable the service below first, then forward it in your router.' + '
' : ""; html += '
' + '
Ports to Forward in Your Router
' + '
Only needed if you want to reach this service from outside your home network. On your local network it already works without any router changes.
' + renderPortForwardGuideHtml(data.port_statuses, { internalIp: localInternalIp || null, tableClass: "svc-detail-port-table", introClass: "svc-detail-port-note", noteClass: "svc-detail-port-note", }) + localNote + '
'; } // Section E: Credentials & Links if (_isNwcServiceUnit(unit)) { html += '
' + '
Wallet Connections & Node Liquidity
'; if (effectiveEnabled || data.enabled) { html += '
' + '
' + '' + '
' + '

Lightning Channel & Liquidity Guide

' + '

How NWC & Lightning Addresses work on new nodes

' + '
' + '
' + '

' + 'Your Alby Hub, NWC pairing keys, and Lightning Address endpoints (like name@yourdomain.com) are set up and working instantly. However, sending and receiving actual Bitcoin payments requires an operational Lightning node with active channels.' + '

' + '
' + '
' + '
1
' + '
' + 'Outbound Liquidity (Sending)' + '

Required so apps connected via NWC can pay invoices and send funds.

' + '
' + '
' + '
' + '
2
' + '
' + 'Inbound Liquidity (Receiving)' + '

Required so your Lightning Address can receive incoming payments from anyone.

' + '
' + '
' + '
' + '
' + '

💧 The Plumbing Analogy: Think of channels like two-way water pipes. To pour water in (receive) or pump water out (send), you need open channels with liquidity on both sides.

' + '' + '
' + '
' + '

Create, verify, drain, and delete isolated NWC wallet connections without leaving the Hub.

' + '

Loading wallet connections…

'; } else { html += '

Wallet Connections is disabled. Enable this feature below, rebuild, then return here to create and manage NWC wallet connections.

'; } html += '
'; } else if (data.has_credentials && data.credentials && data.credentials.length > 0) { html += '
' + '
Credentials & Access
' + _renderCredsHtml(data.credentials, unit) + (unit === "matrix-synapse.service" ? '
' + '' + '' + '
' : "") + (unit === "root-password-setup.service" ? '
' + '' + '
' : "") + '
'; } else if (!data.enabled && !data.feature) { html += '
' + '

This service is not enabled in your configuration.

' + '
'; } // Section F: Addon Feature toggle if (data.feature) { var feat = data.feature; // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state if (!_featuresData) { _featuresData = { features: [feat], ssl_email_configured: false }; } else { var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); if (fidx >= 0) { _featuresData.features[fidx] = feat; } else { _featuresData.features.push(feat); } } var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; // Section title: use a more specific label for mutually-exclusive Bitcoin node features var addonSectionTitle = (feat.id === "bitcoin-core") ? "\u20BF Bitcoin Node Selection" : "\uD83D\uDD27 Addon Feature"; // Description: prefer the feature's own description over a generic fallback var addonDesc = feat.description ? feat.description : "This is an optional addon feature. You can enable or disable it at any time."; // Conflicts warning: list mutually-exclusive feature names when present var conflictsHtml = ""; if (feat.conflicts_with && feat.conflicts_with.length > 0) { var conflictNames = feat.conflicts_with.map(function(cid) { if (_featuresData && Array.isArray(_featuresData.features)) { var cf = _featuresData.features.find(function(f) { return f.id === cid; }); if (cf) return cf.name; } return cid; }); conflictsHtml = '
\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
'; } html += '
' + '
' + addonSectionTitle + '
' + '

' + escHtml(addonDesc) + '

' + conflictsHtml + '
' + '' + addonStatusLabel + '' + '' + '
' + '
'; } if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") { html += '
' + '
Troubleshooting
' + '

If you\'re experiencing issues with this service, try restarting it.

' + '' + '
' + '
'; } $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) { await _nwcInitWalletFlow(unit, name, icon); var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn"); if (nwcRtlBtn) { nwcRtlBtn.addEventListener("click", function() { openServiceDetailModal("rtl.service", "Ride The Lightning", "rtl"); }); } } if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn"); if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); }); if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); } if (unit === "root-password-setup.service") { var sysPwBtn = document.getElementById("sys-change-pw-btn"); if (sysPwBtn) sysPwBtn.addEventListener("click", function() { openSystemChangePasswordModal(unit, name, icon); }); } if (data.feature) { var addonBtn = document.getElementById("svc-detail-addon-btn"); if (addonBtn) { var addonFeat = data.feature; addonBtn.addEventListener("click", function() { closeCredsModal(); handleFeatureToggle(addonFeat, !addonFeat.enabled); }); } } var restartBtn = document.getElementById("svc-detail-restart-btn"); var restartResult = document.getElementById("svc-detail-restart-result"); if (restartBtn && restartResult) { var RESTART_REFRESH_DELAY_MS = 3000; restartBtn.addEventListener("click", async function() { restartBtn.disabled = true; restartBtn.textContent = "Restarting…"; restartResult.className = "svc-detail-restart-result"; restartResult.textContent = ""; try { await apiFetch("/api/service/" + encodeURIComponent(unit) + "/restart", { method: "POST" }); restartResult.classList.add("success"); restartResult.textContent = "✅ Service restarted successfully."; restartBtn.disabled = false; restartBtn.textContent = "🔄 Restart Service"; setTimeout(function() { openServiceDetailModal(unit, name, icon); }, RESTART_REFRESH_DELAY_MS); } catch (e) { restartResult.classList.add("error"); restartResult.textContent = e && e.message ? e.message : "Failed to restart service. Please check service logs and try again."; restartBtn.disabled = false; restartBtn.textContent = "🔄 Restart Service"; } }); } // Configure / Reconfigure Domain buttons (for non-feature services that need a domain) var configDomainBtn = document.getElementById("svc-detail-config-domain-btn"); var reconfigDomainBtn = document.getElementById("svc-detail-reconfig-domain-btn"); if ((configDomainBtn || reconfigDomainBtn) && data.needs_domain && data.domain_name) { var pseudoFeat = { id: data.domain_name, name: name, domain_name: data.domain_name, needs_ddns: true, extra_fields: [] }; if (configDomainBtn) configDomainBtn.addEventListener("click", function() { closeCredsModal(); openDomainSetupModal(pseudoFeat, function() { openServiceDetailModal(unit, name, icon); }); }); if (reconfigDomainBtn) reconfigDomainBtn.addEventListener("click", function() { closeCredsModal(); openDomainReconfigureModal(pseudoFeat, data.domain || "", function() { openServiceDetailModal(unit, name, icon); }); }); } } catch (err) { if ($credsBody) $credsBody.innerHTML = '

Could not load service details.

'; } } // ── Credentials info modal ──────────────────────────────────────── async function openCredsModal(unit, name, icon) { if (!$credsModal) return; if ($credsTitle) { $credsTitle.innerHTML = ''; if (icon) { var iconImg = document.createElement("img"); iconImg.className = "creds-title-icon"; iconImg.src = "/static/icons/" + escHtml(icon) + ".svg"; iconImg.alt = name; iconImg.onerror = function() { this.style.display = "none"; }; $credsTitle.appendChild(iconImg); } var nameSpan = document.createElement("span"); nameSpan.textContent = name + " — Connection Info"; $credsTitle.appendChild(nameSpan); } if ($credsBody) $credsBody.innerHTML = '

Loading…

'; $credsModal.classList.add("open"); try { var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit)); if (!data.credentials || data.credentials.length === 0) { $credsBody.innerHTML = '

No connection info available yet.

'; return; } var html = _renderCredsHtml(data.credentials, unit); if (unit === "matrix-synapse.service") { html += '
' + '' + '' + '
'; } $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn"); if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); } } catch (err) { $credsBody.innerHTML = '

Could not load credentials.

'; } } function openMatrixCreateUserModal(unit, name, icon) { if (!$credsBody) return; $credsBody.innerHTML = '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; document.getElementById("matrix-create-back-btn").addEventListener("click", function() { openServiceDetailModal(unit, name, icon); }); document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("matrix-create-submit-btn"); var resultEl = document.getElementById("matrix-create-result"); var username = (document.getElementById("matrix-new-username").value || "").trim(); var password = document.getElementById("matrix-new-password").value || ""; var isAdmin = document.getElementById("matrix-new-admin").checked; if (!username || !password) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Username and password are required."; return; } submitBtn.disabled = true; submitBtn.textContent = "Creating…"; resultEl.className = "matrix-form-result"; resultEl.textContent = ""; try { var resp = await apiFetch("/api/matrix/create-user", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, password: password, admin: isAdmin }) }); resultEl.className = "matrix-form-result success"; resultEl.textContent = "✅ User @" + escHtml(resp.username) + " created successfully."; submitBtn.textContent = "Create User"; submitBtn.disabled = false; } catch (err) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "❌ " + (err.message || "Failed to create user."); submitBtn.textContent = "Create User"; submitBtn.disabled = false; } }); } function openMatrixChangePasswordModal(unit, name, icon) { if (!$credsBody) return; $credsBody.innerHTML = '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { openServiceDetailModal(unit, name, icon); }); document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("matrix-chpw-submit-btn"); var resultEl = document.getElementById("matrix-chpw-result"); var username = (document.getElementById("matrix-chpw-username").value || "").trim(); var newPassword = document.getElementById("matrix-chpw-password").value || ""; if (!username || !newPassword) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Username and new password are required."; return; } submitBtn.disabled = true; submitBtn.textContent = "Changing…"; resultEl.className = "matrix-form-result"; resultEl.textContent = ""; try { var resp = await apiFetch("/api/matrix/change-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, new_password: newPassword }) }); resultEl.className = "matrix-form-result success"; resultEl.textContent = "✅ Password for @" + escHtml(resp.username) + " changed successfully."; submitBtn.textContent = "Change Password"; submitBtn.disabled = false; } catch (err) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "❌ " + (err.message || "Failed to change password."); submitBtn.textContent = "Change Password"; submitBtn.disabled = false; } }); } function openSystemChangePasswordModal(unit, name, icon) { if (!$credsBody) return; $credsBody.innerHTML = '
' + '
🔑 Change Free Account & Hub Login Password
' + '
This updates the password for the free user account. This is also your Sovran Hub login password — both will change.
' + '
' + '
' + '
' + '' + '' + '
' + '
Password must be at least 8 characters.
' + '
' + '
' + '' + '' + '
' + '
⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile.
' + '
' + '' + '' + '
' + '
'; document.getElementById("sys-chpw-back-btn").addEventListener("click", function() { openServiceDetailModal(unit, name, icon); }); document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() { var inp = document.getElementById("sys-chpw-new"); var isHidden = inp.type === "password"; inp.type = isHidden ? "text" : "password"; this.textContent = isHidden ? "👁‍🗨" : "👁"; }); document.getElementById("sys-chpw-confirm-toggle").addEventListener("click", function() { var inp = document.getElementById("sys-chpw-confirm"); var isHidden = inp.type === "password"; inp.type = isHidden ? "text" : "password"; this.textContent = isHidden ? "👁‍🗨" : "👁"; }); document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("sys-chpw-submit-btn"); var resultEl = document.getElementById("sys-chpw-result"); var newPassword = document.getElementById("sys-chpw-new").value || ""; var confirmPassword = document.getElementById("sys-chpw-confirm").value || ""; if (!newPassword || !confirmPassword) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Both password fields are required."; return; } if (newPassword.length < 8) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Password must be at least 8 characters."; return; } if (newPassword !== confirmPassword) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "Passwords do not match."; return; } submitBtn.disabled = true; submitBtn.textContent = "Changing…"; resultEl.className = "matrix-form-result"; resultEl.textContent = ""; try { await apiFetch("/api/change-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword }) }); resultEl.className = "matrix-form-result success"; resultEl.textContent = "✅ Free account & Hub login password changed successfully."; submitBtn.textContent = "Change Password"; submitBtn.disabled = false; } catch (err) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "❌ " + (err.message || "Failed to change password."); submitBtn.textContent = "Change Password"; submitBtn.disabled = false; } }); } function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }