@adminforth/two-factors-auth 4.7.1 → 4.8.1

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.
package/build.log CHANGED
@@ -6,6 +6,10 @@ sending incremental file list
6
6
  custom/
7
7
  custom/LoginWithPasskeyButton.vue
8
8
  custom/TwoFAModal.vue
9
+ custom/TwoFaCard.vue
10
+ custom/TwoFaCodeInput.vue
11
+ custom/TwoFaHint.vue
12
+ custom/TwoFaLinkButton.vue
9
13
  custom/TwoFactorsConfirmation.vue
10
14
  custom/TwoFactorsPasskeysSettings.vue
11
15
  custom/TwoFactorsSetup.vue
@@ -17,5 +21,5 @@ custom/tsconfig.json
17
21
  custom/use2faApi.ts
18
22
  custom/utils.js
19
23
 
20
- sent 311,411 bytes received 248 bytes 623,318.00 bytes/sec
21
- total size is 310,438 speedup is 1.00
24
+ sent 306,997 bytes received 324 bytes 614,642.00 bytes/sec
25
+ total size is 305,746 speedup is 0.99
@@ -1,13 +1,12 @@
1
1
  <template>
2
2
 
3
3
  <Button
4
- class="w-full !py-2"
4
+ variant="secondary"
5
+ class="af-login-with-passkey-btn w-full gap-2"
5
6
  @click="handleLoginWithPasskey"
6
7
  >
7
- <div class="flex items-center justify-center">
8
- <IconShieldOutline class="w-6 h-6 mr-1" />
9
- <span>{{ $t('Continue with Passkey') }}</span>
10
- </div>
8
+ <IconFingerprintOutline class="w-5 h-5 text-lightPrimary dark:text-darkPrimary" />
9
+ {{ $t('Continue with Passkey') }}
11
10
  </Button>
12
11
 
13
12
  </template>
@@ -15,7 +14,7 @@
15
14
 
16
15
 
17
16
  <script setup lang="ts">
18
- import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
17
+ import { IconFingerprintOutline } from '@iconify-prerendered/vue-flowbite';
19
18
  import { getPasskey } from "./utils.js";
20
19
  import { callAdminForthApi } from '@/utils';
21
20
  import { useUserStore } from '@/stores/user';
@@ -44,7 +43,7 @@
44
43
  return;
45
44
  }
46
45
 
47
- if ( resp.allowedLogin === true ) {
46
+ if ( resp.allowedLogin === true ) {
48
47
  await userStore.finishLogin();
49
48
  } else if (resp.redirectTo) {
50
49
  router.push(resp.redirectTo);
@@ -54,6 +53,6 @@
54
53
  }
55
54
  } catch (error) {
56
55
  console.error("Error during passkey login:", error);
57
- }
56
+ }
58
57
  }
59
- </script>
58
+ </script>
@@ -1,72 +1,79 @@
1
1
  <template>
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"
3
- v-show ="modelShow && (isLoading === false)">
4
- <div :class="`af-two-factor-modal-${modalMode}`" class="flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
- <div class="af-2fa-header flex flex-col items-center justify-center gap-3">
6
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
7
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
8
- </div>
9
- <div id="mfaCode-label" class="af-2fa-title-wrap">
10
- <p class="af-2fa-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ dialogTitle }}</p>
11
- <p class="af-2fa-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ subtitles[modalMode] }}</p>
12
- </div>
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-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="text-[15px] leading-[22px] break-words text-gray-800 dark:text-gray-100">{{ item }}</span>
20
+ </li>
21
+ </ul>
13
22
  </div>
14
23
 
15
- <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ dialogDetails }}</pre>
16
-
17
- <ul v-if="confirmItems.length > 1" class="af-2fa-actions-list max-h-60 overflow-auto list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
18
- <li v-for="(item, i) in confirmItems" :key="i">{{ item }}</li>
19
- </ul>
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>
20
33
 
