@adminforth/two-factors-auth 4.8.0 → 4.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,76 +1,46 @@
1
1
  <template>
2
2
  <div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0 px-4"
3
- v-show ="modelShow && (isLoading === false)">
4
- <div :class="`af-two-factor-modal-${modalMode}`" class="relative flex flex-col w-full max-w-[460px] overflow-hidden rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund shadow-2xl dark:border-darkDialogBreakLine dark:bg-darkDialogBackgorund">
5
- <div class="af-2fa-header flex items-start gap-3.5 border-b border-lightDialogBreakLine py-5 pl-6 pr-4 dark:border-darkDialogBreakLine">
6
- <div class="af-2fa-icon-wrap flex w-10 h-10 shrink-0 items-center justify-center rounded-[10px] bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
7
- <IconShieldCheckOutline class="af-2fa-shield-icon w-6 h-6" />
8
- </div>
9
- <div class="af-2fa-title-wrap flex-1 min-w-0">
10
- <p class="af-2fa-title text-[17px] leading-6 font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ dialogTitle }}</p>
11
- <p class="af-2fa-subtitle mt-0.5 text-[13px] leading-[18px] text-gray-500 dark:text-gray-400">{{ subtitles[modalMode] }}</p>
12
- </div>
13
- <button type="button" :aria-label="$t('Cancel')" class="af-2fa-close-btn -mt-1 inline-flex w-9 h-9 shrink-0 items-center justify-center rounded-lg text-lightDialogCloseButton hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover" @click="onCancel">
14
- <IconCloseOutline class="w-5 h-5" />
15
- </button>
3
+ v-if="modelShow && (isLoading === false)">
4
+ <TwoFaCard
5
+ :class="`af-two-factor-modal-${modalMode}`"
6
+ :title="dialogTitle"
7
+ :subtitle="subtitles[modalMode]"
8
+ :error="modalMode === 'totp' ? codeError : ''"
9
+ closable
10
+ @close="onCancel"
11
+ >
12
+ <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-[10px] border border-lightDialogBreakLine bg-gray-50 p-3 text-xs text-gray-800 dark:border-darkDialogBreakLine dark:bg-gray-800 dark:text-gray-100">{{ dialogDetails }}</pre>
13
+
14
+ <div v-if="confirmItems.length" class="af-2fa-actions">
15
+ <p class="mb-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ $t('You are approving') }}</p>
16
+ <ul class="af-2fa-actions-list max-h-60 overflow-auto divide-y divide-lightDialogBreakLine rounded-[10px] border border-lightDialogBreakLine bg-gray-50 dark:divide-darkDialogBreakLine dark:border-darkDialogBreakLine dark:bg-gray-800">
17
+ <li v-for="(item, i) in confirmItems" :key="i" class="flex gap-3 px-3.5 py-3">
18
+ <span aria-hidden="true" class="mt-2 w-1.5 h-1.5 shrink-0 rounded-full bg-lightPrimary dark:bg-darkPrimary"></span>
19
+ <span class="min-w-0 text-[15px] leading-[22px] break-words text-gray-800 dark:text-gray-100">{{ item }}</span>
20
+ </li>
21
+ </ul>
16
22
  </div>
17
23
 
