- 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>
252 lines
9.1 KiB
Vue
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>
|