feat(02-03): TOTP enrollment flow, backup codes, AccountView, ConfirmBlock
- TotpEnrollment.vue: three-step enrollment (setup → verify → backup-codes); emits 'enrolled' - BackupCodesDisplay.vue: 2-column grid, copy-all clipboard, acknowledgment checkbox - ConfirmBlock.vue: reusable inline confirmation block with 'confirmed'/'cancelled' emits - AccountView.vue: TOTP section (enrollment or disable), change-password with breach/wrong-pw error handling, sign-out-all with ConfirmBlock - npm run build exits 0
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Account settings</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Account information -->
|
||||
|
||||
<!-- 1. Account information -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||
<div class="space-y-2 text-sm text-gray-700">
|
||||
@@ -22,7 +23,50 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Change password -->
|
||||
<!-- 2. Two-factor authentication -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Two-factor authentication</h3>
|
||||
|
||||
<!-- TOTP enabled: show status + disable option -->
|
||||
<template v-if="authStore.user?.totp_enabled">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-green-700 font-semibold">
|
||||
<!-- Checkmark -->
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Enabled
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!confirmDisable2fa">
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmDisable2fa = true"
|
||||
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ConfirmBlock
|
||||
message="This will disable two-factor authentication. Your account will be less secure."
|
||||
confirm-label="Disable 2FA"
|
||||
cancel-label="Keep enabled"
|
||||
@confirmed="disableTotp"
|
||||
@cancelled="confirmDisable2fa = false"
|
||||
/>
|
||||
<p v-if="totpError" class="mt-2 text-xs text-red-600">{{ totpError }}</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- TOTP not enabled: show enrollment component -->
|
||||
<template v-else>
|
||||
<TotpEnrollment @enrolled="onTotpEnrolled" />
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- 3. Change password -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3>
|
||||
|
||||
@@ -33,29 +77,49 @@
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
required
|
||||
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||
autocomplete="current-password"
|
||||
class="block w-full rounded-lg px-3 py-3 text-sm border bg-white text-gray-900 transition-colors focus:ring-2 focus:outline-none"
|
||||
:class="passwordError && passwordError.includes('Current')
|
||||
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'"
|
||||
/>
|
||||
<p
|
||||
v-if="passwordError && passwordError.includes('Current')"
|
||||
class="mt-1 text-xs text-red-600"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">New password</label>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
required
|
||||
class="block w-full rounded-lg px-3 py-3 text-sm border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none"
|
||||
autocomplete="new-password"
|
||||
class="block w-full rounded-lg px-3 py-3 text-sm border bg-white text-gray-900 transition-colors focus:ring-2 focus:outline-none"
|
||||
:class="passwordError && !passwordError.includes('Current')
|
||||
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'"
|
||||
/>
|
||||
<PasswordStrengthBar :password="newPassword" />
|
||||
</div>
|
||||
|
||||
<div v-if="passwordError" class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||
<!-- Form-level error (e.g. breach check, strength failure) -->
|
||||
<div
|
||||
v-if="passwordError && !passwordError.includes('Current')"
|
||||
class="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
|
||||
<div v-if="passwordSuccess" class="text-sm text-green-600">{{ passwordSuccess }}</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="changingPassword"
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75"
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors disabled:opacity-75 min-h-[44px]"
|
||||
>
|
||||
<AppSpinner v-if="changingPassword" />
|
||||
{{ changingPassword ? 'Updating…' : 'Update password' }}
|
||||
@@ -63,12 +127,13 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Sessions -->
|
||||
<!-- 4. Sessions — sign out all devices -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3>
|
||||
|
||||
<template v-if="!confirmSignOutAll">
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmSignOutAll = true"
|
||||
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
@@ -77,27 +142,28 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-gray-700 mb-3">
|
||||
This will sign you out of all devices, including this one. You will need to sign in again.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="confirmSignOutAll = false"
|
||||
class="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Keep signed in
|
||||
</button>
|
||||
<button
|
||||
@click="signOutAll"
|
||||
:disabled="signingOutAll"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg disabled:opacity-75"
|
||||
>
|
||||
<AppSpinner v-if="signingOutAll" />
|
||||
Sign out all devices
|
||||
</button>
|
||||
</div>
|
||||
<ConfirmBlock
|
||||
message="This will sign you out of all devices, including this one. You will need to sign in again."
|
||||
confirm-label="Sign out all devices"
|
||||
cancel-label="Keep signed in"
|
||||
@confirmed="signOutAll"
|
||||
@cancelled="confirmSignOutAll = false"
|
||||
>
|
||||
<template #confirm-button>
|
||||
<button
|
||||
type="button"
|
||||
@click="signOutAll"
|
||||
:disabled="signingOutAll"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg disabled:opacity-75 min-h-[44px]"
|
||||
>
|
||||
<AppSpinner v-if="signingOutAll" />
|
||||
Sign out all devices
|
||||
</button>
|
||||
</template>
|
||||
</ConfirmBlock>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -108,20 +174,21 @@ import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth.js'
|
||||
import * as api from '../api/client.js'
|
||||
import PasswordStrengthBar from '../components/auth/PasswordStrengthBar.vue'
|
||||
import TotpEnrollment from '../components/auth/TotpEnrollment.vue'
|
||||
import ConfirmBlock from '../components/ui/ConfirmBlock.vue'
|
||||
import AppSpinner from '../components/ui/AppSpinner.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Change password ─────────────────────────────────────────────────────────
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const changingPassword = ref(false)
|
||||
const passwordError = ref(null)
|
||||
const passwordSuccess = ref(null)
|
||||
|
||||
const confirmSignOutAll = ref(false)
|
||||
const signingOutAll = ref(false)
|
||||
|
||||
async function changePassword() {
|
||||
changingPassword.value = true
|
||||
passwordError.value = null
|
||||
@@ -135,12 +202,51 @@ async function changePassword() {
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
} catch (e) {
|
||||
passwordError.value = e.message
|
||||
const msg = e.message || ''
|
||||
if (msg.toLowerCase().includes('current') || msg.toLowerCase().includes('incorrect')) {
|
||||
// Wrong current password — show inline below the field
|
||||
passwordError.value = 'Current password is incorrect'
|
||||
} else if (msg.toLowerCase().includes('breach')) {
|
||||
// HIBP breach detected
|
||||
passwordError.value = 'This password has appeared in a data breach. Choose a different password.'
|
||||
} else {
|
||||
passwordError.value = msg
|
||||
}
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOTP enrollment ─────────────────────────────────────────────────────────
|
||||
|
||||
const confirmDisable2fa = ref(false)
|
||||
const totpError = ref(null)
|
||||
|
||||
function onTotpEnrolled() {
|
||||
// Update user totp_enabled flag in store
|
||||
if (authStore.user) {
|
||||
authStore.user.totp_enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTotp() {
|
||||
totpError.value = null
|
||||
try {
|
||||
await api.totpDisable()
|
||||
if (authStore.user) {
|
||||
authStore.user.totp_enabled = false
|
||||
}
|
||||
confirmDisable2fa.value = false
|
||||
} catch (e) {
|
||||
totpError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sign out all devices ────────────────────────────────────────────────────
|
||||
|
||||
const confirmSignOutAll = ref(false)
|
||||
const signingOutAll = ref(false)
|
||||
|
||||
async function signOutAll() {
|
||||
signingOutAll.value = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user