Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
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) {
const existing = groups.find(g => g.label === cat.group)
if (existing) {
existing.items.push(cat)
} else {
groups.push({ label: cat.group, items: [cat] })
}
}
return groups
})
</script>
<template>
<!-- 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>
<p class="text-sm text-white/60 mb-6">{{ t('settings.aiDataAccessDesc') }}</p>
<button
@click="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-6"
:class="aiPermissions.allEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.allEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
</div>
<ToggleSwitch :model-value="aiPermissions.allEnabled" @update:model-value="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()" @click.stop />
</button>
<div class="space-y-5">
<div v-for="group in aiCategoryGroups" :key="group.label">
<p class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 px-1">{{ group.label }}</p>
<div class="space-y-2">
<button
v-for="cat in group.items"
:key="cat.id"
@click="aiPermissions.toggle(cat.id)"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="aiPermissions.isEnabled(cat.id)
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.isEnabled(cat.id) ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="cat.icon" />
</svg>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<!-- Honest-copy rule: these categories grant the assistant
visibility only — it has no tools to act on them yet, and
the toggle must not imply otherwise. -->
<span v-if="cat.contextOnly" class="shrink-0 px-1.5 py-0.5 text-[10px] rounded bg-white/10 text-white/50" title="The assistant can see this information but has no actions for it yet">context only</span>
</div>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}<template v-if="cat.contextOnly"> — the assistant can see this but can't take actions with it yet</template></p>
</div>
<ToggleSwitch :model-value="aiPermissions.isEnabled(cat.id)" @update:model-value="aiPermissions.toggle(cat.id)" @click.stop />
</button>
</div>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
async function handleLogout() {
try { await store.logout() } catch (e) { if (import.meta.env.DEV) console.warn('Logout failed, proceeding anyway', e) }
router.push('/login').catch(() => { window.location.href = '/login' })
}
</script>
<template>
<!-- Account Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-6">{{ t('settings.account') }}</h2>
<AccountInfoSection />
<ChangePasswordSection />
<TwoFactorSection />
<SessionTimeoutSection />
<!-- Logout Button -->
<button
@click="handleLogout"
class="w-full path-action-button path-action-button--continue flex items-center justify-center gap-2"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>{{ t('settings.logout') }}</span>
</button>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<template>
<!-- App Registries Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
<p class="text-sm text-white/60 mt-1">
Choose the primary registry for app installs and add mirrors for fallback.
</p>
</div>
<RouterLink
to="/dashboard/settings/registries"
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 12V3m0 0l-4 4m4-4l4 4" />
</svg>
Manage registries
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,472 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
const { t } = useI18n()
// Backup & Restore
interface BackupEntry {
id: string
created_at: string
size_bytes: number
encrypted: boolean
description: string | null
}
const backupList = ref<BackupEntry[]>([])
const loadingBackups = ref(false)
const showCreateBackupModal = ref(false)
const backupPassphrase = ref('')
const backupDescription = ref('')
const creatingBackup = ref(false)
const showRestoreModal = ref(false)
const restoreBackupId = ref('')
const restorePassphrase = ref('')
const restoringBackup = ref(false)
const verifyingBackupId = ref<string | null>(null)
const deletingBackupId = ref<string | null>(null)
const backupStatusMsg = ref('')
const backupStatusType = ref<'success' | 'error'>('success')
const backupLoadError = ref('')
function formatBackupSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
function showBackupStatus(msg: string, type: 'success' | 'error') {
backupStatusMsg.value = msg
backupStatusType.value = type
setTimeout(() => { backupStatusMsg.value = '' }, 5000)
}
async function loadBackups() {
const hadBackups = backupList.value.length > 0
loadingBackups.value = true
backupLoadError.value = ''
try {
const res = await rpcClient.call<{ backups: BackupEntry[] }>({ method: 'backup.list' })
backupList.value = res.backups || []
} catch (e: unknown) {
backupLoadError.value = e instanceof Error ? e.message : t('settings.backupListFailed')
if (!hadBackups) backupList.value = []
} finally {
loadingBackups.value = false
}
}
async function createBackup() {
if (creatingBackup.value || !backupPassphrase.value) return
creatingBackup.value = true
try {
await rpcClient.call({ method: 'backup.create', params: { passphrase: backupPassphrase.value, description: backupDescription.value || undefined } })
showCreateBackupModal.value = false
backupPassphrase.value = ''
backupDescription.value = ''
showBackupStatus(t('settings.backupCreatedSuccess'), 'success')
await loadBackups()
} catch {
showBackupStatus(t('settings.backupCreateFailed'), 'error')
} finally {
creatingBackup.value = false
}
}
async function verifyBackup(id: string) {
const passphrase = prompt(t('settings.verifyPassphrasePrompt'))
if (!passphrase) return
verifyingBackupId.value = id
try {
const res = await rpcClient.call<{ valid: boolean; error: string | null }>({ method: 'backup.verify', params: { id, passphrase } })
if (res.valid) {
showBackupStatus(t('settings.backupVerifiedOk'), 'success')
} else {
showBackupStatus(t('settings.backupVerifyFailed', { error: res.error || 'Unknown error' }), 'error')
}
} catch {
showBackupStatus(t('settings.backupVerifyRequestFailed'), 'error')
} finally {
verifyingBackupId.value = null
}
}
function confirmRestoreBackup(id: string) {
restoreBackupId.value = id
restorePassphrase.value = ''
showRestoreModal.value = true
}
async function restoreBackup() {
if (restoringBackup.value || !restorePassphrase.value) return
restoringBackup.value = true
try {
await rpcClient.call({ method: 'backup.restore', params: { id: restoreBackupId.value, passphrase: restorePassphrase.value } })
showRestoreModal.value = false
showBackupStatus(t('settings.backupRestored'), 'success')
} catch {
showBackupStatus(t('settings.backupRestoreFailed'), 'error')
} finally {
restoringBackup.value = false
}
}
async function deleteBackup(id: string) {
if (!confirm(t('settings.deleteBackupConfirm'))) return
deletingBackupId.value = id
try {
await rpcClient.call({ method: 'backup.delete', params: { id } })
showBackupStatus(t('settings.backupDeleted'), 'success')
await loadBackups()
} catch {
showBackupStatus(t('settings.backupDeleteFailed'), 'error')
} finally {
deletingBackupId.value = null
}
}
// Download the encrypted backup archive through the browser — the only
// path off the node for remote/companion users with no USB access.
const downloadingId = ref<string | null>(null)
async function downloadBackup(id: string) {
downloadingId.value = id
try {
const res = await fetch(`/api/blob/backup/${encodeURIComponent(id)}`, { credentials: 'include' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `archipelago-backup-${id}.bak`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
showBackupStatus('Backup download started', 'success')
} catch {
showBackupStatus('Backup download failed', 'error')
} finally {
downloadingId.value = null
}
}
// Recovery phrase reveal — re-auth gated (password + 2FA when enabled).
const showRevealModal = ref(false)
const revealPassword = ref('')
const revealCode = ref('')
const revealPassphrase = ref('')
const revealing = ref(false)
const revealError = ref('')
const revealedWords = ref<string[]>([])
const wordsHidden = ref(true)
const wordsCopied = ref(false)
function openReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
revealError.value = ''
revealedWords.value = []
wordsHidden.value = true
showRevealModal.value = true
}
async function submitReveal() {
if (revealing.value || !revealPassword.value) return
revealing.value = true
revealError.value = ''
try {
const params: Record<string, string> = { password: revealPassword.value }
if (revealCode.value) params.code = revealCode.value
if (revealPassphrase.value) params.passphrase = revealPassphrase.value
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.reveal', params })
revealedWords.value = res.words || []
wordsHidden.value = true
} catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : t('settings.revealSeedFailed')
} finally {
revealing.value = false
}
}
function closeReveal() {
showRevealModal.value = false
revealedWords.value = []
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
}
async function copyRevealedWords() {
try {
await navigator.clipboard.writeText(revealedWords.value.join(' '))
wordsCopied.value = true
setTimeout(() => { wordsCopied.value = false }, 2000)
} catch { /* clipboard unavailable */ }
}
// USB Drive Backup
interface UsbDriveInfo {
device: string
mount_point: string | null
label: string | null
size_bytes: number
removable: boolean
}
const usbCopyingId = ref<string | null>(null)
async function backupToUsb(backupId: string) {
usbCopyingId.value = backupId
try {
const drivesRes = await rpcClient.call<{ drives: UsbDriveInfo[] }>({ method: 'backup.list-drives' })
const drives = drivesRes.drives || []
const mounted = drives.filter(d => d.mount_point)
const target = mounted[0]
if (!target?.mount_point) {
showBackupStatus(t('settings.noUsbDrives'), 'error')
return
}
const label = target.label || target.device
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
} catch {
showBackupStatus(t('settings.backupUsbFailed'), 'error')
} finally {
usbCopyingId.value = null
}
}
// Lightning channel backup
const exportingChannelBackup = ref(false)
const channelBackupData = ref('')
const channelBackupChannels = ref(0)
const channelBackupTime = ref('')
const channelBackupError = ref('')
const channelBackupCopied = ref(false)
async function exportChannelBackup() {
exportingChannelBackup.value = true
channelBackupError.value = ''
try {
const res = await rpcClient.call<{ backup: string; channel_count: number; timestamp: string }>({
method: 'lnd.export-channel-backup',
timeout: 15000,
})
channelBackupData.value = res.backup
channelBackupChannels.value = res.channel_count
channelBackupTime.value = new Date(res.timestamp).toLocaleString()
} catch (err: unknown) {
channelBackupError.value = err instanceof Error ? err.message : 'Failed to export'
} finally {
exportingChannelBackup.value = false
}
}
function copyChannelBackup() {
if (channelBackupData.value) {
navigator.clipboard.writeText(channelBackupData.value).catch(() => {})
channelBackupCopied.value = true
setTimeout(() => { channelBackupCopied.value = false }, 2000)
}
}
loadBackups()
defineExpose({ loadBackups })
</script>
<template>
<!-- Recovery phrase Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Recovery phrase</h2>
<p class="text-sm text-white/60">
View this node's 24-word recovery phrase. You'll need to confirm your password
(and 2FA code, if enabled). Only reveal it somewhere private anyone with these
words controls this node.
</p>
<a
href="/entropy/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 mt-2 text-sm text-orange-300/90 hover:text-orange-200 transition-colors"
>
How your seed &amp; keys work the full guide
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="openReveal"
>Reveal</button>
</div>
</div>
<!-- Reveal recovery phrase modal -->
<Teleport to="body">
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-seed-title">
<h3 id="reveal-seed-title" class="text-lg font-semibold text-white mb-1">Reveal recovery phrase</h3>
<template v-if="revealedWords.length === 0">
<p class="text-sm text-white/60 mb-4">Confirm your credentials to display the 24-word phrase.</p>
<form @submit.prevent="submitReveal" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
</div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
{{ revealing ? 'Verifying…' : 'Reveal' }}
</button>
</div>
</form>
</template>
<template v-else>
<SeedRevealPanel :words="revealedWords" />
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</div>
</template>
</div>
</div>
</Teleport>
<!-- Backup & Restore Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('settings.backup') }}</h2>
<p class="text-sm text-white/60 mb-3">{{ t('settings.backupRestoreDesc') }}</p>
<button @click="showCreateBackupModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('settings.createBackup') }}
</button>
</div>
<div v-if="loadingBackups && backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
<div v-else class="space-y-2">
<div v-if="loadingBackups" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing backups...
</div>
<div v-else-if="backupLoadError" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ backupLoadError }}
</div>
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
<div class="min-w-0">
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
<div class="text-xs text-white/50">{{ new Date(b.created_at).toLocaleString() }} &middot; {{ formatBackupSize(b.size_bytes) }}</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" :title="t('common.verify')">
{{ verifyingBackupId === b.id ? '...' : t('common.verify') }}
</button>
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
{{ usbCopyingId === b.id ? '...' : 'USB' }}
</button>
<button @click="downloadBackup(b.id)" :disabled="downloadingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-green-400 disabled:opacity-50" title="Download backup file">
{{ downloadingId === b.id ? '...' : 'Download' }}
</button>
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
{{ t('common.restore') }}
</button>
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" :aria-label="t('settings.deleteBackup')" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" :title="t('common.delete')">
&times;
</button>
</div>
</div>
</div>
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ backupStatusMsg }}
</div>
</div>
<!-- Create Backup Modal -->
<Teleport to="body">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showCreateBackupModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
<input v-model="backupPassphrase" type="password" :placeholder="t('settings.enterPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.descriptionOptional') }}</label>
<input v-model="backupDescription" type="text" :placeholder="t('settings.descriptionPlaceholder')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
</div>
<div class="flex gap-3 mt-5">
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Restore Backup Modal -->
<Teleport to="body">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showRestoreModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
<input v-model="restorePassphrase" type="password" :placeholder="t('settings.enterBackupPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<div class="flex gap-3 mt-5">
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button glass-button-danger px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Lightning Channel Backup -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning Channel Backup</h2>
<p class="text-sm text-white/60">Export your channel state so you can restore channels on a new node. Does not include on-chain wallet seed.</p>
</div>
<button @click="exportChannelBackup" :disabled="exportingChannelBackup" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 w-full md:w-auto shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ exportingChannelBackup ? 'Exporting...' : 'Export Channel Backup' }}
</button>
</div>
<div v-if="channelBackupData" class="mt-3 bg-black/30 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">{{ channelBackupChannels }} channel{{ channelBackupChannels !== 1 ? 's' : '' }} backed up at {{ channelBackupTime }}</p>
<textarea readonly :value="channelBackupData" rows="3" class="w-full bg-black/20 text-xs font-mono text-white/60 rounded p-2 resize-none border border-white/10"></textarea>
<button @click="copyChannelBackup" class="mt-2 glass-button px-3 py-1.5 rounded text-xs">{{ channelBackupCopied ? 'Copied!' : 'Copy Backup Data' }}</button>
</div>
<p v-if="channelBackupError" class="mt-2 text-xs text-red-400">{{ channelBackupError }}</p>
</div>
</template>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const { t } = useI18n()
const showChangePasswordModal = ref(false)
const changePasswordModalRef = ref<HTMLElement | null>(null)
const changePasswordRestoreFocusRef = ref<HTMLElement | null>(null)
useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePasswordModal, { restoreFocusRef: changePasswordRestoreFocusRef })
const changingPassword = ref(false)
const changePasswordError = ref('')
const changePasswordSuccess = ref('')
const changePasswordWarning = ref('')
const changePasswordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
alsoChangeSsh: true,
})
function validatePasswordStrength(pw: string): string | null {
if (pw.length < 12) return t('settings.passwordMinLength')
if (!/[A-Z]/.test(pw)) return t('settings.passwordNeedUppercase')
if (!/[a-z]/.test(pw)) return t('settings.passwordNeedLowercase')
if (!/\d/.test(pw)) return t('settings.passwordNeedDigit')
if (!/[^A-Za-z0-9]/.test(pw)) return t('settings.passwordNeedSpecial')
return null
}
async function handleChangePassword() {
changePasswordError.value = ''
changePasswordSuccess.value = ''
changePasswordWarning.value = ''
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
if (!currentPassword || !newPassword || !confirmPassword) {
changePasswordError.value = t('settings.passwordAllFieldsRequired')
return
}
if (newPassword !== confirmPassword) {
changePasswordError.value = t('settings.passwordMismatch')
return
}
const strengthError = validatePasswordStrength(newPassword)
if (strengthError) {
changePasswordError.value = strengthError
return
}
changingPassword.value = true
try {
const result = await rpcClient.changePassword({
currentPassword,
newPassword,
alsoChangeSsh,
})
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
if (alsoChangeSsh && result.ssh_error) {
changePasswordWarning.value = `${t('settings.passwordUpdatedSshFailed')} ${result.ssh_error}`
}
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
if (!changePasswordWarning.value) {
setTimeout(() => {
closeChangePasswordModal()
}, 2000)
}
} catch (e) {
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
} finally {
changingPassword.value = false
}
}
function closeChangePasswordModal() {
changePasswordRestoreFocusRef.value?.focus?.()
showChangePasswordModal.value = false
changePasswordError.value = ''
changePasswordSuccess.value = ''
changePasswordWarning.value = ''
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
}
</script>
<template>
<!-- Change Password -->
<div class="mb-6">
<button
ref="changePasswordRestoreFocusRef"
@click="showChangePasswordModal = true"
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
>
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span>{{ t('settings.changePassword') }}</span>
</button>
</div>
<!-- Change Password Modal -->
<Teleport to="body">
<div
v-if="showChangePasswordModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeChangePasswordModal()"
>
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('settings.changePasswordTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">{{ t('settings.changePasswordDesc') }}</p>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.currentPassword') }}</label>
<input
v-model="changePasswordForm.currentPassword"
type="password"
required
autocomplete="current-password"
class="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="t('login.enterPasswordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.newPassword') }}</label>
<input
v-model="changePasswordForm.newPassword"
type="password"
required
autocomplete="new-password"
class="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="t('settings.passwordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.confirmNewPassword') }}</label>
<input
v-model="changePasswordForm.confirmPassword"
type="password"
required
autocomplete="new-password"
class="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="t('settings.confirmNewPassword')"
/>
</div>
<label class="flex items-center gap-2 text-sm text-white/80">
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
{{ t('settings.updateSshCheckbox') }}
</label>
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
<p v-if="changePasswordWarning" class="text-sm text-yellow-300">{{ changePasswordWarning }}</p>
<div class="flex gap-3 pt-2">
<button
type="submit"
:disabled="changingPassword"
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"
>
{{ changingPassword ? t('settings.updatingPassword') : t('settings.updatePassword') }}
</button>
<button
type="button"
@click="closeChangePasswordModal"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
{{ t('common.cancel') }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
const apiKey = ref('')
const saved = ref(false)
const saving = ref(false)
const error = ref('')
const hasKey = ref(false)
async function checkApiKey() {
try {
const result = await rpcClient.call({ method: 'system.settings.get', params: { key: 'claude_api_key_set' } }) as { value: boolean } | null
hasKey.value = !!result?.value
} catch {
hasKey.value = false
}
}
async function saveApiKey() {
if (!apiKey.value.startsWith('sk-ant-')) {
error.value = 'API key should start with sk-ant-'
return
}
saving.value = true
error.value = ''
saved.value = false
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'claude_api_key', value: apiKey.value } })
saved.value = true
hasKey.value = true
apiKey.value = ''
setTimeout(() => { saved.value = false }, 3000)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to save API key'
} finally {
saving.value = false
}
}
async function removeApiKey() {
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'claude_api_key', value: '' } })
hasKey.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to remove API key'
}
}
onMounted(checkApiKey)
</script>
<template>
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white/96">{{ t('settings.claudeAuth') }}</h3>
<p class="text-sm text-white/50">Enter your Anthropic API key for AI features</p>
</div>
</div>
<div class="mt-4">
<div v-if="hasKey && !apiKey" class="flex items-center justify-between bg-white/5 rounded-lg px-4 py-3 mb-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-400"></span>
<span class="text-sm text-white/80">API key configured</span>
</div>
<button @click="removeApiKey" class="text-xs text-red-400 hover:text-red-300 transition-colors">Remove</button>
</div>
<div class="flex gap-2">
<input
v-model="apiKey"
type="password"
:placeholder="hasKey ? 'Replace existing key...' : 'sk-ant-...'"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 transition-colors"
@keyup.enter="saveApiKey"
/>
<button
@click="saveApiKey"
:disabled="!apiKey || saving"
class="glass-button px-4 py-2.5 text-sm font-medium disabled:opacity-30"
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
<p v-if="saved" class="text-sm text-green-400 mt-2">API key saved successfully</p>
<p v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</p>
</div>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n'
import { useUIModeStore } from '@/stores/uiMode'
import type { UIMode } from '@/types/api'
const { t, locale } = useI18n()
const uiMode = useUIModeStore()
const interfaceModes = computed<{ id: UIMode; label: string; description: string; iconPaths: string[] }[]>(() => [
{
id: 'easy',
label: t('settings.modeEasy'),
description: t('settings.modeEasyDesc'),
iconPaths: ['M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
},
{
id: 'gamer',
label: t('settings.modePro'),
description: t('settings.modeProDesc'),
iconPaths: ['M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'],
},
{
id: 'chat',
label: t('settings.modeChat'),
description: t('settings.modeChatDesc'),
iconPaths: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
},
])
const supportedLocales = SUPPORTED_LOCALES
const currentLocale = computed(() => locale.value)
async function changeLocale(code: string) {
await setLocale(code as SupportedLocale)
}
</script>
<template>
<!-- Interface Mode Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
v-for="m in interfaceModes"
:key="m.id"
@click="uiMode.setMode(m.id)"
class="path-option-card text-left p-5"
:class="{ 'path-option-card--selected': uiMode.mode === m.id }"
>
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in m.iconPaths"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<h3 class="text-lg font-semibold text-white/96">{{ m.label }}</h3>
</div>
<p class="text-sm text-white/60 leading-relaxed">{{ m.description }}</p>
</button>
</div>
</div>
<!-- Language Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Language</h2>
<p class="text-sm text-white/60 mb-4">Choose your preferred language</p>
<div class="flex gap-3 flex-wrap">
<button
v-for="loc in supportedLocales"
:key="loc.code"
@click="changeLocale(loc.code)"
class="glass-button px-4 py-2 rounded-lg text-sm font-medium transition-all"
:class="currentLocale === loc.code ? 'ring-2 ring-orange-400/60 bg-white/10' : ''"
>
<span class="mr-2">{{ loc.flag }}</span>{{ loc.name }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
// Kiosk display resolution/scaling presets. Only shown on nodes that actually
// have a kiosk display (has_kiosk from the backend).
const hasKiosk = ref(false)
const preset = ref('auto')
const applying = ref(false)
const error = ref('')
const presets = [
{
id: 'auto',
label: 'Auto (recommended)',
description: 'Pick a comfortable size for the detected screen — 4K TVs get a Full-HD-sized layout at double sharpness.',
},
{
id: 'large',
label: 'Large UI',
description: 'Biggest text and buttons — easiest to read from across the room, fits less on screen.',
},
{
id: 'balanced',
label: 'Balanced',
description: 'Desktop-sized layout on any screen that can carry it — more on screen, still sharp.',
},
{
id: 'native',
label: 'Native (no scaling)',
description: 'Use the screens full native resolution 1:1 — the most content, the smallest UI.',
},
]
onMounted(async () => {
try {
const res = await rpcClient.call<{ has_kiosk: boolean; preset: string }>({ method: 'system.kiosk-display.get' })
hasKiosk.value = res.has_kiosk
preset.value = res.preset
} catch { /* backend without the RPC — leave the section hidden */ }
})
async function apply(id: string) {
if (applying.value || id === preset.value) return
applying.value = true
error.value = ''
const prev = preset.value
preset.value = id
try {
await rpcClient.call({ method: 'system.kiosk-display.set', params: { preset: id }, timeout: 20000 })
} catch (e: unknown) {
preset.value = prev
error.value = e instanceof Error ? e.message : 'Failed to apply display setting'
} finally {
applying.value = false
}
}
</script>
<template>
<!-- Kiosk Display Section only on nodes with an attached kiosk screen -->
<div v-if="hasKiosk" class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Display</h2>
<p class="text-sm text-white/60 mb-6">
How big the interface renders on the screen attached to this node. Changing this restarts the on-screen display.
</p>
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
v-for="p in presets"
:key="p.id"
:disabled="applying"
@click="apply(p.id)"
class="path-option-card text-left p-5 disabled:opacity-60"
:class="{ 'path-option-card--selected': preset === p.id }"
>
<div class="font-medium text-white/90 mb-1">{{ p.label }}</div>
<p class="text-sm text-white/60">{{ p.description }}</p>
</button>
</div>
<div v-if="error" class="mt-4 alert-error text-sm">{{ error }}</div>
</div>
</template>
@@ -0,0 +1,412 @@
<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>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
// This node signs its own certificates with a CA that never leaves it. Install
// that CA once per device and every port on this node is trusted — which is what
// lets a gated app load inside the dashboard's frame at all: a cert warning
// cannot be clicked through inside an iframe, so an untrusted app port simply
// fails to render.
const fingerprint = ref('')
const fingerprintError = ref('')
const loading = ref(true)
const caAvailable = ref(false)
// SHA-256 over the DER bytes — the same number `openssl x509 -fingerprint
// -sha256` prints, so the two can be compared character for character.
async function computeFingerprint(pem: string): Promise<string> {
const body = pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, '')
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))
const digest = await crypto.subtle.digest('SHA-256', der)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
.join(':')
}
onMounted(async () => {
try {
const res = await fetch('/ca.crt', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const pem = await res.text()
if (!pem.includes('BEGIN CERTIFICATE')) throw new Error('not a certificate')
caAvailable.value = true
// crypto.subtle only exists in a secure context. That is exactly the case
// this feature is meant to fix, so an HTTP dashboard lands here — say so
// and give the offline command rather than showing nothing.
if (!window.crypto?.subtle) {
fingerprintError.value =
'The fingerprint cannot be computed over a plain HTTP connection. Verify it on the node instead: openssl x509 -in /etc/archipelago/ssl/ca.crt -noout -fingerprint -sha256'
} else {
fingerprint.value = await computeFingerprint(pem)
}
} catch {
caAvailable.value = false
} finally {
loading.value = false
}
})
</script>
<template>
<!-- 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
embedded frame, so an untrusted app shows nothing at all.
</p>
<div v-if="loading" class="text-sm text-white/50">Checking…</div>
<div
v-else-if="!caAvailable"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
This node has not generated a certificate authority yet. Run
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
on the node, then reload this page.
</div>
<div v-else class="space-y-4">
<div>
<a
href="/ca.crt"
download="archipelago-node-ca.crt"
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download this node's certificate
</a>
</div>
<div>
<p class="text-sm font-medium text-white/80 mb-1">Fingerprint (SHA-256)</p>
<p v-if="fingerprint" class="font-mono text-xs text-white/70 break-all select-all">{{ fingerprint }}</p>
<p v-else class="text-xs text-orange-300/80">{{ fingerprintError }}</p>
<p class="text-xs text-white/50 mt-2">
Check this matches the fingerprint the node itself prints before you trust
it. If they differ, something is intercepting the connection do not install it.
</p>
</div>
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
How to install it
</summary>
<div class="mt-2 space-y-3 text-sm text-white/60">
<p><strong class="text-white/80">macOS</strong> open the file, add it to the
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
and set When using this certificate to <em>Always Trust</em>.</p>
<p><strong class="text-white/80">iOS / iPadOS</strong> download it in Safari and
allow the profile, then Settings General VPN &amp; Device Management to
install it, and finally Settings General About Certificate Trust Settings
to switch it on. Both steps are required.</p>
<p><strong class="text-white/80">Windows</strong> right-click Install
Certificate Local Machine place it in <em>Trusted Root Certification
Authorities</em>.</p>
<p><strong class="text-white/80">Android</strong> Settings Security
Encryption &amp; credentials Install a certificate CA certificate.</p>
<p><strong class="text-white/80">Linux</strong> copy to
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
Firefox keeps its own store add it under Settings Privacy &amp; Security
View Certificates Authorities.</p>
<p class="text-white/50">
You are trusting this node, not a company. The signing key stays on the node
and only ever signs this node's own address. Anyone who takes the node also
takes that key remove the certificate from your devices if you retire it.
</p>
</div>
</details>
</div>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup lang="ts">
/**
* How long a login lasts on this node.
*
* Presented as two plain questions — "sign me out after quiet" and "always
* sign me out after" — rather than as the two-token mechanism underneath.
* The distinction that matters to the operator is that the second one is
* what actually guarantees a login ends: this dashboard polls constantly,
* so an idle timeout alone never fires on an open tab.
*/
import { ref, onMounted, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
type Policy = {
idle_timeout_secs: number
absolute_timeout_secs: number | null
reauth_for_funds: boolean
}
const idle = ref<number>(86400)
const absolute = ref<number | null>(30 * 24 * 3600)
const reauthForFunds = ref(true)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
// Offered as presets rather than a free number field: the useful values are
// few, and a box accepting "5" invites someone to lock themselves out.
const idleChoices = [
{ label: '15 minutes', value: 15 * 60 },
{ label: '1 hour', value: 3600 },
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
]
const absoluteChoices = [
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
{ label: '90 days', value: 90 * 24 * 3600 },
{ label: 'Never', value: null },
]
const shortIdleWarning = computed(() => idle.value <= 3600)
async function load() {
loading.value = true
try {
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
// The backend clamps and returns what it stored, so reflect that back
// rather than assuming our values were taken verbatim.
const p = await rpcClient.call<Policy>({
method: 'auth.session-policy.set',
params: {
idle_timeout_secs: idle.value,
absolute_timeout_secs: absolute.value,
reauth_for_funds: reauthForFunds.value,
},
})
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
saved.value = true
setTimeout(() => { saved.value = false }, 2500)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
<p class="text-sm text-white/60 mb-4">
How long this node keeps you signed in. TV and kiosk screens are never
signed out for sitting idle there is nobody there to sign them back in.
</p>
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
{{ error }}
</div>
<div v-if="!loading" class="space-y-4">
<div>
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
<select
id="idle-timeout"
v-model.number="idle"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
Short timeouts are what payment-industry rules ask for when funds are involved expect to sign in often.
</p>
</div>
<div>
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
<select
id="absolute-timeout"
v-model="absolute"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p class="text-xs text-white/50 mt-2">
Counts from when you signed in, whatever you are doing. This is the one that
guarantees a session ends: an open dashboard is never idle, so the setting
above would not fire on it.
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
<span class="text-sm text-white/80">
Ask for my password again before sending funds
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
</span>
</label>
<button
:disabled="saving"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
@click="save"
>
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,289 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
const router = useRouter()
const { t } = useI18n()
// Reboot
const showRebootConfirm = ref(false)
const rebooting = ref(false)
const rebootPassword = ref('')
const rebootError = ref('')
// Reboot overlay — full-screen progress shown once the reboot is committed.
// Mirrors the update overlay pattern in SystemUpdate.vue: poll /health,
// auto-reload when the backend returns, stall fallback at 3 min.
type RebootStage = 'rebooting' | 'reconnecting' | 'ready' | 'stalled'
const rebootOverlay = ref(false)
const rebootStage = ref<RebootStage>('rebooting')
const rebootStartedAt = ref(0)
const rebootElapsedSec = ref(0)
let rebootPollTimer: ReturnType<typeof setInterval> | null = null
let rebootElapsedTimer: ReturnType<typeof setInterval> | null = null
const rebootElapsedLabel = computed(() => {
const s = rebootElapsedSec.value
if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
})
function startRebootOverlay() {
rebootOverlay.value = true
rebootStage.value = 'rebooting'
rebootStartedAt.value = Date.now()
rebootElapsedSec.value = 0
rebootElapsedTimer = setInterval(() => {
rebootElapsedSec.value = Math.floor((Date.now() - rebootStartedAt.value) / 1000)
if (rebootElapsedSec.value >= 180 && rebootStage.value !== 'ready') {
rebootStage.value = 'stalled'
}
}, 1000)
// Start health polling after 2.5s — the kernel has to go down before
// /health can disappear, and we don't want to see the pre-reboot health
// reply and mis-report "ready".
setTimeout(() => {
rebootStage.value = 'reconnecting'
rebootPollTimer = setInterval(pollRebootHealth, 1500)
}, 2500)
}
async function pollRebootHealth() {
if (rebootStage.value === 'ready' || rebootStage.value === 'stalled') return
try {
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
if (!res.ok) throw new Error(`health ${res.status}`)
rebootStage.value = 'ready'
if (rebootPollTimer) { clearInterval(rebootPollTimer); rebootPollTimer = null }
setTimeout(() => { window.location.reload() }, 1200)
} catch {
// Fetch failing is the normal state while the host is down.
}
}
function rebootReloadNow() { window.location.reload() }
onBeforeUnmount(() => {
if (rebootPollTimer) clearInterval(rebootPollTimer)
if (rebootElapsedTimer) clearInterval(rebootElapsedTimer)
})
async function performReboot() {
if (!rebootPassword.value) return
rebooting.value = true
rebootError.value = ''
try {
await rpcClient.call({ method: 'system.reboot', params: { password: rebootPassword.value } })
showRebootConfirm.value = false
rebootPassword.value = ''
startRebootOverlay()
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Reboot failed'
rebooting.value = false
}
}
// Factory Reset
const showFactoryResetConfirm = ref(false)
const factoryResetLoading = ref(false)
async function performFactoryReset() {
factoryResetLoading.value = true
try {
await rpcClient.call({ method: 'system.factory-reset', params: { confirm: true } })
localStorage.clear()
showFactoryResetConfirm.value = false
router.push('/onboarding/intro')
} catch {
localStorage.clear()
showFactoryResetConfirm.value = false
router.push('/onboarding/intro')
}
}
</script>
<template>
<!-- Network Diagnostics Link -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('common.network') }}</h2>
<p class="text-sm text-white/60">{{ t('settings.networkDesc') }}</p>
</div>
<button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 w-full md:w-auto shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
{{ t('common.networkDiagnostics') }}
</button>
</div>
</div>
<!-- Reboot Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Reboot</h2>
<p class="text-sm text-white/60">Restart the machine. All containers will restart automatically.</p>
</div>
<button
class="glass-button px-6 py-2 rounded-lg text-sm w-full md:w-auto shrink-0"
:disabled="rebooting"
@click="showRebootConfirm = true"
>
{{ rebooting ? 'Rebooting...' : 'Reboot' }}
</button>
</div>
</div>
<!-- Reboot Confirmation Modal -->
<Teleport to="body">
<div v-if="showRebootConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showRebootConfirm = false">
<div class="glass-card px-8 py-8 max-w-md mx-4">
<h3 class="text-lg font-semibold text-white/90 mb-3">Reboot Node</h3>
<p class="text-sm text-white/60 mb-4">Enter your password to confirm reboot. The node will be temporarily unavailable.</p>
<input
v-model="rebootPassword"
type="password"
class="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 mb-4"
placeholder="Password"
@keydown.enter="performReboot"
/>
<p v-if="rebootError" class="text-sm text-red-400 mb-3">{{ rebootError }}</p>
<div class="flex gap-3 justify-end">
<button class="glass-button" @click="showRebootConfirm = false">Cancel</button>
<button
class="glass-button px-6"
:disabled="rebooting || !rebootPassword"
@click="performReboot"
>
{{ rebooting ? 'Rebooting...' : 'Confirm Reboot' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Reboot Progress Overlay -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="rebootOverlay"
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
>
<!-- Centered animated ring + logo same composition as the screensaver -->
<div class="reboot-ring-content">
<ScreensaverRing />
<div class="reboot-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
<!-- Stage text + progress bar underneath -->
<div class="mt-8 w-[min(520px,80vw)] text-center">
<h2 class="text-xl font-semibold text-white mb-1">
{{ rebootStage === 'rebooting' ? 'Rebooting…'
: rebootStage === 'reconnecting' ? 'Reconnecting to your node…'
: rebootStage === 'ready' ? 'Back online'
: 'Reboot is taking longer than expected' }}
</h2>
<p class="text-sm text-white/60 mb-4">
Your node is restarting. This page will refresh automatically once it's back.
</p>
<!-- Animated progress bar: indeterminate stripe while working,
solid green when ready, paused at half while stalled. -->
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
<div v-if="rebootStage === 'ready'" class="absolute inset-0 bg-green-400"></div>
<div v-else-if="rebootStage === 'stalled'" class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"></div>
<div v-else class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full reboot-overlay-bar-anim"></div>
</div>
<p class="text-xs text-white/40">{{ rebootElapsedLabel }}</p>
<button
v-if="rebootStage === 'stalled'"
@click="rebootReloadNow"
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
>
Reload now
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Factory Reset Section -->
<div class="glass-card px-6 py-6 mb-6 border border-red-500/30">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-red-400/90 mb-1">Factory Reset</h2>
<p class="text-sm text-white/60">
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
</p>
</div>
<button
class="glass-button glass-button-danger w-full md:w-auto shrink-0"
@click="showFactoryResetConfirm = true"
>
Factory Reset
</button>
</div>
</div>
<!-- Factory Reset Confirmation Modal -->
<Teleport to="body">
<div v-if="showFactoryResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="glass-card px-8 py-8 max-w-md mx-4">
<h3 class="text-lg font-semibold text-white/90 mb-3">Are you sure?</h3>
<p class="text-sm text-white/60 mb-6">
This will delete all identities, credentials, and settings. This cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
<button
class="glass-button glass-button-danger"
:disabled="factoryResetLoading"
@click="performFactoryReset"
>
{{ factoryResetLoading ? 'Resetting...' : 'Yes, Reset' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.reboot-ring-content {
position: relative;
display: grid;
place-items: center;
}
.reboot-logo-wrapper {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
.reboot-overlay-bar-anim {
animation: rebootBarSlide 1.8s ease-in-out infinite;
}
@keyframes rebootBarSlide {
0% { transform: translateX(-100%); }
50% { transform: translateX(120%); }
100% { transform: translateX(300%); }
}
</style>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
import KioskDisplaySection from '@/views/settings/KioskDisplaySection.vue'
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
</script>
<template>
<InterfaceModeSection />
<KioskDisplaySection />
<ClaudeAuthSection />
<AIDataAccessSection />
<WebhookSection />
<TelemetrySection />
<NodeCertificateSection />
<LightningCredentialsSection />
<BackupSection />
<SystemDangerZone />
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<!-- System Updates Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
</div>
<RouterLink
to="/dashboard/settings/update"
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ t('common.manageUpdates') }}
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
const telemetryEnabled = ref(false)
const telemetryLoading = ref(false)
async function loadTelemetryStatus() {
try {
const res = await rpcClient.call<{ enabled: boolean }>({ method: 'analytics.get-status' })
telemetryEnabled.value = res.enabled
} catch { /* ignore */ }
}
async function toggleTelemetry() {
telemetryLoading.value = true
try {
const method = telemetryEnabled.value ? 'analytics.disable' : 'analytics.enable'
await rpcClient.call({ method })
telemetryEnabled.value = !telemetryEnabled.value
} catch { /* ignore */ }
telemetryLoading.value = false
}
loadTelemetryStatus()
</script>
<template>
<!-- Beta Telemetry Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-3">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Beta Telemetry</h2>
<p class="text-sm text-white/60">Help improve Archipelago by sharing anonymous system health data. No wallet data, no keys, no personal info.</p>
</div>
<button
@click="toggleTelemetry"
:disabled="telemetryLoading"
class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="telemetryEnabled ? 'glass-button glass-button-success' : 'glass-button'"
>
{{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }}
</button>
</div>
<div v-if="telemetryEnabled" class="mt-3 text-xs text-white/50 space-y-1">
<p>Reporting: version, uptime, container states, CPU/RAM, error alerts.</p>
<p>Not reporting: wallet balances, private keys, DIDs, IP addresses.</p>
</div>
</div>
</template>
@@ -0,0 +1,135 @@
<template>
<div class="glass-card p-6 transition-all">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<h2 class="text-xl font-semibold text-white">Transport Preferences</h2>
</div>
<p class="text-white/60 text-sm mb-5">
Pick how each service reaches federated peers. FIPS is the overlay mesh
(fast, authenticated). Tor is the anonymous hidden-service fallback.
<span class="text-white/40">Auto = FIPS first, Tor on failure.</span>
</p>
<div v-if="loading && !prefs" class="text-white/50 text-sm">Loading</div>
<div v-else-if="error && !prefs" class="text-red-400 text-sm">{{ error }}</div>
<div v-else class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing transport preferences...
</div>
<div v-else-if="error" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ error }}
</div>
<div
v-for="svc in services"
:key="svc.key"
class="flex items-center justify-between gap-4 p-3 bg-white/5 rounded-lg"
>
<div class="min-w-0">
<div class="text-sm font-medium text-white">{{ svc.label }}</div>
<div class="text-xs text-white/50 truncate">{{ svc.hint }}</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="px-3 py-1.5 rounded text-xs font-medium transition-colors"
:class="classesFor(svc.key, opt.value)"
:disabled="saving === svc.key"
@click="setPref(svc.key, opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
type Pref = 'auto' | 'fips' | 'tor'
type Service = 'federation' | 'peers' | 'peer_files' | 'messaging' | 'mesh_file_sharing'
interface Preferences {
federation: Pref
peers: Pref
peer_files: Pref
messaging: Pref
mesh_file_sharing: Pref
}
const services: { key: Service; label: string; hint: string }[] = [
{ key: 'federation', label: 'Federation', hint: 'State sync, invites, peer notifications' },
{ key: 'peers', label: 'Peer Linking', hint: 'Address + DID rotation broadcasts' },
{ key: 'peer_files', label: 'Peer Files', hint: 'Content catalog download / browse' },
{ key: 'messaging', label: 'Archipelago Messaging', hint: 'Node-to-node chat + mesh bridge' },
{ key: 'mesh_file_sharing', label: 'Mesh File Sharing', hint: 'Blob fetches for shared mesh content' },
]
const options: { value: Pref; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: 'fips', label: 'FIPS' },
{ value: 'tor', label: 'Tor' },
]
const prefs = ref<Preferences | null>(null)
const loading = ref(true)
const saving = ref<Service | null>(null)
const error = ref<string | null>(null)
async function load() {
const hadPrefs = prefs.value !== null
loading.value = true
error.value = null
try {
prefs.value = await rpcClient.call<Preferences>({ method: 'transport.preferences' })
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load transport preferences'
if (!hadPrefs) prefs.value = null
} finally {
loading.value = false
}
}
function classesFor(service: Service, value: Pref): string {
const active = prefs.value?.[service] === value
if (active) {
if (value === 'fips') return 'bg-cyan-500/30 text-cyan-200 ring-1 ring-cyan-400/50'
if (value === 'tor') return 'bg-purple-500/30 text-purple-200 ring-1 ring-purple-400/50'
return 'bg-white/20 text-white ring-1 ring-white/30'
}
return 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/80'
}
async function setPref(service: Service, pref: Pref) {
if (!prefs.value) return
if (prefs.value[service] === pref) return
saving.value = service
const previous = prefs.value[service]
prefs.value[service] = pref
try {
prefs.value = await rpcClient.call<Preferences>({
method: 'transport.set-preference',
params: { service, pref },
})
} catch (e) {
if (prefs.value) prefs.value[service] = previous
error.value = e instanceof Error ? e.message : 'Failed to save preference'
} finally {
saving.value = null
}
}
onMounted(load)
defineExpose({ load })
</script>
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import DOMPurify from 'dompurify'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
// 2FA / TOTP
const totpEnabled = ref(false)
const showTotpSetupModal = ref(false)
const showTotpDisableModal = ref(false)
const totpSetupStep = ref(1)
const totpSetupPassword = ref('')
const totpSetupCode = ref('')
const totpSetupError = ref('')
const totpSetupLoading = ref(false)
const totpQrSvg = ref('')
const sanitizedQrSvg = computed(() => DOMPurify.sanitize(totpQrSvg.value, { USE_PROFILES: { svg: true } }))
const totpSecretBase32 = ref('')
const showTotpSecret = ref(false)
const totpPendingToken = ref('')
const totpBackupCodes = ref<string[]>([])
const backupCodesCopied = ref(false)
const totpDisablePassword = ref('')
const totpDisableCode = ref('')
const totpDisableError = ref('')
const totpDisableLoading = ref(false)
async function loadTotpStatus() {
try {
const res = await rpcClient.totpStatus()
totpEnabled.value = res.enabled
} catch (e) {
if (import.meta.env.DEV) console.warn('TOTP status may not be available', e)
}
}
async function beginTotpSetup() {
totpSetupError.value = ''
totpSetupLoading.value = true
try {
const res = await rpcClient.totpSetupBegin(totpSetupPassword.value)
totpQrSvg.value = res.qr_svg
totpSecretBase32.value = res.secret_base32
totpPendingToken.value = res.pending_token
totpSetupStep.value = 2
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : t('settings.setupFailed')
} finally {
totpSetupLoading.value = false
}
}
async function confirmTotpSetup() {
totpSetupError.value = ''
totpSetupLoading.value = true
try {
const res = await rpcClient.totpSetupConfirm({
code: totpSetupCode.value,
password: totpSetupPassword.value,
pendingToken: totpPendingToken.value,
})
totpBackupCodes.value = res.backup_codes
totpEnabled.value = true
totpSetupStep.value = 3
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : t('settings.verificationFailed')
} finally {
totpSetupLoading.value = false
}
}
function closeTotpSetup() {
showTotpSetupModal.value = false
totpSetupStep.value = 1
totpSetupPassword.value = ''
totpSetupCode.value = ''
totpSetupError.value = ''
totpQrSvg.value = ''
totpSecretBase32.value = ''
totpPendingToken.value = ''
totpBackupCodes.value = []
backupCodesCopied.value = false
}
async function disableTotp() {
totpDisableError.value = ''
totpDisableLoading.value = true
try {
await rpcClient.totpDisable(totpDisablePassword.value, totpDisableCode.value)
totpEnabled.value = false
closeTotpDisable()
} catch (e) {
totpDisableError.value = e instanceof Error ? e.message : t('settings.disableFailed')
} finally {
totpDisableLoading.value = false
}
}
function closeTotpDisable() {
showTotpDisableModal.value = false
totpDisablePassword.value = ''
totpDisableCode.value = ''
totpDisableError.value = ''
}
async function copyBackupCodes() {
const text = totpBackupCodes.value.join('\n')
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
backupCodesCopied.value = true
setTimeout(() => { backupCodesCopied.value = false }, 2000)
}
loadTotpStatus()
</script>
<template>
<!-- Two-Factor Authentication -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<p class="text-sm font-medium text-white/90">{{ t('settings.twoFactorAuth') }}</p>
<p class="text-xs text-white/50">{{ t('settings.twoFaProtect') }}</p>
</div>
</div>
<span
class="text-xs font-semibold px-2 py-1 rounded-full"
:class="totpEnabled ? 'status-success' : 'bg-white/10 text-white/50'"
>
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<button
v-if="!totpEnabled"
@click="showTotpSetupModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
>
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>{{ t('settings.enable2fa') }}</span>
</button>
<button
v-else
@click="showTotpDisableModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-danger font-medium"
>
<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="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span>{{ t('settings.disable2fa') }}</span>
</button>
</div>
<!-- TOTP Setup Modal -->
<Teleport to="body">
<div
v-if="showTotpSetupModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeTotpSetup"
@keydown.escape="closeTotpSetup"
>
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-setup-title">
<template v-if="totpSetupStep === 1">
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.setup2faTitle') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.setup2faPasswordPrompt') }}</p>
<form @submit.prevent="beginTotpSetup" class="space-y-4">
<input
v-model="totpSetupPassword"
type="password"
required
autocomplete="current-password"
class="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="t('login.enterPasswordPlaceholder')"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpSetupLoading"
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"
>
{{ totpSetupLoading ? t('common.loading') : t('common.continue') }}
</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
</div>
</form>
</template>
<template v-else-if="totpSetupStep === 2">
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="sanitizedQrSvg" />
<div v-if="totpSecretBase32" class="bg-black/30 rounded-lg px-3 py-2 mb-4">
<p class="text-xs text-white/50 mb-1">Manual entry key (keep secret!):</p>
<div v-if="showTotpSecret" class="flex items-center gap-2">
<p class="text-sm font-mono text-orange-400 break-all">{{ totpSecretBase32 }}</p>
<button type="button" class="glass-button text-xs px-2 py-1" @click="showTotpSecret = false">Hide</button>
</div>
<button v-else type="button" class="glass-button text-xs px-3 py-1" @click="showTotpSecret = true">
Show manual entry key
</button>
</div>
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
<input
v-model="totpSetupCode"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
:placeholder="t('login.totpPlaceholder')"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
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"
>
{{ totpSetupLoading ? t('login.verifying') : t('settings.verifyAndEnable') }}
</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
</div>
</form>
</template>
<template v-else-if="totpSetupStep === 3">
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.saveBackupCodes') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.backupCodesInstruction') }}</p>
<div class="bg-black/30 rounded-xl p-4 mb-4">
<div class="grid grid-cols-2 gap-2">
<div
v-for="(code, i) in totpBackupCodes"
:key="i"
class="text-sm font-mono text-white/90 bg-white/5 rounded px-3 py-2 text-center"
>
{{ code }}
</div>
</div>
</div>
<button
@click="copyBackupCodes"
class="w-full mb-3 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-white/20 text-white/80 font-medium hover:bg-white/5 transition-colors"
>
<svg v-if="!backupCodesCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>{{ backupCodesCopied ? t('common.copiedBang') : t('settings.copyAllCodes') }}</span>
</button>
<button
@click="closeTotpSetup"
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
>
{{ t('common.done') }}
</button>
</template>
</div>
</div>
</Teleport>
<!-- TOTP Disable Modal -->
<Teleport to="body">
<div
v-if="showTotpDisableModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeTotpDisable"
@keydown.escape="closeTotpDisable"
>
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-disable-title">
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.disable2faTitle') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.disable2faDesc') }}</p>
<form @submit.prevent="disableTotp" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('login.password') }}</label>
<input
v-model="totpDisablePassword"
type="password"
required
autocomplete="current-password"
class="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="t('login.enterPasswordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.authenticatorCode') }}</label>
<input
v-model="totpDisableCode"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
:placeholder="t('login.totpPlaceholder')"
/>
</div>
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpDisableLoading"
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpDisableLoading ? t('common.disabling') : t('settings.disable2fa') }}
</button>
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
interface VpnStatus {
connected: boolean
provider: string | null
interface: string | null
ip_address: string | null
hostname: string | null
peers_connected: number
bytes_in: number
bytes_out: number
}
const vpnStatus = ref<VpnStatus | null>(null)
const loading = ref(true)
const error = ref('')
async function fetchVpnStatus() {
try {
loading.value = true
error.value = ''
const result = await rpcClient.call({ method: 'vpn.status', params: {} })
vpnStatus.value = result as VpnStatus
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to get VPN status'
} finally {
loading.value = false
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
onMounted(fetchVpnStatus)
defineExpose({ fetchVpnStatus })
</script>
<template>
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white/96">Nostr VPN</h3>
<p class="text-sm text-white/50">Mesh VPN with Nostr signaling</p>
</div>
</div>
<div v-if="vpnStatus" class="flex items-center gap-2">
<span
class="w-2.5 h-2.5 rounded-full"
:class="vpnStatus.connected ? 'bg-green-400 animate-pulse' : 'bg-white/30'"
/>
<span class="text-sm" :class="vpnStatus.connected ? 'text-green-400' : 'text-white/50'">
{{ vpnStatus.connected ? 'Connected' : 'Inactive' }}
</span>
</div>
</div>
<div v-if="loading && !vpnStatus" class="text-sm text-white/50">Loading VPN status...</div>
<div v-else-if="vpnStatus?.connected" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing VPN status...
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Provider</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
</div>
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Peers</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
</div>
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">VPN Address</div>
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
</div>
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Traffic</div>
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
</div>
</div>
</div>
<div v-else-if="vpnStatus && !vpnStatus.connected" class="space-y-2">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing VPN status...
</div>
<div class="text-sm text-white/50">
VPN will activate automatically when peers are discovered via Nostr relays.
</div>
</div>
<div v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</div>
</div>
</template>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const { t } = useI18n()
interface WebhookConfigData {
enabled: boolean
url: string
secret: string
events: string[]
}
const webhookConfig = ref<WebhookConfigData>({
enabled: false,
url: '',
secret: '',
events: [],
})
const savingWebhook = ref(false)
const testingWebhook = ref(false)
const webhookStatusMsg = ref('')
const webhookStatusType = ref<'success' | 'error'>('success')
const webhookEventTypes = computed(() => [
{ id: 'container_crash', label: t('settings.containerCrash'), description: t('settings.containerCrashDesc') },
{ id: 'update_available', label: t('settings.updateAvailableEvent'), description: t('settings.updateAvailableDesc') },
{ id: 'disk_warning', label: t('settings.diskSpaceWarning'), description: t('settings.diskWarningDesc') },
{ id: 'backup_complete', label: t('settings.backupComplete'), description: t('settings.backupCompleteDesc') },
])
function showWebhookStatus(msg: string, type: 'success' | 'error') {
webhookStatusMsg.value = msg
webhookStatusType.value = type
setTimeout(() => { webhookStatusMsg.value = '' }, 5000)
}
function toggleWebhookEvent(id: string) {
const idx = webhookConfig.value.events.indexOf(id)
if (idx >= 0) {
webhookConfig.value.events.splice(idx, 1)
} else {
webhookConfig.value.events.push(id)
}
}
function toggleWebhookEnabled() {
webhookConfig.value.enabled = !webhookConfig.value.enabled
}
async function loadWebhookConfig() {
try {
const res = await rpcClient.call<{ enabled: boolean; url: string; events: string[]; has_secret: boolean }>({ method: 'webhook.get-config' })
webhookConfig.value.enabled = res.enabled
webhookConfig.value.url = res.url
webhookConfig.value.events = res.events || []
} catch {
// Webhook system may not be available
}
}
async function saveWebhookConfig() {
savingWebhook.value = true
try {
await rpcClient.call({
method: 'webhook.configure',
params: {
enabled: webhookConfig.value.enabled,
url: webhookConfig.value.url,
secret: webhookConfig.value.secret || null,
events: webhookConfig.value.events,
},
})
showWebhookStatus(t('settings.webhookSaved'), 'success')
} catch {
showWebhookStatus(t('settings.webhookSaveFailed'), 'error')
} finally {
savingWebhook.value = false
}
}
async function testWebhook() {
testingWebhook.value = true
try {
const res = await rpcClient.call<{ sent: boolean; url: string }>({ method: 'webhook.test' })
if (res.sent) {
showWebhookStatus(t('settings.webhookTestSent'), 'success')
} else {
showWebhookStatus(t('settings.webhookTestFailed'), 'error')
}
} catch {
showWebhookStatus(t('settings.webhookSendFailed'), 'error')
} finally {
testingWebhook.value = false
}
}
loadWebhookConfig()
</script>
<template>
<!-- Webhook Notifications Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
</div>
<ToggleSwitch :model-value="webhookConfig.enabled" @update:model-value="toggleWebhookEnabled" />
</div>
<div class="space-y-4">
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookUrlLabel') }}</label>
<input
v-model="webhookConfig.url"
type="url"
:placeholder="t('settings.webhookUrlPlaceholder')"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookSecretLabel') }}</label>
<input
v-model="webhookConfig.secret"
type="password"
:placeholder="t('settings.webhookSecretPlaceholderFull')"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div>
<label class="text-xs text-white/50 block mb-2">{{ t('settings.eventsToNotify') }}</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
v-for="evt in webhookEventTypes"
:key="evt.id"
@click="toggleWebhookEvent(evt.id)"
role="checkbox"
:aria-checked="webhookConfig.events.includes(evt.id)"
:aria-label="evt.label"
class="flex items-center gap-3 p-3 rounded-lg border transition-colors text-left"
:class="webhookConfig.events.includes(evt.id)
? 'bg-orange-500/10 border-orange-500/30'
: 'bg-white/5 border-white/10 hover:border-white/20'"
>
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
:class="webhookConfig.events.includes(evt.id)
? 'border-orange-500 bg-orange-500'
: 'border-white/30'"
>
<svg v-if="webhookConfig.events.includes(evt.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="min-w-0">
<p class="text-sm text-white/90 font-medium">{{ evt.label }}</p>
<p class="text-xs text-white/50">{{ evt.description }}</p>
</div>
</button>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 pt-2">
<button
@click="saveWebhookConfig"
:disabled="savingWebhook"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
>
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
</button>
<button
@click="testWebhook"
:disabled="testingWebhook || !webhookConfig.url"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
>
{{ testingWebhook ? t('common.sending') : t('common.sendTest') }}
</button>
</div>
</div>
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ webhookStatusMsg }}
</div>
</div>
</template>
@@ -0,0 +1,72 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import BackupSection from '../BackupSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeBackup() {
return {
id: 'backup-one',
created_at: '2026-06-10T10:00:00Z',
size_bytes: 2048,
encrypted: true,
description: 'Before upgrade',
}
}
describe('BackupSection', () => {
it('keeps backups visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ backups: [makeBackup()] })
const wrapper = mount(BackupSection, {
global: {
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('2.0 KB')
const pending = deferred<{ backups: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadBackups: () => Promise<void> }).loadBackups()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('Refreshing backups...')
expect(wrapper.text()).not.toContain('settings.loadingBackups')
expect(wrapper.text()).not.toContain('settings.noBackups')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing backups...')
expect(wrapper.text()).not.toContain('settings.noBackups')
})
})
@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChangePasswordSection from '../ChangePasswordSection.vue'
const { changePassword } = vi.hoisted(() => ({
changePassword: vi.fn(),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
changePassword,
},
}))
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
describe('ChangePasswordSection', () => {
beforeEach(() => {
changePassword.mockReset()
})
it('shows a warning when SSH update fails after web password update succeeds', async () => {
changePassword.mockResolvedValueOnce({
success: true,
ssh_updated: false,
ssh_error: 'sudo unavailable',
})
const wrapper = mount(ChangePasswordSection, {
global: {
stubs: {
Teleport: true,
},
},
})
await wrapper.find('button').trigger('click')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('password123')
await inputs[1]!.setValue('MyP@ssw0rd!123')
await inputs[2]!.setValue('MyP@ssw0rd!123')
await wrapper.find('form').trigger('submit.prevent')
expect(changePassword).toHaveBeenCalledWith({
currentPassword: 'password123',
newPassword: 'MyP@ssw0rd!123',
alsoChangeSsh: true,
})
expect(wrapper.text()).toContain('settings.passwordUpdatedSuccess')
expect(wrapper.text()).toContain('settings.passwordUpdatedSshFailed sudo unavailable')
expect(wrapper.text()).not.toContain('settings.passwordChangeFailed')
})
})
@@ -0,0 +1,353 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LightningCredentialsSection from '../LightningCredentialsSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
lndMacaroonStatus: vi.fn(),
lndRotateMacaroons: vi.fn(),
lndMacaroonRotationProgress: vi.fn(),
},
}))
const STEP_KEYS = ['preflight', 'backup', 'stop', 'remove', 'start', 'verify', 'btcpay'] as const
type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
function steps(overrides: Partial<Record<string, { state: StepState; detail?: string }>> = {}) {
return STEP_KEYS.map((key) => ({
key,
label: `label-${key}`,
state: overrides[key]?.state ?? ('pending' as StepState),
detail: overrides[key]?.detail ?? null,
}))
}
function idleRotation() {
return {
running: false,
ok: null,
started_at: null,
finished_at: null,
error: null,
steps: steps(),
backup_path: null,
identity_pubkey: null,
channels_before: null,
channels_after: null,
new_admin_macaroon_sha256: null,
}
}
function status(overrides: Record<string, unknown> = {}) {
return {
installed: true,
admin_macaroon_sha256: 'a'.repeat(64),
issued_at: '2026-08-08 06:03:11',
identity_pubkey: '024a5fd7de13623aeec81095cf8776fedbc0c4109363022c3ec948196202130b92',
channels_open: 3,
channels_pending: 1,
lnd_error: null,
btcpay_uses_internal_lnd: true,
btcpay_credential_current: true,
rotation: idleRotation(),
...overrides,
}
}
function mountSection() {
return mount(LightningCredentialsSection, {
global: { stubs: { Teleport: true } },
})
}
describe('LightningCredentialsSection', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('shows what must survive before offering to rotate', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
const wrapper = mountSection()
await flushPromises()
// The channel census is the reassurance an operator needs before clicking a
// button that invalidates every credential their wallet holds.
expect(wrapper.text()).toContain('3 open')
expect(wrapper.text()).toContain('1 pending')
expect(wrapper.text()).toContain('2026-08-08 06:03:11')
// A digest is fine to display; the token itself must never be fetched.
expect(wrapper.text()).toContain('aaaaaaaaaaaaaaaa…')
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
it('warns when BTCPay is stranded on a rotated-out credential', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_credential_current: false }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('BTCPay Server is holding an old Lightning credential')
})
it('stays quiet about BTCPay when there is no internal node to warn about', async () => {
// null means "not configured" — an absence, not a fault. Reporting it as a
// problem would train operators to ignore the warning that matters.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_uses_internal_lnd: false, btcpay_credential_current: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('holding an old Lightning credential')
})
it('blocks rotation while LND is not answering', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
lnd_error: 'LND is not answering on its REST port',
channels_open: null,
channels_pending: null,
identity_pubkey: null,
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not answering right now')
// Without a before-reading there is no way to prove the channels came back,
// so the button must be unavailable rather than merely discouraged.
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
it('says Lightning is not installed instead of offering a no-op rotation', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ installed: false, admin_macaroon_sha256: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
expect(wrapper.findAll('button')).toHaveLength(0)
})
async function startRotation(wrapper: ReturnType<typeof mountSection>, pw = 'node-password') {
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue(pw)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
it('sends the password and starts polling for progress', async () => {
vi.mocked(rpcClient.lndMacaroonStatus)
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ rotation: { ...idleRotation(), running: true } }))
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
expect(rpcClient.lndRotateMacaroons).toHaveBeenCalledWith('node-password')
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('keeps polling when the first status after starting has not caught up yet', async () => {
// The node accepts the rotation and then answers a status request that was
// computed a moment earlier, still saying `running: false`. Cancelling the
// poll here would freeze the screen on the one action that most needs to show
// progress — the operator has just invalidated every credential their wallet
// holds and would be told nothing is happening.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('gives up polling if the node never reports the rotation as running', async () => {
// Bounded, so a request that was accepted but never acted on stops polling
// instead of hammering the node forever.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
vi.advanceTimersByTime(180_000)
await flushPromises()
const callsAfterGiveUp = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterGiveUp)
})
it('surfaces a rejected password without starting anything', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockRejectedValue(
new Error('Password verification failed'),
)
const wrapper = mountSection()
await flushPromises()
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Password verification failed')
// The dialog stays open so the operator can correct the password.
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('reports a finished rotation with the re-pair and backup-cleanup steps', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: true,
finished_at: '2026-08-08T12:00:00Z',
steps: steps({
preflight: { state: 'done' },
backup: { state: 'done' },
stop: { state: 'done' },
remove: { state: 'done' },
start: { state: 'done' },
verify: { state: 'done', detail: 'same node, same 3 channel(s)' },
btcpay: { state: 'done' },
}),
backup_path: '/var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z',
channels_before: 3,
channels_after: 3,
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation complete')
expect(wrapper.text()).toContain('same node, same 3 channel(s)')
expect(wrapper.text()).toContain('Re-pair anything that connects to this node')
// The backup holds the OLD root key, so telling the operator to delete it is
// part of the job, not a nicety.
expect(wrapper.text()).toContain('macaroon-rotation-20260808T120000Z')
})
it('reports a failed rotation as failed rather than silently idle', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: false,
finished_at: '2026-08-08T12:00:00Z',
error: 'backup incomplete — refusing to delete anything',
steps: steps({ preflight: { state: 'done' }, backup: { state: 'failed' } }),
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation failed')
expect(wrapper.text()).toContain('backup incomplete')
})
it('does not poll the node when nothing is running', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
mountSection()
await flushPromises()
const callsAfterLoad = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
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')
})
})
@@ -0,0 +1,65 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TransportPrefsCard from '../TransportPrefsCard.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makePrefs() {
return {
federation: 'auto',
peers: 'fips',
peer_files: 'tor',
messaging: 'auto',
mesh_file_sharing: 'fips',
}
}
describe('TransportPrefsCard', () => {
it('keeps transport preferences visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce(makePrefs())
const wrapper = mount(TransportPrefsCard)
await flushPromises()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('Auto')
expect(wrapper.text()).toContain('FIPS')
expect(wrapper.text()).toContain('Tor')
const pending = deferred<ReturnType<typeof makePrefs>>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { load: () => Promise<void> }).load()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('Refreshing transport preferences...')
expect(wrapper.text()).not.toContain('Loading…')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing transport preferences...')
})
})
@@ -0,0 +1,66 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import VpnStatusSection from '../VpnStatusSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeVpnStatus() {
return {
connected: true,
provider: 'wireguard',
interface: 'wg0',
ip_address: '10.0.0.2/32',
hostname: 'node',
peers_connected: 2,
bytes_in: 1024,
bytes_out: 2048,
}
}
describe('VpnStatusSection', () => {
it('keeps VPN status visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce(makeVpnStatus())
const wrapper = mount(VpnStatusSection)
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('10.0.0.2/32')
const pending = deferred<ReturnType<typeof makeVpnStatus>>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { fetchVpnStatus: () => Promise<void> }).fetchVpnStatus()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('Refreshing VPN status...')
expect(wrapper.text()).not.toContain('Loading VPN status...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing VPN status...')
})
})