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:
curo1305
2026-05-22 19:54:53 +02:00
parent 43e1d0145e
commit d73e2f6112
4 changed files with 446 additions and 30 deletions
@@ -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>