frontend: polish app launch and release experience
This commit is contained in:
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
@@ -13,6 +14,7 @@ const serverName = computed(() => store.serverName)
|
||||
const editingServerName = ref(false)
|
||||
const serverNameDraft = ref('')
|
||||
const serverNameInput = ref<HTMLInputElement | null>(null)
|
||||
const serverNameWarning = ref('')
|
||||
|
||||
function startEditServerName() {
|
||||
serverNameDraft.value = serverName.value
|
||||
@@ -22,13 +24,17 @@ function startEditServerName() {
|
||||
|
||||
async function saveServerName() {
|
||||
const name = serverNameDraft.value.trim()
|
||||
serverNameWarning.value = ''
|
||||
if (!name || name === serverName.value) {
|
||||
editingServerName.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.set-name', params: { name } })
|
||||
const result = await rpcClient.call<{ hostname_error?: string | null }>({ method: 'server.set-name', params: { name } })
|
||||
store.updateServerName(name)
|
||||
if (result.hostname_error) {
|
||||
serverNameWarning.value = `Display name saved, but hostname update failed: ${result.hostname_error}`
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
|
||||
}
|
||||
@@ -38,6 +44,7 @@ async function saveServerName() {
|
||||
// Version & release notes
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
const showReleaseNotes = ref(false)
|
||||
useBodyScrollLock(showReleaseNotes)
|
||||
|
||||
// Identity
|
||||
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
|
||||
@@ -148,6 +155,7 @@ init()
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p v-if="serverNameWarning" class="mt-2 text-xs text-yellow-300">{{ serverNameWarning }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Version Card -->
|
||||
|
||||
@@ -27,6 +27,7 @@ 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`
|
||||
@@ -42,12 +43,15 @@ function showBackupStatus(msg: string, type: 'success' | 'error') {
|
||||
}
|
||||
|
||||
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 {
|
||||
backupList.value = []
|
||||
} catch (e: unknown) {
|
||||
backupLoadError.value = e instanceof Error ? e.message : t('settings.backupListFailed')
|
||||
if (!hadBackups) backupList.value = []
|
||||
} finally {
|
||||
loadingBackups.value = false
|
||||
}
|
||||
@@ -189,6 +193,8 @@ function copyChannelBackup() {
|
||||
}
|
||||
|
||||
loadBackups()
|
||||
|
||||
defineExpose({ loadBackups })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -204,9 +210,19 @@ loadBackups()
|
||||
{{ t('settings.createBackup') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</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>
|
||||
@@ -235,7 +251,7 @@ loadBackups()
|
||||
|
||||
<!-- 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/10 backdrop-blur-md" @click.self="showCreateBackupModal = false">
|
||||
<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">
|
||||
@@ -260,7 +276,7 @@ loadBackups()
|
||||
|
||||
<!-- 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/10 backdrop-blur-md" @click.self="showRestoreModal = false">
|
||||
<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>
|
||||
|
||||
@@ -13,6 +13,7 @@ useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePas
|
||||
const changingPassword = ref(false)
|
||||
const changePasswordError = ref('')
|
||||
const changePasswordSuccess = ref('')
|
||||
const changePasswordWarning = ref('')
|
||||
const changePasswordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
@@ -32,6 +33,7 @@ function validatePasswordStrength(pw: string): string | 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')
|
||||
@@ -48,16 +50,21 @@ async function handleChangePassword() {
|
||||
}
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await rpcClient.changePassword({
|
||||
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 }
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
if (!changePasswordWarning.value) {
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
|
||||
} finally {
|
||||
@@ -70,6 +77,7 @@ function closeChangePasswordModal() {
|
||||
showChangePasswordModal.value = false
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
changePasswordWarning.value = ''
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
}
|
||||
</script>
|
||||
@@ -93,7 +101,7 @@ function closeChangePasswordModal() {
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
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">
|
||||
@@ -139,6 +147,7 @@ function closeChangePasswordModal() {
|
||||
</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"
|
||||
|
||||
@@ -12,9 +12,19 @@
|
||||
<span class="text-white/40">Auto = FIPS first, Tor on failure.</span>
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="text-white/50 text-sm">Loading…</div>
|
||||
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<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"
|
||||
@@ -77,12 +87,14 @@ 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
|
||||
}
|
||||
@@ -118,4 +130,6 @@ async function setPref(service: Service, pref: Pref) {
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
defineExpose({ load })
|
||||
</script>
|
||||
|
||||
@@ -172,7 +172,7 @@ loadTotpStatus()
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpSetupModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
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"
|
||||
>
|
||||
@@ -279,7 +279,7 @@ loadTotpStatus()
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpDisableModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -38,6 +38,8 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
onMounted(fetchVpnStatus)
|
||||
|
||||
defineExpose({ fetchVpnStatus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,29 +67,47 @@ onMounted(fetchVpnStatus)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-white/50">Loading VPN status...</div>
|
||||
<div v-if="loading && !vpnStatus" class="text-sm text-white/50">Loading VPN status...</div>
|
||||
|
||||
<div v-else-if="vpnStatus?.connected" 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 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="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 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?.connected" class="text-sm text-white/50">
|
||||
VPN will activate automatically when peers are discovered via Nostr relays.
|
||||
<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>
|
||||
|
||||
@@ -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