Each NWC wallet connection gets its own shareable LNURL QR so the owner
can receive payments from anyone without creating invoices (the core
LNURL value proposition: a static QR that never expires).
Hub UI (service detail modal):
- New 'Share QR' button on every wallet connection card
- Dedicated share view: large QR, Lightning Address + copy, raw bech32
LNURL + copy
- Download PNG (websites/social), download SVG (vector, print-sharp at
any size), and a Print button that opens a print-ready payment card
which auto-invokes the browser print dialog
Backend (FastAPI, session-authenticated):
- GET /api/nwc/wallets/{id}/lnurl -> alias, lightning_address, LNURL
- GET /api/nwc/wallets/{id}/lnurl-qr.png (?download=1, ?scale=)
- GET /api/nwc/wallets/{id}/lnurl-qr.svg (?download=1)
- GET /api/nwc/wallets/{id}/lnurl-qr/print (print-ready HTML card)
- Stdlib bech32 encoder (BIP-173, verified against the official LUD-01
golden vector); LNURL is uppercased for QR alphanumeric-mode density
per LUD-01 guidance; QR rendered with the existing qrencode package
- Resolves wallets by app id or pubkey; 404 unknown wallet, 503 when no
Lightning domain is configured; all routes return 401 unauthenticated
No new system dependencies.
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
1056 lines
49 KiB
JavaScript
1056 lines
49 KiB
JavaScript
"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 = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
||
}
|
||
// If qronly, render the label + QR block only — skip value and copy button
|
||
if (cred.qronly) {
|
||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
|
||
continue;
|
||
}
|
||
var displayValue = linkify(cred.value);
|
||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
|
||
}
|
||
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 '<div class="matrix-form-result ' + msgClass + '">' + escHtml(_nwcModalState.message) + '</div>';
|
||
}
|
||
|
||
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 +=
|
||
'<p class="svc-detail-desc">Create a new isolated wallet connection for your app.</p>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Connection Name</label>' +
|
||
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
||
'<input class="matrix-form-input" id="nwc-wallet-alias" type="text" placeholder="my-wallet" value="' + escHtml(state.createForm.alias || "") + '" autocomplete="off">' +
|
||
'<div class="creds-qr-hint">Lowercase letters, numbers, "_" and "-" only.</div></div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-preset">Access Preset</label>' +
|
||
'<select class="matrix-form-input" id="nwc-wallet-preset">' +
|
||
'<option value="receive_only"' + (state.createForm.access_preset === "receive_only" ? " selected" : "") + '>Receive only</option>' +
|
||
'<option value="send_receive_limited"' + (selectedLimited ? " selected" : "") + '>Send + receive (limited)</option>' +
|
||
'</select></div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-limit">Spending Limit (sats)</label>' +
|
||
'<input class="matrix-form-input" id="nwc-wallet-limit" type="number" min="1" step="1" placeholder="50000" value="' + escHtml(state.createForm.spending_limit_sats || "") + '"' + (selectedLimited ? "" : " disabled") + "></div>" +
|
||
'<div class="matrix-form-actions">' +
|
||
'<button class="matrix-form-back" id="nwc-create-cancel-btn"' + (state.busy ? " disabled" : "") + '>← Back</button>' +
|
||
'<button class="matrix-form-submit" id="nwc-create-submit-btn"' + (state.busy ? " disabled" : "") + '>' + (state.busy ? "Creating…" : "Create Wallet") + '</button>' +
|
||
'</div>';
|
||
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 += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
||
if (created.pairing_qrcode) {
|
||
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>';
|
||
}
|
||
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
||
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
||
if (created.wallet && created.wallet.lightning_address) {
|
||
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
||
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
||
}
|
||
html += '<div class="matrix-form-actions">' +
|
||
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
||
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
||
'</div>';
|
||
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 += '<p class="svc-detail-desc">Share the Lightning Address for <strong>' + escHtml(sw.name || "Wallet") + '</strong>. This QR never expires — anyone can scan it with any Lightning wallet to send sats, as many times as they like.</p>' +
|
||
'<div class="creds-qr-wrap">' +
|
||
'<img class="creds-qr-img" src="' + pngUrl + '" alt="LNURL QR code for ' + escHtml(swAddress) + '">' +
|
||
'<div class="creds-qr-hint">Scan with any Lightning wallet to pay ' + escHtml(swAddress) + '</div>' +
|
||
'</div>' +
|
||
(swAddress
|
||
? '<div class="creds-row"><div class="creds-label">Lightning Address</div><div class="creds-value-wrap"><div class="creds-value" id="' + shareAddrId + '">' + escHtml(swAddress) + '</div><button class="creds-copy-btn" data-target="' + shareAddrId + '">Copy</button></div></div>'
|
||
: "") +
|
||
'<div class="creds-row"><div class="creds-label">LNURL</div><div class="creds-value-wrap"><div class="creds-value nwc-lnurl-value" id="' + shareLnurlId + '">Loading…</div><button class="creds-copy-btn" data-target="' + shareLnurlId + '">Copy</button></div></div>' +
|
||
'<div class="nwc-share-actions">' +
|
||
'<button class="matrix-form-submit" id="nwc-share-dl-png-btn">⬇ Download PNG</button>' +
|
||
'<button class="matrix-form-back" id="nwc-share-dl-svg-btn">⬇ Download SVG</button>' +
|
||
'<button class="matrix-form-back" id="nwc-share-print-btn">🖨 Print…</button>' +
|
||
'<button class="matrix-form-back" id="nwc-share-back-btn">← Back</button>' +
|
||
'</div>' +
|
||
'<div class="creds-qr-hint nwc-share-formats-hint">PNG is ideal for websites and social posts. SVG stays sharp at any print size.</div>';
|
||
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 += '<div class="matrix-actions-row">' +
|
||
'<button class="matrix-action-btn" id="nwc-open-create-btn"' + (canCreate ? "" : " disabled") + '>' + (state.domain ? '➕ Create Wallet Connection' : 'Configure Domain First') + '</button>' +
|
||
'<button class="matrix-form-back" id="nwc-refresh-btn"' + (state.busy ? " disabled" : "") + '>Refresh</button>' +
|
||
'</div>';
|
||
if (state.domain) {
|
||
html += '<p class="svc-detail-desc">Lightning Address domain: <strong>' + escHtml(state.domain) + '</strong></p>';
|
||
} else {
|
||
html += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px"><strong>Domain required</strong><div style="margin-top:6px">Configure the Lightning Address domain in the Domain Diagnostic Checklist above before creating Wallet Connections. Use a unique hostname such as <strong>lightning.yourdomain.com</strong>.</div></div>';
|
||
}
|
||
|
||
if (!state.wallets || state.wallets.length === 0) {
|
||
html += '<p class="creds-empty">No wallet connections yet. Create your first wallet connection to generate a one-time pairing secret.</p>';
|
||
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 += '<div class="nwc-wallet-list">';
|
||
state.wallets.forEach(function(wallet) {
|
||
var id = wallet.id || wallet.pubkey || "";
|
||
var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8);
|
||
html += '<div class="nwc-wallet-card">' +
|
||
'<div class="nwc-wallet-card-title">' + escHtml(wallet.name || "Wallet") + '</div>' +
|
||
'<div class="creds-row"><div class="creds-label">Alias</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(wallet.alias || "") + '</div></div></div>' +
|
||
(wallet.lightning_address
|
||
? '<div class="creds-row"><div class="creds-label">Lightning Address</div><div class="creds-value-wrap"><div class="creds-value" id="' + addressId + '">' + escHtml(wallet.lightning_address) + '</div><button class="creds-copy-btn" data-target="' + addressId + '">Copy</button></div></div>'
|
||
: "") +
|
||
'<div class="creds-row"><div class="creds-label">Balance</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.balance_sats || 0)) + ' sats</div></div></div>' +
|
||
'<div class="creds-row"><div class="creds-label">Pending TX</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.pending_transactions || 0)) + '</div></div></div>' +
|
||
'<div class="nwc-wallet-actions">' +
|
||
'<button class="matrix-action-btn nwc-wallet-action-btn" data-action="share" data-wallet-id="' + escHtml(id) + '"' + ((state.busy || !wallet.lightning_address) ? " disabled" : "") + '>⚡ Share QR</button>' +
|
||
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="test" data-wallet-alias="' + escHtml(wallet.alias || "") + '"' + (state.busy ? " disabled" : "") + '>Verify Address</button>' +
|
||
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="drain" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Drain</button>' +
|
||
'<button class="btn btn-close-modal nwc-wallet-action-btn" data-action="delete" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Delete</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
});
|
||
html += "</div>";
|
||
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 = '<p class="creds-loading">Loading wallet connections…</p>';
|
||
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 = '<p class="creds-loading">Loading…</p>';
|
||
$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 += '<div class="svc-detail-section">' +
|
||
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
|
||
'</div>';
|
||
}
|
||
|
||
// 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 += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">Status</div>' +
|
||
'<div class="svc-detail-status">' +
|
||
'<span class="status-dot ' + sc + '"></span>' +
|
||
'<span>' + escHtml(st) + '</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
// 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 ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
||
'<div class="bip110-status-row">' +
|
||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
||
bip110Source +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// 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, "<br>");
|
||
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
|
||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||
'</div>';
|
||
});
|
||
|
||
var domainActionHtml = "";
|
||
var ds = data.domain_status || {};
|
||
if (!data.domain && data.domain_name) {
|
||
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>';
|
||
} else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) {
|
||
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
|
||
}
|
||
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
||
stepsHtml +
|
||
domainActionHtml +
|
||
'</div>';
|
||
|
||
if (data.router_ports && data.router_ports.length > 0) {
|
||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||
renderPortForwardGuideHtml(data.router_ports, {
|
||
internalIp: trimmedInternalIp || null,
|
||
tableClass: "svc-detail-port-table",
|
||
introClass: "svc-detail-port-note",
|
||
noteClass: "svc-detail-port-note",
|
||
}) +
|
||
'</div>';
|
||
}
|
||
} 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
|
||
? '<div class="svc-detail-port-note port-status-closed">' +
|
||
'⚠ 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.' +
|
||
'</div>'
|
||
: "";
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||
'<div class="svc-detail-port-note">Only needed if you want to reach this service from <strong>outside</strong> your home network. On your local network it already works without any router changes.</div>' +
|
||
renderPortForwardGuideHtml(data.port_statuses, {
|
||
internalIp: localInternalIp || null,
|
||
tableClass: "svc-detail-port-table",
|
||
introClass: "svc-detail-port-note",
|
||
noteClass: "svc-detail-port-note",
|
||
}) +
|
||
localNote +
|
||
'</div>';
|
||
}
|
||
|
||
// Section E: Credentials & Links
|
||
if (_isNwcServiceUnit(unit)) {
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">Wallet Connections</div>';
|
||
if (effectiveEnabled || data.enabled) {
|
||
html += '<p class="svc-detail-desc">Create, verify, drain, and delete isolated NWC wallet connections without leaving the Hub.</p>' +
|
||
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallet connections…</p></div>';
|
||
} else {
|
||
html += '<p class="creds-empty">Wallet Connections is disabled. Enable this feature below, rebuild, then return here to create and manage NWC wallet connections.</p>';
|
||
}
|
||
html += '</div>';
|
||
} else if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||
_renderCredsHtml(data.credentials, unit) +
|
||
(unit === "matrix-synapse.service" ?
|
||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||
'</div>' : "") +
|
||
(unit === "root-password-setup.service" ?
|
||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||
'<button class="matrix-action-btn" id="sys-change-pw-btn">🔑 Change Free Account Password</button>' +
|
||
'</div>' : "") +
|
||
'</div>';
|
||
} else if (!data.enabled && !data.feature) {
|
||
html += '<div class="svc-detail-section">' +
|
||
'<p class="creds-empty">This service is not enabled in your configuration.</p>' +
|
||
'</div>';
|
||
}
|
||
|
||
// 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 = '<div class="feature-conflict-warning">\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||
}
|
||
|
||
html += '<div class="svc-detail-section">' +
|
||
'<div class="svc-detail-section-title">' + addonSectionTitle + '</div>' +
|
||
'<p class="svc-detail-desc">' + escHtml(addonDesc) + '</p>' +
|
||
conflictsHtml +
|
||
'<div class="svc-detail-addon-row">' +
|
||
'<span class="svc-detail-addon-status ' + addonStatusCls + '">' + addonStatusLabel + '</span>' +
|
||
'<button class="' + addonBtnCls + '" id="svc-detail-addon-btn">' + escHtml(addonBtnLabel) + '</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||
html += '<div class="svc-detail-section svc-detail-restart-section">' +
|
||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
|
||
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
|
||
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
$credsBody.innerHTML = html;
|
||
_attachCopyHandlers($credsBody);
|
||
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
||
await _nwcInitWalletFlow(unit, name, icon);
|
||
}
|
||
|
||
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 = '<p class="creds-empty">Could not load service details.</p>';
|
||
}
|
||
}
|
||
|
||
// ── 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 = '<p class="creds-loading">Loading…</p>';
|
||
$credsModal.classList.add("open");
|
||
try {
|
||
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
|
||
if (!data.credentials || data.credentials.length === 0) {
|
||
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
|
||
return;
|
||
}
|
||
var html = _renderCredsHtml(data.credentials, unit);
|
||
if (unit === "matrix-synapse.service") {
|
||
html += '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||
'</div>';
|
||
}
|
||
$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 = '<p class="creds-empty">Could not load credentials.</p>';
|
||
}
|
||
}
|
||
|
||
function openMatrixCreateUserModal(unit, name, icon) {
|
||
if (!$credsBody) return;
|
||
$credsBody.innerHTML =
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-username">Username</label>' +
|
||
'<input class="matrix-form-input" type="text" id="matrix-new-username" placeholder="alice" autocomplete="off"></div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-password">Password</label>' +
|
||
'<input class="matrix-form-input" type="password" id="matrix-new-password" placeholder="Strong password" autocomplete="new-password"></div>' +
|
||
'<div class="matrix-form-checkbox-row"><input type="checkbox" id="matrix-new-admin"><label class="matrix-form-label" for="matrix-new-admin" style="margin:0">Make admin</label></div>' +
|
||
'<div class="matrix-form-actions">' +
|
||
'<button class="matrix-form-back" id="matrix-create-back-btn">← Back</button>' +
|
||
'<button class="matrix-form-submit" id="matrix-create-submit-btn">Create User</button>' +
|
||
'</div>' +
|
||
'<div class="matrix-form-result" id="matrix-create-result"></div>';
|
||
|
||
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 =
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-username">Username (localpart only, e.g. <em>alice</em>)</label>' +
|
||
'<input class="matrix-form-input" type="text" id="matrix-chpw-username" placeholder="alice" autocomplete="off"></div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-password">New Password</label>' +
|
||
'<input class="matrix-form-input" type="password" id="matrix-chpw-password" placeholder="New strong password" autocomplete="new-password"></div>' +
|
||
'<div class="matrix-form-actions">' +
|
||
'<button class="matrix-form-back" id="matrix-chpw-back-btn">← Back</button>' +
|
||
'<button class="matrix-form-submit" id="matrix-chpw-submit-btn">Change Password</button>' +
|
||
'</div>' +
|
||
'<div class="matrix-form-result" id="matrix-chpw-result"></div>';
|
||
|
||
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 =
|
||
'<div class="sys-chpw-header">' +
|
||
'<div class="sys-chpw-title">🔑 Change Free Account & Hub Login Password</div>' +
|
||
'<div class="sys-chpw-desc">This updates the password for the <strong>free</strong> user account. <strong>This is also your Sovran Hub login password</strong> — both will change.</div>' +
|
||
'</div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
|
||
'<div class="pw-input-wrap">' +
|
||
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
|
||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-new-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||
'</div>' +
|
||
'<div class="pw-hint">Password must be at least 8 characters.</div></div>' +
|
||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-confirm">Confirm Password</label>' +
|
||
'<div class="pw-input-wrap">' +
|
||
'<input class="matrix-form-input" type="password" id="sys-chpw-confirm" placeholder="Confirm new password" autocomplete="new-password">' +
|
||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-confirm-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||
'</div></div>' +
|
||
'<div class="pw-credentials-note">⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile.</div>' +
|
||
'<div class="matrix-form-actions">' +
|
||
'<button class="matrix-form-back" id="sys-chpw-back-btn">← Back</button>' +
|
||
'<button class="matrix-form-submit" id="sys-chpw-submit-btn">Change Password</button>' +
|
||
'</div>' +
|
||
'<div class="matrix-form-result" id="sys-chpw-result"></div>';
|
||
|
||
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"); }
|