| <template> |
| <div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')"> |
| <div class="flex min-h-full items-center justify-center p-4"> |
| <div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></div> |
| |
| <div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800"> |
| |
| <div class="mb-6 text-center"> |
| <h3 class="text-xl font-semibold text-gray-900 dark:text-white"> |
| {{ t('profile.totp.setupTitle') }} |
| </h3> |
| <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> |
| {{ stepDescription }} |
| </p> |
| </div> |
| |
| |
| <div v-if="step === 0" class="space-y-6"> |
| |
| <div v-if="methodLoading" class="flex items-center justify-center py-8"> |
| <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div> |
| </div> |
| |
| <template v-else> |
| |
| <div v-if="verificationMethod === 'email'" class="space-y-4"> |
| <div> |
| <label class="input-label">{{ t('profile.totp.emailCode') }}</label> |
| <div class="flex gap-2"> |
| <input |
| v-model="verifyForm.emailCode" |
| type="text" |
| maxlength="6" |
| inputmode="numeric" |
| class="input flex-1" |
| :placeholder="t('profile.totp.enterEmailCode')" |
| /> |
| <button |
| type="button" |
| class="btn btn-secondary whitespace-nowrap" |
| :disabled="sendingCode || codeCooldown > 0" |
| @click="handleSendCode" |
| > |
| {{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }} |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-else class="space-y-4"> |
| <div> |
| <label class="input-label">{{ t('profile.currentPassword') }}</label> |
| <input |
| v-model="verifyForm.password" |
| type="password" |
| autocomplete="current-password" |
| class="input" |
| :placeholder="t('profile.totp.enterPassword')" |
| /> |
| </div> |
| </div> |
| |
| <div v-if="verifyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"> |
| {{ verifyError }} |
| </div> |
| |
| <div class="flex justify-end gap-3 pt-4"> |
| <button type="button" class="btn btn-secondary" @click="$emit('close')"> |
| {{ t('common.cancel') }} |
| </button> |
| <button |
| type="button" |
| class="btn btn-primary" |
| :disabled="!canProceedFromVerify || setupLoading" |
| @click="handleVerifyAndSetup" |
| > |
| {{ setupLoading ? t('common.loading') : t('common.next') }} |
| </button> |
| </div> |
| </template> |
| </div> |
| |
| |
| <div v-if="step === 1" class="space-y-6"> |
| |
| <template v-if="setupData"> |
| <div class="flex justify-center"> |
| <div class="rounded-lg border border-gray-200 p-4 bg-white dark:border-dark-600 dark:bg-white"> |
| <img :src="qrCodeDataUrl" alt="QR Code" class="h-48 w-48" /> |
| </div> |
| </div> |
| |
| <div class="text-center"> |
| <p class="text-sm text-gray-500 dark:text-gray-400 mb-2"> |
| {{ t('profile.totp.manualEntry') }} |
| </p> |
| <div class="flex items-center justify-center gap-2"> |
| <code class="rounded bg-gray-100 px-3 py-2 font-mono text-sm dark:bg-dark-700"> |
| {{ setupData.secret }} |
| </code> |
| <button |
| type="button" |
| class="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-dark-700" |
| @click="copySecret" |
| > |
| <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> |
| <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> |
| </div> |
| </template> |
| |
| <div class="flex justify-end gap-3 pt-4"> |
| <button type="button" class="btn btn-secondary" @click="$emit('close')"> |
| {{ t('common.cancel') }} |
| </button> |
| <button |
| type="button" |
| class="btn btn-primary" |
| :disabled="!setupData" |
| @click="step = 2" |
| > |
| {{ t('common.next') }} |
| </button> |
| </div> |
| </div> |
| |
| |
| <div v-if="step === 2" class="space-y-6"> |
| <form @submit.prevent="handleVerify"> |
| <div class="mb-6"> |
| <label class="input-label text-center block mb-3"> |
| {{ t('profile.totp.enterCode') }} |
| </label> |
| <div class="flex justify-center gap-2"> |
| <input |
| v-for="(_, index) in 6" |
| :key="index" |
| :ref="(el) => setInputRef(el, index)" |
| type="text" |
| maxlength="1" |
| inputmode="numeric" |
| pattern="[0-9]" |
| class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700" |
| @input="handleCodeInput($event, index)" |
| @keydown="handleKeydown($event, index)" |
| @paste="handlePaste" |
| /> |
| </div> |
| </div> |
| |
| <div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"> |
| {{ error }} |
| </div> |
| |
| <div class="flex justify-end gap-3"> |
| <button type="button" class="btn btn-secondary" @click="step = 1"> |
| {{ t('common.back') }} |
| </button> |
| <button |
| type="submit" |
| class="btn btn-primary" |
| :disabled="verifying || code.join('').length !== 6" |
| > |
| {{ verifying ? t('common.verifying') : t('profile.totp.verify') }} |
| </button> |
| </div> |
| </form> |
| </div> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useAppStore } from '@/stores/app' |
| import { totpAPI } from '@/api' |
| import type { TotpSetupResponse } from '@/types' |
| import QRCode from 'qrcode' |
| |
| const emit = defineEmits<{ |
| close: [] |
| success: [] |
| }>() |
| |
| const { t } = useI18n() |
| const appStore = useAppStore() |
| |
| |
| const step = ref(0) |
| const methodLoading = ref(true) |
| const verificationMethod = ref<'email' | 'password'>('password') |
| const verifyForm = ref({ emailCode: '', password: '' }) |
| const verifyError = ref('') |
| const sendingCode = ref(false) |
| const codeCooldown = ref(0) |
| const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null) |
| |
| const setupLoading = ref(false) |
| const setupData = ref<TotpSetupResponse | null>(null) |
| const verifying = ref(false) |
| const error = ref('') |
| const code = ref<string[]>(['', '', '', '', '', '']) |
| const inputRefs = ref<(HTMLInputElement | null)[]>([]) |
| const qrCodeDataUrl = ref('') |
| |
| const stepDescription = computed(() => { |
| switch (step.value) { |
| case 0: |
| return verificationMethod.value === 'email' |
| ? t('profile.totp.verifyEmailFirst') |
| : t('profile.totp.verifyPasswordFirst') |
| case 1: |
| return t('profile.totp.setupStep1') |
| case 2: |
| return t('profile.totp.setupStep2') |
| default: |
| return '' |
| } |
| }) |
| |
| const canProceedFromVerify = computed(() => { |
| if (verificationMethod.value === 'email') { |
| return verifyForm.value.emailCode.length === 6 |
| } |
| return verifyForm.value.password.length > 0 |
| }) |
| |
| |
| watch( |
| () => setupData.value?.qr_code_url, |
| async (url) => { |
| if (url) { |
| try { |
| qrCodeDataUrl.value = await QRCode.toDataURL(url, { |
| width: 200, |
| margin: 2, |
| color: { |
| dark: '#000000', |
| light: '#ffffff' |
| } |
| }) |
| } catch (err) { |
| console.error('Failed to generate QR code:', err) |
| } |
| } |
| }, |
| { immediate: true } |
| ) |
| |
| const setInputRef = (el: any, index: number) => { |
| inputRefs.value[index] = el as HTMLInputElement | null |
| } |
| |
| const handleCodeInput = (event: Event, index: number) => { |
| const input = event.target as HTMLInputElement |
| const value = input.value.replace(/[^0-9]/g, '') |
| code.value[index] = value |
| |
| if (value && index < 5) { |
| nextTick(() => { |
| inputRefs.value[index + 1]?.focus() |
| }) |
| } |
| } |
| |
| const handleKeydown = (event: KeyboardEvent, index: number) => { |
| if (event.key === 'Backspace') { |
| const input = event.target as HTMLInputElement |
| |
| if (!input.value && index > 0) { |
| event.preventDefault() |
| inputRefs.value[index - 1]?.focus() |
| } |
| |
| |
| } |
| } |
| |
| const handlePaste = (event: ClipboardEvent) => { |
| event.preventDefault() |
| const pastedData = event.clipboardData?.getData('text') || '' |
| const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('') |
| |
| |
| digits.forEach((digit, index) => { |
| code.value[index] = digit |
| if (inputRefs.value[index]) { |
| inputRefs.value[index]!.value = digit |
| } |
| }) |
| |
| |
| for (let i = digits.length; i < 6; i++) { |
| code.value[i] = '' |
| if (inputRefs.value[i]) { |
| inputRefs.value[i]!.value = '' |
| } |
| } |
| |
| const focusIndex = Math.min(digits.length, 5) |
| nextTick(() => { |
| inputRefs.value[focusIndex]?.focus() |
| }) |
| } |
| |
| const copySecret = async () => { |
| if (setupData.value) { |
| try { |
| await navigator.clipboard.writeText(setupData.value.secret) |
| appStore.showSuccess(t('common.copied')) |
| } catch { |
| appStore.showError(t('common.copyFailed')) |
| } |
| } |
| } |
| |
| const loadVerificationMethod = async () => { |
| methodLoading.value = true |
| try { |
| const method = await totpAPI.getVerificationMethod() |
| verificationMethod.value = method.method |
| } catch (err: any) { |
| appStore.showError(err.response?.data?.message || t('common.error')) |
| emit('close') |
| } finally { |
| methodLoading.value = false |
| } |
| } |
| |
| const handleSendCode = async () => { |
| sendingCode.value = true |
| try { |
| await totpAPI.sendVerifyCode() |
| appStore.showSuccess(t('profile.totp.codeSent')) |
| |
| codeCooldown.value = 60 |
| if (cooldownTimer.value) { |
| clearInterval(cooldownTimer.value) |
| cooldownTimer.value = null |
| } |
| cooldownTimer.value = setInterval(() => { |
| codeCooldown.value-- |
| if (codeCooldown.value <= 0) { |
| if (cooldownTimer.value) { |
| clearInterval(cooldownTimer.value) |
| cooldownTimer.value = null |
| } |
| } |
| }, 1000) |
| } catch (err: any) { |
| appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed')) |
| } finally { |
| sendingCode.value = false |
| } |
| } |
| |
| const handleVerifyAndSetup = async () => { |
| setupLoading.value = true |
| verifyError.value = '' |
| |
| try { |
| const request = verificationMethod.value === 'email' |
| ? { email_code: verifyForm.value.emailCode } |
| : { password: verifyForm.value.password } |
| |
| setupData.value = await totpAPI.initiateSetup(request) |
| step.value = 1 |
| } catch (err: any) { |
| verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed') |
| } finally { |
| setupLoading.value = false |
| } |
| } |
| |
| const handleVerify = async () => { |
| const totpCode = code.value.join('') |
| if (totpCode.length !== 6 || !setupData.value) return |
| |
| verifying.value = true |
| error.value = '' |
| |
| try { |
| await totpAPI.enable({ |
| totp_code: totpCode, |
| setup_token: setupData.value.setup_token |
| }) |
| appStore.showSuccess(t('profile.totp.enableSuccess')) |
| emit('success') |
| } catch (err: any) { |
| error.value = err.response?.data?.message || t('profile.totp.verifyFailed') |
| code.value = ['', '', '', '', '', ''] |
| nextTick(() => { |
| inputRefs.value[0]?.focus() |
| }) |
| } finally { |
| verifying.value = false |
| } |
| } |
| |
| onMounted(() => { |
| loadVerificationMethod() |
| }) |
| |
| onUnmounted(() => { |
| if (cooldownTimer.value) { |
| clearInterval(cooldownTimer.value) |
| cooldownTimer.value = null |
| } |
| }) |
| </script> |
| |