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:
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-1">Save your backup codes</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Store these codes somewhere safe. Each can only be used once if you lose access to your authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2-column grid of backup codes -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="code in codes"
|
||||||
|
:key="code"
|
||||||
|
class="font-mono text-sm text-gray-800 bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
{{ code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy all button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="copyAll"
|
||||||
|
class="flex items-center gap-2 text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Clipboard icon -->
|
||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||||
|
</svg>
|
||||||
|
{{ copied ? 'Copied' : 'Copy all codes' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Acknowledgment checkbox -->
|
||||||
|
<div class="flex items-start gap-3 pt-2">
|
||||||
|
<input
|
||||||
|
id="backup-codes-ack"
|
||||||
|
v-model="acknowledged"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label for="backup-codes-ack" class="text-sm text-gray-700 cursor-pointer">
|
||||||
|
I have saved these codes in a secure place. I understand they will not be shown again.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable 2FA CTA — disabled until acknowledged -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="acknowledged && $emit('acknowledged')"
|
||||||
|
:disabled="!acknowledged"
|
||||||
|
class="w-full px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors min-h-[44px]"
|
||||||
|
:class="!acknowledged ? 'opacity-50 cursor-not-allowed' : ''"
|
||||||
|
>
|
||||||
|
Enable two-factor authentication
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
codes: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['acknowledged'])
|
||||||
|
|
||||||
|
const acknowledged = ref(false)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function copyAll() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.codes.join('\n'))
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
// Clipboard API may fail in some environments — fail silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
|
||||||
|
<!-- Step: setup — initial prompt to begin enrollment -->
|
||||||
|
<template v-if="step === 'setup'">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Two-factor authentication adds an extra layer of security. You will need an authenticator app (e.g. Google Authenticator, Authy) on your phone.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="startSetup"
|
||||||
|
:disabled="loading"
|
||||||
|
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="loading" />
|
||||||
|
{{ loading ? 'Setting up…' : 'Set up two-factor authentication' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="error" class="mt-1 text-xs text-red-600">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: verify — show QR/secret and accept code -->
|
||||||
|
<template v-else-if="step === 'verify'">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
Open your authenticator app and scan this QR code, or enter the key manually.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- QR code — rendered via provisioning URI link (no QR library dependency) -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center gap-4">
|
||||||
|
<p class="text-xs text-gray-500 text-center">Scan with your authenticator app using the link below, or enter the secret key manually:</p>
|
||||||
|
<a
|
||||||
|
:href="qrUri"
|
||||||
|
class="text-sm font-semibold text-indigo-600 hover:text-indigo-700 underline break-all text-center"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open in authenticator app
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual secret display -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 mb-1 font-semibold">Or enter this secret key manually:</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
class="font-mono text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-200 break-all flex-1"
|
||||||
|
>{{ secret }}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="copySecret"
|
||||||
|
aria-label="Copy secret key"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="secretCopied" class="mt-1 text-xs text-green-600">Copied!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verification code input -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="verifyCode"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="000000"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="block w-36 rounded-lg px-3 py-3 text-sm border transition-colors focus:ring-2 focus:outline-none"
|
||||||
|
:class="error
|
||||||
|
? 'border-red-500 ring-red-500 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="mt-1 text-xs text-red-600">{{ error }}</p>
|
||||||
|
<p v-if="verified" class="mt-1 text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<!-- Checkmark icon -->
|
||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
Authenticator connected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="confirmEnrollment"
|
||||||
|
:disabled="loading || verifyCode.length !== 6"
|
||||||
|
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="loading" />
|
||||||
|
{{ loading ? 'Verifying…' : 'Verify code' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: backup-codes — show BackupCodesDisplay -->
|
||||||
|
<template v-else-if="step === 'backup-codes'">
|
||||||
|
<BackupCodesDisplay
|
||||||
|
:codes="backupCodes"
|
||||||
|
@acknowledged="finishEnrollment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
import AppSpinner from '../ui/AppSpinner.vue'
|
||||||
|
import BackupCodesDisplay from './BackupCodesDisplay.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['enrolled'])
|
||||||
|
|
||||||
|
const step = ref('setup')
|
||||||
|
const qrUri = ref('')
|
||||||
|
const secret = ref('')
|
||||||
|
const verifyCode = ref('')
|
||||||
|
const backupCodes = ref([])
|
||||||
|
const error = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const verified = ref(false)
|
||||||
|
const secretCopied = ref(false)
|
||||||
|
|
||||||
|
async function startSetup() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.totpSetup()
|
||||||
|
qrUri.value = data.provisioning_uri
|
||||||
|
secret.value = data.secret
|
||||||
|
step.value = 'verify'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEnrollment() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.totpEnable(verifyCode.value)
|
||||||
|
backupCodes.value = data.backup_codes
|
||||||
|
verified.value = true
|
||||||
|
// Brief success flash before transitioning to backup codes screen
|
||||||
|
setTimeout(() => {
|
||||||
|
step.value = 'backup-codes'
|
||||||
|
}, 800)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Incorrect code. Try again.'
|
||||||
|
verifyCode.value = ''
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishEnrollment() {
|
||||||
|
emit('enrolled')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySecret() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(secret.value)
|
||||||
|
secretCopied.value = true
|
||||||
|
setTimeout(() => { secretCopied.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
// Fail silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-gray-700">{{ message }}</p>
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancelled')"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('confirmed')"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors min-h-[44px]"
|
||||||
|
:class="confirmClass || 'bg-red-600 hover:bg-red-700 text-white'"
|
||||||
|
>
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Confirm',
|
||||||
|
},
|
||||||
|
cancelLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Cancel',
|
||||||
|
},
|
||||||
|
confirmClass: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['confirmed', 'cancelled'])
|
||||||
|
</script>
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Account settings</h2>
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Account settings</h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Account information -->
|
|
||||||
|
<!-- 1. Account information -->
|
||||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||||
<div class="space-y-2 text-sm text-gray-700">
|
<div class="space-y-2 text-sm text-gray-700">
|
||||||
@@ -22,7 +23,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3>
|
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3>
|
||||||
|
|
||||||
@@ -33,29 +77,49 @@
|
|||||||
v-model="currentPassword"
|
v-model="currentPassword"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-1">New password</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-1">New password</label>
|
||||||
<input
|
<input
|
||||||
v-model="newPassword"
|
v-model="newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
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" />
|
<PasswordStrengthBar :password="newPassword" />
|
||||||
</div>
|
</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 }}
|
{{ passwordError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="passwordSuccess" class="text-sm text-green-600">{{ passwordSuccess }}</div>
|
<div v-if="passwordSuccess" class="text-sm text-green-600">{{ passwordSuccess }}</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="changingPassword"
|
: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" />
|
<AppSpinner v-if="changingPassword" />
|
||||||
{{ changingPassword ? 'Updating…' : 'Update password' }}
|
{{ changingPassword ? 'Updating…' : 'Update password' }}
|
||||||
@@ -63,12 +127,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sessions -->
|
<!-- 4. Sessions — sign out all devices -->
|
||||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3>
|
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3>
|
||||||
|
|
||||||
<template v-if="!confirmSignOutAll">
|
<template v-if="!confirmSignOutAll">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
@click="confirmSignOutAll = true"
|
@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"
|
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>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-gray-700 mb-3">
|
<ConfirmBlock
|
||||||
This will sign you out of all devices, including this one. You will need to sign in again.
|
message="This will sign you out of all devices, including this one. You will need to sign in again."
|
||||||
</p>
|
confirm-label="Sign out all devices"
|
||||||
<div class="flex gap-3">
|
cancel-label="Keep signed in"
|
||||||
<button
|
@confirmed="signOutAll"
|
||||||
@click="confirmSignOutAll = false"
|
@cancelled="confirmSignOutAll = false"
|
||||||
class="text-sm text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
>
|
||||||
Keep signed in
|
<template #confirm-button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
@click="signOutAll"
|
@click="signOutAll"
|
||||||
:disabled="signingOutAll"
|
: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"
|
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" />
|
<AppSpinner v-if="signingOutAll" />
|
||||||
Sign out all devices
|
Sign out all devices
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
|
</ConfirmBlock>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -108,20 +174,21 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAuthStore } from '../stores/auth.js'
|
import { useAuthStore } from '../stores/auth.js'
|
||||||
import * as api from '../api/client.js'
|
import * as api from '../api/client.js'
|
||||||
import PasswordStrengthBar from '../components/auth/PasswordStrengthBar.vue'
|
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'
|
import AppSpinner from '../components/ui/AppSpinner.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Change password ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const changingPassword = ref(false)
|
const changingPassword = ref(false)
|
||||||
const passwordError = ref(null)
|
const passwordError = ref(null)
|
||||||
const passwordSuccess = ref(null)
|
const passwordSuccess = ref(null)
|
||||||
|
|
||||||
const confirmSignOutAll = ref(false)
|
|
||||||
const signingOutAll = ref(false)
|
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
changingPassword.value = true
|
changingPassword.value = true
|
||||||
passwordError.value = null
|
passwordError.value = null
|
||||||
@@ -135,12 +202,51 @@ async function changePassword() {
|
|||||||
currentPassword.value = ''
|
currentPassword.value = ''
|
||||||
newPassword.value = ''
|
newPassword.value = ''
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
changingPassword.value = false
|
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() {
|
async function signOutAll() {
|
||||||
signingOutAll.value = true
|
signingOutAll.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user