Merge remote-tracking branch 'gitea-ai/gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf'
Demo images / Build & push demo images (push) Successful in 3m14s

This commit is contained in:
archipelago
2026-08-09 08:17:22 -04:00
481 changed files with 90667 additions and 210 deletions
+233 -3
View File
@@ -30,7 +30,17 @@
</div>
</Transition>
<!-- AIUI iframe on mobile, leave room for close bar + tab bar at bottom -->
<!-- AIUI iframe on mobile, leave room for close bar + tab bar at bottom.
No `sandbox` attribute: it was considered and rejected for this
phase (AIUI-04, 13-RESEARCH.md Open Question 2). `allow-scripts`
together with `allow-same-origin` is the well-known escape pattern,
and dropping `allow-same-origin` moves AIUI to an opaque origin,
breaking its storage and its origin-checked postMessage bridge a
change bigger than this phase budgeted. The enforced boundary
instead is the /aiui/-scoped Content-Security-Policy (nginx) plus
the node-side rate limit (G-B3, 13-12); the residual risk (a
browser that ignores or partially enforces CSP) is named in
13-AI-SPEC.md §6, not silently assumed away. -->
<iframe
v-if="aiuiUrl"
ref="aiuiFrame"
@@ -38,6 +48,7 @@
:title="t('chat.aiAssistant')"
class="chat-iframe chat-iframe-mobile"
allow="microphone"
referrerpolicy="no-referrer"
style="background: transparent"
/>
@@ -59,19 +70,67 @@
</div>
</div>
<!-- 13-08 (D-11): the destructive-tool confirmation dialog trusted
chrome, mounted as a SIBLING of the iframe, never inside it. The
component Teleports to body with a full-screen backdrop, so it
covers the whole viewport including the area over the iframe, and
no ancestor transform can trap its position: fixed. Its text is
node-authored, fetched by the ContextBroker over the page's own
RPC session — nothing the iframe sends can open or resolve it. -->
<ToolConfirmModal
:show="!!toolConfirm"
:description="toolConfirm?.description ?? ''"
@approve="resolveToolConfirm(true)"
@deny="resolveToolConfirm(false)"
@dismiss="dismissToolConfirm"
/>
<!-- A tool the operator asked for was blocked by an ungranted
category. Trusted chrome, and Teleported to body for the same
reason ToolConfirmModal is: a transformed ancestor would trap
position:fixed. This only OFFERS the settings screen — it never
changes a grant itself, so nothing the iframe or the model says
can widen permissions. -->
<Teleport to="body">
<Transition name="fade">
<div v-if="permissionNeeded.length" class="chat-permission-offer" role="status">
<p class="text-sm text-white/85">
{{ t('chat.permissionNeeded', { categories: permissionNeededLabels }) }}
</p>
<div class="flex items-center gap-2 shrink-0">
<button class="chat-permission-btn" @click="openAISettings">
{{ t('chat.openAISettings') }}
</button>
<button
class="chat-permission-dismiss"
:aria-label="t('common.dismiss')"
@click="permissionNeeded = []"
>
<svg class="w-4 h-4" aria-hidden="true" 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>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker'
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import { IS_DEMO } from '@/composables/useDemoIntro'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
@@ -109,6 +168,47 @@ const aiuiUrl = computed(() => {
return ''
})
// ⌘K → "Talk to AIUI about it" hands the typed text over as `?ask=`.
//
// It is delivered by postMessage, NOT by adding a query param to `aiuiUrl`.
// That is deliberate: the comment on D14_FLAGS above explains that aiuiUrl must
// have no reactive dependencies so the iframe `src` stays byte-identical and
// AIUI survives a tab switch. Threading `ask` through the URL would rebuild the
// src on every question and reload AIUI, discarding the conversation — the
// exact opposite of what this feature is for.
//
// The ask is queued rather than sent directly, because the common case is
// arriving from ⌘K on a cold Chat tab where the iframe has not handshaked yet.
// `ready` flushes it.
const pendingAsk = ref('')
function flushAsk() {
const text = pendingAsk.value
if (!text || !aiuiConnected.value) return
const frame = aiuiFrame.value
if (!frame?.contentWindow || !aiuiUrl.value) return
let targetOrigin: string
try {
targetOrigin = new URL(aiuiUrl.value, window.location.origin).origin
} catch { return }
frame.contentWindow.postMessage({ type: 'chat:prefill', text }, targetOrigin)
pendingAsk.value = ''
// Drop ask/askedAt from the URL so a refresh or a back-nav does not re-ask.
const { ask: _a, askedAt: _t, ...rest } = route.query
router.replace({ path: route.path, query: rest })
}
watch(
() => route.query.askedAt,
() => {
const ask = route.query.ask
if (!ask) return
pendingAsk.value = String(ask)
flushAsk()
},
{ immediate: true },
)
function closeChat() {
if (window.history.length > 1) {
router.back()
@@ -117,6 +217,73 @@ function closeChat() {
}
}
// 13-08 (D-11): the pending destructive-tool confirmation the trusted
// chrome is currently showing. Set ONLY from the ContextBroker's
// aiui:tool-confirm-request CustomEvent, whose payload is node-fetched
// over the page's own RPC session — never from anything the iframe posts.
const toolConfirm = ref<{ reqId: string; description: string } | null>(null)
function onToolConfirmRequest(e: Event) {
const detail = (e as CustomEvent).detail as { reqId?: string; description?: string }
if (!detail?.reqId || typeof detail.description !== 'string') return
toolConfirm.value = { reqId: detail.reqId, description: detail.description }
}
function resolveToolConfirm(approved: boolean) {
const current = toolConfirm.value
toolConfirm.value = null
if (!current) return
// The decision travels back to the broker (and from there to the node
// over the authenticated RPC session) — never through the iframe.
window.dispatchEvent(
new CustomEvent('aiui:tool-confirm-response', {
detail: { reqId: current.reqId, approved },
}),
)
}
function dismissToolConfirm() {
// Closed without a decision: send nothing. The action stays pending on
// the node until its own timeout declines it — never silently approved.
toolConfirm.value = null
}
function onToolConfirmExpired(e: Event) {
// 13-08 on-device UAT: the node no longer holds this pending action
// (timed out, or resolved elsewhere) — close the dialog rather than
// leave the human an Approve button whose click can only be refused.
const detail = (e as CustomEvent).detail as { reqId?: string }
if (toolConfirm.value && detail?.reqId === toolConfirm.value.reqId) {
toolConfirm.value = null
}
}
// A tool the operator's question needed was refused because its category
// is off. The node reports WHICH categories; we name them and offer the
// screen that owns the toggles. Never flips a toggle here — the operator
// decides, on the settings screen, in the trusted chrome.
const permissionNeeded = ref<string[]>([])
const permissionNeededLabels = computed(() =>
permissionNeeded.value
.map((id) => AI_PERMISSION_CATEGORIES.find((c) => c.id === id)?.label ?? id)
.join(', '),
)
function onPermissionNeeded(e: Event) {
const detail = (e as CustomEvent).detail as { categories?: unknown }
const categories = Array.isArray(detail?.categories) ? detail.categories : []
const known = categories.filter(
(c): c is string => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some((k) => k.id === c),
)
if (known.length) permissionNeeded.value = known
}
function openAISettings() {
permissionNeeded.value = []
router.push({ path: '/dashboard/settings', hash: '#ai-data-access' })
}
function onAiuiMessage(event: MessageEvent) {
if (!aiuiUrl.value) return
// Validate origin — only accept messages from AIUI
@@ -128,6 +295,8 @@ function onAiuiMessage(event: MessageEvent) {
if (event.data?.type === 'ready') {
aiuiConnected.value = true
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
// A ⌘K ask that arrived before the handshake is waiting — send it now.
flushAsk()
}
}
@@ -142,6 +311,12 @@ function onAiuiMessage(event: MessageEvent) {
function armChatLive() {
window.removeEventListener('message', onAiuiMessage)
window.addEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
window.addEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
window.addEventListener('aiui:permission-needed', onPermissionNeeded)
broker?.stop()
broker = null
if (aiuiUrl.value) {
@@ -161,6 +336,9 @@ onActivated(() => armChatLive())
onDeactivated(() => {
window.removeEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
broker?.stop()
broker = null
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
@@ -174,6 +352,9 @@ onMounted(() => armChatLive())
onBeforeUnmount(() => {
window.removeEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
broker?.stop()
broker = null
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
@@ -181,6 +362,55 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Teleported to body, so this is positioned against the viewport, not the
chat panel. Sits above the iframe but below the confirm modal — a
blocking decision must always win over a passive offer. */
.chat-permission-offer {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(1.25rem + var(--safe-bottom, 0px));
z-index: 60;
max-width: min(40rem, calc(100vw - 2rem));
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
border-radius: 0.875rem;
background: rgba(24, 24, 27, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.chat-permission-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
color: #fdba74;
background: rgba(251, 146, 60, 0.14);
border: 1px solid rgba(251, 146, 60, 0.3);
transition: background 0.15s ease;
}
.chat-permission-btn:hover {
background: rgba(251, 146, 60, 0.24);
}
.chat-permission-dismiss {
padding: 0.375rem;
border-radius: 0.5rem;
color: rgba(255, 255, 255, 0.5);
transition: color 0.15s ease, background 0.15s ease;
}
.chat-permission-dismiss:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.08);
}
.chat-loading {
position: absolute;
inset: 0;
+16 -6
View File
@@ -1656,9 +1656,18 @@ const fetchedUrls = ref<Map<string, string>>(new Map())
// `immediate: true` so already-loaded history gets the same treatment as
// newly-arriving messages.
const autoFetchedCids = new Set<string>()
watch(
() => chatMessages.value.length,
() => {
// NOT `immediate: true`. This watcher calls handleFetchContent, whose body
// touches consts declared further down the setup block — and an immediate
// watcher runs DURING setup, before those exist. Vue surfaced it as
// "ReferenceError: Cannot access 'b' before initialization" from
// Ye.immediate, and it fired whenever history already contained an inline
// content_ref, taking the whole Mesh view down with it.
//
// onMounted runs after setup completes, so every binding is initialized and
// already-loaded history still gets the same treatment as new messages —
// which is what `immediate` was there for.
function autoFetchInlineContent() {
for (const msg of chatMessages.value) {
const payload = msg.typed_payload as { cid?: string; inline?: boolean } | undefined
if (
@@ -1673,9 +1682,10 @@ watch(
void handleFetchContent(msg.typed_payload as any)
}
}
},
{ immediate: true },
)
}
watch(() => chatMessages.value.length, autoFetchInlineContent)
onMounted(autoFetchInlineContent)
// Transport chooser modal state — populated when advice comes back as
// "choose" (size fits both inline-over-mesh AND Tor). User picks a path;
@@ -13,9 +13,16 @@ import Chat from '../Chat.vue'
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
const routerReplaceMock = vi.fn()
// Chat reads route.query.ask/askedAt to receive a ⌘K "Talk to AIUI about it"
// handoff, and route.path when it strips those params back off. Kept empty by
// default so the byte-stability assertions below see no ask in play.
const routeMock = { path: '/dashboard/chat', query: {} as Record<string, string> }
vi.mock('vue-router', () => ({
useRouter: () => ({ back: routerBackMock, push: routerPushMock }),
useRouter: () => ({ back: routerBackMock, push: routerPushMock, replace: routerReplaceMock }),
useRoute: () => routeMock,
}))
vi.mock('vue-i18n', () => ({
@@ -62,6 +69,8 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
afterEach(() => {
vi.unstubAllEnvs()
routeMock.query = {}
routerReplaceMock.mockClear()
})
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
@@ -75,6 +84,56 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
wrapper.unmount()
})
// ⌘K → "Talk to AIUI about it" hands the typed text to AIUI. It must travel
// by postMessage: putting it in the URL would give aiuiUrl a reactive
// dependency and reload AIUI on every question, which is precisely the
// byte-stability property the rest of this file exists to protect.
it('delivers a ⌘K ask by postMessage on ready, leaving the iframe src untouched', async () => {
routeMock.query = { ask: 'why is bitcoin syncing slowly', askedAt: '111' }
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
expect(before).not.toContain('ask=')
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).toHaveBeenCalledWith(
{ type: 'chat:prefill', text: 'why is bitcoin syncing slowly' },
'http://localhost:5173',
)
// src must be byte-identical after the ask round-trip
expect(iframeSrc(wrapper)).toBe(before)
// and the params are stripped so a refresh does not silently re-ask
expect(routerReplaceMock).toHaveBeenCalled()
const replaceArg = routerReplaceMock.mock.calls[0]![0]
expect(replaceArg.query.ask).toBeUndefined()
expect(replaceArg.query.askedAt).toBeUndefined()
wrapper.unmount()
})
it('does not post a prefill when there is no ask in the route', async () => {
const { wrapper } = mountChatInKeepAlive()
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).not.toHaveBeenCalled()
wrapper.unmount()
})
it('is string-equal before and after a simulated viewport resize across the mobile breakpoint', async () => {
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry {
return {
@@ -82,6 +82,13 @@ describe('appsConfig service filtering', () => {
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
})
it('an unmapped id gets the A mark, not a guessed png that 404s', () => {
// strfry 404'd live on 2026-08-07: no curated entry, no fallback entry,
// no service prefix — the old `${id}.png` guess produced a broken tile.
const pkg = makePkg('strfry', 'strfry', 'nostr')
expect(resolveAppIcon('strfry', pkg)).toBe(DEFAULT_APP_ICON)
})
it('classifies an unknown app by whether its manifest declares a UI (#45)', () => {
// Headless: a LAN address but no declared UI → Website.
const headless = makePkg('some-backend', 'Some Backend', 'other')
+3 -1
View File
@@ -249,7 +249,9 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
curatedIcon ||
APP_ICON_FALLBACKS[id] ||
serviceParentIcon(id) ||
`/assets/img/app-icons/${id}.png`
// Never guess `${id}.png` — an unmapped id 404s (strfry did, 2026-08-07).
// The A mark is the honest unknown-app tile.
DEFAULT_APP_ICON
)
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
@@ -7,6 +7,12 @@ import ToggleSwitch from '@/components/ToggleSwitch.vue'
const { t } = useI18n()
const aiPermissions = useAIPermissionsStore()
// Grants live on the node, not in this browser's localStorage — reconcile on
// open so the switches show the node's truth rather than whatever this origin
// happens to remember. Without this the same node shows different settings at
// its LAN address and its Tailscale address.
onMounted(() => { void aiPermissions.hydrate() })
const aiCategoryGroups = computed(() => {
const groups: { label: string; items: typeof AI_PERMISSION_CATEGORIES }[] = []
for (const cat of AI_PERMISSION_CATEGORIES) {
@@ -22,8 +28,8 @@ const aiCategoryGroups = computed(() => {
</script>
<template>
<!-- AI Data Access Section -->
<div class="glass-card px-6 py-6 mb-6">
<!-- AI Data Access Section id is the banner's #ai-data-access hash target -->
<div id="ai-data-access" class="glass-card px-6 py-6 mb-6 scroll-mt-4">
<div class="mb-2">
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.aiDataAccess') }}</h2>
</div>
@@ -36,12 +36,28 @@ let poll: ReturnType<typeof setInterval> | null = null
///
/// Bounded rather than a plain flag, so a request the node accepted but never
/// acted on stops polling instead of hammering it forever.
let awaitUntil = 0
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,
@@ -68,8 +84,9 @@ async function load() {
/// waking the node every few seconds.
function syncPolling() {
const running = status.value?.rotation.running === true
if (running) awaitUntil = 0
if (running || Date.now() < awaitUntil) startPolling()
if (running) awaitUntil.value = 0
now.value = Date.now()
if (running || Date.now() < awaitUntil.value) startPolling()
else stopPolling()
}
@@ -103,7 +120,8 @@ async function rotate() {
try {
await rpcClient.lndRotateMacaroons(password.value)
closeConfirm()
awaitUntil = Date.now() + AWAIT_START_MS
awaitUntil.value = Date.now() + AWAIT_START_MS
now.value = Date.now()
startPolling()
await load()
} catch (e) {
@@ -155,8 +173,16 @@ onUnmounted(stopPolling)
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Lightning credentials</h3>
<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
@@ -173,14 +199,29 @@ onUnmounted(stopPolling)
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"
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">
@@ -52,9 +52,12 @@ onMounted(async () => {
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Node certificate</h3>
<p class="text-sm text-white/60 mb-4">
<!-- Node Certificate Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="mb-2">
<h2 class="text-xl font-semibold text-white/96">Node certificate</h2>
</div>
<p class="text-sm text-white/60 mb-6">
Install this node's certificate on a device and it stops warning you about
this node — on every port, not just the dashboard. Apps that open inside
the dashboard need this: a certificate warning cannot be accepted inside an
@@ -289,4 +289,65 @@ describe('LightningCredentialsSection', () => {
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad)
})
it('renders inside a card, like every other Settings section', () => {
// Operator-reported twice: the section rendered as bare text on the
// Settings page. A new section carries its own wrapper, and nothing about
// adding it to SystemSection.vue's list reminds you it needs one.
// `wrapper.element` is not the div: the confirm modal is a second root
// node, so the component is a fragment. Assert on the first div.
const wrapper = mountSection()
expect(wrapper.find('div').classes()).toContain('glass-card')
})
it('does not claim Lightning is missing while a rotation is running', async () => {
// Rotation restarts LND, so `installed` goes false for a moment. The
// screen used to read that literally and tell the operator "Lightning is
// not set up on this node yet" — seconds after they rotated, on a node
// with a working wallet — replacing the progress they were watching.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ installed: false, rotation: { ...idleRotation(), running: true } }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
expect(wrapper.text()).toContain('Lightning is restarting')
})
it('still tells a node with no Lightning that there is nothing to rotate', async () => {
// The other half: the message must survive for its real audience, or the
// fix above has just hidden a true statement.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status({ installed: false }))
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
})
it('does not claim Lightning is missing in the gap before the node reports the rotation', async () => {
// The window `awaitUntil` exists for: the rotate RPC has been accepted but
// the node has not yet reported `running: true`. `installed` can already be
// false there, so the guard has to cover the await window too, not just
// `running`.
vi.mocked(rpcClient.lndMacaroonStatus)
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ installed: false }))
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue(undefined as never)
const wrapper = mountSection()
await flushPromises()
await wrapper.find('button').trigger('click')
await flushPromises()
const confirm = wrapper.findAll('button').find((b) => /rotate/i.test(b.text()))
if (confirm) {
await confirm.trigger('click')
await flushPromises()
}
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
})
})