| <template> |
| <div class="fixed inset-0 z-50 overflow-y-auto"> |
| <div class="flex min-h-full items-center justify-center p-4"> |
| <div class="fixed inset-0 bg-black/50 transition-opacity"></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"> |
| <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"> |
| <svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> |
| </svg> |
| </div> |
| <h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white"> |
| {{ t('profile.totp.loginTitle') }} |
| </h3> |
| <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('profile.totp.loginHint') }} |
| </p> |
| <p v-if="userEmailMasked" class="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ userEmailMasked }} |
| </p> |
| </div> |
| |
| |
| <div class="mb-6"> |
| <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" |
| :disabled="verifying" |
| @input="handleCodeInput($event, index)" |
| @keydown="handleKeydown($event, index)" |
| @paste="handlePaste" |
| /> |
| </div> |
| |
| <div v-if="verifying" class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-500"> |
| <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div> |
| {{ t('common.verifying') }} |
| </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> |
| |
| |
| <button |
| type="button" |
| class="btn btn-secondary w-full" |
| :disabled="verifying" |
| @click="$emit('cancel')" |
| > |
| {{ t('common.cancel') }} |
| </button> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, watch, nextTick, onMounted } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| |
| defineProps<{ |
| tempToken: string |
| userEmailMasked?: string |
| }>() |
| |
| const emit = defineEmits<{ |
| verify: [code: string] |
| cancel: [] |
| }>() |
| |
| const { t } = useI18n() |
| |
| const verifying = ref(false) |
| const error = ref('') |
| const code = ref<string[]>(['', '', '', '', '', '']) |
| const inputRefs = ref<(HTMLInputElement | null)[]>([]) |
| |
| |
| watch( |
| () => code.value.join(''), |
| (newCode) => { |
| if (newCode.length === 6 && !verifying.value) { |
| emit('verify', newCode) |
| } |
| } |
| ) |
| |
| defineExpose({ |
| setVerifying: (value: boolean) => { verifying.value = value }, |
| setError: (message: string) => { |
| error.value = message |
| code.value = ['', '', '', '', '', ''] |
| |
| inputRefs.value.forEach(input => { |
| if (input) input.value = '' |
| }) |
| nextTick(() => { |
| inputRefs.value[0]?.focus() |
| }) |
| } |
| }) |
| |
| 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() |
| }) |
| } |
| |
| onMounted(() => { |
| nextTick(() => { |
| inputRefs.value[0]?.focus() |
| }) |
| }) |
| </script> |
| |