21
- <template v-if="modalMode === 'totp'">
22
- <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
23
- <v-otp-input
24
- ref="confirmationResult"
25
- container-class="grid grid-cols-6 gap-3"
26
- input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
27
- :num-inputs="6"
28
- inputType="number"
29
- inputmode="numeric"
30
- :should-auto-focus="true"
31
- :should-focus-order="true"
32
- v-model:value="bindValue"
33
- @on-complete="handleOnComplete"
34
- />
34
+ <template #footer>
35
+ <TwoFaLinkButton v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-switch-to-passkey" @click="modalMode = 'passkey'">
36
+ <IconFingerprintOutline class="w-4 h-4" />
37
+ {{ $t('Use passkey instead') }}
38
+ </TwoFaLinkButton>
39
+ <TwoFaLinkButton v-else-if="modalMode === 'passkey'" class="af-2fa-switch-to-totp" @click="modalMode = 'totp'">
40
+ <IconMobilePhoneOutline class="w-4 h-4" />
41
+ {{ $t('Use code instead') }}
42
+ </TwoFaLinkButton>
43
+ <div class="af-2fa-buttons ms-auto flex gap-2 whitespace-nowrap">
44
+ <Button variant="secondary" class="af-2fa-cancel-btn" @click="onCancel">{{ $t('Cancel') }}</Button>
45
+ <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
46
+ <IconFingerprintOutline class="w-4 h-4" />
47
+ {{ $t('Use passkey') }}
48
+ </Button>
49
+ <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn gap-2" @click="approveInGracePeriod">
50
+ <IconCheckOutline class="w-4 h-4" />
51
+ {{ approveLabel }}
52
+ </Button>
35
53
  </div>
36
- <p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
37
54
  </template>
38
-
39
- <div class="af-2fa-buttons flex gap-2">
40
- <Button variant="secondary" class="af-2fa-cancel-btn flex-1" @click="onCancel">{{ $t('Cancel') }}</Button>
41
- <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn flex-1 flex items-center justify-center gap-2">
42
- <IconShieldOutline class="w-4 h-4" />
43
- {{ $t('Use passkey') }}
44
- </Button>
45
- <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn flex-1" @click="approveInGracePeriod">{{ $t('Approve') }}</Button>
46
- </div>
47
-
48
- <p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
49
- {{$t('Having trouble?')}}
50
- <button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
51
- </p>
52
- <p v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
53
- {{$t('Having trouble?')}}
54
- <button type="button" class="af-2fa-switch-to-totp text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'totp'">{{$t('Use TOTP instead')}}</button>
55
- </p>
56
- </div>
55
+ </TwoFaCard>
57
56
  </div>
58
57
  </template>
59
58
 
60
59
  <script setup lang="ts">
61
60
 
62
- import VOtpInput from 'vue3-otp-input';
63
- import { ref, computed, nextTick, watch } from 'vue';
61
+ import { ref, computed, watch } from 'vue';
64
62
  import { useUserStore } from '@/stores/user';
65
63
  import { useI18n } from 'vue-i18n';
66
64
  import { callAdminForthApi } from '@/utils';
67
65
  import { Link, Button } from '@/afcl';
68
- import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
69
- import { getPasskey } from './utils.js'
66
+ import {
67
+ IconCheckCircleOutline,
68
+ IconCheckOutline,
69
+ IconFingerprintOutline,
70
+ IconMobilePhoneOutline,
71
+ } from '@iconify-prerendered/vue-flowbite';
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';
70
77
  import { useAdminforth } from '@/adminforth';
71
78
  import websocket from '@/websocket';
72
79
  import { getAdminForthClientId } from '@/utils/clientId';
@@ -139,7 +146,7 @@
139
146
  let confirmationResult;
