2026-01-24 22:59:20 +00:00
< template >
< div >
2026-02-17 15:03:34 +00:00
< div class = "mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4" >
< div >
2026-03-11 13:45:59 +00:00
< h1 class = "text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]" >{{ t ( 'settings.title' ) }}</ h1 >
< p class = "text-white/80" >{{ t ( 'settings.subtitle' ) }}</ p >
2026-02-17 15:03:34 +00:00
</ div >
<!-- Controller indicator - Mobile only ( desktop shows in sidebar ) -->
< div class = "md:hidden" >
< ControllerIndicator />
</ div >
2026-01-24 22:59:20 +00:00
</ div >
<!-- Account Section -->
2026-03-10 23:58:33 +00:00
< div class = "glass-card px-6 py-6 mb-6" >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white/96 mb-6" >{{ t ( 'settings.account' ) }}</ h2 >
2026-01-24 22:59:20 +00:00
<!-- Info Grid -->
< div class = "grid grid-cols-1 md:grid-cols-2 gap-4 mb-6" >
<!-- Server Name Card -->
< div class = "bg-black/20 rounded-xl px-5 py-4 border border-white/10" >
< div class = "flex items-center gap-3 mb-2" >
< 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 = "M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</ svg >
2026-03-11 13:45:59 +00:00
< p class = "text-xs font-semibold text-white/60 uppercase tracking-wide" >{{ t ( 'settings.serverName' ) }}</ p >
2026-01-24 22:59:20 +00:00
</ div >
< p class = "text-lg font-semibold text-white/95" >{{ serverName }}</ p >
</ div >
<!-- Version Card -->
< div class = "bg-black/20 rounded-xl px-5 py-4 border border-white/10" >
< div class = "flex items-center gap-3 mb-2" >
< 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 = "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</ svg >
2026-03-11 13:45:59 +00:00
< p class = "text-xs font-semibold text-white/60 uppercase tracking-wide" >{{ t ( 'common.version' ) }}</ p >
2026-01-24 22:59:20 +00:00
</ div >
< p class = "text-lg font-semibold text-white/95" >{{ version }}</ p >
</ div >
<!-- Session Card -->
< div class = "bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2" >
< div class = "flex items-center gap-3 mb-2" >
< svg class = "w-5 h-5 text-green-400" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap =" round " stroke -linejoin =" round " stroke -width =" 2 " d = "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</ svg >
2026-03-11 13:45:59 +00:00
< p class = "text-xs font-semibold text-white/60 uppercase tracking-wide" >{{ t ( 'settings.sessionStatus' ) }}</ p >
2026-01-24 22:59:20 +00:00
</ div >
2026-03-11 13:45:59 +00:00
< p class = "text-base font-medium text-white/90" >{{ t ( 'settings.loggedIn' ) }}</ p >
2026-01-24 22:59:20 +00:00
</ div >
2026-02-17 15:03:34 +00:00
<!-- Identity Card : DID + Tor Address ( onion below DID , with copy ) -->
< div v-if = "userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4" >
<!-- DID -- >
< div v-if = "userDid" >
2026-03-11 12:46:02 +00:00
< div class = "flex items-center justify-between gap-2 mb-2" >
< div class = "flex items-center gap-3 min-w-0" >
< svg class = "w-5 h-5 text-amber-400 shrink-0" 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-xs font-semibold text-white/60 uppercase tracking-wide" >{{ t ( 'settings.yourDid' ) }}</ p >
2026-03-11 12:46:02 +00:00
</ div >
< button
@click ="copyDid"
class = "shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
>
< svg v-if = "!copiedDid" 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 >
2026-03-11 13:45:59 +00:00
< span v-else class = "text-green-400 text-xs" >{{ t ( 'common.copied' ) }}</ span >
< span v-if = "!copiedDid" >{{ t (' common.copy ') }}</ span >
2026-03-11 12:46:02 +00:00
</ button >
2026-02-17 15:03:34 +00:00
</ div >
< p class = "text-sm font-mono text-white/90 break-all" :title = "userDid" >{{ userDid }}</ p >
2026-03-11 13:45:59 +00:00
< p class = "text-xs text-white/50 mt-1" >{{ t ( 'settings.didHelper' ) }}</ p >
2026-02-17 15:03:34 +00:00
</ div >
<!-- Tor / Onion Address ( below DID , with copy button ) -->
< div v-if = "serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''" >
< div class = "flex items-center justify-between gap-2 mb-2" >
< div class = "flex items-center gap-3 min-w-0" >
< svg class = "w-5 h-5 text-amber-400 shrink-0" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap =" round " stroke -linejoin =" round " stroke -width =" 2 " d = "M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</ svg >
2026-03-11 13:45:59 +00:00
< p class = "text-xs font-semibold text-white/60 uppercase tracking-wide" >{{ t ( 'settings.onionAddress' ) }}</ p >
2026-02-17 15:03:34 +00:00
</ div >
< button
@click ="copyOnionAddress"
class = "shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
>
< svg v-if = "!copiedOnion" 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 >
2026-03-11 13:45:59 +00:00
< span v-else class = "text-green-400 text-xs" >{{ t ( 'common.copied' ) }}</ span >
< span v-if = "!copiedOnion" >{{ t (' common.copy ') }}</ span >
2026-02-17 15:03:34 +00:00
</ button >
</ div >
< p class = "text-sm font-mono text-amber-400/90 break-all" :title = "serverTorAddress" >{{ serverTorAddress }}</ p >
2026-03-11 13:45:59 +00:00
< p class = "text-xs text-white/50 mt-1" >{{ t ( 'settings.onionHelper' ) }}</ p >
2026-02-17 15:03:34 +00:00
</ div >
</ div >
2026-01-24 22:59:20 +00:00
</ div >
2026-02-17 15:03:34 +00:00
<!-- Change Password -->
2026-02-18 11:29:05 +00:00
< div data -controller-container tabindex = "0" class = "mb-6" >
2026-02-17 15:03:34 +00:00
< button
@click ="showChangePasswordModal = true"
class = "w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
>
< 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 >
2026-03-11 13:45:59 +00:00
< span >{{ t ( 'settings.changePassword' ) }}</ span >
2026-02-17 15:03:34 +00:00
</ 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-sm"
2026-02-17 22:10:38 +00:00
@click.self ="closeChangePasswordModal()"
2026-02-17 15:03:34 +00:00
>
2026-02-17 22:10:38 +00:00
< div ref = "changePasswordModalRef" class = "glass-card p-6 max-w-md w-full" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-02-17 15:03:34 +00:00
< form @submit.prevent ="handleChangePassword" class = "space-y-4" >
< div >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" >{{ t ( 'settings.currentPassword' ) }}</ label >
2026-02-17 15:03:34 +00:00
< 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('login.enterPasswordPlaceholder')"
2026-02-17 15:03:34 +00:00
/>
</ div >
< div >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" >{{ t ( 'settings.newPassword' ) }}</ label >
2026-02-17 15:03:34 +00:00
< 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('settings.passwordPlaceholder')"
2026-02-17 15:03:34 +00:00
/>
</ div >
< div >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" >{{ t ( 'settings.confirmNewPassword' ) }}</ label >
2026-02-17 15:03:34 +00:00
< 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('settings.confirmNewPassword')"
2026-02-17 15:03:34 +00:00
/>
</ 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" />
2026-03-11 13:45:59 +00:00
{{ t ( 'settings.updateSshCheckbox' ) }}
2026-02-17 15:03:34 +00:00
</ 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 >
< 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"
>
2026-03-11 13:45:59 +00:00
{{ changingPassword ? t ( 'settings.updatingPassword' ) : t ( 'settings.updatePassword' ) }}
2026-02-17 15:03:34 +00:00
</ 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"
>
2026-03-11 13:45:59 +00:00
{{ t ( 'common.cancel' ) }}
2026-02-17 15:03:34 +00:00
</ button >
</ div >
</ form >
</ div >
</ div >
</ Teleport >
2026-03-06 12:23:57 +00:00
<!-- 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-sm font-medium text-white/90" >{{ t ( 'settings.twoFactorAuth' ) }}</ p >
< p class = "text-xs text-white/50" >{{ t ( 'settings.twoFaProtect' ) }}</ p >
2026-03-06 12:23:57 +00:00
</ div >
</ div >
< span
class = "text-xs font-semibold px-2 py-1 rounded-full"
: class = "totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
>
2026-03-11 13:45:59 +00:00
{{ totpEnabled ? t ( 'common.enabled' ) : t ( 'common.disabled' ) }}
2026-03-06 12:23:57 +00:00
</ 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 border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
>
< 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 >
2026-03-11 13:45:59 +00:00
< span >{{ t ( 'settings.enable2fa' ) }}</ span >
2026-03-06 12:23:57 +00:00
</ button >
< button
v-else
@click ="showTotpDisableModal = true"
class = "w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-red-500/50 text-red-400 font-medium hover:bg-red-500/10 transition-colors"
>
< 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 >
2026-03-11 13:45:59 +00:00
< span >{{ t ( 'settings.disable2fa' ) }}</ span >
2026-03-06 12:23:57 +00:00
</ 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-sm"
@click.self ="closeTotpSetup"
2026-03-11 13:11:45 +00:00
@keydown.escape ="closeTotpSetup"
2026-03-06 12:23:57 +00:00
>
2026-03-11 13:11:45 +00:00
< div class = "glass-card p-6 max-w-md w-full" role = "dialog" aria -modal =" true " aria -labelledby =" totp -setup-title " >
2026-03-06 12:23:57 +00:00
<!-- Step 1 : Enter password -->
< template v-if = "totpSetupStep === 1" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
< 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('login.enterPasswordPlaceholder')"
2026-03-06 12:23:57 +00:00
/>
< 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"
>
2026-03-11 13:45:59 +00:00
{{ totpSetupLoading ? t ( 'common.loading' ) : t ( 'common.continue' ) }}
2026-03-06 12:23:57 +00:00
</ button >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
</ div >
</ form >
</ template >
<!-- Step 2 : Scan QR + verify code -->
< template v-else-if = "totpSetupStep === 2" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
< div class = "flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html = "totpQrSvg" />
< div class = "bg-black/30 rounded-lg px-3 py-2 mb-4" >
2026-03-11 13:45:59 +00:00
< p class = "text-xs text-white/50 mb-1" >{{ t ( 'settings.manualEntryKey' ) }}</ p >
2026-03-06 12:23:57 +00:00
< p class = "text-sm font-mono text-orange-400 break-all select-all" >{{ totpSecretBase32 }}</ p >
</ 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('login.totpPlaceholder')"
2026-03-06 12:23:57 +00:00
/>
< 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"
>
2026-03-11 13:45:59 +00:00
{{ totpSetupLoading ? t ( 'login.verifying' ) : t ( 'settings.verifyAndEnable' ) }}
2026-03-06 12:23:57 +00:00
</ button >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
</ div >
</ form >
</ template >
<!-- Step 3 : Show backup codes -->
< template v-else-if = "totpSetupStep === 3" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< span >{{ backupCodesCopied ? t ( 'common.copiedBang' ) : t ( 'settings.copyAllCodes' ) }}</ span >
2026-03-06 12:23:57 +00:00
</ 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"
>
2026-03-11 13:45:59 +00:00
{{ t ( 'common.done' ) }}
2026-03-06 12:23:57 +00:00
</ 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-sm"
@click.self ="closeTotpDisable"
2026-03-11 13:11:45 +00:00
@keydown.escape ="closeTotpDisable"
2026-03-06 12:23:57 +00:00
>
2026-03-11 13:11:45 +00:00
< div class = "glass-card p-6 max-w-md w-full" role = "dialog" aria -modal =" true " aria -labelledby =" totp -disable-title " >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
< form @submit.prevent ="disableTotp" class = "space-y-4" >
< div >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" >{{ t ( 'login.password' ) }}</ label >
2026-03-06 12:23:57 +00:00
< 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('login.enterPasswordPlaceholder')"
2026-03-06 12:23:57 +00:00
/>
</ div >
< div >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" >{{ t ( 'settings.authenticatorCode' ) }}</ label >
2026-03-06 12:23:57 +00:00
< 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"
2026-03-11 13:45:59 +00:00
:placeholder = "t('login.totpPlaceholder')"
2026-03-06 12:23:57 +00:00
/>
</ 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"
>
2026-03-11 13:45:59 +00:00
{{ totpDisableLoading ? t ( 'common.disabling' ) : t ( 'settings.disable2fa' ) }}
2026-03-06 12:23:57 +00:00
</ button >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 12:23:57 +00:00
</ div >
</ form >
</ div >
</ div >
</ Teleport >
2026-01-24 22:59:20 +00:00
<!-- 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 >
2026-03-11 13:45:59 +00:00
< span >{{ t ( 'settings.logout' ) }}</ span >
2026-01-24 22:59:20 +00:00
</ button >
</ div >
2026-03-04 07:09:31 +00:00
<!-- Interface Mode Section -->
2026-03-10 23:58:33 +00:00
< div class = "glass-card px-6 py-6 mb-6" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-04 07:09:31 +00:00
< div 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 >
2026-03-04 23:05:01 +00:00
<!-- Claude Authentication Section -->
2026-03-10 23:58:33 +00:00
< div class = "glass-card px-6 py-6 mb-6" >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white/96 mb-2" >{{ t ( 'settings.claudeAuth' ) }}</ h2 >
< p class = "text-sm text-white/60 mb-6" >{{ t ( 'settings.claudeAuthDesc' ) }}</ p >
2026-03-04 23:05:01 +00:00
<!-- Status -->
< div class = "bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" >
< div class = "flex items-center gap-3 mb-2" >
< svg class = "w-5 h-5 shrink-0" : class = "claudeConnected ? 'text-green-400' : 'text-white/40'" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path v-if = "claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
< path v-else stroke -linecap =" round " stroke -linejoin =" round " stroke -width =" 2 " d = "M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
</ svg >
2026-03-11 13:45:59 +00:00
< p class = "text-xs font-semibold text-white/60 uppercase tracking-wide" >{{ t ( 'settings.connectionStatus' ) }}</ p >
2026-03-04 23:05:01 +00:00
</ div >
< p class = "text-base font-medium" : class = "claudeConnected ? 'text-green-400' : 'text-white/50'" >
2026-03-11 13:45:59 +00:00
{{ claudeConnected ? t ( 'common.connected' ) : t ( 'settings.notConnected' ) }}
2026-03-04 23:05:01 +00:00
</ p >
</ div >
< button
@click ="showClaudeLoginModal = true"
class = "w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
: class = "claudeConnected
? 'border-white/20 text-white/70 hover:bg-white/5'
: 'border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10'"
>
< 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 = "M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</ svg >
2026-03-11 13:45:59 +00:00
< span >{{ claudeConnected ? t ( 'settings.reAuthenticate' ) : t ( 'settings.loginWithClaude' ) }}</ span >
2026-03-04 23:05:01 +00:00
</ button >
</ div >
<!-- Claude Login Modal ( iframe ) -->
< Teleport to = "body" >
< div
v-if = "showClaudeLoginModal"
class = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self ="showClaudeLoginModal = false"
>
< div class = "glass-card p-0 max-w-lg w-full overflow-hidden" style = "height: 480px" >
< div class = "flex items-center justify-between px-4 py-3 border-b border-white/10" >
2026-03-11 13:45:59 +00:00
< h3 class = "text-sm font-semibold text-white/80" >{{ t ( 'settings.claudeAuth' ) }}</ h3 >
2026-03-04 23:05:01 +00:00
< button @click ="showClaudeLoginModal = false" class = "text-white/50 hover:text-white/80 transition-colors" >
< 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 = "M6 18L18 6M6 6l12 12" />
</ svg >
</ button >
</ div >
< iframe
src = "/claude-login"
class = "w-full border-0"
style = "height: calc(100% - 49px)"
@load ="onClaudeIframeLoad"
/>
</ div >
</ div >
</ Teleport >
2026-03-04 12:06:20 +00:00
<!-- AI Data Access Section -->
2026-03-10 23:58:33 +00:00
< div class = "glass-card px-6 py-6 mb-6" >
2026-03-06 01:11:06 +00:00
< div class = "mb-2" >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white/96" >{{ t ( 'settings.aiDataAccess' ) }}</ h2 >
2026-03-04 12:06:20 +00:00
</ div >
2026-03-11 13:45:59 +00:00
< p class = "text-sm text-white/60 mb-6" >{{ t ( 'settings.aiDataAccessDesc' ) }}</ p >
2026-03-04 12:06:20 +00:00
2026-03-06 01:11:06 +00:00
<!-- Enable All toggle -->
< 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" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-06 01:11:06 +00:00
</ div >
< div
class = "w-10 h-6 rounded-full shrink-0 transition-colors relative"
: class = "aiPermissions.allEnabled ? 'bg-orange-500' : 'bg-white/15'"
>
< div
class = "absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
: class = "aiPermissions.allEnabled ? 'translate-x-5' : 'translate-x-1'"
/>
</ div >
</ button >
2026-03-04 14:34:02 +00:00
< 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 >
< div
class = "w-10 h-6 rounded-full shrink-0 transition-colors relative"
: class = "aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
>
< div
class = "absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
: class = "aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
/>
</ div >
</ button >
2026-03-04 12:06:20 +00:00
</ div >
2026-03-04 14:34:02 +00:00
</ div >
2026-01-24 22:59:20 +00:00
</ div >
</ div >
2026-03-09 07:43:12 +00:00
2026-03-11 12:46:02 +00:00
<!-- System Updates Section -->
< div class = "glass-card px-6 py-6 mb-6" >
< div class = "flex items-center justify-between" >
< div >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-11 12:46:02 +00:00
</ div >
< RouterLink to = "/dashboard/settings/update" class = "glass-button px-4 py-2 rounded-lg text-sm flex items-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 = "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</ svg >
2026-03-11 13:45:59 +00:00
{{ t ( 'common.manageUpdates' ) }}
2026-03-11 12:46:02 +00:00
</ RouterLink >
</ div >
</ div >
2026-03-13 00:13:38 +00:00
<!-- Tor Services 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" > Tor Services </ h2 >
< p class = "text-sm text-white/60 mt-1" > Manage hidden service addresses for your node and apps </ p >
</ div >
< button @click ="loadTorServices" class = "glass-button px-4 py-2 rounded-lg text-sm flex items-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 = "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</ svg >
Refresh
</ button >
</ div >
< div v-if = "torLoading" class="text-sm text-white/40 py-4 text-center" > Loading Tor services... </ div >
< div v-else-if = "torServices.length === 0" class="text-sm text-white/40 py-4 text-center" > No Tor services configured </ div >
< div v-else class = "space-y-2" >
< div v-for = "svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3" >
< div class = "flex-1 min-w-0" >
< p class = "text-white text-sm font-medium" >{{ svc . name }}</ p >
< p v-if = "svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate" >{{ svc.onion_address }}</ p >
< p v-else class = "text-white/30 text-xs" > No . onion address </ p >
</ div >
< div class = "flex items-center gap-2 shrink-0" >
< button
v-if = "svc.name === 'archipelago'"
@click ="rotateNodeAddress"
:disabled = "torRotating"
class = "glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating ? 'Rotating...' : 'Rotate' }}
</ button >
< label class = "tor-toggle-label" >
< input
type = "checkbox"
:checked = "svc.enabled"
@change ="toggleTorApp(svc.name, !svc.enabled)"
class = "tor-toggle-input"
/>
< span class = "tor-toggle-slider" ></ span >
</ label >
</ div >
</ div >
</ div >
</ div >
2026-03-11 12:55:13 +00:00
<!-- Webhook Notifications Section -->
< div class = "glass-card px-6 py-6 mb-6" >
< div class = "flex items-center justify-between mb-4" >
< div >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-11 12:55:13 +00:00
</ div >
< div class = "flex items-center gap-3" >
< button
@click ="toggleWebhookEnabled"
2026-03-11 13:04:31 +00:00
role = "switch"
:aria-checked = "webhookConfig.enabled"
2026-03-11 13:45:59 +00:00
: aria -label =" webhookConfig.enabled ? t (' settings.disableWebhooks ') : t (' settings.enableWebhooks ')"
2026-03-11 12:55:13 +00:00
class = "w-10 h-6 rounded-full shrink-0 transition-colors relative"
: class = "webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
2026-03-11 13:45:59 +00:00
: title = "webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
2026-03-11 12:55:13 +00:00
>
< div
class = "absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
: class = "webhookConfig.enabled ? 'translate-x-5' : 'translate-x-1'"
/>
</ button >
</ div >
</ div >
< div class = "space-y-4" >
<!-- Webhook URL -->
< div >
2026-03-11 13:45:59 +00:00
< label class = "text-xs text-white/50 block mb-1" >{{ t ( 'settings.webhookUrlLabel' ) }}</ label >
2026-03-11 12:55:13 +00:00
< input
v-model = "webhookConfig.url"
type = "url"
2026-03-11 13:45:59 +00:00
:placeholder = "t('settings.webhookUrlPlaceholder')"
2026-03-11 12:55:13 +00:00
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 >
<!-- Secret ( optional ) -->
< div >
2026-03-11 13:45:59 +00:00
< label class = "text-xs text-white/50 block mb-1" >{{ t ( 'settings.webhookSecretLabel' ) }}</ label >
2026-03-11 12:55:13 +00:00
< input
v-model = "webhookConfig.secret"
type = "password"
2026-03-11 13:45:59 +00:00
:placeholder = "t('settings.webhookSecretPlaceholderFull')"
2026-03-11 12:55:13 +00:00
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 >
<!-- Event Types -->
< div >
2026-03-11 13:45:59 +00:00
< label class = "text-xs text-white/50 block mb-2" >{{ t ( 'settings.eventsToNotify' ) }}</ label >
2026-03-11 12:55:13 +00:00
< 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)"
2026-03-11 13:04:31 +00:00
role = "checkbox"
:aria-checked = "webhookConfig.events.includes(evt.id)"
:aria-label = "evt.label"
2026-03-11 12:55:13 +00:00
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 >
<!-- Actions -->
< div class = "flex flex-col sm:flex-row gap-2 pt-2" >
< button
@click ="saveWebhookConfig"
:disabled = "savingWebhook"
class = "glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
>
2026-03-11 13:45:59 +00:00
{{ savingWebhook ? t ( 'settings.savingWebhook' ) : t ( 'common.saveConfiguration' ) }}
2026-03-11 12:55:13 +00:00
</ 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"
>
2026-03-11 13:45:59 +00:00
{{ testingWebhook ? t ( 'common.sending' ) : t ( 'common.sendTest' ) }}
2026-03-11 12:55:13 +00:00
</ button >
</ div >
</ div >
<!-- Webhook status message -->
2026-03-11 13:04:31 +00:00
< div v-if = "webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'" >
2026-03-11 12:55:13 +00:00
{{ webhookStatusMsg }}
</ div >
</ div >
2026-03-11 12:46:02 +00:00
<!-- Backup & Restore Section -- >
< div class = "glass-card px-6 py-6 mb-6" >
< div class = "flex items-center justify-between mb-4" >
< div >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white/96" >{{ t ( 'settings.backup' ) }}</ h2 >
< p class = "text-sm text-white/60 mt-1" >{{ t ( 'settings.backupRestoreDesc' ) }}</ p >
2026-03-11 12:46:02 +00:00
</ div >
< button @click ="showCreateBackupModal = true" class = "glass-button px-4 py-2 rounded-lg text-sm flex items-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 >
2026-03-11 13:45:59 +00:00
{{ t ( 'settings.createBackup' ) }}
2026-03-11 12:46:02 +00:00
</ button >
</ div >
<!-- Backup List -->
2026-03-11 13:45:59 +00:00
< div v-if = "loadingBackups" 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 >
2026-03-11 12:46:02 +00:00
< div v-else class = "space-y-2" >
< 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" >
2026-03-11 13:45:59 +00:00
< div class = "text-sm text-white font-medium" >{{ b . description || t ( 'settings.systemBackup' ) }}</ div >
2026-03-11 12:46:02 +00:00
< div class = "text-xs text-white/50" >{{ new Date ( b . created_at ). toLocaleString () }} & middot ; {{ formatBackupSize ( b . size_bytes ) }}</ div >
</ div >
< div class = "flex items-center gap-2 shrink-0 flex-wrap" >
2026-03-11 13:45:59 +00:00
< 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' ) }}
2026-03-11 12:46:02 +00:00
</ button >
2026-03-11 13:45:59 +00:00
< 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')" >
2026-03-11 12:46:02 +00:00
{{ usbCopyingId === b . id ? '...' : 'USB' }}
</ button >
2026-03-11 13:45:59 +00:00
< 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' ) }}
2026-03-11 12:46:02 +00:00
</ button >
2026-03-11 13:45:59 +00:00
< 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')" >
2026-03-11 12:46:02 +00:00
& times ;
</ button >
</ div >
</ div >
</ div >
<!-- Backup status message -->
2026-03-11 13:04:31 +00:00
< div v-if = "backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'" >
2026-03-11 12:46:02 +00:00
{{ 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-sm" @click.self="showCreateBackupModal = false" >
2026-03-11 13:04:31 +00:00
< div class = "glass-card p-6 w-full max-w-md" role = "dialog" aria -modal =" true " aria -labelledby =" create -backup-title " >
2026-03-11 13:45:59 +00:00
< h3 id = "create-backup-title" class = "text-lg font-semibold text-white mb-4" >{{ t ( 'settings.createEncryptedBackup' ) }}</ h3 >
2026-03-11 12:46:02 +00:00
< div class = "space-y-3" >
< div >
2026-03-11 13:45:59 +00:00
< 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" />
2026-03-11 12:46:02 +00:00
</ div >
< div >
2026-03-11 13:45:59 +00:00
< 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" />
2026-03-11 12:46:02 +00:00
</ div >
</ div >
< div class = "flex gap-3 mt-5" >
2026-03-11 13:45:59 +00:00
< button @click ="showCreateBackupModal = false" class = "glass-button px-4 py-2 rounded-lg text-sm flex-1" >{{ t ( 'common.cancel' ) }}</ button >
2026-03-11 12:46:02 +00:00
< button @click ="createBackup" : disabled = "creatingBackup || !backupPassphrase" class = "glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50" >
2026-03-11 13:45:59 +00:00
{{ creatingBackup ? t ( 'settings.creatingBackup' ) : t ( 'settings.createBackup' ) }}
2026-03-11 12:46:02 +00:00
</ 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-sm" @click.self="showRestoreModal = false" >
2026-03-11 13:04:31 +00:00
< div class = "glass-card p-6 w-full max-w-md" role = "dialog" aria -modal =" true " aria -labelledby =" restore -backup-title " >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-11 12:46:02 +00:00
< div >
2026-03-11 13:45:59 +00:00
< 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" />
2026-03-11 12:46:02 +00:00
</ div >
< div class = "flex gap-3 mt-5" >
2026-03-11 13:45:59 +00:00
< button @click ="showRestoreModal = false" class = "glass-button px-4 py-2 rounded-lg text-sm flex-1" >{{ t ( 'common.cancel' ) }}</ button >
2026-03-11 12:46:02 +00:00
< button @click ="restoreBackup" : disabled = "restoringBackup || !restorePassphrase" class = "glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50" >
2026-03-11 13:45:59 +00:00
{{ restoringBackup ? t ( 'common.restoring' ) : t ( 'common.restore' ) }}
2026-03-11 12:46:02 +00:00
</ button >
</ div >
</ div >
</ div >
</ Teleport >
2026-03-11 00:01:15 +00:00
<!-- Network Diagnostics Link -->
2026-03-10 23:58:33 +00:00
< div class = "glass-card px-6 py-6 mb-6" >
2026-03-11 00:01:15 +00:00
< div class = "flex items-center justify-between" >
< div >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white/96" >{{ t ( 'common.network' ) }}</ h2 >
< p class = "text-sm text-white/60 mt-1" >{{ t ( 'settings.networkDesc' ) }}</ p >
2026-03-09 07:43:12 +00:00
</ div >
2026-03-11 00:01:15 +00:00
< button @click ="router.push('/dashboard/server')" class = "glass-button px-4 py-2 rounded-lg text-sm flex items-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 = "M13 7l5 5m0 0l-5 5m5-5H6" />
</ svg >
2026-03-11 13:45:59 +00:00
{{ t ( 'common.networkDiagnostics' ) }}
2026-03-09 07:43:12 +00:00
</ button >
</ div >
</ div >
2026-01-24 22:59:20 +00:00
</ div >
</ template >
< script setup lang = "ts" >
2026-02-17 15:03:34 +00:00
import { computed , ref , onMounted } from 'vue'
2026-01-24 22:59:20 +00:00
import { useRouter } from 'vue-router'
2026-03-11 13:45:59 +00:00
import { useI18n } from 'vue-i18n'
2026-01-24 22:59:20 +00:00
import { useAppStore } from '../stores/app'
2026-03-04 07:09:31 +00:00
import { useUIModeStore } from '@/stores/uiMode'
2026-03-04 12:06:20 +00:00
import { useAIPermissionsStore , AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
2026-02-17 15:03:34 +00:00
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
2026-02-17 22:10:38 +00:00
import { useModalKeyboard } from '@/composables/useModalKeyboard'
2026-03-04 07:09:31 +00:00
import type { UIMode } from '@/types/api'
2026-01-24 22:59:20 +00:00
const router = useRouter ()
2026-03-11 13:45:59 +00:00
const { t } = useI18n ()
2026-01-24 22:59:20 +00:00
const store = useAppStore ()
2026-03-04 07:09:31 +00:00
const uiMode = useUIModeStore ()
2026-03-04 12:06:20 +00:00
const aiPermissions = useAIPermissionsStore ()
2026-03-04 14:34:02 +00:00
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
})
2026-03-04 07:09:31 +00:00
2026-03-11 13:45:59 +00:00
const interfaceModes = computed < { id : UIMode ; label : string ; description : string ; iconPaths : string [] }[] > (() => [
2026-03-04 07:09:31 +00:00
{
id : 'easy' ,
2026-03-11 13:45:59 +00:00
label : t ( 'settings.modeEasy' ),
description : t ( 'settings.modeEasyDesc' ),
2026-03-04 07:09:31 +00:00
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' ,
2026-03-11 13:45:59 +00:00
label : t ( 'settings.modePro' ),
description : t ( 'settings.modeProDesc' ),
2026-03-04 07:09:31 +00:00
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' ,
2026-03-11 13:45:59 +00:00
label : t ( 'settings.modeChat' ),
description : t ( 'settings.modeChatDesc' ),
2026-03-04 07:09:31 +00:00
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' ],
},
2026-03-11 13:45:59 +00:00
])
2026-01-24 22:59:20 +00:00
const serverName = computed (() => store . serverName )
const version = computed (() => store . serverInfo ? . version || '0.0.0' )
2026-02-17 15:03:34 +00:00
const serverTorAddressFromStore = computed (() => store . serverInfo ? .[ 'tor-address' ] || null )
const torAddressFromRpc = ref < string | null >( null )
const serverTorAddress = computed (() => serverTorAddressFromStore . value || torAddressFromRpc . value )
const userDid = computed (() => {
try {
return localStorage . getItem ( 'neode_did' ) || null
} catch {
return null
}
})
2026-03-04 23:05:01 +00:00
const claudeConnected = ref ( false )
const showClaudeLoginModal = ref ( false )
function checkClaudeStatus () {
fetch ( '/aiui/api/claude/v1/messages' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ model : 'haiku' , messages : [{ role : 'user' , content : 'ping' }] }) })
. then ( r => {
if ( ! r . ok ) { claudeConnected . value = false ; return }
const reader = r . body ? . getReader ()
if ( ! reader ) return
const decoder = new TextDecoder ()
let text = ''
function read () : Promise < void > {
return reader ! . read (). then (({ done , value }) => {
if ( done ) {
claudeConnected . value = ! text . includes ( 'Not logged in' ) && ! text . includes ( 'error' )
return
}
text += decoder . decode ( value , { stream : true })
return read ()
})
}
read ()
})
. catch (() => { claudeConnected . value = false })
}
function onClaudeIframeLoad () {
// Listen for success message from login iframe
window . addEventListener ( 'message' , handleClaudeLoginMessage )
}
function handleClaudeLoginMessage ( e : MessageEvent ) {
if ( e . data ? . type === 'claude-auth-success' ) {
claudeConnected . value = true
showClaudeLoginModal . value = false
window . removeEventListener ( 'message' , handleClaudeLoginMessage )
}
}
2026-03-06 12:23:57 +00:00
// --- 2FA State ---
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 totpSecretBase32 = ref ( '' )
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
2026-03-11 00:58:55 +00:00
} catch ( e ) {
if ( import . meta . env . DEV ) console . warn ( 'TOTP status may not be available' , e )
2026-03-06 12:23:57 +00:00
}
}
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 ) {
2026-03-11 13:45:59 +00:00
totpSetupError . value = e instanceof Error ? e . message : t ( 'settings.setupFailed' )
2026-03-06 12:23:57 +00:00
} 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 ) {
2026-03-11 13:45:59 +00:00
totpSetupError . value = e instanceof Error ? e . message : t ( 'settings.verificationFailed' )
2026-03-06 12:23:57 +00:00
} 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 ) {
2026-03-11 13:45:59 +00:00
totpDisableError . value = e instanceof Error ? e . message : t ( 'settings.disableFailed' )
2026-03-06 12:23:57 +00:00
} 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 )
}
2026-02-17 15:03:34 +00:00
const copiedOnion = ref ( false )
2026-03-11 12:46:02 +00:00
const copiedDid = ref ( false )
2026-02-17 15:03:34 +00:00
const showChangePasswordModal = ref ( false )
2026-02-17 22:10:38 +00:00
const changePasswordModalRef = ref < HTMLElement | null >( null )
const changePasswordRestoreFocusRef = ref < HTMLElement | null >( null )
useModalKeyboard ( changePasswordModalRef , showChangePasswordModal , closeChangePasswordModal , { restoreFocusRef : changePasswordRestoreFocusRef })
2026-02-17 15:03:34 +00:00
const changingPassword = ref ( false )
const changePasswordError = ref ( '' )
const changePasswordSuccess = ref ( '' )
const changePasswordForm = ref ({
currentPassword : '' ,
newPassword : '' ,
confirmPassword : '' ,
alsoChangeSsh : true ,
})
function validatePasswordStrength ( pw : string ) : string | null {
2026-03-11 13:45:59 +00:00
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' )
2026-02-17 15:03:34 +00:00
return null
}
async function handleChangePassword () {
changePasswordError . value = ''
changePasswordSuccess . value = ''
const { currentPassword , newPassword , confirmPassword , alsoChangeSsh } = changePasswordForm . value
if ( ! currentPassword || ! newPassword || ! confirmPassword ) {
2026-03-11 13:45:59 +00:00
changePasswordError . value = t ( 'settings.passwordAllFieldsRequired' )
2026-02-17 15:03:34 +00:00
return
}
if ( newPassword !== confirmPassword ) {
2026-03-11 13:45:59 +00:00
changePasswordError . value = t ( 'settings.passwordMismatch' )
2026-02-17 15:03:34 +00:00
return
}
const strengthError = validatePasswordStrength ( newPassword )
if ( strengthError ) {
changePasswordError . value = strengthError
return
}
changingPassword . value = true
try {
await rpcClient . changePassword ({
currentPassword ,
newPassword ,
alsoChangeSsh ,
})
2026-03-11 13:45:59 +00:00
changePasswordSuccess . value = t ( 'settings.passwordUpdatedSuccess' )
2026-02-17 15:03:34 +00:00
changePasswordForm . value = { currentPassword : '' , newPassword : '' , confirmPassword : '' , alsoChangeSsh : true }
setTimeout (() => {
closeChangePasswordModal ()
}, 2000 )
} catch ( e ) {
2026-03-11 13:45:59 +00:00
changePasswordError . value = e instanceof Error ? e . message : t ( 'settings.passwordChangeFailed' )
2026-02-17 15:03:34 +00:00
} finally {
changingPassword . value = false
}
}
2026-03-01 18:07:35 +00:00
let copiedTimer : ReturnType < typeof setTimeout > | null = null
2026-02-17 15:03:34 +00:00
async function copyOnionAddress () {
const addr = serverTorAddress . value
if ( ! addr ) return
try {
await navigator . clipboard . writeText ( addr )
} catch {
const ta = document . createElement ( 'textarea' )
ta . value = addr
2026-03-01 18:07:35 +00:00
ta . style . position = 'fixed'
ta . style . opacity = '0'
2026-02-17 15:03:34 +00:00
document . body . appendChild ( ta )
ta . select ()
document . execCommand ( 'copy' )
document . body . removeChild ( ta )
}
2026-03-01 18:07:35 +00:00
copiedOnion . value = true
if ( copiedTimer ) clearTimeout ( copiedTimer )
copiedTimer = setTimeout (() => { copiedOnion . value = false }, 2000 )
2026-02-17 15:03:34 +00:00
}
2026-03-11 12:46:02 +00:00
async function copyDid () {
if ( ! userDid . value ) return
try {
await navigator . clipboard . writeText ( userDid . value )
} catch {
const ta = document . createElement ( 'textarea' )
ta . value = userDid . value
ta . style . position = 'fixed'
ta . style . opacity = '0'
document . body . appendChild ( ta )
ta . select ()
document . execCommand ( 'copy' )
document . body . removeChild ( ta )
}
copiedDid . value = true
setTimeout (() => { copiedDid . value = false }, 2000 )
}
2026-02-17 15:03:34 +00:00
function closeChangePasswordModal () {
2026-02-17 22:10:38 +00:00
changePasswordRestoreFocusRef . value ? . focus ? .()
2026-02-17 15:03:34 +00:00
showChangePasswordModal . value = false
changePasswordError . value = ''
changePasswordSuccess . value = ''
changePasswordForm . value = { currentPassword : '' , newPassword : '' , confirmPassword : '' , alsoChangeSsh : true }
}
onMounted ( async () => {
2026-03-04 23:05:01 +00:00
checkClaudeStatus ()
2026-03-06 12:23:57 +00:00
loadTotpStatus ()
2026-03-11 12:46:02 +00:00
loadBackups ()
2026-03-13 00:13:38 +00:00
loadTorServices ()
2026-03-11 12:55:13 +00:00
loadWebhookConfig ()
2026-02-17 15:03:34 +00:00
if ( ! serverTorAddressFromStore . value ) {
try {
const res = await rpcClient . getTorAddress ()
torAddressFromRpc . value = res . tor_address ?? null
2026-03-11 00:58:55 +00:00
} catch ( e ) {
if ( import . meta . env . DEV ) console . warn ( 'Tor address may not be available yet' , e )
2026-02-17 15:03:34 +00:00
}
}
})
2026-01-24 22:59:20 +00:00
async function handleLogout () {
2026-03-11 00:58:55 +00:00
try { await store . logout () } catch ( e ) { if ( import . meta . env . DEV ) console . warn ( 'Logout failed, proceeding anyway' , e ) }
2026-03-01 18:07:35 +00:00
router . push ( '/login' ). catch (() => { window . location . href = '/login' })
2026-01-24 22:59:20 +00:00
}
2026-03-11 12:46:02 +00:00
// 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 )
2026-03-13 00:13:38 +00:00
// Tor services state
interface TorServiceInfo {
name : string
local_port : number
onion_address : string | null
enabled : boolean
}
const torServices = ref < TorServiceInfo [] >([])
const torLoading = ref ( false )
const torRotating = ref ( false )
2026-03-11 12:46:02 +00:00
const backupStatusMsg = ref ( '' )
const backupStatusType = ref < 'success' | 'error' > ( 'success' )
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 )
}
2026-03-13 00:13:38 +00:00
async function loadTorServices () {
torLoading . value = true
try {
const res = await rpcClient . torListServices ()
torServices . value = res . services || []
} catch {
torServices . value = []
} finally {
torLoading . value = false
}
}
async function toggleTorApp ( appId : string , enabled : boolean ) {
try {
const res = await rpcClient . torToggleApp ( appId , enabled )
if ( res . changed ) {
await loadTorServices ()
}
} catch ( e ) {
if ( import . meta . env . DEV ) console . warn ( 'Failed to toggle Tor app:' , e )
}
}
async function rotateNodeAddress () {
if ( torRotating . value ) return
if ( ! confirm ( 'This will generate a new .onion address. The old address will work for 24 hours during transition. Federated peers will be notified automatically.' )) return
torRotating . value = true
try {
await rpcClient . torRotateService ( 'archipelago' )
await loadTorServices ()
} catch ( e ) {
if ( import . meta . env . DEV ) console . warn ( 'Failed to rotate Tor address:' , e )
} finally {
torRotating . value = false
}
}
2026-03-11 12:46:02 +00:00
async function loadBackups () {
loadingBackups . value = true
try {
const res = await rpcClient . call < { backups : BackupEntry [] } > ({ method : 'backup.list' })
backupList . value = res . backups || []
} catch {
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 = ''
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupCreatedSuccess' ), 'success' )
2026-03-11 12:46:02 +00:00
await loadBackups ()
} catch {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupCreateFailed' ), 'error' )
2026-03-11 12:46:02 +00:00
} finally {
creatingBackup . value = false
}
}
async function verifyBackup ( id : string ) {
2026-03-11 13:45:59 +00:00
const passphrase = prompt ( t ( 'settings.verifyPassphrasePrompt' ))
2026-03-11 12:46:02 +00:00
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 ) {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupVerifiedOk' ), 'success' )
2026-03-11 12:46:02 +00:00
} else {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupVerifyFailed' , { error : res . error || 'Unknown error' }), 'error' )
2026-03-11 12:46:02 +00:00
}
} catch {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupVerifyRequestFailed' ), 'error' )
2026-03-11 12:46:02 +00:00
} 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
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupRestored' ), 'success' )
2026-03-11 12:46:02 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupRestoreFailed' ), 'error' )
2026-03-11 12:46:02 +00:00
} finally {
restoringBackup . value = false
}
}
async function deleteBackup ( id : string ) {
2026-03-11 13:45:59 +00:00
if ( ! confirm ( t ( 'settings.deleteBackupConfirm' ))) return
2026-03-11 12:46:02 +00:00
deletingBackupId . value = id
try {
await rpcClient . call ({ method : 'backup.delete' , params : { id } })
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupDeleted' ), 'success' )
2026-03-11 12:46:02 +00:00
await loadBackups ()
} catch {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupDeleteFailed' ), 'error' )
2026-03-11 12:46:02 +00:00
} finally {
deletingBackupId . value = null
}
}
2026-03-11 12:55:13 +00:00
// Webhook Notifications
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' )
2026-03-11 13:45:59 +00:00
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' ) },
])
2026-03-11 12:55:13 +00:00
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 || []
// Don't overwrite secret — server doesn't return it
} 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 ,
},
})
2026-03-11 13:45:59 +00:00
showWebhookStatus ( t ( 'settings.webhookSaved' ), 'success' )
2026-03-11 12:55:13 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showWebhookStatus ( t ( 'settings.webhookSaveFailed' ), 'error' )
2026-03-11 12:55:13 +00:00
} 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 ) {
2026-03-11 13:45:59 +00:00
showWebhookStatus ( t ( 'settings.webhookTestSent' ), 'success' )
2026-03-11 12:55:13 +00:00
} else {
2026-03-11 13:45:59 +00:00
showWebhookStatus ( t ( 'settings.webhookTestFailed' ), 'error' )
2026-03-11 12:55:13 +00:00
}
} catch {
2026-03-11 13:45:59 +00:00
showWebhookStatus ( t ( 'settings.webhookSendFailed' ), 'error' )
2026-03-11 12:55:13 +00:00
} finally {
testingWebhook . value = false
}
}
2026-03-11 12:46:02 +00:00
// 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 ) {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.noUsbDrives' ), 'error' )
2026-03-11 12:46:02 +00:00
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 } })
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupCopiedToUsb' , { path : target . mount_point }), 'success' )
2026-03-11 12:46:02 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showBackupStatus ( t ( 'settings.backupUsbFailed' ), 'error' )
2026-03-11 12:46:02 +00:00
} finally {
usbCopyingId . value = null
}
}
2026-01-24 22:59:20 +00:00
</ script >