feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements

Bitcoin UI:
- Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts)
- Make all asset paths relative for nginx proxy compatibility
- Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely)
- Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332)

HTTPS mixed content fix:
- Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS,
  iframe loads through nginx proxy instead of direct HTTP port
- Prevents browser blocking HTTP iframes inside HTTPS pages
- All Tailscale servers use HTTPS, this was breaking all app iframes

Deploy & first-boot improvements:
- first-boot-containers.sh auto-detects disk size for pruning vs txindex
- first-boot-containers.sh checks fallback source path for UI containers
- Added mempool-electrs to APP_PORTS mapping
- ElectrumX container creation in first-boot
- Podman doctor/fix/uptime skills added

Also includes: session persistence, identity management, LND transactions,
ElectrumX status UI, nostr-provider improvements, Web5 enhancements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-16 12:58:35 +00:00
parent 4e54b8bd4d
commit 367b483a72
49 changed files with 6180 additions and 495 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.6f1usind3cc"
"revision": "0.tmc04bnmkho"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -29,6 +29,7 @@
"concurrently": "^9.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"express": "^4.21.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",
@@ -1688,6 +1689,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
@@ -2275,6 +2283,58 @@
"node": ">=18"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.3",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.2.5",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -2804,6 +2864,17 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2876,6 +2947,80 @@
"node": ">=18"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@@ -4312,6 +4457,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -4480,6 +4635,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -4493,6 +4669,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -4515,6 +4701,18 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -4615,6 +4813,31 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4622,6 +4845,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"dev": true,
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -4818,6 +5051,13 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"dev": true,
"license": "ISC"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -5135,6 +5375,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5872,6 +6127,41 @@
"dev": true,
"license": "MIT"
},
"node_modules/docker-modem": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
"integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.1",
"readable-stream": "^3.5.0",
"split-ca": "^1.0.1",
"ssh2": "^1.15.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dockerode": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz",
"integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"@grpc/grpc-js": "^1.11.1",
"@grpc/proto-loader": "^0.7.13",
"docker-modem": "^5.0.6",
"protobufjs": "^7.3.2",
"tar-fs": "^2.1.4",
"uuid": "^10.0.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5960,6 +6250,16 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -6512,6 +6812,13 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true,
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -6955,6 +7262,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -7791,6 +8119,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -7805,6 +8140,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -7988,6 +8330,13 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8014,6 +8363,14 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -8159,6 +8516,16 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -8563,6 +8930,31 @@
"dev": true,
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -8577,6 +8969,17 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8687,6 +9090,21 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -9485,6 +9903,31 @@
"node": ">=0.10.0"
}
},
"node_modules/split-ca": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -9523,6 +9966,16 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -9878,6 +10331,36 @@
"node": ">=14.0.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -10203,6 +10686,13 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"dev": true,
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@@ -10526,6 +11016,20 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -11626,6 +12130,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@@ -44,6 +44,7 @@
"concurrently": "^9.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"express": "^4.21.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,79 +1,160 @@
/**
* NIP-07 Nostr Provider Shim
* NIP-07 Nostr Provider Shim — Archipelago
*
* Injected into proxied iframe apps via nginx sub_filter.
* Implements window.nostr interface (getPublicKey, signEvent)
* by communicating with the parent Archipelago frame via postMessage.
*
* Security: validates postMessage origin, never exposes secret key.
* Provides window.nostr (NIP-07) for iframe apps.
* Auto sign-in: does NIP-98 auth directly then reloads so the app
* picks up the valid session. Shows a loading overlay during auth.
*/
(function () {
'use strict';
// Only inject if we're inside an iframe
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
// Don't override existing NIP-07 extensions
if (window.nostr) return;
var pending = {};
var nextId = 1;
var pending = {}, nextId = 1;
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage(
{ type: 'nostr-request', id: id, method: method, params: params || {} },
'*'
);
// Timeout after 30 seconds
setTimeout(function () {
if (pending[id]) {
pending[id].reject(new Error('NIP-07 request timed out'));
delete pending[id];
}
}, 30000);
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
});
}
window.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'nostr-response') return;
var handler = pending[event.data.id];
if (!handler) return;
delete pending[event.data.id];
if (event.data.error) {
handler.reject(new Error(event.data.error));
} else {
handler.resolve(event.data.result);
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () {
return request('getPublicKey');
},
signEvent: function (event) {
return request('signEvent', { event: event });
},
getRelays: function () {
return request('getRelays');
},
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pubkey, plaintext) {
return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function (pubkey, ciphertext) {
return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
},
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
},
nip44: {
encrypt: function (pubkey, plaintext) {
return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function (pubkey, ciphertext) {
return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
},
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
},
};
// --- Loading Overlay ---
var overlay = null;
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Direct NIP-98 Auth ---
var authDone = false;
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
var apiBase = '/api';
var healthUrl = window.location.origin + apiBase + '/nostr-auth/health';
var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session';
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
return fetch(sessionUrl, {
method: 'POST',
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
return res.json();
}).then(function (data) {
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
});
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
});
})();

