@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.
- package/build.log +6 -2
- package/custom/LoginWithPasskeyButton.vue +8 -9
- package/custom/TwoFAModal.vue +48 -225
- package/custom/TwoFaCard.vue +43 -0
- package/custom/TwoFaCodeInput.vue +131 -0
- package/custom/TwoFaHint.vue +10 -0
- package/custom/TwoFaLinkButton.vue +5 -0
- package/custom/TwoFactorsConfirmation.vue +39 -230
- package/custom/TwoFactorsPasskeysSettings.vue +45 -36
- package/custom/TwoFactorsSetup.vue +59 -150
- package/dist/custom/LoginWithPasskeyButton.vue +8 -9
- package/dist/custom/TwoFAModal.vue +48 -225
- package/dist/custom/TwoFaCard.vue +43 -0
- package/dist/custom/TwoFaCodeInput.vue +131 -0
- package/dist/custom/TwoFaHint.vue +10 -0
- package/dist/custom/TwoFaLinkButton.vue +5 -0
- package/dist/custom/TwoFactorsConfirmation.vue +39 -230
- package/dist/custom/TwoFactorsPasskeysSettings.vue +45 -36
- package/dist/custom/TwoFactorsSetup.vue +59 -150
- package/package.json +1 -1
|
@@ -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-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</
|
|
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
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
</
|
|
69
|
-
<
|
|
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
|
-
</
|
|
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
|
-
</
|
|
85
|
-
</
|
|
54
|
+
</template>
|
|
55
|
+
</TwoFaCard>
|
|
86
56
|
</div>
|
|
87
57
|
</template>
|
|
88
58
|
|
|
89
59
|
<script setup lang="ts">
|
|
90
60
|
|
|
91
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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>
|