wraith/frontend/pages/profile.vue
Vantz Stockwell 13111ae007 feat: nav bar (Home link), profile management, TOTP 2FA
- Add Home/Profile links to nav bar alongside Vault/Settings/Logout
- Profile page: change email, display name, password
- TOTP 2FA: setup with QR code, verify, disable with password
- Login flow: two-step TOTP challenge when 2FA is enabled
- Backend: new endpoints PUT /profile, POST /totp/setup|verify|disable
- Migration: add totp_secret and totp_enabled columns to users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:36:03 -04:00

252 lines
9.1 KiB
Vue

<script setup lang="ts">
const auth = useAuthStore()
const email = ref('')
const displayName = ref('')
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const profileMsg = ref('')
const profileError = ref('')
const profileLoading = ref(false)
// TOTP state
const totpEnabled = ref(false)
const totpSetupData = ref<{ secret: string; qrCode: string } | null>(null)
const totpCode = ref('')
const totpDisablePassword = ref('')
const totpMsg = ref('')
const totpError = ref('')
const totpLoading = ref(false)
function headers() {
return { Authorization: `Bearer ${auth.token}` }
}
onMounted(async () => {
try {
const profile = await $fetch<{ id: number; email: string; displayName: string | null; totpEnabled: boolean }>('/api/auth/profile', {
headers: headers(),
})
email.value = profile.email
displayName.value = profile.displayName || ''
totpEnabled.value = profile.totpEnabled
} catch {
// handled by layout auth guard
}
})
async function saveProfile() {
profileMsg.value = ''
profileError.value = ''
if (newPassword.value && newPassword.value !== confirmPassword.value) {
profileError.value = 'New passwords do not match'
return
}
profileLoading.value = true
try {
const body: Record<string, string> = {}
if (email.value) body.email = email.value
if (displayName.value !== undefined) body.displayName = displayName.value
if (newPassword.value) {
body.currentPassword = currentPassword.value
body.newPassword = newPassword.value
}
const res = await $fetch<{ message: string }>('/api/auth/profile', {
method: 'PUT',
body,
headers: headers(),
})
profileMsg.value = res.message
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// Refresh profile in auth store
await auth.fetchProfile()
} catch (e: any) {
profileError.value = e.data?.message || 'Failed to update profile'
} finally {
profileLoading.value = false
}
}
async function setupTotp() {
totpError.value = ''
totpMsg.value = ''
totpLoading.value = true
try {
totpSetupData.value = await $fetch('/api/auth/totp/setup', {
method: 'POST',
headers: headers(),
})
} catch (e: any) {
totpError.value = e.data?.message || 'Failed to start TOTP setup'
} finally {
totpLoading.value = false
}
}
async function verifyTotp() {
totpError.value = ''
totpMsg.value = ''
totpLoading.value = true
try {
const res = await $fetch<{ message: string }>('/api/auth/totp/verify', {
method: 'POST',
body: { code: totpCode.value },
headers: headers(),
})
totpMsg.value = res.message
totpEnabled.value = true
totpSetupData.value = null
totpCode.value = ''
} catch (e: any) {
totpError.value = e.data?.message || 'Verification failed'
} finally {
totpLoading.value = false
}
}
async function disableTotp() {
totpError.value = ''
totpMsg.value = ''
totpLoading.value = true
try {
const res = await $fetch<{ message: string }>('/api/auth/totp/disable', {
method: 'POST',
body: { password: totpDisablePassword.value },
headers: headers(),
})
totpMsg.value = res.message
totpEnabled.value = false
totpDisablePassword.value = ''
} catch (e: any) {
totpError.value = e.data?.message || 'Failed to disable TOTP'
} finally {
totpLoading.value = false
}
}
</script>
<template>
<div class="max-w-2xl mx-auto p-6 space-y-8">
<h2 class="text-xl font-bold text-white">Profile</h2>
<!-- Profile form -->
<form @submit.prevent="saveProfile" class="space-y-4 bg-gray-900 p-6 rounded-lg border border-gray-800">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Account Details</h3>
<div>
<label class="block text-sm text-gray-400 mb-1">Email</label>
<input v-model="email" type="email"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Display Name</label>
<input v-model="displayName" type="text"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<hr class="border-gray-800" />
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Change Password</h3>
<div>
<label class="block text-sm text-gray-400 mb-1">Current Password</label>
<input v-model="currentPassword" type="password" autocomplete="current-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">New Password</label>
<input v-model="newPassword" type="password" autocomplete="new-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Confirm New Password</label>
<input v-model="confirmPassword" type="password" autocomplete="new-password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<p v-if="profileMsg" class="text-green-400 text-sm">{{ profileMsg }}</p>
<p v-if="profileError" class="text-red-400 text-sm">{{ profileError }}</p>
<button type="submit" :disabled="profileLoading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded disabled:opacity-50">
{{ profileLoading ? 'Saving...' : 'Save Changes' }}
</button>
</form>
<!-- TOTP Section -->
<div class="bg-gray-900 p-6 rounded-lg border border-gray-800 space-y-4">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Two-Factor Authentication</h3>
<!-- TOTP is enabled -->
<template v-if="totpEnabled && !totpSetupData">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-sm text-green-400">TOTP is enabled</span>
</div>
<div class="space-y-3">
<div>
<label class="block text-sm text-gray-400 mb-1">Password (to disable)</label>
<input v-model="totpDisablePassword" type="password"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
</div>
<button @click="disableTotp" :disabled="totpLoading || !totpDisablePassword"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-sm disabled:opacity-50">
{{ totpLoading ? 'Disabling...' : 'Disable TOTP' }}
</button>
</div>
</template>
<!-- TOTP setup in progress -->
<template v-else-if="totpSetupData">
<p class="text-sm text-gray-400">Scan this QR code with your authenticator app, then enter the code below to verify.</p>
<div class="flex justify-center">
<img :src="totpSetupData.qrCode" alt="TOTP QR Code" class="w-48 h-48 rounded" />
</div>
<div class="text-center">
<p class="text-xs text-gray-500 mb-1">Manual key:</p>
<code class="text-xs text-wraith-400 select-all">{{ totpSetupData.secret }}</code>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Verification Code</label>
<input v-model="totpCode" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6"
placeholder="000000" autocomplete="one-time-code"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-center text-lg tracking-widest focus:border-wraith-500 focus:outline-none" />
</div>
<div class="flex gap-2">
<button @click="verifyTotp" :disabled="totpLoading || totpCode.length < 6"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ totpLoading ? 'Verifying...' : 'Verify & Enable' }}
</button>
<button @click="totpSetupData = null; totpCode = ''"
class="px-4 py-2 text-sm text-gray-500 hover:text-gray-300">
Cancel
</button>
</div>
</template>
<!-- TOTP not enabled -->
<template v-else>
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-gray-600" />
<span class="text-sm text-gray-500">TOTP is not enabled</span>
</div>
<button @click="setupTotp" :disabled="totpLoading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
{{ totpLoading ? 'Setting up...' : 'Enable TOTP' }}
</button>
</template>
<p v-if="totpMsg" class="text-green-400 text-sm">{{ totpMsg }}</p>
<p v-if="totpError" class="text-red-400 text-sm">{{ totpError }}</p>
</div>
</div>
</template>