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 screen’s 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...')
})
})