"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 displayValue = linkify(cred.value); var qrBlock = ""; if (cred.qrcode) { qrBlock = '
QR Code for ' + escHtml(cred.label) + '
Scan with Zeus app on your phone
'; } 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(); } }); }); } async function openServiceDetailModal(unit, name, icon) { if (!$credsModal) return; if ($credsTitle) $credsTitle.textContent = name; 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 C: Ports (only if service has port_requirements) if (data.port_statuses && data.port_statuses.length > 0) { var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; }); var portTableRows = ""; data.port_statuses.forEach(function(p) { var statusIcon, statusClass2; if (p.status === "listening") { statusIcon = "✅ Open"; statusClass2 = "port-status-listening"; } else if (p.status === "firewall_open") { statusIcon = "🟡 Firewall open"; statusClass2 = "port-status-open"; } else if (p.status === "closed") { statusIcon = "🔴 Closed"; statusClass2 = "port-status-closed"; } else { statusIcon = "— Unknown"; statusClass2 = "port-status-unknown"; } var desc = p.description; var portNum = parseInt(p.port, 10); if (portNum === 80 || portNum === 443) { desc += " (shared — all services)"; } portTableRows += '' + '' + escHtml(p.port) + '' + '' + escHtml(p.protocol) + '' + '' + escHtml(desc) + '' + '' + statusIcon + '' + ''; }); var troubleshootHtml = ""; if (anyPortClosed) { var sharedPorts = []; var specificPorts = []; data.port_statuses.forEach(function(p) { if (p.status === "closed") { var portNum = parseInt(p.port, 10); if (portNum === 80 || portNum === 443) { sharedPorts.push(p); } else { specificPorts.push(p); } } }); var troubleParts = []; if (sharedPorts.length > 0) { troubleParts.push( '⚠️ Ports 80 and 443 need to be forwarded on your router.' + '

These are shared system ports — you only need to set them up once and they cover all your domain-based services ' + '(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).

' + '

If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:

' + '
    ' + '
  1. Log into your router\'s admin panel (usually http://192.168.1.1)
  2. ' + '
  3. Find the Port Forwarding section
  4. ' + '
  5. Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
  6. ' + '
  7. Save your router settings
  8. ' + '
' + '

💡 Once these two ports are forwarded, you won\'t see this warning on any service again.

' ); } if (specificPorts.length > 0) { var portList = specificPorts.map(function(p) { return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') — ' + escHtml(p.description); }).join('
'); troubleParts.push( '⚠️ This service requires additional ports to be forwarded:' + '

' + portList + '

' + '
    ' + '
  1. Log into your router\'s admin panel
  2. ' + '
  3. Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
  4. ' + '
  5. Save your router settings
  6. ' + '
' ); } troubleshootHtml = '
' + troubleParts.join('
') + '
'; } html += '
' + '
Port Status
' + '' + '' + '' + '' + '' + portTableRows + '' + '
PortProtocolDescriptionStatus
' + troubleshootHtml + '
'; } // Section D: Domain (only if service needs_domain) if (data.needs_domain) { var domainStatusHtml = ""; var ds = data.domain_status || {}; var domainBadge = ""; if (data.domain) { if (ds.status === "connected") { domainBadge = '✓ ' + escHtml(data.domain) + ''; } else if (ds.status === "dns_mismatch") { domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch)'; domainStatusHtml = '
' + '⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.' + '

This usually means the DNS record needs to be updated:

' + '
    ' + '
  1. Go to njal.la and log into your account
  2. ' + '
  3. Find your domain and check the Dynamic DNS record
  4. ' + '
  5. Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + '
  6. ' + '
  7. If you set up a DDNS curl command during onboarding, verify it\'s running correctly
  8. ' + '
' + '
'; } else if (ds.status === "unresolvable") { domainBadge = '✗ ' + escHtml(data.domain) + ' (DNS error)'; domainStatusHtml = '
' + '⚠️ This domain cannot be resolved. DNS is not configured yet.' + '

Let\'s get it set up:

' + '
    ' + '
  1. Go to njal.la and log into your account
  2. ' + '
  3. Find the domain you purchased for this service
  4. ' + '
  5. Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + '
  6. ' + '
  7. Copy the DDNS curl command from Njal.la\'s dashboard
  8. ' + '
  9. You can re-enter it in the Feature Manager to update your configuration
  10. ' + '
' + '
'; } else { domainBadge = '' + escHtml(data.domain) + ''; } } else { domainBadge = 'Not configured'; domainStatusHtml = '
' + '⚠️ No domain has been configured for this service yet.' + '

To get this service working:

' + '
    ' + '
  1. Purchase a subdomain at njal.la (if you haven\'t already)
  2. ' + '
  3. Go to the Feature Manager in the sidebar
  4. ' + '
  5. Find this service and configure your domain through the setup wizard
  6. ' + '
' + '
'; } html += '
' + '
Domain
' + domainBadge + domainStatusHtml + '
'; } // Section E: Credentials & Links if (data.has_credentials && data.credentials && data.credentials.length > 0) { html += '
' + '
Credentials & Access
' + _renderCredsHtml(data.credentials, unit) + (unit === "matrix-synapse.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 === "bip110" || 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 + '' + '' + '
' + '
'; } $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, icon); }); if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(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); }); } } } catch (err) { if ($credsBody) $credsBody.innerHTML = '

Could not load service details.

'; } } // ── Credentials info modal ──────────────────────────────────────── async function openCredsModal(unit, name) { if (!$credsModal) return; if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; 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 closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }