Files
archy/neode-ui/src/views/settings/LightningCredentialsSection.vue
T
archipelagoandClaude Opus 5 51a4b59ce8 fix(settings): card the Lightning section, and stop it crying "not installed" mid-rotation
Two operator reports on the same screen.

The section had no card. Every other Settings section wraps itself in
`glass-card px-6 py-6 mb-6` — AccountSection, AIDataAccessSection,
NodeCertificateSection, BackupSection, the lot — and this one rendered as
bare text on the page. Reported twice, because the wrapper lives in the new
component and nothing about adding `<LightningCredentialsSection />` to
SystemSection.vue's list tells you it is missing. Heading moved to h2/text-xl
to match its siblings. A test now asserts the card, so a third report is not
needed.

And rotating told the operator Lightning did not exist. Rotation restarts
LND, so `status.installed` reads false for a moment — and the template read
that literally: "Lightning is not set up on this node yet, so there are no
credentials to rotate. Install the Lightning app first." Seconds after
rotating. On a node with a working wallet. It also replaced the progress they
had every reason to be watching, on the one action that invalidates every
credential their wallet holds.

A container briefly absent is what rotating LOOKS like, not evidence
Lightning was never there. The not-installed message is now gated on
`!rotationInFlight`, which covers both `running: true` and the awaitUntil
window between asking for a rotation and the node reporting one — `installed`
can already be false in that gap, so gating on `running` alone would have
left the same hole. Mid-rotation with no status yet says "Rotating
credentials — Lightning is restarting" instead of falling through to a
details block with empty fields.

awaitUntil became a ref so the computed re-evaluates rather than holding a
stale value until some other reactive dependency happens to change.

Three tests: the card exists; a running rotation does not claim Lightning is
missing; and — the half that matters just as much — a node with genuinely no
Lightning still gets told there is nothing to rotate, so the fix has not
simply hidden a true statement. 16/16, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:11:18 -04:00

413 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { rpcClient, type LndMacaroonStatus, type LndRotationProgress } from '@/api/rpc-client'
// A Lightning macaroon is a bearer token: whoever holds one can spend from this
// node's wallet. Rotating them is the only way to take that ability back from
// anything that has seen one — a lost phone, a shared screenshot, an app that
// ran a version with a published vulnerability. Until now that meant SSHing in
// and running a script, which in practice meant it did not happen.
//
// The screen is deliberately fact-first. Before anyone clicks the button they
// can see when the credentials were issued, which node they belong to, how many
// channels must survive, and whether anything on this node is already out of
// step — because "will this close my channels?" is the question that stops
// people rotating, and the honest answer is on the page.
const status = ref<LndMacaroonStatus | null>(null)
const loading = ref(true)
const loadError = ref('')
const showConfirm = ref(false)
const password = ref('')
const submitting = ref(false)
const confirmError = ref('')
let poll: ReturnType<typeof setInterval> | null = null
/// Epoch ms until which we keep polling even though the node has not reported a
/// running rotation yet.
///
/// Without this the screen can freeze on the one action that most needs to show
/// progress: `rotate()` starts polling, the `load()` right behind it observes a
/// status snapshot that does not yet carry `running: true`, and `syncPolling`
/// cancels the interval. The operator has just invalidated every credential
/// their wallet holds and the page tells them nothing is happening.
///
/// Bounded rather than a plain flag, so a request the node accepted but never
/// acted on stops polling instead of hammering it forever.
const awaitUntil = ref(0)
const AWAIT_START_MS = 120_000
const rotation = computed<LndRotationProgress | null>(() => status.value?.rotation ?? null)
const isRunning = computed(() => rotation.value?.running === true)
/// Ticks while a rotation is being awaited, so `rotationInFlight` re-evaluates
/// as the await window expires instead of holding a stale value until the next
/// poll happens to touch a reactive dependency.
const now = ref(Date.now())
/// Is a rotation happening, INCLUDING the gap between asking for one and the
/// node reporting it?
///
/// Rotation restarts LND, so `status.installed` goes false for a moment
/// mid-rotation. Read literally that says "Lightning is not set up on this
/// node" — which the screen then told the operator, seconds after they
/// rotated, on a node with a working Lightning wallet. The container being
/// briefly absent is what rotating LOOKS like, not evidence it was never
/// there.
const rotationInFlight = computed(() => isRunning.value || now.value < awaitUntil.value)
/** A finished rotation, successful or not. `ok` is null while running. */
const finished = computed(
() => rotation.value !== null && !rotation.value.running && rotation.value.ok !== null,
)
/** BTCPay embeds a copy of the macaroon inline and cannot self-heal, so it is
* the one dependency that can silently fall out of step. `false` is the state
* worth shouting about; `null` just means BTCPay has no internal node. */
const btcpayStale = computed(() => status.value?.btcpay_credential_current === false)
async function load() {
try {
status.value = await rpcClient.lndMacaroonStatus()
loadError.value = ''
syncPolling()
} catch (e) {
loadError.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
/// Poll only while there is something to watch, so an idle Settings tab isn't
/// waking the node every few seconds.
function syncPolling() {
const running = status.value?.rotation.running === true
if (running) awaitUntil.value = 0
now.value = Date.now()
if (running || Date.now() < awaitUntil.value) startPolling()
else stopPolling()
}
function startPolling() {
if (poll) return
poll = setInterval(load, 4000)
}
function stopPolling() {
if (poll) {
clearInterval(poll)
poll = null
}
}
function openConfirm() {
password.value = ''
confirmError.value = ''
showConfirm.value = true
}
function closeConfirm() {
showConfirm.value = false
password.value = ''
confirmError.value = ''
}
async function rotate() {
submitting.value = true
confirmError.value = ''
try {
await rpcClient.lndRotateMacaroons(password.value)
closeConfirm()
awaitUntil.value = Date.now() + AWAIT_START_MS
now.value = Date.now()
startPolling()
await load()
} catch (e) {
confirmError.value = e instanceof Error ? e.message : String(e)
} finally {
submitting.value = false
password.value = ''
}
}
function stepIcon(state: string): string {
switch (state) {
case 'done':
return '✓'
case 'failed':
return '✕'
case 'skipped':
return ''
case 'running':
return '…'
default:
return '·'
}
}
function stepClass(state: string): string {
switch (state) {
case 'done':
return 'text-emerald-400'
case 'failed':
return 'text-red-400'
case 'skipped':
return 'text-white/40'
case 'running':
return 'text-orange-300'
default:
return 'text-white/30'
}
}
/** First 16 characters is plenty to compare two digests by eye, and keeps the
* line readable on a phone. */
function shortHash(h: string | null): string {
return h ? `${h.slice(0, 16)}…` : '—'
}
onMounted(load)
onUnmounted(stopPolling)
</script>
<template>
<div class="glass-card px-6 py-6 mb-6">
<!-- glass-card, like every other Settings section (AccountSection,
AIDataAccessSection, NodeCertificateSection, BackupSection ). This
rendered as bare text on the Settings page twice, because a new
section carries its own wrapper and nothing about adding it to
SystemSection.vue's list reminds you. Heading is h2/text-xl to match
those siblings. Kept INSIDE the root: a leading comment makes the
component a fragment, which drops the root class and breaks attribute
inheritance. -->
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning credentials</h2>
<p class="text-sm text-white/60 mb-4">
Wallet apps like Zeus connect to this node using a Lightning credential — a
token that lets them spend. Rotating replaces every one of them, so anything
that copied an old token can no longer use it. Your coins and channels are
not touched: the node keeps its identity and no channel is closed.
</p>
<div v-if="loading" class="text-sm text-white/50">Checking…</div>
<div
v-else-if="loadError"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
Could not read the Lightning credential state: {{ loadError }}
</div>
<!-- `&& !rotationInFlight`: rotating restarts LND, so `installed` reads
false for a moment mid-rotation. Without the guard this told the
operator "Lightning is not set up on this node yet" seconds after they
rotated on a node with a working wallet — and it replaced the progress
they were watching. A container briefly absent is what rotating looks
like, not proof Lightning was never installed. -->
<div
v-else-if="!status?.installed && !rotationInFlight"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
Lightning is not set up on this node yet, so there are no credentials to
rotate. Install the Lightning app first.
</div>
<!-- Mid-rotation with no status to render yet: say what is happening
rather than falling through to the details block with empty fields. -->
<div
v-else-if="!status?.installed"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
Rotating credentials — Lightning is restarting. This takes a moment.
</div>
<div v-else class="space-y-4">
<!-- What exists right now -->
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<dt class="text-white/50 text-xs">Issued</dt>
<dd class="text-white/80">{{ status.issued_at || 'unknown' }}</dd>
</div>
<div>
<dt class="text-white/50 text-xs">Credential fingerprint</dt>
<dd class="text-white/80 font-mono text-xs break-all">
{{ shortHash(status.admin_macaroon_sha256) }}
</dd>
</div>
<div>
<dt class="text-white/50 text-xs">Channels that must survive</dt>
<dd class="text-white/80">
<template v-if="status.channels_open !== null">
{{ status.channels_open }} open<span v-if="status.channels_pending">
, {{ status.channels_pending }} pending</span
>
</template>
<span v-else class="text-white/50">not readable — Lightning is not answering</span>
</dd>
</div>
<div>
<dt class="text-white/50 text-xs">Node identity</dt>
<dd class="text-white/80 font-mono text-xs break-all">
{{ status.identity_pubkey ? `${status.identity_pubkey.slice(0, 16)}…` : '' }}
</dd>
</div>
</dl>
<!-- Lightning has to be answering for a rotation to be verifiable at all,
so this is a blocker rather than a footnote. -->
<div
v-if="status.lnd_error && !isRunning"
class="p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm text-orange-100/90"
>
<p class="font-medium mb-1">Lightning is not answering right now.</p>
<p class="text-orange-100/70">
Rotation is blocked until it is: without a reading from before the
change there is no way to prove afterwards that your channels came
back. Wait for Lightning to finish starting and reload this page.
</p>
<p class="text-xs text-orange-100/50 mt-2 font-mono break-all">{{ status.lnd_error }}</p>
</div>
<!-- The failure this whole feature exists to prevent. -->
<div
v-if="btcpayStale"
class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-100/90"
>
<p class="font-medium mb-1">BTCPay Server is holding an old Lightning credential.</p>
<p class="text-red-100/70">
BTCPay keeps its own copy of the credential, and the copy it has no
longer works — so its Lightning payments will fail even though both
apps look healthy. Rotating now repairs this as part of the run.
</p>
</div>
<!-- Progress. Shown while running and kept afterwards, because the
verdict ("same node, same channels") is the reassurance the operator
came here for. -->
<div v-if="rotation && (isRunning || finished)" class="p-3 bg-white/5 border border-white/10 rounded-lg">
<p class="text-sm font-medium text-white/80 mb-2">
<span v-if="isRunning">Rotating…</span>
<span v-else-if="rotation.ok" class="text-emerald-400">Rotation complete</span>
<span v-else class="text-red-400">Rotation failed</span>
</p>
<ul class="space-y-1.5">
<li v-for="step in rotation.steps" :key="step.key" class="text-sm">
<span class="font-mono mr-2" :class="stepClass(step.state)">{{
stepIcon(step.state)
}}</span>
<span :class="step.state === 'pending' ? 'text-white/40' : 'text-white/80'">{{
step.label
}}</span>
<p v-if="step.detail" class="ml-6 text-xs text-white/50">{{ step.detail }}</p>
</li>
</ul>
<p v-if="rotation.error" class="mt-3 text-xs text-red-300/90 break-words">
{{ rotation.error }}
</p>
<div v-if="finished && rotation.ok" class="mt-3 space-y-2 text-xs text-white/60">
<p class="text-white/80">
Re-pair anything that connects to this node — Zeus most importantly.
Open the Lightning app and scan its pairing QR again; it serves the
new credential.
</p>
<p v-if="rotation.backup_path">
The old credentials were backed up on the node so a mistake is
recoverable. That backup is still sensitive. Once every app is
re-paired, delete it:
<code class="block mt-1 px-2 py-1 bg-black/30 rounded font-mono break-all"
>sudo rm -rf {{ rotation.backup_path }}</code
>
</p>
</div>
</div>
<button
:disabled="isRunning || !!status.lnd_error"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium disabled:opacity-50 disabled:cursor-not-allowed"
@click="openConfirm"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{{ isRunning ? 'Rotating' : 'Rotate Lightning credentials' }}</span>
</button>
</div>
</div>
<!-- Confirmation. Teleported to body: a glass-panel ancestor creates a
transform context that would trap a position:fixed backdrop. -->
<Teleport to="body">
<div
v-if="showConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeConfirm"
@keydown.escape="closeConfirm"
>
<div
class="glass-card p-6 max-w-md w-full"
role="dialog"
aria-modal="true"
aria-labelledby="rotate-macaroon-title"
>
<h3 id="rotate-macaroon-title" class="text-lg font-semibold text-white mb-2">
Rotate Lightning credentials
</h3>
<div class="text-sm text-white/70 space-y-2 mb-4">
<p>
<strong class="text-white/90">What changes:</strong> every app paired
with this node stops working until you re-pair it. Zeus and any other
remote wallet will need to scan a fresh pairing code.
</p>
<p>
<strong class="text-white/90">What does not:</strong> your coins and
your channels. The node keeps its identity, nothing is closed, and
this run refuses to report success unless it has confirmed both.
</p>
<p>
Lightning restarts as part of this, which takes a few minutes on a
busy node. Payments cannot be sent or received during that window.
</p>
</div>
<form class="space-y-4" @submit.prevent="rotate">
<label class="block">
<span class="text-xs text-white/60">Confirm with your node password</span>
<input
v-model="password"
type="password"
required
autocomplete="current-password"
class="mt-1 w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Node password"
/>
</label>
<p v-if="confirmError" class="text-sm text-red-400 break-words">{{ confirmError }}</p>
<div class="flex gap-3">
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg glass-button font-medium"
@click="closeConfirm"
>
Cancel
</button>
<button
type="submit"
:disabled="submitting || !password"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ submitting ? 'Starting' : 'Rotate' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>