@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 +6 -2
- package/custom/LoginWithPasskeyButton.vue +8 -9
- package/custom/TwoFAModal.vue +93 -205
- 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 +93 -205
- 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
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
|
|
21
|
-
total size is
|
|
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
|
-
|
|
4
|
+
variant="secondary"
|
|
5
|
+
class="af-login-with-passkey-btn w-full gap-2"
|
|
5
6
|
@click="handleLoginWithPasskey"
|
|
6
7
|
>
|
|
7
|
-
<
|
|
8
|
-
|
|
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 {
|
|
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>
|
package/custom/TwoFAModal.vue
CHANGED
|
@@ -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-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
</
|
|
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
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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 {
|
|
69
|
-
|
|
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(
|
|
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
|
|
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(() =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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('
|
|
264
|
+
totp: t('Enter the 6-digit code from your authenticator app'),
|
|
264
265
|
passkey: t('Confirm with your passkey'),
|
|
265
|
-
grace: t('
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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>
|