View File

@@ -6,13 +6,16 @@
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop near-black -->
<div class="absolute inset-0 bg-black/90 backdrop-blur-xl"></div>
<!-- Backdrop frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<!-- Main panel -->
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
:aria-label="`Select identity for ${appName}`"
class="relative z-10 w-full max-w-lg"
>
<!-- Header: screensaver-style glass disc + radial viz ring -->
@@ -43,7 +46,7 @@
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-3 max-h-[50vh] overflow-y-auto">
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
@@ -61,15 +64,18 @@
v-for="identity in identities"
:key="identity.id"
type="button"
class="w-full text-left p-3 rounded-lg border transition-all duration-200"
role="radio"
:aria-checked="selectedId === identity.id"
:aria-label="`Identity: ${identity.name}`"
class="w-full text-left p-3 rounded-lg transition-all duration-200"
:class="selectedId === identity.id
? 'bg-white/8 border-white/25'
: 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'"
? 'bg-white/10 ring-1 ring-white/20'
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
@click="selectedId = identity.id"
>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 border"
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
:class="avatarClasses(identity.purpose)"
>
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
@@ -85,10 +91,10 @@
</div>
</div>
<div class="shrink-0">
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/20 border border-white/50 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/80"></div>
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
</div>
<div v-else class="w-5 h-5 rounded-full border border-white/15"></div>
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
</div>
</div>
</button>
@@ -104,8 +110,8 @@
:disabled="!selectedId || !hasNostrKey"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="selectedId && hasNostrKey
? 'bg-white/10 border border-white/25 text-white hover:bg-white/15'
: 'bg-white/3 border border-white/8 text-white/40'"
? 'bg-white/10 text-white hover:bg-white/15'
: 'bg-white/[0.03] text-white/40'"
>
Authenticate
</button>
@@ -193,9 +199,9 @@ function truncateNpub(npub: string): string {
function avatarClasses(purpose: string): string {
switch (purpose) {
case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25'
case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25'
default: return 'bg-white/10 text-white/80 border-white/20'
case 'business': return 'bg-blue-500/15 text-blue-400'
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
default: return 'bg-white/10 text-white/80'
}
}
</script>

View File

@@ -222,16 +222,18 @@ router.beforeEach(async (to, _from, next) => {
// If authenticated and visiting /login: show login immediately, validate in background.
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
if (to.path === '/login' && store.isAuthenticated) {
// Redirect back to intended page (from ?redirect= query) or default to home
const redirectTo = (to.query.redirect as string) || '/dashboard'
if (store.needsSessionValidation()) {
next()
checkSessionWithTimeout(store).then((valid) => {
if (valid) {
router.replace({ name: 'home' }).catch(() => {})
router.replace(redirectTo).catch(() => {})
}
})
return
}
next({ name: 'home' })
next(redirectTo)
return
}
next()
@@ -245,7 +247,7 @@ router.beforeEach(async (to, _from, next) => {
next()
store.checkSession().then((valid) => {
if (!valid) {
router.replace('/login').catch(() => {})
router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {})
}
})
return
@@ -258,7 +260,7 @@ router.beforeEach(async (to, _from, next) => {
next()
return
}
next('/login')
next({ path: '/login', query: { redirect: to.fullPath } })
return
}

View File

@@ -3,97 +3,59 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
* These always open in a new tab. Other external sites load directly in the iframe. */
/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */
const IFRAME_BLOCKED_HOSTS: string[] = []
/** External site proxy paths — disabled. External URLs load directly in the iframe
* via their standard https:// URL. The /ext/ subpath approach broke SPAs. */
const EXTERNAL_PROXY_PATH: Record<string, string> = {}
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
'23000', // BTCPay — X-Frame-Options: DENY
'3000', // Grafana — X-Frame-Options: deny
'2342', // PhotoPrism — X-Frame-Options: DENY
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'9001', // Penpot — not reachable
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
// External sites that block iframes
if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
return true
}
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
if (
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
u.port === '3000' || // Grafana — X-Frame-Options: deny
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none'
u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN
u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN
u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN
) {
return true
}
return false
return NEW_TAB_PORTS.has(u.port)
} catch {
return false
}
}
/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */
const PORT_TO_PROXY: Record<string, string> = {
'81': '/app/nginx-proxy-manager/',
'3000': '/app/grafana/',
'3001': '/app/uptime-kuma/',
'8080': '/app/endurain/',
'8081': '/app/lnd/',
'8082': '/app/vaultwarden/',
'8083': '/app/filebrowser/',
'8085': '/app/nextcloud/',
'8096': '/app/jellyfin/',
'8123': '/app/homeassistant/',
'8240': '/app/tailscale/',
'8334': '/app/bitcoin-ui/',
'8888': '/app/searxng/',
'9000': '/app/portainer/',
'9001': '/app/penpot/',
'9980': '/app/onlyoffice/',
'11434': '/app/ollama/',
'2283': '/app/immich/',
'23000': '/app/btcpay/',
'2342': '/app/photoprism/',
'4080': '/app/mempool/',
'8175': '/app/fedimint/',
'8176': '/app/fedimint-gateway/',
'3100': '/app/dwn/',
'18081': '/app/nostr-rs-relay/',
'7777': '/app/indeedhub/',
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'3000': 'grafana',
'3001': 'uptime-kuma',
'8080': 'endurain',
'8081': 'lnd',
'8082': 'vaultwarden',
'8083': 'filebrowser',
'8085': 'nextcloud',
'8096': 'jellyfin',
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9001': 'penpot',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
'23000': 'btcpay-server',
'2342': 'photoprism',
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'18081': 'nostr-rs-relay',
'7777': 'indeedhub',
'50002': 'electrumx',
}
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
* On HTTP, direct port URLs are used — they avoid subpath routing issues
* (apps' root-relative asset paths like /static/main.js break under /app/xxx/).
* On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options.
*/
function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
const origin = window.location.origin
// External sites proxied through nginx path-based locations
const extPath = EXTERNAL_PROXY_PATH[u.hostname]
if (extPath) {
return `${origin}${extPath}`
}
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
if (proxyPath && sameHost && needsProxy) {
return `${origin}${proxyPath}`
}
} catch {
/* ignore */
}
return url
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
@@ -121,6 +83,8 @@ export interface NostrConsentRequest {
reject: () => void
}
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
@@ -129,9 +93,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
/** Open app in full-page session view (preferred — no iframe subpath issues) */
/** Active app in panel mode (store-based, no route change) */
const panelAppId = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function openSession(appId: string) {
router.push({ name: 'app-session', params: { appId } })
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId } })
}
}
function closePanel() {
panelAppId.value = null
}
/** Legacy: open app in iframe overlay (kept for backward compat) */
@@ -142,13 +119,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
openSession(resolvedId)
return
}
// Apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(payload.url, '_blank', 'noopener,noreferrer')
return
}
const embeddableUrl = toEmbeddableUrl(payload.url)
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = embeddableUrl
url.value = payload.url
title.value = payload.title
isOpen.value = true
}
@@ -158,11 +135,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
try {
const u = new URL(urlStr)
// Check port-based apps
for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) {
if (u.port === port) {
return proxyPath.replace('/app/', '').replace(/\/$/, '')
}
}
const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId
// Check external URLs
const EXTERNAL_APP_HOSTS: Record<string, string> = {
'botfights.net': 'botfights',
@@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
open,
openSession,
close,
closePanel,
panelAppId,
showConsent,
consentRequest,
approveConsent,

View File

@@ -16,6 +16,17 @@ export interface BundledApp {
lan_address?: string // Runtime launch URL from backend
}
/** Map bundled app ID to the podman container name(s) used for status matching.
* Some apps have a different container name than their app ID, or use a
* separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */
const CONTAINER_NAME_MAP: Record<string, string[]> = {
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
'lnd': ['lnd', 'archy-lnd-ui'],
'btcpay-server': ['btcpay-server'],
'mempool': ['archy-mempool-web'],
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
}
export const BUNDLED_APPS: BundledApp[] = [
{
id: 'bitcoin-knots',
@@ -23,7 +34,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'localhost/bitcoinknots/bitcoin:29',
description: 'Full Bitcoin node with additional features',
icon: '₿',
ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }],
ports: [{ host: 8334, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
category: 'bitcoin',
},
@@ -33,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
description: 'Lightning Network Daemon for fast Bitcoin payments',
icon: '⚡',
ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }],
ports: [{ host: 8081, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
category: 'lightning',
},
@@ -48,12 +59,12 @@ export const BUNDLED_APPS: BundledApp[] = [
category: 'home',
},
{
id: 'btcpayserver',
id: 'btcpay-server',
name: 'BTCPay Server',
image: 'docker.io/btcpayserver/btcpayserver:latest',
description: 'Self-hosted Bitcoin payment processor',
icon: '💳',
ports: [{ host: 23000, container: 23000 }],
ports: [{ host: 23000, container: 49392 }],
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
category: 'bitcoin',
},
@@ -63,30 +74,10 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/mempool/frontend:latest',
description: 'Bitcoin blockchain and mempool visualizer',
icon: '🔍',
ports: [{ host: 8080, container: 8080 }],
ports: [{ host: 4080, container: 8080 }],
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
category: 'bitcoin',
},
{
id: 'nostr-rs-relay',
name: 'Nostr Relay (RS)',
image: 'docker.io/scsibug/nostr-rs-relay:latest',
description: 'Rust-based Nostr relay for decentralized social',
icon: '🦩',
ports: [{ host: 8008, container: 8080 }],
volumes: [{ host: '/var/lib/archipelago/nostr-rs', container: '/usr/src/app/db' }],
category: 'other',
},
{
id: 'strfry',
name: 'Strfry Relay',
image: 'docker.io/hoytech/strfry:latest',
description: 'High-performance Nostr relay',
icon: '⚡',
ports: [{ host: 7777, container: 7777 }],
volumes: [{ host: '/var/lib/archipelago/strfry', container: '/app/strfry-db' }],
category: 'other',
},
{
id: 'tailscale',
name: 'Tailscale VPN',
@@ -124,14 +115,18 @@ export const useContainerStore = defineStore('container', () => {
healthStatus.value[appId] || 'unknown'
)
// Get container for a bundled app (matches by name)
// Get container for a bundled app (matches by explicit name map, then by exact name)
const getContainerForApp = computed(() => (appId: string) => {
return containers.value.find(c =>
c.name === appId ||
c.name.includes(appId) ||
c.name === `archipelago-${appId}` ||
c.name === `archipelago-${appId}-dev`
)
const nameList = CONTAINER_NAME_MAP[appId]
if (nameList) {
// Try each known container name in priority order
for (const n of nameList) {
const found = containers.value.find(c => c.name === n)
if (found) return found
}
}
// Fallback: exact match on app ID
return containers.value.find(c => c.name === appId)
})
// Check if an app is currently loading (starting/stopping)

View File

@@ -462,6 +462,106 @@ input[type="radio"]:active + * {
transform: translateX(1rem);
}
/* Incoming Transactions badge */
.incoming-tx-badge {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
color: #4ade80;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
white-space: nowrap;
}
.incoming-tx-badge:hover {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.4);
transform: translateY(-1px);
}
.incoming-tx-ping {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #4ade80;
border-radius: 9999px;
animation: incoming-pulse 2s ease-in-out infinite;
}
@keyframes incoming-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.5); }
}
/* Incoming transaction row */
.incoming-tx-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.2s ease;
}
.incoming-tx-row:hover {
background: rgba(34, 197, 94, 0.08);
}
.incoming-tx-icon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.incoming-tx-icon-pending {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
}
.incoming-tx-icon-confirmed {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
/* Slide-down transition for incoming tx panel */
.incoming-tx-slide-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.incoming-tx-slide-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.incoming-tx-slide-enter-from,
.incoming-tx-slide-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-8px);
margin-bottom: 0;
}
.incoming-tx-slide-enter-to,
.incoming-tx-slide-leave-from {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
/* BANNED: gradient-card, gradient-card-dark, gradient-button
Use .glass-card or .path-option-card for containers.
Use .glass-button for all buttons.

View File

@@ -526,8 +526,9 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
indeedhub: 'indeedhub',
electrs: 'mempool-electrs',
'mempool-electrs': 'mempool-electrs',
electrumx: 'electrumx',
electrs: 'electrumx',
'mempool-electrs': 'electrumx',
}
/** Backend may register under variant container names */
@@ -536,7 +537,7 @@ const PACKAGE_ALIASES: Record<string, string[]> = {
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
'mempool-web': ['archy-mempool-web'],
indeedhub: ['indeedhub-build_app_1'],
electrs: ['mempool-electrs', 'archy-electrs'],
electrumx: ['mempool-electrs', 'electrs', 'archy-electrs'],
}
function resolvePackageKey(routeId: string): string {

View File

@@ -1,9 +1,9 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="displayMode === 'panel'">
<Teleport to="body" :disabled="isInlinePanel">
<div
:class="backdropClasses"
@click.self="goBack"
@click.self="handleBackdropClick"
>
<div
ref="sessionRef"
@@ -178,12 +178,24 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
const props = defineProps<{
appIdProp?: string
}>()
const emit = defineEmits<{
close: []
}>()
/** True when rendered inline via store (panel mode), false when route-based */
const isInlinePanel = computed(() => !!props.appIdProp)
const route = useRoute()
const router = useRouter()
@@ -214,6 +226,25 @@ function setMode(mode: DisplayMode) {
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
showModeMenu.value = false
// Switch from inline panel → route-based overlay/fullscreen
if (isInlinePanel.value && mode !== 'panel') {
const id = appId.value
emit('close')
router.push({ name: 'app-session', params: { appId: id } })
return
}
// Switch from route-based → inline panel
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
router.push({ name: 'apps' }).then(() => {
launcher.panelAppId = id
})
return
}
// Enter fullscreen if selected
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
@@ -222,49 +253,92 @@ function setMode(mode: DisplayMode) {
// Reactive classes based on display mode
const backdropClasses = computed(() => {
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
return 'app-session-backdrop-overlay'
}
return 'app-session-backdrop-panel'
if (isInlinePanel.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
if (isInlinePanel.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
return `${base} app-session-inpanel`
return `${base} app-session-overlay`
})
const appId = computed(() => route.params.appId as string)
const appId = computed(() => props.appIdProp || (route.params.appId as string))
const APP_URLS: Record<string, string> = {
// Container apps — use nginx proxy paths (strips X-Frame-Options)
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334,
'bitcoin-ui': 8334,
'electrumx': 50002,
'electrs': 50002,
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'lnd': 8081,
'archy-lnd-ui': 8081,
'mempool': 4080,
'mempool-web': 4080,
'archy-mempool-web': 4080,
'homeassistant': 8123,
'grafana': 3000,
'searxng': 8888,
'ollama': 11434,
'onlyoffice': 9980,
'penpot': 9001,
'nextcloud': 8085,
'vaultwarden': 8082,
'jellyfin': 8096,
'photoprism': 2342,
'immich': 2283,
'immich_server': 2283,
'filebrowser': 8083,
'nginx-proxy-manager': 81,
'portainer': 9000,
'uptime-kuma': 3001,
'tailscale': 8240,
'fedimint': 8175,
'fedimint-gateway': 8176,
'nostr-rs-relay': 18081,
'indeedhub': 7777,
'dwn': 3100,
'endurain': 8080,
}
/** Apps that need nginx proxy for iframe embedding.
* IndeedHub loads via direct port 7777 — deploy script removes X-Frame-Options
* from the container's internal nginx so iframe works on all servers. */
const PROXY_APPS: Record<string, string> = {}
/** Nginx proxy paths — used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */
const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'electrs': '/app/electrs/',
'btcpay-server': '/app/btcpay/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'electrumx': '/app/electrs/',
'electrs': '/app/electrs/',
'mempool-electrs': '/app/electrs/',
'mempool': '/app/mempool/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'mempool-web': '/app/mempool/',
'archy-mempool-web': '/app/mempool/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'jellyfin': '/app/jellyfin/',
'searxng': '/app/searxng/',
'filebrowser': '/app/filebrowser/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'penpot': '/app/penpot/',
'nextcloud': '/app/nextcloud/',
'vaultwarden': '/app/vaultwarden/',
'jellyfin': '/app/jellyfin/',
'photoprism': '/app/photoprism/',
'immich': '/app/immich/',
'filebrowser': '/app/filebrowser/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'immich_server': '/app/immich/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'endurain': '/app/endurain/',
'indeedhub': '/app/indeedhub/',
'dwn': '/app/dwn/',
'endurain': '/app/endurain/',
}
/** External HTTPS apps — always loaded directly */
const EXTERNAL_URLS: Record<string, string> = {
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
@@ -286,15 +360,47 @@ const APP_TITLES: Record<string, string> = {
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
const NEW_TAB_APPS = new Set([
'btcpay-server', // X-Frame-Options: DENY
'grafana', // X-Frame-Options: deny
'photoprism', // X-Frame-Options: DENY
'homeassistant', // X-Frame-Options: SAMEORIGIN
'vaultwarden', // X-Frame-Options: SAMEORIGIN
'nextcloud', // X-Frame-Options: SAMEORIGIN
'uptime-kuma', // X-Frame-Options: SAMEORIGIN
'penpot', // Not reachable / blocks iframe
])
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const appUrl = computed(() => {
const url = APP_URLS[appId.value]
if (!url) return ''
// Proxy paths — same origin
if (url.startsWith('/')) return `${window.location.origin}${url}`
// External HTTPS sites — direct
if (url.startsWith('https://')) return url
// Fallback: localhost port URLs (shouldn't reach here normally)
return url.replace('localhost', window.location.hostname)
const id = appId.value
// External HTTPS apps — iframe overlay
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
const proxyPath = PROXY_APPS[id]
if (proxyPath) return `${window.location.origin}${proxyPath}`
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
if (window.location.protocol === 'https:') {
const httpsProxy = HTTPS_PROXY_PATHS[id]
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
}
// HTTP: direct port access (faster, no proxy overhead)
const port = APP_PORTS[id]
if (!port) return ''
let base = `http://${window.location.hostname}:${port}`
// Append sub-path from query param (e.g. ?path=/tx/abc123)
const subpath = route.query.path as string | undefined
if (subpath) base += subpath
return base
})
// --- Identity ---
@@ -325,6 +431,9 @@ function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
storeIdentity(identity)
sendIdentity(identity)
// NIP-98 auto-login disabled — apps like IndeedHub have their own login flow
// that properly sets up internal account state. We provide window.nostr via
// nostr-provider.js so the app's built-in "Sign In" button works.
}
async function sendIdentity(identity: SelectedIdentity) {
@@ -339,6 +448,8 @@ async function sendIdentity(identity: SelectedIdentity) {
} catch {}
}
// NIP-98 auto-login removed — apps handle their own login via window.nostr (NIP-07)
// --- Lifecycle ---
function onLoad() {
@@ -393,7 +504,7 @@ function startLoadTimeout() {
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
goBack()
closeSession()
}
function openNewTab() {
@@ -408,14 +519,14 @@ function iframeGoForward() {
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
}
function goBack() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.back()
function handleBackdropClick() {
closeSession()
}
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.push({ name: 'apps' })
if (isInlinePanel.value) emit('close')
else router.push({ name: 'apps' })
}
function onKeyDown(e: KeyboardEvent) {
@@ -463,12 +574,18 @@ async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
const identityId = getStoredIdentity()?.id || null
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
try {
let result: unknown
if (method === 'getPublicKey') {
if (identityId) {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
} else {
@@ -476,11 +593,13 @@ async function handleNostrRequest(event: MessageEvent) {
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
console.log('[NIP-07] signEvent OK')
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
@@ -489,23 +608,30 @@ async function handleNostrRequest(event: MessageEvent) {
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
} catch (err) {
console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
}
}
onMounted(() => {
// Apps that block iframes (X-Frame-Options) — open in new tab, close session
if (mustOpenNewTab.value && appUrl.value) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else router.back()
return
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('click', onClickOutside)
document.addEventListener('fullscreenchange', onFullscreenChange)
// Known blocked apps — show fallback immediately
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
startLoadTimeout()
}
// Enter fullscreen if that's the stored mode
if (displayMode.value === 'fullscreen') {
requestAnimationFrame(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
@@ -528,19 +654,18 @@ onBeforeUnmount(() => {
width: 100%;
height: 100%;
}
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
.app-session-backdrop-panel {
/* Inline panel mode — fills content area, no blur, original layout */
.app-session-backdrop-inline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
padding: 0;
}
.app-session-inpanel {
.app-session-inline {
display: flex;
flex-direction: column;
overflow: hidden;
@@ -550,10 +675,10 @@ onBeforeUnmount(() => {
}
@media (min-width: 768px) {
.app-session-backdrop-panel {
.app-session-backdrop-inline {
padding: 1.5rem;
}
.app-session-inpanel {
.app-session-inline {
border-radius: 1rem;
max-width: calc(100% - 1rem);
max-height: calc(100vh - 6rem);

View File

@@ -81,8 +81,9 @@
:key="item.path"
:to="item.path"
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace')) }"
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
@click="appLauncher.closePanel()"
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -292,6 +293,13 @@
</RouterView>
</div>
</div>
<!-- Panel mode app session renders alongside current page content -->
<Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container">
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
</div>
</Transition>
</main>
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
@@ -308,11 +316,12 @@
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
@click="appLauncher.closePanel()"
class="flex flex-col items-center justify-center w-full py-1.5 rounded-lg text-white/70 transition-all duration-300 relative z-10 gap-0.5"
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace'))
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: undefined
}"
@@ -385,6 +394,8 @@ import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import AppSession from '@/views/AppSession.vue'
const { t } = useI18n()
import { useLoginTransitionStore } from '../stores/loginTransition'
@@ -405,6 +416,7 @@ const chatFullscreen = computed(() => route.path === '/dashboard/chat')
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const loginTransition = useLoginTransitionStore()
const web5Badge = useWeb5BadgeStore()
@@ -1257,6 +1269,27 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
}
/* Wrapper to contain perspective without clipping */
/* Panel mode app session — fills content area, sidebar stays untouched */
.app-panel-container {
position: absolute;
inset: 0;
z-index: 100;
}
.panel-slide-enter-active {
transition: opacity 0.25s ease;
}
.panel-slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.panel-slide-enter-from {
opacity: 0;
}
.panel-slide-leave-to {
transform: translateX(40px) scale(0.97);
opacity: 0;
}
.perspective-container-wrapper {
position: relative;
overflow: hidden;

View File

@@ -96,14 +96,28 @@
</div>
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('home.installed') }}</p>
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
<p class="text-xs text-white/60 mb-1">Installed / Running</p>
<p class="text-2xl font-bold text-white">{{ appCount }}/{{ runningCount }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
<button
v-for="app in quickLaunchApps"
:key="app.id"
@click="useAppLauncherStore().openSession(app.id)"
class="group"
:title="app.name"
>
<div
class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center"
:style="app.bg ? { background: app.bg } : {}"
:class="{ 'bg-white/5': !app.bg }"
>
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
</div>
</button>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('home.browseStore') }}
@@ -476,6 +490,12 @@ const runningCount = computed(() =>
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
)
const quickLaunchApps = [
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
]
// Network card computed values
const servicesAllRunning = computed(() =>
appCount.value > 0 && runningCount.value === appCount.value

View File

@@ -203,7 +203,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
@@ -214,6 +214,10 @@ import { rpcClient } from '../api/rpc-client'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
const router = useRouter()
const currentRoute = useRoute()
/** After login, redirect to the intended page or default to home */
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard')
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
@@ -377,8 +381,8 @@ async function handleSetup() {
loginTransition.setJustLoggedIn(true)
await store.login(password.value)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
})
} catch (err) {
whooshAway.value = false
@@ -421,8 +425,8 @@ async function handleLogin() {
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
})
} catch (err) {
whooshAway.value = false
@@ -456,8 +460,8 @@ async function handleTotpVerify() {
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
})
} catch (err) {
const msg = err instanceof Error ? err.message : ''

View File

@@ -445,12 +445,12 @@ const features = computed(() => {
/** App dependency definitions */
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
'electrs': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'mempool': [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' },
{ id: 'electrs', title: 'Electrs', dockerImage: 'docker.io/mempool/electrs:latest' },
{ id: 'electrumx', title: 'ElectrumX', dockerImage: 'docker.io/lukechilds/electrumx:v1.18.0' },
],
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
}
@@ -533,7 +533,7 @@ async function installDependencies() {
installError.value = null
try {
// Install dependencies sequentially (order matters: bitcoin before electrs)
// Install dependencies sequentially (order matters: bitcoin before electrumx)
for (const dep of missingDeps) {
await rpcClient.call({
method: 'package.install',

View File

@@ -42,8 +42,7 @@
</svg>
</div>
</div>
<p v-if="autoAdvancing" class="text-lg text-white/80 mb-2">DID retrieved, continuing...</p>
<p v-else class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
Your node's decentralized identifier
</p>
</div>
@@ -128,10 +127,8 @@ const generatedDid = ref<string>('')
const nostrNpub = ref<string>('')
const isGenerating = ref(false)
const waitingForServer = ref(false)
const autoAdvancing = ref(false)
const didCopied = ref(false)
const npubCopied = ref(false)
const elapsedSeconds = ref(0)
const elapsedDisplay = ref('0:00')
let retryTimer: ReturnType<typeof setTimeout> | null = null
let elapsedTimer: ReturnType<typeof setInterval> | null = null
@@ -141,7 +138,6 @@ function startElapsedTimer() {
startTime = Date.now()
elapsedTimer = setInterval(() => {
const secs = Math.floor((Date.now() - startTime) / 1000)
elapsedSeconds.value = secs
const m = Math.floor(secs / 60)
const s = secs % 60
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
@@ -179,7 +175,6 @@ async function fetchDid() {
}
}).catch(() => { /* Nostr key may not exist yet */ })
autoAdvanceAfterDelay()
} catch {
isGenerating.value = false
if (!waitingForServer.value) {
@@ -190,13 +185,6 @@ async function fetchDid() {
}
}
function autoAdvanceAfterDelay() {
autoAdvancing.value = true
setTimeout(() => {
router.push('/onboarding/identity').catch(() => {})
}, 2000)
}
onMounted(() => {
const cached = localStorage.getItem('neode_did')
const cachedNpub = localStorage.getItem('neode_nostr_npub')

View File

@@ -119,7 +119,7 @@ async function signChallenge() {
if (did) {
const result = await rpcClient.call({
method: 'identity.verify',
params: { did, data: currentChallenge.value, signature: sig },
params: { did, message: currentChallenge.value, signature: sig },
}) as { valid: boolean }
verified.value = result.valid !== false
} else {

View File

@@ -328,12 +328,70 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
</div>
<!-- Incoming Transactions Badge -->
<button
v-if="incomingTxCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span class="incoming-tx-ping"></span>
</button>
</div>
<!-- Incoming Transactions Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="incoming-tx-row"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" :class="tx.num_confirmations === 0 ? 'incoming-tx-icon-pending' : 'incoming-tx-icon-confirmed'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</div>
</transition>
<div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@@ -1149,20 +1207,24 @@
class="card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Purpose Icon -->
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<svg class="w-5 h-5" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<!-- Avatar (clickable to edit profile) -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-sm font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<!-- Info -->
<div class="flex-1 min-w-0">
@@ -1175,14 +1237,25 @@
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<p class="text-white/50 text-xs font-mono truncate mt-0.5" :title="identity.did">{{ identity.did }}</p>
<div class="flex items-center gap-1 mt-0.5">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
@@ -1201,6 +1274,7 @@
</div>
<!-- Create Identity Modal -->
<Teleport to="body">
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
@@ -1233,8 +1307,10 @@
</div>
</div>
</div>
</Teleport>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
@@ -1247,7 +1323,221 @@
</div>
</div>
</div>
</Teleport>
<!-- Key Viewer Modal -->
<Teleport to="body">
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
}">
<svg class="w-5 h-5" :class="{
'text-blue-400': keyViewerIdentity.purpose === 'personal',
'text-orange-400': keyViewerIdentity.purpose === 'business',
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
</div>
</div>
<!-- Public Keys -->
<div class="space-y-3 mb-5">
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
Public Keys
</h3>
<div class="space-y-2">
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">DID (Ed25519)</span>
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
</div>
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
</div>
</div>
</div>
<!-- Private Keys Section -->
<div class="border-t border-white/10 pt-5">
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
Private Keys
</h3>
<!-- Locked state password required -->
<div v-if="!keyViewerPrivateKeys">
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
<div class="flex gap-2">
<input
v-model="keyViewerPassword"
type="password"
placeholder="Password"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
@keydown.enter="unlockPrivateKeys"
/>
<button
@click="unlockPrivateKeys"
:disabled="!keyViewerPassword || keyViewerUnlocking"
class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50"
>
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
</button>
</div>
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
</div>
<!-- Unlocked state show private keys -->
<div v-else class="space-y-2">
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
</div>
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">
Lock private keys
</button>
</div>
</div>
<!-- Close button -->
<div class="flex justify-end mt-5">
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Profile Editor Modal -->
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-else class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
<div>
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} &middot; {{ profileEditorIdentity.purpose }}</p>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Display Name</label>
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30 resize-none"></textarea>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Website</label>
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
</div>
</div>
<div v-if="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ profileError }}</p>
</div>
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg">
<p class="text-green-300 text-xs">{{ profileSuccess }}</p>
</div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ profileSaving ? 'Saving...' : 'Save' }}
</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Unified Send Modal -->
<Teleport to="body">
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
@@ -1346,8 +1636,10 @@
</div>
</div>
</div>
</Teleport>
<!-- Unified Receive Modal -->
<Teleport to="body">
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
@@ -1411,6 +1703,7 @@
</div>
</div>
</div>
</Teleport>
<!-- Decentralized Web Node (DWN) -->
<div class="glass-card p-6 mb-8">
@@ -1641,6 +1934,7 @@
</div>
<!-- Domains Management Modal -->
<Teleport to="body">
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
<div class="flex items-center justify-between mb-4">
@@ -1716,8 +2010,10 @@
</div>
</div>
</div>
</Teleport>
<!-- Relay Management Modal -->
<Teleport to="body">
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
<div class="flex items-center justify-between mb-4">
@@ -1759,6 +2055,7 @@
</div>
</div>
</div>
</Teleport>
<!-- Identity Toast -->
<Transition name="content-fade">
@@ -1770,7 +2067,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
@@ -2194,6 +2491,62 @@ const connectingWallet = ref(false)
const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0)
// Incoming Transactions
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
const walletTransactions = ref<WalletTransaction[]>([])
const showIncomingTxPanel = ref(false)
const incomingTransactions = computed(() =>
walletTransactions.value.filter(tx => tx.direction === 'incoming')
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
async function loadTransactions() {
try {
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
walletTransactions.value = res.transactions || []
// Auto-show panel when new unconfirmed incoming txs appear
const pending = res.incoming_pending_count || 0
if (pending > 0 && !showIncomingTxPanel.value) {
showIncomingTxPanel.value = true
}
} catch {
walletTransactions.value = []
}
}
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
}
// Auto-refresh wallet data every 30s
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
// Nostr Relays
interface NostrRelayData {
url: string
@@ -2988,6 +3341,16 @@ function copyOnionAddress() {
}
// --- Identity Management ---
interface IdentityProfile {
display_name?: string
about?: string
picture?: string
banner?: string
website?: string
nip05?: string
lud16?: string
}
interface ManagedIdentity {
id: string
name: string
@@ -2996,6 +3359,131 @@ interface ManagedIdentity {
did: string
created_at: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
profile?: IdentityProfile
}
// --- Key Viewer Modal ---
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
const keyViewerPassword = ref('')
const keyViewerUnlocking = ref(false)
const keyViewerError = ref('')
const keyViewerCopied = ref<string | null>(null)
function openKeyViewer(identity: ManagedIdentity) {
keyViewerIdentity.value = identity
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
}
function closeKeyViewer() {
// Clear sensitive data immediately
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
keyViewerIdentity.value = null
}
async function unlockPrivateKeys() {
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
keyViewerUnlocking.value = true
keyViewerError.value = ''
try {
const res = await rpcClient.call<{
ed25519_secret_hex: string
nostr_secret_hex: string | null
nostr_nsec: string | null
}>({
method: 'identity.export-keys',
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
})
keyViewerPrivateKeys.value = {
ed25519_secret_hex: res.ed25519_secret_hex,
nostr_secret_hex: res.nostr_secret_hex || '',
nostr_nsec: res.nostr_nsec || '',
}
keyViewerPassword.value = '' // Clear password from memory immediately
} catch (err: unknown) {
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
} finally {
keyViewerUnlocking.value = false
}
}
function copyKeyValue(label: string, value: string) {
safeClipboardWrite(value)
keyViewerCopied.value = label
setTimeout(() => { keyViewerCopied.value = null }, 2000)
}
// --- Profile Editor ---
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
const profileSaving = ref(false)
const profilePublishing = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
}
async function saveProfile() {
if (!profileEditorIdentity.value || profileSaving.value) return
profileSaving.value = true
profileError.value = ''
profileSuccess.value = ''
try {
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
await loadIdentities()
profileSuccess.value = 'Profile saved'
setTimeout(() => { profileSuccess.value = '' }, 3000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to save'
} finally {
profileSaving.value = false
}
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
try {
// Save first, then publish
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
const res = await rpcClient.call<{ event_id: string }>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
})
await loadIdentities()
profileSuccess.value = `Published to relay (${res.event_id.slice(0, 12)}...)`
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
} finally {
profilePublishing.value = false
}
}
const managedIdentities = ref<ManagedIdentity[]>([])
@@ -3090,7 +3578,14 @@ onMounted(() => {
loadNostrRelays()
loadCredentials()
loadLndBalances()
loadTransactions()
detectHardwareWallets()
// Auto-refresh wallet balances and transactions every 30s
walletRefreshInterval = setInterval(() => {
loadLndBalances()
loadTransactions()
loadEcashBalance()
}, 30000)
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
if (route.query.tab === 'messages') {
nodesContainerTab.value = 'messages'
@@ -3101,6 +3596,13 @@ onMounted(() => {
}
})
onUnmounted(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
watch(() => route.query.tab, (tab) => {
if (tab === 'messages') {
nodesContainerTab.value = 'messages'