feat: add TOTP 2FA, API key switcher, login progress bar, and alpha hardening plan
- TOTP 2FA: full setup/confirm/disable/login flow with Argon2id + ChaCha20-Poly1305 encrypted secret storage, QR code generation, and bcrypt-hashed backup codes - API key switcher: OAuth vs personal API key toggle in AIUI chat settings with status indicator, key validation, and help text - Login progress bar: server startup detection with health check polling, form disabled until server is ready - AI quarantine docs: comprehensive HTML page documenting all 6 security layers - Settings: AI Data Access permission toggles with per-category control - Alpha hardening plan: 28-task overnight automation plan across 7 phases (onboarding, login, app install, AIUI, UI polish, security, ISO build) - Backlog: node discovery spatial map feature for alpha demo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,18 +87,47 @@ class RPCClient {
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async login(password: string): Promise<void> {
|
||||
async login(password: string): Promise<{ requires_totp?: boolean } | null> {
|
||||
return this.call({
|
||||
method: 'auth.login',
|
||||
params: {
|
||||
password,
|
||||
metadata: {
|
||||
// Add any metadata needed
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async loginTotp(code: string): Promise<{ success: boolean }> {
|
||||
return this.call({ method: 'auth.login.totp', params: { code } })
|
||||
}
|
||||
|
||||
async loginBackup(code: string): Promise<{ success: boolean }> {
|
||||
return this.call({ method: 'auth.login.backup', params: { code } })
|
||||
}
|
||||
|
||||
async totpSetupBegin(password: string): Promise<{
|
||||
qr_svg: string
|
||||
secret_base32: string
|
||||
pending_token: string
|
||||
}> {
|
||||
return this.call({ method: 'auth.totp.setup.begin', params: { password } })
|
||||
}
|
||||
|
||||
async totpSetupConfirm(params: {
|
||||
code: string
|
||||
password: string
|
||||
pendingToken: string
|
||||
}): Promise<{ enabled: boolean; backup_codes: string[] }> {
|
||||
return this.call({ method: 'auth.totp.setup.confirm', params })
|
||||
}
|
||||
|
||||
async totpDisable(password: string, code: string): Promise<{ disabled: boolean }> {
|
||||
return this.call({ method: 'auth.totp.disable', params: { password, code } })
|
||||
}
|
||||
|
||||
async totpStatus(): Promise<{ enabled: boolean }> {
|
||||
return this.call({ method: 'auth.totp.status', params: {} })
|
||||
}
|
||||
|
||||
async changePassword(params: {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
|
||||
@@ -27,23 +27,28 @@ export const useAppStore = defineStore('app', () => {
|
||||
const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value)
|
||||
|
||||
// Actions
|
||||
async function login(password: string): Promise<void> {
|
||||
async function login(password: string): Promise<{ requires_totp?: boolean }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await rpcClient.login(password)
|
||||
const result = await rpcClient.login(password)
|
||||
if (result && result.requires_totp) {
|
||||
return { requires_totp: true }
|
||||
}
|
||||
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
|
||||
|
||||
// Initialize data structure immediately so dashboard can render
|
||||
await initializeData()
|
||||
|
||||
|
||||
// Connect WebSocket in background - don't block login flow
|
||||
connectWebSocket().catch((err) => {
|
||||
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
|
||||
})
|
||||
return {}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
@@ -52,6 +57,16 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLoginAfterTotp(): Promise<void> {
|
||||
isAuthenticated.value = true
|
||||
sessionValidated = true
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
await initializeData()
|
||||
connectWebSocket().catch((err) => {
|
||||
console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
||||
})
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await rpcClient.logout()
|
||||
@@ -285,6 +300,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
// Actions
|
||||
login,
|
||||
completeLoginAfterTotp,
|
||||
logout,
|
||||
checkSession,
|
||||
needsSessionValidation,
|
||||
|
||||
@@ -19,6 +19,20 @@
|
||||
<span v-else>Welcome to Archipelago</span>
|
||||
</h1>
|
||||
|
||||
<!-- Server Startup Progress -->
|
||||
<div v-if="!serverReady" class="mb-6">
|
||||
<div class="flex items-center justify-center gap-2 mb-3">
|
||||
<svg class="animate-spin h-4 w-4 text-orange-400" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
<span class="text-sm text-white/60">Server starting up...</span>
|
||||
</div>
|
||||
<div class="startup-progress-track">
|
||||
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
@@ -42,7 +56,7 @@
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter a password (min 8 characters)"
|
||||
@keyup.enter="handleSetupWithSound"
|
||||
:disabled="loading"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -57,13 +71,13 @@
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Confirm your password"
|
||||
@keyup.enter="handleSetupWithSound"
|
||||
:disabled="loading"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleSetupWithSound"
|
||||
:disabled="loading || !password || password !== confirmPassword"
|
||||
:disabled="loading || formDisabled || !password || password !== confirmPassword"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Set Up Node</span>
|
||||
@@ -77,6 +91,55 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- TOTP Verification Step -->
|
||||
<template v-else-if="requiresTotp">
|
||||
<div class="mb-6 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p class="text-white/80 text-sm mb-1">Two-Factor Authentication</p>
|
||||
<p class="text-white/50 text-xs">Enter the 6-digit code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
ref="totpInputRef"
|
||||
v-model="totpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@keyup.enter="handleTotpVerify"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleTotpVerify"
|
||||
:disabled="loading || !totpCode"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
|
||||
>
|
||||
<span v-if="!loading">Verify</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="useBackupCode = !useBackupCode; totpCode = ''"
|
||||
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
|
||||
>
|
||||
{{ useBackupCode ? 'Use authenticator code' : 'Use a backup code instead' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Normal Login Mode -->
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
@@ -90,13 +153,13 @@
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
@keyup.enter="handleLoginWithSound"
|
||||
:disabled="loading"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleLoginWithSound"
|
||||
:disabled="loading || !password"
|
||||
:disabled="loading || formDisabled || !password"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Login</span>
|
||||
@@ -156,12 +219,72 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSetup = ref(false)
|
||||
const whooshAway = ref(false)
|
||||
const requiresTotp = ref(false)
|
||||
const totpCode = ref('')
|
||||
const useBackupCode = ref(false)
|
||||
const totpInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Server startup state
|
||||
const serverReady = ref(false)
|
||||
const serverChecking = ref(true)
|
||||
const startupProgress = ref(0)
|
||||
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Check if we're in setup mode (original StartOS node setup)
|
||||
const isSetupMode = computed(() => {
|
||||
return import.meta.env.VITE_DEV_MODE === 'setup'
|
||||
})
|
||||
|
||||
// Whether the login form should be disabled (server not ready)
|
||||
const formDisabled = computed(() => !serverReady.value)
|
||||
|
||||
async function checkServerHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'server.echo', params: { message: 'ping' } }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
// Any HTTP response from backend (200, 401, 403, etc.) means it's up
|
||||
// Only 502/503 from nginx means backend isn't running yet
|
||||
return response.status !== 502 && response.status !== 503
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function pollServerStartup(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Animate progress slowly while waiting
|
||||
startupProgressInterval = setInterval(() => {
|
||||
if (startupProgress.value < 90) {
|
||||
startupProgress.value += Math.random() * 8 + 2
|
||||
if (startupProgress.value > 90) startupProgress.value = 90
|
||||
}
|
||||
}, 600)
|
||||
|
||||
const poll = async () => {
|
||||
const healthy = await checkServerHealth()
|
||||
if (healthy) {
|
||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||
startupProgress.value = 100
|
||||
// Brief pause to show 100% before revealing form
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
serverReady.value = true
|
||||
serverChecking.value = false
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Retry in 2s
|
||||
startupPollTimer = setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
let unlockHandler: (() => void) | null = null
|
||||
|
||||
function removeUnlockListeners() {
|
||||
@@ -173,7 +296,11 @@ function removeUnlockListeners() {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(removeUnlockListeners)
|
||||
onBeforeUnmount(() => {
|
||||
removeUnlockListeners()
|
||||
if (startupPollTimer) clearTimeout(startupPollTimer)
|
||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
|
||||
@@ -188,6 +315,18 @@ onMounted(async () => {
|
||||
document.addEventListener('click', unlockHandler, { once: true })
|
||||
document.addEventListener('touchstart', unlockHandler, { once: true })
|
||||
document.addEventListener('keydown', unlockHandler, { once: true })
|
||||
|
||||
// Check server health first
|
||||
const healthy = await checkServerHealth()
|
||||
if (healthy) {
|
||||
serverReady.value = true
|
||||
serverChecking.value = false
|
||||
} else {
|
||||
// Server not ready — start polling with progress bar
|
||||
await pollServerStartup()
|
||||
}
|
||||
|
||||
// Only check setup mode after server is confirmed ready
|
||||
if (isSetupMode.value) {
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||
@@ -265,7 +404,14 @@ async function handleLogin() {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await store.login(password.value)
|
||||
const result = await store.login(password.value)
|
||||
if (result?.requires_totp) {
|
||||
requiresTotp.value = true
|
||||
loading.value = false
|
||||
// Focus the TOTP input after DOM update
|
||||
setTimeout(() => totpInputRef.value?.focus(), 100)
|
||||
return
|
||||
}
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
@@ -288,6 +434,43 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!totpCode.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (useBackupCode.value) {
|
||||
await rpcClient.loginBackup(totpCode.value)
|
||||
} else {
|
||||
await rpcClient.loginTotp(totpCode.value)
|
||||
}
|
||||
await store.completeLoginAfterTotp()
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/expired|too many/i.test(msg)) {
|
||||
// Session expired, go back to password step
|
||||
requiresTotp.value = false
|
||||
totpCode.value = ''
|
||||
error.value = msg
|
||||
} else {
|
||||
error.value = msg || 'Invalid code. Please try again.'
|
||||
}
|
||||
totpCode.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function replayIntro() {
|
||||
// Clear the intro seen flag
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
@@ -318,6 +501,22 @@ async function restartOnboarding() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Server startup progress bar */
|
||||
.startup-progress-track {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.startup-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease-out;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
/* Perspective for 3D fly effect */
|
||||
.login-fly-perspective {
|
||||
perspective: 1200px;
|
||||
|
||||
@@ -174,6 +174,204 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Two-Factor Authentication -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white/90">Two-Factor Authentication</p>
|
||||
<p class="text-xs text-white/50">Protect your account with an authenticator app</p>
|
||||
</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'"
|
||||
>
|
||||
{{ totpEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!totpEnabled"
|
||||
@click="showTotpSetupModal = true"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg 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>
|
||||
<span>Enable 2FA</span>
|
||||
</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>
|
||||
<span>Disable 2FA</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Setup Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpSetupModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
@click.self="closeTotpSetup"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<!-- Step 1: Enter password -->
|
||||
<template v-if="totpSetupStep === 1">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Enable Two-Factor Authentication</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Enter your password to begin setup.</p>
|
||||
<form @submit.prevent="beginTotpSetup" class="space-y-4">
|
||||
<input
|
||||
v-model="totpSetupPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="totpSetupLoading"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpSetupLoading ? 'Loading...' : 'Continue' }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Scan QR + verify code -->
|
||||
<template v-else-if="totpSetupStep === 2">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Scan QR Code</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.</p>
|
||||
<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">
|
||||
<p class="text-xs text-white/50 mb-1">Manual entry key:</p>
|
||||
<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"
|
||||
placeholder="000000"
|
||||
/>
|
||||
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpSetupLoading ? 'Verifying...' : 'Verify & Enable' }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: Show backup codes -->
|
||||
<template v-else-if="totpSetupStep === 3">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Save Your Backup Codes</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Store these codes safely. Each can be used once if you lose access to your authenticator app.</p>
|
||||
<div class="bg-black/30 rounded-xl p-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="(code, i) in totpBackupCodes"
|
||||
:key="i"
|
||||
class="text-sm font-mono text-white/90 bg-white/5 rounded px-3 py-2 text-center"
|
||||
>
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="copyBackupCodes"
|
||||
class="w-full mb-3 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-white/20 text-white/80 font-medium hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<svg v-if="!backupCodesCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ backupCodesCopied ? 'Copied!' : 'Copy All Codes' }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="closeTotpSetup"
|
||||
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- TOTP Disable Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpDisableModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
@click.self="closeTotpDisable"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Disable Two-Factor Authentication</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Enter your password and a current TOTP code to disable 2FA.</p>
|
||||
<form @submit.prevent="disableTotp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Password</label>
|
||||
<input
|
||||
v-model="totpDisablePassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Authenticator Code</label>
|
||||
<input
|
||||
v-model="totpDisableCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="totpDisableLoading"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpDisableLoading ? 'Disabling...' : 'Disable 2FA' }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
@click="handleLogout"
|
||||
@@ -447,6 +645,121 @@ function handleClaudeLoginMessage(e: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
} catch {
|
||||
// Ignore - may not be available
|
||||
}
|
||||
}
|
||||
|
||||
async function beginTotpSetup() {
|
||||
totpSetupError.value = ''
|
||||
totpSetupLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.totpSetupBegin(totpSetupPassword.value)
|
||||
totpQrSvg.value = res.qr_svg
|
||||
totpSecretBase32.value = res.secret_base32
|
||||
totpPendingToken.value = res.pending_token
|
||||
totpSetupStep.value = 2
|
||||
} catch (e) {
|
||||
totpSetupError.value = e instanceof Error ? e.message : 'Setup failed'
|
||||
} finally {
|
||||
totpSetupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotpSetup() {
|
||||
totpSetupError.value = ''
|
||||
totpSetupLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.totpSetupConfirm({
|
||||
code: totpSetupCode.value,
|
||||
password: totpSetupPassword.value,
|
||||
pendingToken: totpPendingToken.value,
|
||||
})
|
||||
totpBackupCodes.value = res.backup_codes
|
||||
totpEnabled.value = true
|
||||
totpSetupStep.value = 3
|
||||
} catch (e) {
|
||||
totpSetupError.value = e instanceof Error ? e.message : 'Verification failed'
|
||||
} finally {
|
||||
totpSetupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeTotpSetup() {
|
||||
showTotpSetupModal.value = false
|
||||
totpSetupStep.value = 1
|
||||
totpSetupPassword.value = ''
|
||||
totpSetupCode.value = ''
|
||||
totpSetupError.value = ''
|
||||
totpQrSvg.value = ''
|
||||
totpSecretBase32.value = ''
|
||||
totpPendingToken.value = ''
|
||||
totpBackupCodes.value = []
|
||||
backupCodesCopied.value = false
|
||||
}
|
||||
|
||||
async function disableTotp() {
|
||||
totpDisableError.value = ''
|
||||
totpDisableLoading.value = true
|
||||
try {
|
||||
await rpcClient.totpDisable(totpDisablePassword.value, totpDisableCode.value)
|
||||
totpEnabled.value = false
|
||||
closeTotpDisable()
|
||||
} catch (e) {
|
||||
totpDisableError.value = e instanceof Error ? e.message : 'Failed to disable 2FA'
|
||||
} 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)
|
||||
}
|
||||
|
||||
const copiedOnion = ref(false)
|
||||
const showChangePasswordModal = ref(false)
|
||||
const changePasswordModalRef = ref<HTMLElement | null>(null)
|
||||
@@ -539,6 +852,7 @@ function closeChangePasswordModal() {
|
||||
|
||||
onMounted(async () => {
|
||||
checkClaudeStatus()
|
||||
loadTotpStatus()
|
||||
if (!serverTorAddressFromStore.value) {
|
||||
try {
|
||||
const res = await rpcClient.getTorAddress()
|
||||
|
||||
Reference in New Issue
Block a user