140
147
  try {
141
148
  isAwaiting2FAResult.value = true;
142
- confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(sessionsToResolve.value[0].actionDescription);
149
+ confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult();
143
150
  } catch (error) {
144
151
  if (!isTwoFaCancel(error)) {
145
152
  console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
@@ -168,24 +175,6 @@
168
175
  (e: 'closed'): void
169
176
  }>();
170
177
 
171
- async function addEventListenerForOTPInput(){
172
- document.addEventListener('focusin', handleGlobalFocusIn, true);
173
- focusFirstAvailableOtpInput();
174
- isLoading.value = false;
175
- await nextTick();
176
- const rootEl = otpRoot.value;
177
- rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
178
- }
179
-
180
- function removeEventListenerForOTPInput() {
181
- window.removeEventListener('paste', handlePaste);
182
- document.removeEventListener('focusin', handleGlobalFocusIn, true);
183
- const rootEl = otpRoot.value;
184
- rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
185
- // Abort any in-flight WebAuthn request when leaving the component
186
- }
187
-
188
-
189
178
  const modelShow = ref(false);
190
179
  let resolveFn: ((confirmationResult: any) => void) | null = null;
191
180
  let verifyFn: null | ((confirmationResult: string) => Promise<boolean> | boolean) = null;
@@ -223,9 +212,6 @@
223
212
  rejectFn = reject;
224
213
  verifyFn = verifyingCallback ?? null;
225
214
  modelShow.value = true;
226
- if (modalMode.value === 'totp') {
227
- await addEventListenerForOTPInput();
228
- }
229
215
  })().catch((error) => {
230
216
  if (!isTwoFaCancel(error)) {
231
217
  console.error('[AdminForth 2FA] Failed to open 2FA modal', error);
@@ -241,9 +227,7 @@
241
227
  const { t } = useI18n();
242
228
  const user = useUserStore();
243
229
 
244
- const confirmationResult = ref<any>(null);
245
- const otpRoot = ref<HTMLElement | null>(null);
246
- const bindValue = ref('');
230
+ const codeInput = ref<InstanceType<typeof TwoFaCodeInput> | null>(null);
247
231
  const doesUserHavePasskeys = ref(false);
248
232
  const modalMode = ref<"totp" | "passkey" | "grace">("totp");
249
233
  const isLoading = ref(false);
@@ -251,18 +235,35 @@
251
235
  const customDialogTitle = ref("");
252
236
  const dialogDetails = ref("");
253
237
  const dialogItems = ref<string[]>([]);
254
- // verifyAuto calls that arrive together are confirmed with one code.
255
- const confirmItems = computed(() => sessionsToResolve.value.length > 1
256
- ? sessionsToResolve.value.map((session) => session.actionDescription ?? t('Action without a description'))
257
- : dialogItems.value);
258
- const dialogTitle = computed(() => confirmItems.value.length > 1
259
- ? t('Confirm {count} actions', { count: confirmItems.value.length })
260
- : customDialogTitle.value || t("Confirm it's you"));
238
+ // verifyAuto calls that arrive together are confirmed with one code. A single call lists only its own description.
239
+ const confirmItems = computed(() => {
240
+ const sessions = sessionsToResolve.value;
241
+ if (!sessions.length) {
242
+ return dialogItems.value;
243
+ }
244
+ if (sessions.length === 1) {
245
+ return sessions[0].actionDescription ? [sessions[0].actionDescription] : [];
246
+ }
247
+ return sessions.map((session) => session.actionDescription ?? t('Action without a description'));
248
+ });
249
+ const dialogTitle = computed(() => {
250
+ const count = confirmItems.value.length;
251
+ if (customDialogTitle.value) {
252
+ return customDialogTitle.value;
253
+ }
254
+ if (count > 1) {
255
+ return t('Confirm {count} actions', { count });
256
+ }
257
+ return count ? t('Confirm action') : t("Confirm it's you");
258
+ });
259
+ const approveLabel = computed(() => confirmItems.value.length > 1
260
+ ? t('Approve {count} actions', { count: confirmItems.value.length })
261
+ : t('Approve'));
261
262
  const codeError = ref("");
262
263
  const subtitles = computed(() => ({
263
- totp: t('Please enter your authenticator code'),
264
+ totp: t('Enter the 6-digit code from your authenticator app'),
264
265
  passkey: t('Confirm with your passkey'),
265
- grace: t('You verified recently, so no code is needed'),
266
+ grace: t('Review and approve'),
266
267
  }));
267
268
 
268
269
  function approveInGracePeriod() {
@@ -287,37 +288,9 @@
287
288
  result: passkeyData
288
289
  }
289
290
  customDialogTitle.value = "";
290
- removeEventListenerForOTPInput();
291
291
  resolveFn?.(dataToReturn);
292
292
  }
293
293
 
294
- function tagOtpInputs() {
295
- const root = otpRoot.value;
296
- if (!root) return;
297
- const inputs = root.querySelectorAll('input.otp-input');
298
- inputs.forEach((el, idx) => {
299
- el.setAttribute('name', 'mfaCode');
300
- el.setAttribute('id', `mfaCode-${idx + 1}`);
301
- el.setAttribute('autocomplete', 'one-time-code');
302
- el.setAttribute('inputmode', 'numeric');
303
- el.setAttribute('aria-labelledby', 'mfaCode-label');
304
- });
305
- (inputs[0] as HTMLInputElement)?.focus();
306
-
307
- }
308
-
309
- function handlePaste(event: ClipboardEvent) {
310
- event.preventDefault();
311
- const pastedText = event.clipboardData?.getData('text') || '';
312
- if (pastedText.length === 6) {
313
- confirmationResult.value?.fillInput(pastedText);
314
- }
315
- }
316
-
317
- async function handleOnComplete(value: string) {
318
- await sendConfirmationResult(value);
319
- }
320
-
321
294
  async function sendConfirmationResult(value: string) {
322
295
  if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
323
296
  let checked: { error: string } | { result: TwoFaConfirmationResult };
@@ -329,15 +302,13 @@
329
302
  }
330
303
  if ('error' in checked) {
331
304
  codeError.value = checked.error;
332
- bindValue.value = '';
333
- confirmationResult.value?.clearInput();
305
+ codeInput.value!.clear();
334
306
  return;
335
307
  }
336
308
 
337
309
  modelShow.value = false;
338
310
  codeError.value = "";
339
311
  customDialogTitle.value = "";
340
- removeEventListenerForOTPInput();
341
312
  resolveFn(checked.result);
342
313
  }
343
314
 
@@ -355,51 +326,16 @@
355
326
  function onCancel() {
356
327
  modelShow.value = false;
357
328
  codeError.value = "";
358
- bindValue.value = '';
359
- confirmationResult.value?.clearInput();
360
- removeEventListenerForOTPInput();
361
329
  rejectFn?.("Cancel");
362
330
  emit('rejected', new Error('cancelled'));
363
331
  emit('closed');
364
332
  }
365
333
 
366
- watch(modalMode, async (newMode) => {
367
- if (newMode === 'totp') {
368
- await addEventListenerForOTPInput();
369
- } else {
370
- removeEventListenerForOTPInput();
371
- }
334
+ // The page under the open modal does not scroll.
335
+ watch(modelShow, (open) => {
336
+ document.documentElement.style.overflow = open ? 'hidden' : '';
372
337
  });
373
338
 
374
- watch(modelShow, async (open) => {
375
- if (open) {
376
- await nextTick();
377
- const htmlRef = document.querySelector('html');
378
- if (htmlRef) {
379
- htmlRef.style.overflow = 'hidden';
380
- }
381
-
382
- // Wait for conditional rendering to complete
383
- if (modalMode.value === 'totp' && !isLoading.value) {
384
- await nextTick();
385
- setTimeout(() => {
386
- tagOtpInputs();
387
- window.addEventListener('paste', handlePaste);
388
- }, 100);
389
- } else {
390
- window.addEventListener('paste', handlePaste);
391
- }
392
- } else {
393
- window.removeEventListener('paste', handlePaste);
394
- const htmlRef = document.querySelector('html');
395
- if (htmlRef) {
396
- htmlRef.style.overflow = '';
397
- }
398
- bindValue.value = '';
399
- confirmationResult.value?.clearInput();
400
- }
401
- });
402
-
403
339
  async function checkIfUserHasPasskeys() {
404
340
  isLoading.value = true;
405
341
  try {
@@ -420,47 +356,6 @@
420
356
  }
421
357
  }
422
358
 
423
- function getOtpInputs() {
424
- const root = otpRoot.value;
425
- if (!root) return [];
426
- return Array.from(root.querySelectorAll('input.otp-input'));
427
- }
428
-
429
- function focusFirstAvailableOtpInput() {
430
- const inputs = getOtpInputs();
431
- if (!inputs.length) {
432
- // Retry after a short delay if inputs aren't ready yet
433
- setTimeout(() => focusFirstAvailableOtpInput(), 50);
434
- return;
435
- }
436
- const firstEmpty = inputs.find((i) => !(i as HTMLInputElement).value);
437
- ((firstEmpty || inputs[0]) as HTMLInputElement).focus();
438
- }
439
-
440
- function handleGlobalFocusIn(event: FocusEvent) {
441
- const inputs = getOtpInputs();
442
- if (!inputs.length) return;
443
- const target = event.target as Element | null;
444
- if (!target) return;
445
- if (!inputs.includes(target)) {
446
- requestAnimationFrame(() => {
447
- focusFirstAvailableOtpInput();
448
- });
449
- }
450
- }
451
-
452
- function handleFocusOut() {
453
- requestAnimationFrame(() => {
454
- const inputs = getOtpInputs();
455
- if (!inputs.length) return;
456
- const active = document.activeElement;
457
- if (!active || !inputs.includes(active)) {
458
- focusFirstAvailableOtpInput();
459
- }
460
- });
461
- }
462
-
463
-
464
359
  async function checkIfSkipAllowModal(): Promise<{ skipAllowed: boolean; gracePeriod?: boolean }> {
465
360
  try {
466
361
  return await callAdminForthApi({
@@ -474,10 +369,3 @@
474
369
  }
475
370
 
476
371
  </script>
477
-
478
- <style scoped>
479
- :deep(.otp-input-container) {
480
- display: flex;
481
- gap: 0.75rem;
482
- }
483
- </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 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>