Merge pull request #352 from naturallaw777/arena/019fab62-sovran-systemsos
Rename NWC feature to Lightning Wallet Connections and split modal into tabs
This commit is contained in:
@@ -786,7 +786,7 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
| `docs/wallet-connections.md` | Wallet Connections architecture, API, CLI, and security/operations notes |
|
| `docs/wallet-connections.md` | Lightning Wallet Connections architecture, API, CLI, and security/operations notes |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `assets/` | Repository documentation images |
|
| `assets/` | Repository documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Alby Hub manager — shared backend for Wallet Connections API and recovery CLI.
|
Alby Hub manager — shared backend for Lightning Wallet Connections API and recovery CLI.
|
||||||
|
|
||||||
Interfaces with the local Alby Hub instance at
|
Interfaces with the local Alby Hub instance at
|
||||||
http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE).
|
http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Dedicated LNURL service for Wallet Connections.
|
Dedicated LNURL service for Lightning Wallet Connections.
|
||||||
|
|
||||||
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
||||||
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ FEATURE_REGISTRY = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nwc-wallets",
|
"id": "nwc-wallets",
|
||||||
"name": "Wallet Connections",
|
"name": "Lightning Wallet Connections",
|
||||||
"description": "Connect apps to isolated wallets on your Lightning node and create reusable Lightning Addresses.",
|
"description": "Connect apps to isolated wallets on your Lightning node and create reusable Lightning Addresses.",
|
||||||
"category": "bitcoin",
|
"category": "bitcoin",
|
||||||
"needs_domain": True,
|
"needs_domain": True,
|
||||||
@@ -461,7 +461,7 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Sovran_SystemsOS integrates Tor natively across your entire stack."
|
"Sovran_SystemsOS integrates Tor natively across your entire stack."
|
||||||
),
|
),
|
||||||
"albyhub.service": (
|
"albyhub.service": (
|
||||||
"Create isolated Wallet Connections for Lightning apps and attach reusable Lightning "
|
"Create isolated Lightning wallets for your apps and attach reusable Lightning "
|
||||||
"Addresses on your Sovran_SystemsOS node."
|
"Addresses on your Sovran_SystemsOS node."
|
||||||
),
|
),
|
||||||
"gnome-remote-desktop.service": (
|
"gnome-remote-desktop.service": (
|
||||||
@@ -4323,7 +4323,7 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
"error": "domain_conflict",
|
"error": "domain_conflict",
|
||||||
"conflicting_domain_key": conflicting_key,
|
"conflicting_domain_key": conflicting_key,
|
||||||
"message": (
|
"message": (
|
||||||
"Wallet Connections requires its own unique hostname. "
|
"Lightning Wallet Connections requires its own unique hostname. "
|
||||||
"Choose a new subdomain such as lightning.yourdomain.com. "
|
"Choose a new subdomain such as lightning.yourdomain.com. "
|
||||||
f"This hostname is already assigned to: {conflicting_key}."
|
f"This hostname is already assigned to: {conflicting_key}."
|
||||||
),
|
),
|
||||||
@@ -4448,7 +4448,7 @@ async def api_domains_check(req: DomainCheckRequest):
|
|||||||
return {"domains": list(check_results)}
|
return {"domains": list(check_results)}
|
||||||
|
|
||||||
|
|
||||||
# ── Wallet Connections (NWC) endpoints ────────────────────────────
|
# ── Lightning Wallet Connections (NWC) endpoints ────────────────────────────
|
||||||
|
|
||||||
NWC_DOMAIN_FILE = "/var/lib/domains/lightning"
|
NWC_DOMAIN_FILE = "/var/lib/domains/lightning"
|
||||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.creds-dialog--wide {
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
.creds-body {
|
.creds-body {
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -489,38 +493,325 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-wallet-list {
|
/* ── Lightning Wallet Connections: tabbed shell ─────────────────── */
|
||||||
|
|
||||||
|
.nwc-tabs-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin: -6px 0 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tabs-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-btn:hover:not(.active) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-btn.active {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: #0A1A10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-header-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-header-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-header-chip--domain {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-header-chip--warn {
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
animation: nwc-tab-fade 0.16s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nwc-tab-fade {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Setup tab: first section shouldn't double up on top padding */
|
||||||
|
#nwc-tab-setup .svc-detail-section:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-intro-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-intro-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Wallet toolbar ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nwc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-toolbar-count {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-toolbar-actions .matrix-action-btn,
|
||||||
|
.nwc-toolbar-actions .matrix-form-back {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Domain-required callout ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nwc-domain-required {
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-domain-required-body {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-domain-required-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty / disabled states ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nwc-empty-state,
|
||||||
|
.nwc-disabled-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 36px 24px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-empty-icon,
|
||||||
|
.nwc-disabled-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-empty-title,
|
||||||
|
.nwc-disabled-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-empty-desc,
|
||||||
|
.nwc-disabled-desc {
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
max-width: 46ch;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Wallet cards ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nwc-wallet-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-wallet-card {
|
.nwc-wallet-card {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 14px 16px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-card:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-wallet-card-title {
|
.nwc-wallet-card-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 8px;
|
font-size: 0.95rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-chip--balance {
|
||||||
|
background: rgba(109, 191, 139, 0.14);
|
||||||
|
color: var(--green);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-chip--pending {
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-address {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-address .creds-value {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-address .creds-copy-btn {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 7px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-wallet-actions {
|
.nwc-wallet-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-actions-spacer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-wallet-actions .matrix-form-back,
|
.nwc-wallet-actions .matrix-form-back,
|
||||||
.nwc-wallet-actions .btn-close-modal,
|
.nwc-wallet-actions .btn-close-modal,
|
||||||
.nwc-wallet-actions .matrix-action-btn {
|
.nwc-wallet-actions .matrix-action-btn {
|
||||||
padding: 8px 12px;
|
padding: 7px 12px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-danger-btn {
|
||||||
|
color: #f08a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-danger-btn:hover:not(:disabled) {
|
||||||
|
background-color: rgba(220, 80, 80, 0.14);
|
||||||
|
color: #ff9d9d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── NWC LNURL share / download view ───────────────────────────── */
|
/* ── NWC LNURL share / download view ───────────────────────────── */
|
||||||
@@ -547,26 +838,68 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── NWC Liquidity Guide Card ────────────────────────────────────── */
|
/* ── NWC Liquidity Guide (collapsible) ──────────────────────────── */
|
||||||
|
|
||||||
.nwc-liquidity-guide-card {
|
.nwc-liquidity-details {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
margin-top: 20px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
background: rgba(255, 255, 255, 0.015);
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-liquidity-header {
|
.nwc-liquidity-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
padding: 14px 16px;
|
||||||
margin-bottom: 12px;
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-summary::after {
|
||||||
|
content: "▸";
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-details[open] .nwc-liquidity-summary::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-summary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-liquidity-icon {
|
.nwc-liquidity-icon {
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-guide-card {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-body {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-liquidity-steps {
|
.nwc-liquidity-steps {
|
||||||
@@ -583,6 +916,13 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-step-desc {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.step-num {
|
.step-num {
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
color: #0A1A10;
|
color: #0A1A10;
|
||||||
@@ -605,3 +945,60 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nwc-liquidity-analogy {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-rtl-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-rtl-btn:hover {
|
||||||
|
background: #1c2a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Narrow screens ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.nwc-tabs-bar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tabs-btns {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-chips {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-wallet-actions-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
||||||
'</div>'
|
'</div>'
|
||||||
@@ -194,7 +194,7 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
||||||
'</div>'
|
'</div>'
|
||||||
|
|||||||
@@ -89,6 +89,80 @@ function _nwcWalletSectionEl() {
|
|||||||
return document.getElementById("nwc-wallets-body");
|
return document.getElementById("nwc-wallets-body");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _nwcFormatSats(value) {
|
||||||
|
var n = Number(value || 0);
|
||||||
|
if (!Number.isFinite(n)) n = 0;
|
||||||
|
return n.toLocaleString("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Go to Service & Setup" shortcut shown when no Lightning Address domain
|
||||||
|
// is configured yet — jumps to the sibling tab instead of dead-ending.
|
||||||
|
function _nwcWireDomainLink() {
|
||||||
|
var link = document.getElementById("nwc-domain-setup-link");
|
||||||
|
if (link) link.addEventListener("click", function() { _nwcActivateTab("setup"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lightning Wallet Connections: tabbed modal shell ──────────────
|
||||||
|
// The wallet manager is a small app in its own right, so it gets the full
|
||||||
|
// width of the dialog on a "Wallets" tab, while status / enable / domain /
|
||||||
|
// ports / restart live on a "Service & Setup" tab.
|
||||||
|
|
||||||
|
function _setCredsDialogWide(on) {
|
||||||
|
if (!$credsModal) return;
|
||||||
|
var dialog = $credsModal.querySelector(".creds-dialog");
|
||||||
|
if (dialog) dialog.classList.toggle("creds-dialog--wide", !!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcTabsShellHtml(opts) {
|
||||||
|
var domainChip = opts.domain
|
||||||
|
? '<span class="nwc-header-chip nwc-header-chip--domain" title="Lightning Address domain">🌐 ' + escHtml(opts.domain) + '</span>'
|
||||||
|
: '<span class="nwc-header-chip nwc-header-chip--warn" title="No Lightning Address domain configured yet">🌐 Domain not set</span>';
|
||||||
|
|
||||||
|
return '<div class="nwc-tabs" data-active="' + escHtml(opts.activeTab) + '">' +
|
||||||
|
'<div class="nwc-tabs-bar" role="tablist" aria-label="Lightning Wallet Connections sections">' +
|
||||||
|
'<div class="nwc-tabs-btns">' +
|
||||||
|
'<button class="nwc-tab-btn" role="tab" data-tab="wallets" id="nwc-tab-btn-wallets" aria-controls="nwc-tab-wallets">⚡ Wallets</button>' +
|
||||||
|
'<button class="nwc-tab-btn" role="tab" data-tab="setup" id="nwc-tab-btn-setup" aria-controls="nwc-tab-setup">⚙ Service & Setup</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-header-chips">' +
|
||||||
|
'<span class="nwc-header-chip"><span class="status-dot ' + opts.statusDotClass + '"></span>' + escHtml(opts.statusLabel) + '</span>' +
|
||||||
|
domainChip +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-tab-panel" role="tabpanel" id="nwc-tab-wallets" aria-labelledby="nwc-tab-btn-wallets" data-tab="wallets">' + opts.walletsHtml + '</div>' +
|
||||||
|
'<div class="nwc-tab-panel" role="tabpanel" id="nwc-tab-setup" aria-labelledby="nwc-tab-btn-setup" data-tab="setup">' + opts.setupHtml + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcActivateTab(tab) {
|
||||||
|
var root = document.querySelector(".nwc-tabs");
|
||||||
|
if (!root) return;
|
||||||
|
root.setAttribute("data-active", tab);
|
||||||
|
root.querySelectorAll(".nwc-tab-btn").forEach(function(btn) {
|
||||||
|
var on = btn.getAttribute("data-tab") === tab;
|
||||||
|
btn.classList.toggle("active", on);
|
||||||
|
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
});
|
||||||
|
root.querySelectorAll(".nwc-tab-panel").forEach(function(panel) {
|
||||||
|
panel.classList.toggle("active", panel.getAttribute("data-tab") === tab);
|
||||||
|
});
|
||||||
|
var body = document.getElementById("creds-body");
|
||||||
|
if (body) body.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nwcWireTabs() {
|
||||||
|
var root = document.querySelector(".nwc-tabs");
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll(".nwc-tab-btn").forEach(function(btn) {
|
||||||
|
btn.addEventListener("click", function() {
|
||||||
|
_nwcActivateTab(btn.getAttribute("data-tab"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
_nwcActivateTab(root.getAttribute("data-active") || "wallets");
|
||||||
|
var gotoSetup = document.getElementById("nwc-goto-setup-btn");
|
||||||
|
if (gotoSetup) gotoSetup.addEventListener("click", function() { _nwcActivateTab("setup"); });
|
||||||
|
}
|
||||||
|
|
||||||
function _nwcRenderWalletState() {
|
function _nwcRenderWalletState() {
|
||||||
var host = _nwcWalletSectionEl();
|
var host = _nwcWalletSectionEl();
|
||||||
if (!host || !_nwcModalState) return;
|
if (!host || !_nwcModalState) return;
|
||||||
@@ -99,8 +173,9 @@ function _nwcRenderWalletState() {
|
|||||||
if (state.view === "create") {
|
if (state.view === "create") {
|
||||||
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
||||||
html +=
|
html +=
|
||||||
'<p class="svc-detail-desc">Create a new isolated wallet connection for your app.</p>' +
|
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
|
||||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Connection Name</label>' +
|
'<p class="nwc-tab-intro-desc">An isolated wallet you pair to a single app, with its own Lightning Address.</p>' +
|
||||||
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
|
||||||
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
||||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
||||||
'<input class="matrix-form-input" id="nwc-wallet-alias" type="text" placeholder="my-wallet" value="' + escHtml(state.createForm.alias || "") + '" autocomplete="off">' +
|
'<input class="matrix-form-input" id="nwc-wallet-alias" type="text" placeholder="my-wallet" value="' + escHtml(state.createForm.alias || "") + '" autocomplete="off">' +
|
||||||
@@ -143,7 +218,7 @@ function _nwcRenderWalletState() {
|
|||||||
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
||||||
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
||||||
if (created.pairing_qrcode) {
|
if (created.pairing_qrcode) {
|
||||||
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>';
|
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>';
|
||||||
}
|
}
|
||||||
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
||||||
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
||||||
@@ -237,19 +312,30 @@ function _nwcRenderWalletState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var canCreate = !!state.domain && !state.busy;
|
var canCreate = !!state.domain && !state.busy;
|
||||||
html += '<div class="matrix-actions-row">' +
|
var walletCount = (state.wallets || []).length;
|
||||||
'<button class="matrix-action-btn" id="nwc-open-create-btn"' + (canCreate ? "" : " disabled") + '>' + (state.domain ? '➕ Create Wallet Connection' : 'Configure Domain First') + '</button>' +
|
html += '<div class="nwc-toolbar">' +
|
||||||
'<button class="matrix-form-back" id="nwc-refresh-btn"' + (state.busy ? " disabled" : "") + '>Refresh</button>' +
|
'<div class="nwc-toolbar-count">' + (walletCount === 1 ? '1 wallet' : walletCount + ' wallets') + '</div>' +
|
||||||
|
'<div class="nwc-toolbar-actions">' +
|
||||||
|
'<button class="matrix-form-back nwc-toolbar-refresh" id="nwc-refresh-btn"' + (state.busy ? " disabled" : "") + '>↻ Refresh</button>' +
|
||||||
|
'<button class="matrix-action-btn" id="nwc-open-create-btn"' + (canCreate ? "" : " disabled") + '>➕ New Wallet</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
if (!state.domain) {
|
||||||
|
html += '<div class="nwc-domain-required">' +
|
||||||
|
'<strong>⚠ Lightning Address domain required</strong>' +
|
||||||
|
'<div class="nwc-domain-required-body">Set up a unique hostname such as <strong>lightning.yourdomain.com</strong> before creating wallets.</div>' +
|
||||||
|
'<button class="btn btn-primary nwc-domain-required-btn" id="nwc-domain-setup-link">⚙ Go to Service & Setup</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
if (state.domain) {
|
|
||||||
html += '<p class="svc-detail-desc">Lightning Address domain: <strong>' + escHtml(state.domain) + '</strong></p>';
|
|
||||||
} else {
|
|
||||||
html += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px"><strong>Domain required</strong><div style="margin-top:6px">Configure the Lightning Address domain in the Domain Diagnostic Checklist above before creating Wallet Connections. Use a unique hostname such as <strong>lightning.yourdomain.com</strong>.</div></div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.wallets || state.wallets.length === 0) {
|
if (!state.wallets || state.wallets.length === 0) {
|
||||||
html += '<p class="creds-empty">No wallet connections yet. Create your first wallet connection to generate a one-time pairing secret.</p>';
|
html += '<div class="nwc-empty-state">' +
|
||||||
|
'<div class="nwc-empty-icon">👛</div>' +
|
||||||
|
'<div class="nwc-empty-title">No wallets yet</div>' +
|
||||||
|
'<p class="nwc-empty-desc">Create your first wallet to get a one-time pairing secret for an app, plus a reusable Lightning Address.</p>' +
|
||||||
|
'</div>';
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
|
_nwcWireDomainLink();
|
||||||
var openCreateBtn = document.getElementById("nwc-open-create-btn");
|
var openCreateBtn = document.getElementById("nwc-open-create-btn");
|
||||||
if (openCreateBtn) {
|
if (openCreateBtn) {
|
||||||
openCreateBtn.addEventListener("click", function() {
|
openCreateBtn.addEventListener("click", function() {
|
||||||
@@ -268,25 +354,38 @@ function _nwcRenderWalletState() {
|
|||||||
state.wallets.forEach(function(wallet) {
|
state.wallets.forEach(function(wallet) {
|
||||||
var id = wallet.id || wallet.pubkey || "";
|
var id = wallet.id || wallet.pubkey || "";
|
||||||
var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8);
|
var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8);
|
||||||
|
var pending = Number(wallet.pending_transactions || 0);
|
||||||
html += '<div class="nwc-wallet-card">' +
|
html += '<div class="nwc-wallet-card">' +
|
||||||
|
'<div class="nwc-wallet-card-head">' +
|
||||||
'<div class="nwc-wallet-card-title">' + escHtml(wallet.name || "Wallet") + '</div>' +
|
'<div class="nwc-wallet-card-title">' + escHtml(wallet.name || "Wallet") + '</div>' +
|
||||||
'<div class="creds-row"><div class="creds-label">Alias</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(wallet.alias || "") + '</div></div></div>' +
|
'<div class="nwc-wallet-chips">' +
|
||||||
|
'<span class="nwc-wallet-chip nwc-wallet-chip--balance">' + escHtml(_nwcFormatSats(wallet.balance_sats)) + ' sats</span>' +
|
||||||
|
(pending > 0
|
||||||
|
? '<span class="nwc-wallet-chip nwc-wallet-chip--pending" title="Pending transactions">⏳ ' + escHtml(String(pending)) + ' pending</span>'
|
||||||
|
: '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
(wallet.lightning_address
|
(wallet.lightning_address
|
||||||
? '<div class="creds-row"><div class="creds-label">Lightning Address</div><div class="creds-value-wrap"><div class="creds-value" id="' + addressId + '">' + escHtml(wallet.lightning_address) + '</div><button class="creds-copy-btn" data-target="' + addressId + '">Copy</button></div></div>'
|
? '<div class="nwc-wallet-address">' +
|
||||||
: "") +
|
'<div class="creds-label">Lightning Address</div>' +
|
||||||
'<div class="creds-row"><div class="creds-label">Balance</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.balance_sats || 0)) + ' sats</div></div></div>' +
|
'<div class="creds-value-wrap"><div class="creds-value" id="' + addressId + '">' + escHtml(wallet.lightning_address) + '</div>' +
|
||||||
'<div class="creds-row"><div class="creds-label">Pending TX</div><div class="creds-value-wrap"><div class="creds-value">' + escHtml(String(wallet.pending_transactions || 0)) + '</div></div></div>' +
|
'<button class="creds-copy-btn" data-target="' + addressId + '">Copy</button></div>' +
|
||||||
|
'</div>'
|
||||||
|
: '<div class="nwc-wallet-address"><div class="creds-label">Alias</div>' +
|
||||||
|
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(wallet.alias || "") + '</div></div></div>') +
|
||||||
'<div class="nwc-wallet-actions">' +
|
'<div class="nwc-wallet-actions">' +
|
||||||
'<button class="matrix-action-btn nwc-wallet-action-btn" data-action="share" data-wallet-id="' + escHtml(id) + '"' + ((state.busy || !wallet.lightning_address) ? " disabled" : "") + '>⚡ Share QR</button>' +
|
'<button class="matrix-action-btn nwc-wallet-action-btn" data-action="share" data-wallet-id="' + escHtml(id) + '"' + ((state.busy || !wallet.lightning_address) ? " disabled" : "") + '>⚡ Share QR</button>' +
|
||||||
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="test" data-wallet-alias="' + escHtml(wallet.alias || "") + '"' + (state.busy ? " disabled" : "") + '>Verify Address</button>' +
|
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="test" data-wallet-alias="' + escHtml(wallet.alias || "") + '"' + (state.busy ? " disabled" : "") + '>Verify</button>' +
|
||||||
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="drain" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Drain</button>' +
|
'<span class="nwc-wallet-actions-spacer"></span>' +
|
||||||
'<button class="btn btn-close-modal nwc-wallet-action-btn" data-action="delete" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Delete</button>' +
|
'<button class="matrix-form-back nwc-wallet-action-btn nwc-wallet-danger-btn" data-action="drain" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Drain</button>' +
|
||||||
|
'<button class="matrix-form-back nwc-wallet-action-btn nwc-wallet-danger-btn" data-action="delete" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Delete</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += "</div>";
|
html += "</div>";
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
_attachCopyHandlers(host);
|
_attachCopyHandlers(host);
|
||||||
|
_nwcWireDomainLink();
|
||||||
|
|
||||||
var createBtn = document.getElementById("nwc-open-create-btn");
|
var createBtn = document.getElementById("nwc-open-create-btn");
|
||||||
if (createBtn) {
|
if (createBtn) {
|
||||||
@@ -375,7 +474,7 @@ async function _nwcCreateWallet() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
_nwcSetMessage("error", "Wallet Connection name is required.");
|
_nwcSetMessage("error", "Wallet name is required.");
|
||||||
_nwcRenderWalletState();
|
_nwcRenderWalletState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -459,7 +558,7 @@ async function _nwcDrainWallet(walletId) {
|
|||||||
|
|
||||||
async function _nwcDeleteWallet(walletId) {
|
async function _nwcDeleteWallet(walletId) {
|
||||||
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
||||||
if (!window.confirm("Delete this wallet connection? This removes the wallet alias from Wallet Connections.")) return;
|
if (!window.confirm("Delete this wallet connection? This removes the wallet alias from Lightning Wallet Connections.")) return;
|
||||||
_nwcBusy(true);
|
_nwcBusy(true);
|
||||||
_nwcClearMessage();
|
_nwcClearMessage();
|
||||||
_nwcRenderWalletState();
|
_nwcRenderWalletState();
|
||||||
@@ -500,6 +599,8 @@ async function _nwcInitWalletFlow(unit, name, icon) {
|
|||||||
|
|
||||||
async function openServiceDetailModal(unit, name, icon) {
|
async function openServiceDetailModal(unit, name, icon) {
|
||||||
if (!$credsModal) return;
|
if (!$credsModal) return;
|
||||||
|
var isNwc = _isNwcServiceUnit(unit);
|
||||||
|
_setCredsDialogWide(isNwc);
|
||||||
if ($credsTitle) {
|
if ($credsTitle) {
|
||||||
$credsTitle.innerHTML = '';
|
$credsTitle.innerHTML = '';
|
||||||
if (icon) {
|
if (icon) {
|
||||||
@@ -521,13 +622,24 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
var url = "/api/service-detail/" + encodeURIComponent(unit);
|
var url = "/api/service-detail/" + encodeURIComponent(unit);
|
||||||
if (icon) url += "?icon=" + encodeURIComponent(icon);
|
if (icon) url += "?icon=" + encodeURIComponent(icon);
|
||||||
var data = await apiFetch(url);
|
var data = await apiFetch(url);
|
||||||
|
|
||||||
|
// Two content buckets. For most services everything lands in `html` and is
|
||||||
|
// rendered as one column, exactly as before. For Lightning Wallet
|
||||||
|
// Connections the day-to-day wallet manager (html) is split away from the
|
||||||
|
// service plumbing — status, enable/disable, domain, ports, restart
|
||||||
|
// (setupHtml) — and the two are rendered as tabs so neither feels cramped.
|
||||||
var html = "";
|
var html = "";
|
||||||
|
var setupHtml = "";
|
||||||
|
function addSetup(chunk) {
|
||||||
|
if (isNwc) setupHtml += chunk;
|
||||||
|
else html += chunk;
|
||||||
|
}
|
||||||
|
|
||||||
// Section A: Description
|
// Section A: Description
|
||||||
if (data.description) {
|
if (data.description) {
|
||||||
html += '<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
|
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
|
||||||
'</div>';
|
'</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section B: Status
|
// Section B: Status
|
||||||
@@ -539,13 +651,13 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
: (data.health || data.status);
|
: (data.health || data.status);
|
||||||
var sc = statusClass(effectiveHealth);
|
var sc = statusClass(effectiveHealth);
|
||||||
var st = statusText(effectiveHealth, effectiveEnabled);
|
var st = statusText(effectiveHealth, effectiveEnabled);
|
||||||
html += '<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Status</div>' +
|
'<div class="svc-detail-section-title">Status</div>' +
|
||||||
'<div class="svc-detail-status">' +
|
'<div class="svc-detail-status">' +
|
||||||
'<span class="status-dot ' + sc + '"></span>' +
|
'<span class="status-dot ' + sc + '"></span>' +
|
||||||
'<span>' + escHtml(st) + '</span>' +
|
'<span>' + escHtml(st) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>');
|
||||||
|
|
||||||
// Section B2: BIP-110 live status (bip110 tile only)
|
// Section B2: BIP-110 live status (bip110 tile only)
|
||||||
if (icon === 'bip110' && data.bip110) {
|
if (icon === 'bip110' && data.bip110) {
|
||||||
@@ -587,15 +699,15 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
|
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
||||||
stepsHtml +
|
stepsHtml +
|
||||||
domainActionHtml +
|
domainActionHtml +
|
||||||
'</div>';
|
'</div>');
|
||||||
|
|
||||||
if (data.router_ports && data.router_ports.length > 0) {
|
if (data.router_ports && data.router_ports.length > 0) {
|
||||||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||||
html += '<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||||
renderPortForwardGuideHtml(data.router_ports, {
|
renderPortForwardGuideHtml(data.router_ports, {
|
||||||
internalIp: trimmedInternalIp || null,
|
internalIp: trimmedInternalIp || null,
|
||||||
@@ -603,7 +715,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
introClass: "svc-detail-port-note",
|
introClass: "svc-detail-port-note",
|
||||||
noteClass: "svc-detail-port-note",
|
noteClass: "svc-detail-port-note",
|
||||||
}) +
|
}) +
|
||||||
'</div>';
|
'</div>');
|
||||||
}
|
}
|
||||||
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||||
// Non-domain services (e.g. SSH): show what to forward, plus a short
|
// Non-domain services (e.g. SSH): show what to forward, plus a short
|
||||||
@@ -632,48 +744,57 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Section E: Credentials & Links
|
// Section E: Credentials & Links
|
||||||
if (_isNwcServiceUnit(unit)) {
|
if (isNwc) {
|
||||||
html += '<div class="svc-detail-section">' +
|
|
||||||
'<div class="svc-detail-section-title">Wallet Connections & Node Liquidity</div>';
|
|
||||||
if (effectiveEnabled || data.enabled) {
|
if (effectiveEnabled || data.enabled) {
|
||||||
html += '<div class="nwc-liquidity-guide-card">' +
|
html += '<div class="nwc-tab-intro">' +
|
||||||
'<div class="nwc-liquidity-header">' +
|
|
||||||
'<span class="nwc-liquidity-icon">⚡</span>' +
|
|
||||||
'<div>' +
|
'<div>' +
|
||||||
'<h4 style="margin:0 0 4px 0; font-size:15px; font-weight:600;">Lightning Channel & Liquidity Guide</h4>' +
|
'<div class="nwc-tab-intro-title">Your Lightning Wallets</div>' +
|
||||||
'<p class="nwc-liquidity-subtitle" style="margin:0; font-size:12px; color:var(--text-secondary);">How NWC & Lightning Addresses work on new nodes</p>' +
|
'<p class="nwc-tab-intro-desc">Each wallet is isolated: pair it to one app with a one-time secret, and share its Lightning Address to get paid.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<p style="font-size:13px; line-height:1.5; margin:0 0 12px 0;">' +
|
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
|
||||||
'Your Alby Hub, NWC pairing keys, and Lightning Address endpoints (like <code>name@yourdomain.com</code>) are set up and working instantly. However, sending and receiving actual Bitcoin payments requires an operational Lightning node with active channels.' +
|
'<details class="nwc-liquidity-details">' +
|
||||||
|
'<summary class="nwc-liquidity-summary">' +
|
||||||
|
'<span class="nwc-liquidity-icon">⚡</span>' +
|
||||||
|
'<span>' +
|
||||||
|
'<strong>Lightning Channel & Liquidity Guide</strong>' +
|
||||||
|
'<span class="nwc-liquidity-subtitle">Why a brand-new node can\'t send or receive yet</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'</summary>' +
|
||||||
|
'<div class="nwc-liquidity-guide-card">' +
|
||||||
|
'<p class="nwc-liquidity-body">' +
|
||||||
|
'Your wallets, pairing keys, and Lightning Address endpoints (like <code>name@yourdomain.com</code>) work the moment you create them. Moving actual bitcoin, though, needs a Lightning node with open channels.' +
|
||||||
'</p>' +
|
'</p>' +
|
||||||
'<div class="nwc-liquidity-steps">' +
|
'<div class="nwc-liquidity-steps">' +
|
||||||
'<div class="nwc-liquidity-step">' +
|
'<div class="nwc-liquidity-step">' +
|
||||||
'<div class="step-num">1</div>' +
|
'<div class="step-num">1</div>' +
|
||||||
'<div>' +
|
'<div>' +
|
||||||
'<strong>Outbound Liquidity (Sending)</strong>' +
|
'<strong>Outbound Liquidity (Sending)</strong>' +
|
||||||
'<p style="margin:2px 0 0 0; color:var(--text-secondary); font-size:12px;">Required so apps connected via NWC can pay invoices and send funds.</p>' +
|
'<p class="nwc-liquidity-step-desc">Required so connected apps can pay invoices and send funds.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="nwc-liquidity-step">' +
|
'<div class="nwc-liquidity-step">' +
|
||||||
'<div class="step-num">2</div>' +
|
'<div class="step-num">2</div>' +
|
||||||
'<div>' +
|
'<div>' +
|
||||||
'<strong>Inbound Liquidity (Receiving)</strong>' +
|
'<strong>Inbound Liquidity (Receiving)</strong>' +
|
||||||
'<p style="margin:2px 0 0 0; color:var(--text-secondary); font-size:12px;">Required so your Lightning Address can receive incoming payments from anyone.</p>' +
|
'<p class="nwc-liquidity-step-desc">Required so your Lightning Address can receive payments from anyone.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="nwc-liquidity-action">' +
|
'<div class="nwc-liquidity-action">' +
|
||||||
'<p style="margin:0 0 10px 0; font-size:12.55px; line-height:1.4;">💧 <strong>The Plumbing Analogy:</strong> Think of channels like two-way water pipes. To pour water in (receive) or pump water out (send), you need open channels with liquidity on both sides.</p>' +
|
'<p class="nwc-liquidity-analogy">💧 <strong>The Plumbing Analogy:</strong> Channels are two-way water pipes. To pour water in (receive) or pump it out (send), you need open channels with liquidity on the right side.</p>' +
|
||||||
'<button class="matrix-action-btn secondary-btn" id="nwc-open-rtl-btn" style="display:inline-flex; align-items:center; gap:6px; background:var(--border-color); color:var(--text-primary); border:none; padding:8px 14px; border-radius:8px; font-size:12.55px; cursor:pointer;">🚀 Open Ride The Lightning (RTL) to Manage Channels</button>' +
|
'<button class="matrix-action-btn nwc-rtl-btn" id="nwc-open-rtl-btn">🚀 Open Ride The Lightning (RTL) to Manage Channels</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<p class="svc-detail-desc" style="margin-top:16px;">Create, verify, drain, and delete isolated NWC wallet connections without leaving the Hub.</p>' +
|
'</details>';
|
||||||
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallet connections…</p></div>';
|
|
||||||
} else {
|
} else {
|
||||||
html += '<p class="creds-empty">Wallet Connections is disabled. Enable this feature below, rebuild, then return here to create and manage NWC wallet connections.</p>';
|
html += '<div class="nwc-disabled-state">' +
|
||||||
|
'<div class="nwc-disabled-icon">⚡</div>' +
|
||||||
|
'<div class="nwc-disabled-title">Lightning Wallet Connections is turned off</div>' +
|
||||||
|
'<p class="nwc-disabled-desc">Enable it on the <strong>Service & Setup</strong> tab and rebuild, then come back here to create and manage wallets.</p>' +
|
||||||
|
'<button class="btn btn-primary" id="nwc-goto-setup-btn">⚙ Go to Service & Setup</button>' +
|
||||||
|
'</div>';
|
||||||
}
|
}
|
||||||
html += '</div>';
|
|
||||||
} else if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
} else if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||||||
html += '<div class="svc-detail-section">' +
|
html += '<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||||
@@ -733,7 +854,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
conflictsHtml = '<div class="feature-conflict-warning">\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
conflictsHtml = '<div class="feature-conflict-warning">\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">' + addonSectionTitle + '</div>' +
|
'<div class="svc-detail-section-title">' + addonSectionTitle + '</div>' +
|
||||||
'<p class="svc-detail-desc">' + escHtml(addonDesc) + '</p>' +
|
'<p class="svc-detail-desc">' + escHtml(addonDesc) + '</p>' +
|
||||||
conflictsHtml +
|
conflictsHtml +
|
||||||
@@ -741,19 +862,35 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'<span class="svc-detail-addon-status ' + addonStatusCls + '">' + addonStatusLabel + '</span>' +
|
'<span class="svc-detail-addon-status ' + addonStatusCls + '">' + addonStatusLabel + '</span>' +
|
||||||
'<button class="' + addonBtnCls + '" id="svc-detail-addon-btn">' + escHtml(addonBtnLabel) + '</button>' +
|
'<button class="' + addonBtnCls + '" id="svc-detail-addon-btn">' + escHtml(addonBtnLabel) + '</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||||
html += '<div class="svc-detail-section svc-detail-restart-section">' +
|
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||||
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
|
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
|
||||||
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
|
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
|
||||||
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
|
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
|
||||||
'</div>';
|
'</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNwc) {
|
||||||
|
// Land on Setup when the service is off or the domain still needs
|
||||||
|
// configuring — otherwise the Wallets tab is the useful default.
|
||||||
|
var domainReady = !!data.domain;
|
||||||
|
var startTab = (effectiveEnabled || data.enabled) && domainReady ? "wallets" : "setup";
|
||||||
|
html = _nwcTabsShellHtml({
|
||||||
|
walletsHtml: html,
|
||||||
|
setupHtml: setupHtml,
|
||||||
|
statusDotClass: sc,
|
||||||
|
statusLabel: st,
|
||||||
|
domain: data.domain || null,
|
||||||
|
activeTab: startTab
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$credsBody.innerHTML = html;
|
$credsBody.innerHTML = html;
|
||||||
|
if (isNwc) _nwcWireTabs();
|
||||||
_attachCopyHandlers($credsBody);
|
_attachCopyHandlers($credsBody);
|
||||||
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
||||||
await _nwcInitWalletFlow(unit, name, icon);
|
await _nwcInitWalletFlow(unit, name, icon);
|
||||||
@@ -849,6 +986,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
|
|
||||||
async function openCredsModal(unit, name, icon) {
|
async function openCredsModal(unit, name, icon) {
|
||||||
if (!$credsModal) return;
|
if (!$credsModal) return;
|
||||||
|
_setCredsDialogWide(false);
|
||||||
if ($credsTitle) {
|
if ($credsTitle) {
|
||||||
$credsTitle.innerHTML = '';
|
$credsTitle.innerHTML = '';
|
||||||
if (icon) {
|
if (icon) {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ $HAVEN {
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Wallet Connections LNURL ──────────────────────
|
# ── Lightning Wallet Connections LNURL ──────────────────────
|
||||||
if [ -n "$LIGHTNING" ]; then
|
if [ -n "$LIGHTNING" ]; then
|
||||||
cat >> /run/caddy/Caddyfile <<EOF
|
cat >> /run/caddy/Caddyfile <<EOF
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||||
"nwc-wallets" = lib.mkEnableOption "Wallet Connections";
|
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
|
||||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||||
sshd = lib.mkEnableOption "SSH remote access";
|
sshd = lib.mkEnableOption "SSH remote access";
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ let
|
|||||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||||
]; }
|
]; }
|
||||||
{ name = "Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
{ name = "Lightning Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
|
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
|
||||||
]; }
|
]; }
|
||||||
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
assertion = config.services.lnd.enable;
|
assertion = config.services.lnd.enable;
|
||||||
message = "Wallet Connections requires services.lnd.enable = true.";
|
message = "Lightning Wallet Connections requires services.lnd.enable = true.";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config);
|
assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config);
|
||||||
message = "Wallet Connections requires nix-bitcoin.netns-isolation.enable = false.";
|
message = "Lightning Wallet Connections requires nix-bitcoin.netns-isolation.enable = false.";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = albyHubPort != config.services.lnd.restPort;
|
assertion = albyHubPort != config.services.lnd.restPort;
|
||||||
@@ -134,7 +134,7 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.nwc-lnurl = {
|
systemd.services.nwc-lnurl = {
|
||||||
description = "Wallet Connections public LNURL service";
|
description = "Lightning Wallet Connections public LNURL service";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "albyhub.service" "sovran-hub-web.service" ];
|
after = [ "albyhub.service" "sovran-hub-web.service" ];
|
||||||
wants = [ "albyhub.service" ];
|
wants = [ "albyhub.service" ];
|
||||||
|
|||||||
Reference in New Issue
Block a user