From 0e94ae58fcadf1961c3e91dfcbb382e91e578ba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:22:45 +0000 Subject: [PATCH] Implement wallet connections service-detail modal CRUD flow --- app/sovran_systemsos_web/server.py | 6 +- .../static/css/modals.css | 43 +++ .../static/js/service-detail.js | 363 +++++++++++++++++- app/tests/test_wallet_connections.py | 3 + docs/wallet-connections.md | 16 + 5 files changed, 429 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5b25c83..b2835db 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -4422,9 +4422,11 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest): domain = _nwc_domain() verify = _nwc_test_address(alias) + pairing_uri = _nwc_pairing_uri(wallet_id, pairing_secret) + pairing_qrcode = _generate_qr_base64(pairing_uri) response = { "wallet": _nwc_wallet_meta(wallet, domain), - "pairing_uri": _nwc_pairing_uri(wallet_id, pairing_secret), + "pairing_uri": pairing_uri, "lightning_address": f"{alias}@{domain}" if domain else None, "result": { "wallet_created": True, @@ -4433,6 +4435,8 @@ async def api_nwc_create_wallet(req: NwcWalletCreateRequest): "public_endpoint_verification": verify, }, } + if pairing_qrcode: + response["pairing_qrcode"] = pairing_qrcode return JSONResponse(status_code=201, content=response) diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css index c08322a..0894990 100644 --- a/app/sovran_systemsos_web/static/css/modals.css +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -478,3 +478,46 @@ button.btn-reboot:hover:not(:disabled) { color: var(--text-secondary); font-style: italic; } + +.nwc-secret-warning { + margin: 0 0 14px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #f59e0b; + background: rgba(245, 158, 11, 0.12); + color: #fbbf24; + font-size: 0.86rem; +} + +.nwc-wallet-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.nwc-wallet-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.nwc-wallet-card-title { + font-weight: 700; + margin-bottom: 8px; + color: var(--text-primary); +} + +.nwc-wallet-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} + +.nwc-wallet-actions .matrix-form-back, +.nwc-wallet-actions .btn-close-modal { + padding: 8px 12px; + font-size: 0.82rem; +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 036e90f..5022a6a 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -58,6 +58,359 @@ function _attachCopyHandlers(container) { }); } +var _nwcModalState = null; + +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; + } + + html += '
' + + '' + + '' + + '
'; + if (state.domain) { + html += '

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

'; + } else { + html += '

Lightning Address domain is not configured yet. Configure your domain first, then create wallet connections.

'; + } + + 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 === "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")); + }); + }); +} + +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 = _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, + createForm: { + name: "", + alias: "", + access_preset: "receive_only", + spending_limit_sats: "" + } + }; + await _nwcRefreshWallets(); +} + async function openServiceDetailModal(unit, name, icon) { if (!$credsModal) return; if ($credsTitle) { @@ -242,7 +595,12 @@ async function openServiceDetailModal(unit, name, icon) { } // Section E: Credentials & Links - if (data.has_credentials && data.credentials && data.credentials.length > 0) { + if (unit === "nwc-wallets.service") { + html += '
' + + '
Wallet Connections
' + + '

Loading wallet connections…

' + + '
'; + } else if (data.has_credentials && data.credentials && data.credentials.length > 0) { html += '
' + '
Credentials & Access
' + _renderCredsHtml(data.credentials, unit) + @@ -323,6 +681,9 @@ async function openServiceDetailModal(unit, name, icon) { $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); + if (unit === "nwc-wallets.service") { + await _nwcInitWalletFlow(unit, name, icon); + } if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); diff --git a/app/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py index ad119c3..5df8202 100644 --- a/app/tests/test_wallet_connections.py +++ b/app/tests/test_wallet_connections.py @@ -144,6 +144,7 @@ class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase): patch.object(server, "NWC_STATE_FILE", str(state_file)), patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)), patch.object(server, "_nwc_test_address", return_value={"ok": False, "error": "public_endpoint_unreachable"}), + patch.object(server, "_generate_qr_base64", return_value="data:image/png;base64,abc"), ): req = types.SimpleNamespace( name="My Wallet", @@ -154,10 +155,12 @@ class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase): create_resp = await server.api_nwc_create_wallet(req) create_body = json.loads(create_resp.body.decode("utf-8")) self.assertIn("pairing_uri", create_body) + self.assertEqual(create_body.get("pairing_qrcode"), "data:image/png;base64,abc") list_resp = await server.api_nwc_wallets() self.assertEqual(len(list_resp["wallets"]), 1) self.assertNotIn("pairing_uri", list_resp["wallets"][0]) + self.assertNotIn("pairing_qrcode", list_resp["wallets"][0]) async def test_create_reports_public_verification_success(self): with tempfile.TemporaryDirectory() as td: diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md index 5326ef5..0137762 100644 --- a/docs/wallet-connections.md +++ b/docs/wallet-connections.md @@ -11,6 +11,21 @@ Use the existing Hub service tile flow: 3. Complete existing port/domain/DDNS/rebuild flow (80/TCP and 443/TCP). 4. Reopen tile and manage connections. +## Service-detail modal UX (implemented) + +Wallet management now runs inside the existing **Wallet Connections** service-detail modal with dedicated states: + +1. **Empty state**: no wallets yet, with create action. +2. **Create form**: name, alias, access preset, optional spend limit. +3. **Created/secret state**: one-time pairing secret (URI + QR when available) shown with explicit "save now" warning. +4. **Wallet list state**: per-wallet actions for verify/test, drain, and delete. + +Guardrails in modal flow: + +- Action buttons are disabled while API requests are in flight. +- Destructive actions (drain/delete) require user confirmation. +- API errors are surfaced inline in the modal state. + Node role behavior is unchanged: Node onboarding still skips global domain/port setup, and the `lightning` domain is configured on demand through feature enablement. ## Architecture @@ -25,6 +40,7 @@ Security invariants: - LNURL callback/discovery are exposed only through Caddy on 80/443. - Management APIs are authenticated and remain under `/api/nwc/`. - Pairing secrets are returned only on create responses. +- Pairing secret QR data is generated only for create responses and is not rehydrated via wallet list APIs. - Invoice attribution enforces wallet isolation with app-id checks. ## Domain and runtime files