18
- <div class="af-2fa-body flex flex-col gap-5 px-6 pt-5 pb-6">
19
- <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-[10px] border border-lightDialogBreakLine bg-gray-50 p-3 text-xs text-gray-800 dark:border-darkDialogBreakLine dark:bg-gray-800 dark:text-gray-100">{{ dialogDetails }}</pre>
20
-
21
- <div v-if="confirmItems.length" class="af-2fa-actions">
22
- <p class="mb-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ $t('You are approving') }}</p>
23
- <ul class="af-2fa-actions-list max-h-60 overflow-auto divide-y divide-lightDialogBreakLine rounded-[10px] border border-lightDialogBreakLine bg-gray-50 dark:divide-darkDialogBreakLine dark:border-darkDialogBreakLine dark:bg-gray-800">
24
- <li v-for="(item, i) in confirmItems" :key="i" class="flex gap-3 px-3.5 py-3">
25
- <span aria-hidden="true" class="mt-2 w-1.5 h-1.5 shrink-0 rounded-full bg-lightPrimary dark:bg-darkPrimary"></span>
26
- <span class="text-[15px] leading-[22px] break-words text-gray-800 dark:text-gray-100">{{ item }}</span>
27
- </li>
28
- </ul>
29
- </div>
30
-
31
- <div v-if="modalMode === 'totp'">
32
- <p id="mfaCode-label" class="mb-2 text-[13px] font-medium text-gray-700 dark:text-gray-300">{{ $t('Authenticator code') }}</p>
33
- <div class="af-2fa-otp-root text-gray-300 dark:text-gray-500" ref="otpRoot">
34
- <v-otp-input
35
- ref="confirmationResult"
36
- container-class="flex items-center gap-2"
37
- :input-classes="otpInputClasses"
38
- :num-inputs="6"
39
- inputType="number"
40
- inputmode="numeric"
41
- :should-auto-focus="true"
42
- :should-focus-order="true"
43
- v-model:value="bindValue"
44
- @on-complete="handleOnComplete"
45
- />
46
- </div>
47
- <p v-if="codeError" role="alert" class="af-2fa-code-error mt-2.5 flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400">
48
- <IconExclamationCircleOutline class="w-4 h-4 shrink-0" />
49
- {{ codeError }}
50
- </p>
51
- </div>
52
- <div v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-hint flex items-center gap-3.5 rounded-[10px] border border-lightDialogBreakLine bg-gray-50 px-4 py-3.5 dark:border-darkDialogBreakLine dark:bg-gray-800">
53
- <span class="flex w-11 h-11 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
54
- <IconFingerprintOutline class="w-6 h-6" />
55
- </span>
56
- <p class="text-[13px] leading-[19px] text-gray-500 dark:text-gray-400">{{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}</p>
57
- </div>
58
- <p v-else role="status" class="af-2fa-grace-notice flex items-start gap-2.5 rounded-[10px] border border-green-200 bg-green-50 px-3.5 py-3 text-[13px] leading-[19px] text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-300">
59
- <IconCheckCircleOutline class="w-[18px] h-[18px] shrink-0" />
60
- {{ $t('You verified recently, so no code is needed.') }}
61
- </p>
62
- </div>
63
-
64
- <div class="af-2fa-footer flex items-center gap-3 border-t border-lightDialogBreakLine bg-gray-50 py-3 pl-6 pr-5 dark:border-darkDialogBreakLine dark:bg-gray-800/50">
65
- <button v-if="modalMode === 'totp' && doesUserHavePasskeys" type="button" class="af-2fa-switch-to-passkey inline-flex h-11 items-center gap-1.5 text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary" @click="modalMode = 'passkey'">
24
+ <TwoFaCodeInput v-if="modalMode === 'totp'" ref="codeInput" :invalid="Boolean(codeError)" @complete="sendConfirmationResult" />
25
+ <TwoFaHint v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-hint">
26
+ <template #icon><IconFingerprintOutline class="w-6 h-6" /></template>
27
+ {{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}
28
+ </TwoFaHint>
29
+ <p v-else role="status" class="af-2fa-grace-notice flex items-start gap-2.5 rounded-[10px] border border-green-200 bg-green-50 px-3.5 py-3 text-[13px] leading-[19px] text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-300">
30
+ <IconCheckCircleOutline class="w-[18px] h-[18px] shrink-0" />
31
+ {{ $t('You verified recently, so no code is needed.') }}
32
+ </p>
33
+
34
+ <template #footer>
35
+ <TwoFaLinkButton v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-switch-to-passkey" @click="modalMode = 'passkey'">
66
36
  <IconFingerprintOutline class="w-4 h-4" />
67
37
  {{ $t('Use passkey instead') }}
68
- </button>
69
- <button v-else-if="modalMode === 'passkey'" type="button" class="af-2fa-switch-to-totp inline-flex h-11 items-center gap-1.5 text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary" @click="modalMode = 'totp'">
38
+ </TwoFaLinkButton>
39
+ <TwoFaLinkButton v-else-if="modalMode === 'passkey'" class="af-2fa-switch-to-totp" @click="modalMode = 'totp'">
70
40
  <IconMobilePhoneOutline class="w-4 h-4" />
71
41
  {{ $t('Use code instead') }}
72
- </button>
73
- <div class="af-2fa-buttons ms-auto flex gap-2">
42
+ </TwoFaLinkButton>
43
+ <div class="af-2fa-buttons ms-auto flex gap-2 whitespace-nowrap">
74
44
  <Button variant="secondary" class="af-2fa-cancel-btn" @click="onCancel">{{ $t('Cancel') }}</Button>
75
45
  <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
76
46
  <IconFingerprintOutline class="w-4 h-4" />
@@ -81,15 +51,14 @@
81
51
  {{ approveLabel }}
82
52
  </Button>
83
53
  </div>
84
- </div>
85
- </div>
54
+ </template>
55
+ </TwoFaCard>
86
56
  </div>
87
57
  </template>
88
58
 
89
59
  <script setup lang="ts">
90
60
 
91
- import VOtpInput from 'vue3-otp-input';
92
- import { ref, computed, nextTick, watch } from 'vue';
61
+ import { ref, computed, watch } from 'vue';
93
62
  import { useUserStore } from '@/stores/user';
94
63
  import { useI18n } from 'vue-i18n';
95
64
  import { callAdminForthApi } from '@/utils';
@@ -97,13 +66,14 @@
97
66
  import {
98
67
  IconCheckCircleOutline,
99
68
  IconCheckOutline,
100
- IconCloseOutline,
101
- IconExclamationCircleOutline,
102
69
  IconFingerprintOutline,
103
70
  IconMobilePhoneOutline,
104
- IconShieldCheckOutline,
105
71
  } from '@iconify-prerendered/vue-flowbite';
106
- import { getPasskey } from './utils.js'
72
+ import { getPasskey } from './utils.js'
73
+ import TwoFaCard from './TwoFaCard.vue';
74
+ import TwoFaCodeInput from './TwoFaCodeInput.vue';
75
+ import TwoFaHint from './TwoFaHint.vue';
76
+ import TwoFaLinkButton from './TwoFaLinkButton.vue';
107
77
  import { useAdminforth } from '@/adminforth';
108
78
  import websocket from '@/websocket';
109
79
  import { getAdminForthClientId } from '@/utils/clientId';
@@ -205,24 +175,6 @@
205
175
  (e: 'closed'): void
206
176
  }>();
207
177
 
208
- async function addEventListenerForOTPInput(){
209
- document.addEventListener('focusin', handleGlobalFocusIn, true);
210
- focusFirstAvailableOtpInput();
211
- isLoading.value = false;
212
- await nextTick();
213
- const rootEl = otpRoot.value;
214
- rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
215
- }
216
-
217
- function removeEventListenerForOTPInput() {
218
- window.removeEventListener('paste', handlePaste);
219
- document.removeEventListener('focusin', handleGlobalFocusIn, true);
220
- const rootEl = otpRoot.value;
221
- rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
222
- // Abort any in-flight WebAuthn request when leaving the component
223
- }
224
-
225
-
226
178
  const modelShow = ref(false);
227
179
  let resolveFn: ((confirmationResult: any) => void) | null = null;
228
180
  let verifyFn: null | ((confirmationResult: string) => Promise<boolean> | boolean) = null;
@@ -260,9 +212,6 @@
260
212
  rejectFn = reject;
261
213
  verifyFn = verifyingCallback ?? null;
262
214
  modelShow.value = true;
263
- if (modalMode.value === 'totp') {
264
- await addEventListenerForOTPInput();
265
- }
266
215
  })().catch((error) => {
267
216
  if (!isTwoFaCancel(error)) {
268
217
  console.error('[AdminForth 2FA] Failed to open 2FA modal', error);
@@ -278,9 +227,7 @@
278
227
  const { t } = useI18n();
279
228
  const user = useUserStore();
280
229
 
281
- const confirmationResult = ref<any>(null);
282
- const otpRoot = ref<HTMLElement | null>(null);
283
- const bindValue = ref('');
230
+ const codeInput = ref<InstanceType<typeof TwoFaCodeInput> | null>(null);
284
231
  const doesUserHavePasskeys = ref(false);
285
232
  const modalMode = ref<"totp" | "passkey" | "grace">("totp");
286
233
  const isLoading = ref(false);
@@ -313,12 +260,6 @@
313
260
  ? t('Approve {count} actions', { count: confirmItems.value.length })
314
261
  : t('Approve'));
315
262
  const codeError = ref("");
316
- const otpInputClasses = computed(() => [
317
- 'otp-input w-12 h-14 p-0 rounded-[10px] border-[1.5px] bg-gray-50 text-center text-2xl font-medium leading-none tabular-nums text-gray-900 outline-none focus:ring-4 dark:bg-gray-800 dark:text-white',
318
- codeError.value
319
- ? 'border-red-500 focus:border-red-500 focus:ring-red-500/25 dark:border-red-400 dark:focus:border-red-400'
320
- : 'border-gray-300 focus:border-lightPrimary focus:ring-lightPrimary/25 dark:border-gray-600 dark:focus:border-darkPrimary dark:focus:ring-darkPrimary/25',
321
- ].join(' '));
322
263
  const subtitles = computed(() => ({
323
264
  totp: t('Enter the 6-digit code from your authenticator app'),
324
265
  passkey: t('Confirm with your passkey'),
@@ -347,37 +288,9 @@
347
288
  result: passkeyData
348
289
  }
349
290
  customDialogTitle.value = "";
350
- removeEventListenerForOTPInput();
351
291
  resolveFn?.(dataToReturn);
352
292
  }
353
293
 
354
- function tagOtpInputs() {
355
- const root = otpRoot.value;
356
- if (!root) return;
357
- const inputs = root.querySelectorAll('input.otp-input');
358
- inputs.forEach((el, idx) => {
359
- el.setAttribute('name', 'mfaCode');
360
- el.setAttribute('id', `mfaCode-${idx + 1}`);
361
- el.setAttribute('autocomplete', 'one-time-code');
362
- el.setAttribute('inputmode', 'numeric');
363
- el.setAttribute('aria-labelledby', 'mfaCode-label');
364
- });
365
- (inputs[0] as HTMLInputElement)?.focus();
366
-
367
- }
368
-
369
- function handlePaste(event: ClipboardEvent) {
370
- event.preventDefault();
371
- const pastedText = event.clipboardData?.getData('text') || '';
372
- if (pastedText.length === 6) {
373
- confirmationResult.value?.fillInput(pastedText);
374
- }
375
- }
376
-
377
- async function handleOnComplete(value: string) {
378
- await sendConfirmationResult(value);
379
- }
380
-
381
294
  async function sendConfirmationResult(value: string) {
382
295
  if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
383
296
  let checked: { error: string } | { result: TwoFaConfirmationResult };
@@ -389,15 +302,13 @@
389
302
  }
390
303
  if ('error' in checked) {
391
304
  codeError.value = checked.error;
392
- bindValue.value = '';
393
- confirmationResult.value?.clearInput();
305
+ codeInput.value!.clear();
394
306
  return;
395
307
  }
396
308
 
397
309
  modelShow.value = false;
398
310
  codeError.value = "";
399
311
  customDialogTitle.value = "";
400
- removeEventListenerForOTPInput();
401
312
  resolveFn(checked.result);
402
313
  }
403
314
 
@@ -415,51 +326,16 @@
415
326
  function onCancel() {
416
327
  modelShow.value = false;
417
328
  codeError.value = "";
418
- bindValue.value = '';
419
- confirmationResult.value?.clearInput();
420
- removeEventListenerForOTPInput();
421
329
  rejectFn?.("Cancel");
422
330
  emit('rejected', new Error('cancelled'));
423
331
  emit('closed');
424
332
  }
425
333
 
426
- watch(modalMode, async (newMode) => {
427
- if (newMode === 'totp') {
428
- await addEventListenerForOTPInput();
429
- } else {
430
- removeEventListenerForOTPInput();
431
- }
334
+ // The page under the open modal does not scroll.
335
+ watch(modelShow, (open) => {
336
+ document.documentElement.style.overflow = open ? 'hidden' : '';
432
337
  });
433
338
 
434
- watch(modelShow, async (open) => {
435
- if (open) {
436
- await nextTick();
437
- const htmlRef = document.querySelector('html');
438
- if (htmlRef) {
439
- htmlRef.style.overflow = 'hidden';
440
- }
441
-
442
- // Wait for conditional rendering to complete
443
- if (modalMode.value === 'totp' && !isLoading.value) {
444
- await nextTick();
445
- setTimeout(() => {
446
- tagOtpInputs();
447
- window.addEventListener('paste', handlePaste);
448
- }, 100);
449
- } else {
450
- window.addEventListener('paste', handlePaste);
451
- }
452
- } else {
453
- window.removeEventListener('paste', handlePaste);
454
- const htmlRef = document.querySelector('html');
455
- if (htmlRef) {
456
- htmlRef.style.overflow = '';
457
- }
458
- bindValue.value = '';
459
- confirmationResult.value?.clearInput();
460
- }
461
- });
462
-
463
339
  async function checkIfUserHasPasskeys() {
464
340
  isLoading.value = true;
465
341
  try {
@@ -480,47 +356,6 @@
480
356
  }
481
357
  }
482
358
 
483
- function getOtpInputs() {
484
- const root = otpRoot.value;
485
- if (!root) return [];
486
- return Array.from(root.querySelectorAll('input.otp-input'));
487
- }
488
-
489
- function focusFirstAvailableOtpInput() {
490
- const inputs = getOtpInputs();
491
- if (!inputs.length) {
492
- // Retry after a short delay if inputs aren't ready yet
493
- setTimeout(() => focusFirstAvailableOtpInput(), 50);
494
- return;
495
- }
496
- const firstEmpty = inputs.find((i) => !(i as HTMLInputElement).value);
497
- ((firstEmpty || inputs[0]) as HTMLInputElement).focus();
498
- }
499
-
500
- function handleGlobalFocusIn(event: FocusEvent) {
501
- const inputs = getOtpInputs();
502
- if (!inputs.length) return;
503
- const target = event.target as Element | null;
504
- if (!target) return;
505
- if (!inputs.includes(target)) {
506
- requestAnimationFrame(() => {
507
- focusFirstAvailableOtpInput();
508
- });
509
- }
510
- }
511
-
512
- function handleFocusOut() {
513
- requestAnimationFrame(() => {
514
- const inputs = getOtpInputs();
515
- if (!inputs.length) return;
516
- const active = document.activeElement;
517
- if (!active || !inputs.includes(active)) {
518
- focusFirstAvailableOtpInput();
519
- }
520
- });
521
- }
522
-
523
-
524
359
  async function checkIfSkipAllowModal(): Promise<{ skipAllowed: boolean; gracePeriod?: boolean }> {
525
360
  try {
526
361
  return await callAdminForthApi({
@@ -534,15 +369,3 @@
534
369
  }
535
370
 
536
371
  </script>
537
-
538
- <style scoped>
539
- /* a dash after the 3rd digit splits the code into two groups of three */
540
- :deep(.otp-input-container > div:nth-child(3))::after {
541
- content: '';
542
- width: 0.75rem;
543
- height: 2px;
544
- margin-left: 0.5rem;
545
- border-radius: 1px;
546
- background: currentColor;
547
- }
548
- </style>
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <div class="af-2fa-card relative flex flex-col w-full max-w-[460px] overflow-hidden rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund shadow-2xl dark:border-darkDialogBreakLine dark:bg-darkDialogBackgorund">
3
+ <div class="af-2fa-header flex items-start gap-3.5 border-b border-lightDialogBreakLine py-5 pl-6 dark:border-darkDialogBreakLine" :class="closable ? 'pr-4' : 'pr-6'">
4
+ <div class="af-2fa-icon-wrap flex w-10 h-10 shrink-0 items-center justify-center rounded-[10px] bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
5
+ <IconShieldCheckOutline class="af-2fa-shield-icon w-6 h-6" />
6
+ </div>
7
+ <div class="af-2fa-title-wrap flex-1 min-w-0">
8
+ <p class="af-2fa-title break-words text-[17px] leading-6 font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ title }}</p>
9
+ <p class="af-2fa-subtitle mt-0.5 text-[13px] leading-[18px] text-gray-500 dark:text-gray-400">{{ subtitle }}</p>
10
+ </div>
11
+ <button v-if="closable" type="button" :aria-label="$t('Cancel')" class="af-2fa-close-btn -mt-1 inline-flex w-9 h-9 shrink-0 items-center justify-center rounded-lg text-lightDialogCloseButton hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover" @click="emit('close')">
12
+ <IconCloseOutline class="w-5 h-5" />
13
+ </button>
14
+ </div>
15
+
16
+ <div class="af-2fa-body flex flex-col gap-5 px-6 pt-5 pb-6">
17
+ <slot />
18
+ <p v-if="error" role="alert" class="af-2fa-code-error -mt-2.5 flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400">
19
+ <IconExclamationCircleOutline class="w-4 h-4 shrink-0" />
20
+ {{ error }}
21
+ </p>
22
+ </div>
23
+
24
+ <div v-if="$slots.footer" class="af-2fa-footer flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-lightDialogBreakLine bg-gray-50 py-3 pl-6 pr-5 dark:border-darkDialogBreakLine dark:bg-gray-800/50">
25
+ <slot name="footer" />
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { IconCloseOutline, IconExclamationCircleOutline, IconShieldCheckOutline } from '@iconify-prerendered/vue-flowbite';
32
+
33
+ defineProps<{
34
+ title: string;
35
+ subtitle: string;
36
+ error?: string | null;
37
+ closable?: boolean;
38
+ }>();
39
+
40
+ const emit = defineEmits<{
41
+ (e: 'close'): void;
42
+ }>();
43
+ </script>
@@ -0,0 +1,131 @@
1
+ <template>
2
+ <div class="af-2fa-code">
3
+ <div :id="LABEL_ID">
4
+ <slot name="label">
5
+ <p class="mb-2 text-[13px] font-medium text-gray-700 dark:text-gray-300">{{ $t('Authenticator code') }}</p>
6
+ </slot>
7
+ </div>
8
+ <div ref="otpRoot" class="af-2fa-otp-root text-gray-300 dark:text-gray-500">
9
+ <v-otp-input
10
+ ref="otpInput"
11
+ :input-classes="inputClasses"
12
+ :num-inputs="CODE_LENGTH"
13
+ inputType="number"
14
+ inputmode="numeric"
15
+ :should-auto-focus="true"
16
+ :should-focus-order="true"
17
+ @on-complete="emit('complete', $event)"
18
+ />
19
+ </div>
20
+ </div>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import VOtpInput from 'vue3-otp-input';
25
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
26
+
27
+ const props = defineProps<{
28
+ invalid?: boolean;
29
+ }>();
30
+
31
+ const emit = defineEmits<{
32
+ (e: 'complete', code: string): void;
33
+ }>();
34
+
35
+ const CODE_LENGTH = 6;
36
+ const LABEL_ID = 'mfaCode-label';
37
+ const INPUT_CLASS = 'otp-input';
38
+
39
+ const otpRoot = ref<HTMLElement | null>(null);
40
+ const otpInput = ref<{ clearInput(): void; fillInput(value: string): void } | null>(null);
41
+
42
+ const inputClasses = computed(() => [
43
+ `${INPUT_CLASS} w-12 h-14 p-0 rounded-[10px] border-[1.5px] bg-gray-50 text-center text-2xl font-medium leading-none tabular-nums text-gray-900 outline-none focus:ring-4 dark:bg-gray-800 dark:text-white`,
44
+ props.invalid
45
+ ? 'border-red-500 focus:border-red-500 focus:ring-red-500/25 dark:border-red-400 dark:focus:border-red-400'
46
+ : 'border-gray-300 focus:border-lightPrimary focus:ring-lightPrimary/25 dark:border-gray-600 dark:focus:border-darkPrimary dark:focus:ring-darkPrimary/25',
47
+ ].join(' '));
48
+
49
+ // Focus callbacks are deferred with requestAnimationFrame and may run after unmount, when the root is gone.
50
+ function getInputs(): HTMLInputElement[] {
51
+ return otpRoot.value ? Array.from(otpRoot.value.querySelectorAll<HTMLInputElement>(`input.${INPUT_CLASS}`)) : [];
52
+ }
53
+
54
+ function focusFirstEmptyInput() {
55
+ const inputs = getInputs();
56
+ (inputs.find((input) => !input.value) ?? inputs[0])?.focus();
57
+ }
58
+
59
+ // While the code is asked, focus stays in the code inputs, so typing and pasting always go into the code.
60
+ function handleFocusIn(event: FocusEvent) {
61
+ if (!getInputs().includes(event.target as HTMLInputElement)) {
62
+ requestAnimationFrame(focusFirstEmptyInput);
63
+ }
64
+ }
65
+
66
+ function handleFocusOut() {
67
+ requestAnimationFrame(() => {
68
+ if (!getInputs().includes(document.activeElement as HTMLInputElement)) {
69
+ focusFirstEmptyInput();
70
+ }
71
+ });
72
+ }
73
+
74
+ // A paste into one of the inputs is handled by vue3-otp-input itself.
75
+ function handlePaste(event: ClipboardEvent) {
76
+ event.preventDefault();
77
+ if ((event.target as HTMLElement).classList.contains(INPUT_CLASS)) {
78
+ return;
79
+ }
80
+ const pastedText = event.clipboardData?.getData('text') ?? '';
81
+ if (pastedText.length === CODE_LENGTH) {
82
+ otpInput.value!.fillInput(pastedText);
83
+ }
84
+ }
85
+
86
+ function tagInputs() {
87
+ getInputs().forEach((input, index) => {
88
+ input.setAttribute('name', 'mfaCode');
89
+ input.setAttribute('id', `mfaCode-${index + 1}`);
90
+ input.setAttribute('autocomplete', 'one-time-code');
91
+ input.setAttribute('aria-labelledby', LABEL_ID);
92
+ });
93
+ }
94
+
95
+ onMounted(() => {
96
+ tagInputs();
97
+ focusFirstEmptyInput();
98
+ document.addEventListener('focusin', handleFocusIn, true);
99
+ otpRoot.value!.addEventListener('focusout', handleFocusOut, true);
100
+ window.addEventListener('paste', handlePaste);
101
+ });
102
+
103
+ onBeforeUnmount(() => {
104
+ document.removeEventListener('focusin', handleFocusIn, true);
105
+ otpRoot.value!.removeEventListener('focusout', handleFocusOut, true);
106
+ window.removeEventListener('paste', handlePaste);
107
+ });
108
+
109
+ defineExpose({
110
+ clear: () => otpInput.value!.clearInput(),
111
+ });
112
+ </script>
113
+
114
+ <style scoped>
115
+ /* vue3-otp-input renders its own container and ignores container classes, so it is styled here */
116
+ :deep(.otp-input-container) {
117
+ align-items: center;
118
+ justify-content: center;
119
+ gap: 0.5rem;
120
+ }
121
+
122
+ /* a dash after the 3rd digit splits the code into two groups of three */
123
+ :deep(.otp-input-container > div:nth-child(3))::after {
124
+ content: '';
125
+ width: 0.75rem;
126
+ height: 2px;
127
+ margin-left: 0.5rem;
128
+ border-radius: 1px;
129
+ background: currentColor;
130
+ }
131
+ </style>
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <div class="af-2fa-hint flex items-center gap-3.5 rounded-[10px] border border-lightDialogBreakLine bg-gray-50 px-4 py-3.5 dark:border-darkDialogBreakLine dark:bg-gray-800">
3
+ <span class="af-2fa-hint-icon flex w-11 h-11 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
4
+ <slot name="icon" />
5
+ </span>
6
+ <p class="af-2fa-hint-text text-[13px] leading-[19px] text-gray-500 dark:text-gray-400">
7
+ <slot />
8
+ </p>
9
+ </div>
10
+ </template>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <button type="button" class="af-2fa-link-btn inline-flex h-11 items-center gap-1.5 whitespace-nowrap text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary">
3
+ <slot />
4
+ </button>
5
+ </template>