Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } 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()
|
||||
|
||||
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 -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<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">
|
||||
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</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,39 @@
|
||||
<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'
|
||||
|
||||
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 />
|
||||
|
||||
<!-- 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,447 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
</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>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<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() }} · {{ 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="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')">
|
||||
×
|
||||
</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,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,19 @@
|
||||
<script setup lang="ts">
|
||||
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.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 BackupSection from '@/views/settings/BackupSection.vue'
|
||||
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InterfaceModeSection />
|
||||
<ClaudeAuthSection />
|
||||
<AIDataAccessSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<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,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...')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user