@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
|
@@ -8,83 +8,36 @@
|
|
|
8
8
|
}: {}"
|
|
9
9
|
>
|
|
10
10
|
<div v-if="isLoading===false" id="authentication-modal" class="af-two-factors-confirmation flex items-center justify-center w-full p-4">
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<div
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
:num-inputs="6"
|
|
38
|
-
inputType="number"
|
|
39
|
-
inputmode="numeric"
|
|
40
|
-
:should-auto-focus="true"
|
|
41
|
-
v-model:value="bindValue"
|
|
42
|
-
@on-complete="handleOnComplete"
|
|
43
|
-
/>
|
|
44
|
-
|
|
45
|
-
<div class="af-2fa-footer text-center text-xs text-gray-500 dark:text-gray-400">
|
|
46
|
-
<p>
|
|
47
|
-
{{$t('Having trouble?')}}
|
|
48
|
-
<button v-if="doesUserHavePasskeys" type="button"
|
|
49
|
-
class="hover-link bg-transparent text-[#16537E] border-none p-0 cursor-pointer ml-1"
|
|
50
|
-
@click="router.push({ hash: '#passkey' })">
|
|
51
|
-
{{$t('Use passkey instead')}}
|
|
52
|
-
</button>
|
|
53
|
-
<span v-if="doesUserHavePasskeys" class="mx-1">{{$t('or')}}</span>
|
|
54
|
-
<Link to="/login" class="hover-link">
|
|
55
|
-
{{$t('Back to login')}}
|
|
56
|
-
</Link>
|
|
57
|
-
</p>
|
|
58
|
-
</div>
|
|
59
|
-
</div>
|
|
60
|
-
|
|
61
|
-
<div v-else class="af-2fa-passkey-root flex flex-col gap-6">
|
|
62
|
-
<Button @click="usePasskeyButton" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn w-full flex items-center justify-center gap-2">
|
|
63
|
-
<IconShieldOutline class="w-4 h-4" />
|
|
64
|
-
{{$t('Use passkey to verify')}}
|
|
11
|
+
<TwoFaCard
|
|
12
|
+
class="af-login-popup"
|
|
13
|
+
:title="$t('Two-factor authentication')"
|
|
14
|
+
:subtitle="confirmationMode === 'code' ? $t('Enter the 6-digit code from your authenticator app') : $t('Confirm with your passkey')"
|
|
15
|
+
:error="codeError"
|
|
16
|
+
>
|
|
17
|
+
<TwoFaCodeInput v-if="confirmationMode === 'code'" ref="codeInput" :invalid="Boolean(codeError)" @complete="handleOnComplete" />
|
|
18
|
+
<TwoFaHint v-else class="af-2fa-passkey-hint">
|
|
19
|
+
<template #icon><IconFingerprintOutline class="w-6 h-6" /></template>
|
|
20
|
+
{{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}
|
|
21
|
+
</TwoFaHint>
|
|
22
|
+
|
|
23
|
+
<template #footer>
|
|
24
|
+
<TwoFaLinkButton v-if="confirmationMode === 'code' && doesUserHavePasskeys" class="af-2fa-switch-to-passkey" @click="router.push({ hash: '#passkey' })">
|
|
25
|
+
<IconFingerprintOutline class="w-4 h-4" />
|
|
26
|
+
{{ $t('Use passkey instead') }}
|
|
27
|
+
</TwoFaLinkButton>
|
|
28
|
+
<TwoFaLinkButton v-else-if="confirmationMode === 'passkey'" class="af-2fa-switch-to-totp" @click="router.push({ hash: '#code' })">
|
|
29
|
+
<IconMobilePhoneOutline class="w-4 h-4" />
|
|
30
|
+
{{ $t('Use code instead') }}
|
|
31
|
+
</TwoFaLinkButton>
|
|
32
|
+
<div class="af-2fa-buttons ms-auto flex gap-2 whitespace-nowrap">
|
|
33
|
+
<LinkButton to="/login" variant="secondary" class="af-2fa-back-to-login">{{ $t('Back to login') }}</LinkButton>
|
|
34
|
+
<Button v-if="confirmationMode === 'passkey'" @click="usePasskeyButton" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
|
|
35
|
+
<IconFingerprintOutline class="w-4 h-4" />
|
|
36
|
+
{{ $t('Use passkey') }}
|
|
65
37
|
</Button>
|
|
66
|
-
|
|
67
|
-
<div class="af-2fa-footer text-center text-xs text-gray-500 dark:text-gray-400">
|
|
68
|
-
<p>
|
|
69
|
-
{{$t('Having trouble?')}}
|
|
70
|
-
<button type="button"
|
|
71
|
-
class="hover-link bg-transparent text-[#16537E] border-none p-0 cursor-pointer ml-1"
|
|
72
|
-
@click="router.push({ hash: '#code' })">
|
|
73
|
-
{{$t('Use TOTP instead')}}
|
|
74
|
-
</button>
|
|
75
|
-
<span class="mx-1">{{$t('or')}}</span>
|
|
76
|
-
<Link to="/login" class="hover-link">
|
|
77
|
-
{{$t('Back to login')}}
|
|
78
|
-
</Link>
|
|
79
|
-
</p>
|
|
80
|
-
</div>
|
|
81
38
|
</div>
|
|
82
|
-
</
|
|
83
|
-
|
|
84
|
-
<div v-if="codeError" class="af-two-factors-confirmation-error relative bg-red-100 text-red-700 text-xs px-4 py-2 rounded-b-lg shadow border-t border-red-200 text-center">
|
|
85
|
-
{{ codeError }}
|
|
86
|
-
</div>
|
|
87
|
-
</div>
|
|
39
|
+
</template>
|
|
40
|
+
</TwoFaCard>
|
|
88
41
|
</div>
|
|
89
42
|
<div v-else>
|
|
90
43
|
<Spinner class="w-10 h-10" />
|
|
@@ -100,22 +53,23 @@
|
|
|
100
53
|
import { useUserStore } from '@/stores/user';
|
|
101
54
|
import { callAdminForthApi, loadFile } from '@/utils';
|
|
102
55
|
import { showErrorTost } from '@/composables/useFrontendApi';
|
|
103
|
-
import { Button,
|
|
104
|
-
import VOtpInput from "vue3-otp-input";
|
|
56
|
+
import { Button, LinkButton, Spinner } from '@/afcl';
|
|
105
57
|
import { useI18n } from 'vue-i18n';
|
|
106
58
|
import { useRoute } from 'vue-router'
|
|
107
59
|
import { useRouter } from 'vue-router';
|
|
108
|
-
import {
|
|
60
|
+
import { IconFingerprintOutline, IconMobilePhoneOutline } from '@iconify-prerendered/vue-flowbite';
|
|
109
61
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
110
62
|
import { handlePasskeyAlert } from './utils.js';
|
|
111
|
-
|
|
63
|
+
import TwoFaCard from './TwoFaCard.vue';
|
|
64
|
+
import TwoFaCodeInput from './TwoFaCodeInput.vue';
|
|
65
|
+
import TwoFaHint from './TwoFaHint.vue';
|
|
66
|
+
import TwoFaLinkButton from './TwoFaLinkButton.vue';
|
|
67
|
+
|
|
112
68
|
// Global provided by AdminForth runtime
|
|
113
69
|
declare const adminforth: any;
|
|
114
70
|
|
|
115
71
|
const { t } = useI18n();
|
|
116
|
-
const
|
|
117
|
-
const otpRoot = ref(null);
|
|
118
|
-
const bindValue = ref('');
|
|
72
|
+
const codeInput = ref<InstanceType<typeof TwoFaCodeInput> | null>(null);
|
|
119
73
|
const route = useRoute();
|
|
120
74
|
const router = useRouter();
|
|
121
75
|
const codeError = ref(null);
|
|
@@ -159,19 +113,6 @@
|
|
|
159
113
|
sendCode(value, 'TOTP', null);
|
|
160
114
|
};
|
|
161
115
|
|
|
162
|
-
function tagOtpInputs() {
|
|
163
|
-
const root = otpRoot.value;
|
|
164
|
-
if (!root) return;
|
|
165
|
-
const inputs = root.querySelectorAll('input.otp-input');
|
|
166
|
-
inputs.forEach((el, idx) => {
|
|
167
|
-
el.setAttribute('name', 'mfaCode');
|
|
168
|
-
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
169
|
-
el.setAttribute('autocomplete', 'one-time-code');
|
|
170
|
-
el.setAttribute('inputmode', 'numeric');
|
|
171
|
-
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
116
|
const inProgress = ref(false);
|
|
176
117
|
|
|
177
118
|
const coreStore = useCoreStore();
|
|
@@ -187,24 +128,13 @@
|
|
|
187
128
|
cancelPendingWebAuthn('mount-init');
|
|
188
129
|
await nextTick();
|
|
189
130
|
await isCMAAvailable();
|
|
190
|
-
tagOtpInputs();
|
|
191
131
|
if (isPasskeysSupported.value === true) {
|
|
192
132
|
await checkIfUserHasPasskeys();
|
|
193
133
|
}
|
|
194
|
-
if (confirmationMode.value === 'code') {
|
|
195
|
-
await addEventListenerForOTPInput();
|
|
196
|
-
}
|
|
197
134
|
}
|
|
198
135
|
isLoading.value = false;
|
|
199
136
|
});
|
|
200
137
|
|
|
201
|
-
watch(confirmationMode, async (newMode) => {
|
|
202
|
-
if (newMode === 'code') {
|
|
203
|
-
await addEventListenerForOTPInput();
|
|
204
|
-
} else {
|
|
205
|
-
removeEventListenerForOTPInput();
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
138
|
watch(route, (newRoute) => {
|
|
209
139
|
codeError.value = null;
|
|
210
140
|
if ( newRoute.hash === '#passkey' ) {
|
|
@@ -217,24 +147,6 @@
|
|
|
217
147
|
}
|
|
218
148
|
});
|
|
219
149
|
|
|
220
|
-
async function addEventListenerForOTPInput(){
|
|
221
|
-
document.addEventListener('focusin', handleGlobalFocusIn, true);
|
|
222
|
-
focusFirstAvailableOtpInput();
|
|
223
|
-
isLoading.value = false;
|
|
224
|
-
await nextTick();
|
|
225
|
-
const rootEl = otpRoot.value;
|
|
226
|
-
rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function removeEventListenerForOTPInput() {
|
|
230
|
-
window.removeEventListener('paste', handlePaste);
|
|
231
|
-
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
232
|
-
const rootEl = otpRoot.value;
|
|
233
|
-
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
234
|
-
// Abort any in-flight WebAuthn request when leaving the component
|
|
235
|
-
cancelPendingWebAuthn('component-unmount');
|
|
236
|
-
}
|
|
237
|
-
|
|
238
150
|
async function isCMAAvailable() {
|
|
239
151
|
if (window.PublicKeyCredential &&
|
|
240
152
|
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
@@ -246,7 +158,8 @@
|
|
|
246
158
|
}
|
|
247
159
|
|
|
248
160
|
onBeforeUnmount(() => {
|
|
249
|
-
|
|
161
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
162
|
+
cancelPendingWebAuthn('component-unmount');
|
|
250
163
|
});
|
|
251
164
|
|
|
252
165
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
@@ -273,21 +186,11 @@
|
|
|
273
186
|
} else {
|
|
274
187
|
showErrorTost(t(resp.error));
|
|
275
188
|
codeError.value = resp.error || t('Invalid code');
|
|
189
|
+
codeInput.value!.clear();
|
|
276
190
|
}
|
|
277
191
|
}
|
|
278
192
|
}
|
|
279
193
|
|
|
280
|
-
function handlePaste(event) {
|
|
281
|
-
event.preventDefault();
|
|
282
|
-
if (event.target.classList.contains('otp-input')) {
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
const pastedText = event.clipboardData?.getData('text') || '';
|
|
286
|
-
if (pastedText.length === 6) {
|
|
287
|
-
code.value?.fillInput(pastedText);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
194
|
async function checkIfUserHasPasskeys() {
|
|
292
195
|
if(!route.meta.isPasskeysEnabled) {
|
|
293
196
|
return;
|
|
@@ -414,98 +317,4 @@
|
|
|
414
317
|
webAuthn.controller = null;
|
|
415
318
|
}
|
|
416
319
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
function getOtpInputs(): HTMLInputElement[] {
|
|
420
|
-
const root = otpRoot.value;
|
|
421
|
-
if (!root) return [];
|
|
422
|
-
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function focusFirstAvailableOtpInput() {
|
|
426
|
-
const inputs = getOtpInputs();
|
|
427
|
-
if (!inputs.length) return;
|
|
428
|
-
const firstEmpty = inputs.find((i) => !i.value);
|
|
429
|
-
(firstEmpty || inputs[0]).focus();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function handleGlobalFocusIn(event) {
|
|
433
|
-
const inputs = getOtpInputs();
|
|
434
|
-
if (!inputs.length) return;
|
|
435
|
-
const target = event.target as HTMLInputElement | null;
|
|
436
|
-
if (!target) return;
|
|
437
|
-
if (!inputs.includes(target)) {
|
|
438
|
-
requestAnimationFrame(() => {
|
|
439
|
-
focusFirstAvailableOtpInput();
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function handleFocusOut() {
|
|
445
|
-
requestAnimationFrame(() => {
|
|
446
|
-
const inputs = getOtpInputs();
|
|
447
|
-
if (!inputs.length) return;
|
|
448
|
-
const active = document.activeElement as Element | null;
|
|
449
|
-
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
450
|
-
if (!activeInput || !inputs.includes(activeInput)) {
|
|
451
|
-
focusFirstAvailableOtpInput();
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
320
|
</script>
|
|
456
|
-
|
|
457
|
-
<style scoped lang='scss'>
|
|
458
|
-
.vue3-2fa-code-input {
|
|
459
|
-
display: flex;
|
|
460
|
-
justify-content: center;
|
|
461
|
-
gap: 1rem;
|
|
462
|
-
margin-bottom: 1rem;
|
|
463
|
-
}
|
|
464
|
-
:deep(.otp-input-container) {
|
|
465
|
-
display: flex;
|
|
466
|
-
gap: 0.75rem;
|
|
467
|
-
}
|
|
468
|
-
.vue3-2fa-code-input-box {
|
|
469
|
-
&[type='text'] {
|
|
470
|
-
@apply w-10 h-10 text-center text-xl border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500;
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* This particular piece of code makes the last input have a gap in the middle.
|
|
478
|
-
*/
|
|
479
|
-
.spaced-code-input {
|
|
480
|
-
& .vue3-2fa-code-input-box {
|
|
481
|
-
&:nth-child(3) {
|
|
482
|
-
@apply mr-4;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
&:nth-child(4) {
|
|
486
|
-
@apply ml-4;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
:deep(.otp-input-container) {
|
|
492
|
-
display: flex;
|
|
493
|
-
gap: 0.75rem;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
.hover-link {
|
|
497
|
-
text-decoration: none !important;
|
|
498
|
-
display: inline-block;
|
|
499
|
-
width: fit-content;
|
|
500
|
-
margin: 0 auto;
|
|
501
|
-
|
|
502
|
-
&:hover {
|
|
503
|
-
text-decoration: underline !important;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
button.hover-link {
|
|
508
|
-
font-family: inherit;
|
|
509
|
-
font-size: inherit;
|
|
510
|
-
}
|
|
511
|
-
</style>
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="af-passkeys flex flex-col justify-center mr-6 md:mr-12">
|
|
3
3
|
<h2 class="af-passkeys-title flex items-start justify-start leading-none text-gray-800 dark:text-gray-50 text-3xl font-semibold">{{$t('Passkeys')}}</h2>
|
|
4
|
-
<p class="af-passkeys-subtitle text-sm mt-3"> {{$t('Manage your passwordless authentication methods')}} </p>
|
|
4
|
+
<p class="af-passkeys-subtitle text-sm mt-3 text-gray-500 dark:text-gray-400"> {{$t('Manage your passwordless authentication methods')}} </p>
|
|
5
5
|
|
|
6
6
|
<div class="af-passkeys-grid mt-6 flex flex-wrap gap-4">
|
|
7
7
|
<div
|
|
8
8
|
v-for="item in passkeys"
|
|
9
9
|
:key="item.id"
|
|
10
|
-
class="af-passkey-card flex flex-col w-full h-42 lg:w-72
|
|
10
|
+
class="af-passkey-card flex flex-col w-full h-42 lg:w-72 rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund p-4 shadow-sm dark:border-darkDialogBreakLine dark:bg-darkDialogBackgorund"
|
|
11
11
|
>
|
|
12
12
|
<div class="af-passkey-card-header flex items-center gap-3 mb-3">
|
|
13
|
-
<div class="af-passkey-card-icon w-10 h-10
|
|
13
|
+
<div class="af-passkey-card-icon flex w-10 h-10 shrink-0 items-center justify-center rounded-[10px] bg-gray-100 dark:bg-gray-800">
|
|
14
14
|
<img class="max-h-6 max-w-6" :src="coreStore.theme === 'light' ? item.light_icon : item.dark_icon" />
|
|
15
15
|
</div>
|
|
16
16
|
<div class="af-passkey-card-info flex-1 min-w-0">
|
|
17
|
-
<p class="af-passkey-card-name font-semibold text-
|
|
17
|
+
<p class="af-passkey-card-name font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText truncate" :title="item.name">{{ item.name }}</p>
|
|
18
18
|
<p class="af-passkey-card-type text-xs text-gray-500 dark:text-gray-400">{{$t('Synced passkey')}}</p>
|
|
19
19
|
</div>
|
|
20
|
-
<span class="af-passkey-card-badge shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-
|
|
20
|
+
<span class="af-passkey-card-badge shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-800 dark:bg-green-500/10 dark:text-green-300">
|
|
21
21
|
{{$t('Active')}}
|
|
22
22
|
</span>
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
25
|
<div class="af-passkey-card-last-used text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
26
|
-
<span class="af-passkey-card-last-used-label font-medium">{{$t('Last used')}}:</span>
|
|
26
|
+
<span class="af-passkey-card-last-used-label font-medium text-gray-700 dark:text-gray-300">{{$t('Last used')}}:</span>
|
|
27
27
|
<span v-if="item.last_used_at" class="af-passkey-card-last-used-date ml-1">{{ formatDateTime(item.last_used_at) }}</span>
|
|
28
28
|
<span v-else class="af-passkey-card-last-used-never ml-1 italic">{{$t('Never')}}</span>
|
|
29
29
|
</div>
|
|
@@ -38,13 +38,16 @@
|
|
|
38
38
|
header="Edit Passkey"
|
|
39
39
|
>
|
|
40
40
|
<template #trigger>
|
|
41
|
-
<
|
|
41
|
+
<Button
|
|
42
|
+
type="button"
|
|
43
|
+
variant="secondary"
|
|
44
|
+
:shadow="false"
|
|
42
45
|
@click="passkeysNewName = ''"
|
|
43
|
-
class="af-passkey-btn-rename w-full
|
|
46
|
+
class="af-passkey-btn-rename w-full gap-1.5 px-3 py-2 text-[13px]"
|
|
44
47
|
>
|
|
45
|
-
<
|
|
48
|
+
<IconPenOutline class="w-4 h-4" />
|
|
46
49
|
{{$t('Rename')}}
|
|
47
|
-
</
|
|
50
|
+
</Button>
|
|
48
51
|
</template>
|
|
49
52
|
<div class="flex flex-col gap-2">
|
|
50
53
|
<label class="text-sm font-medium">{{$t('Current name:')}}</label>
|
|
@@ -60,10 +63,11 @@
|
|
|
60
63
|
</Dialog>
|
|
61
64
|
|
|
62
65
|
<button
|
|
66
|
+
type="button"
|
|
63
67
|
@click="confirmAndDelete(item.id)"
|
|
64
|
-
class="af-passkey-btn-remove flex items-center justify-center gap-1.5 px-3 py-
|
|
68
|
+
class="af-passkey-btn-remove flex items-center justify-center gap-1.5 px-3 py-2 text-[13px] font-medium rounded-default border border-red-200 dark:border-red-500/30 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
65
69
|
>
|
|
66
|
-
<
|
|
70
|
+
<IconTrashBinOutline class="w-4 h-4" />
|
|
67
71
|
{{$t('Remove')}}
|
|
68
72
|
</button>
|
|
69
73
|
</div>
|
|
@@ -72,8 +76,8 @@
|
|
|
72
76
|
<template v-if="isInitialFinished">
|
|
73
77
|
<Tooltip v-if="!isPasskeySupported && (authenticatorAttachment === 'platform' || authenticatorAttachment === 'both')">
|
|
74
78
|
<div class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 w-full lg:w-72 min-h-36 opacity-50 pointer-events-none">
|
|
75
|
-
<div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-
|
|
76
|
-
<IconPlusOutline class="w-5 h-5
|
|
79
|
+
<div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
|
|
80
|
+
<IconPlusOutline class="w-5 h-5" />
|
|
77
81
|
</div>
|
|
78
82
|
<p class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add local passkey')}}</p>
|
|
79
83
|
<p class="af-passkey-add-card-subtitle text-xs text-gray-400 dark:text-gray-500 text-center">{{$t('Register this device')}}</p>
|
|
@@ -84,40 +88,44 @@
|
|
|
84
88
|
</template>
|
|
85
89
|
</Tooltip>
|
|
86
90
|
|
|
87
|
-
<
|
|
91
|
+
<button
|
|
88
92
|
v-else-if="authenticatorAttachment === 'platform' || authenticatorAttachment === 'both'"
|
|
93
|
+
type="button"
|
|
94
|
+
:disabled="isFetchingPasskey"
|
|
89
95
|
@click="addLocalPasskey"
|
|
90
|
-
class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-
|
|
96
|
+
class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-lightPrimary hover:bg-lightPrimary/5 dark:hover:border-darkPrimary dark:hover:bg-darkPrimary/10 transition-colors w-full lg:w-72 min-h-36"
|
|
91
97
|
:class="isFetchingPasskey ? 'opacity-50 pointer-events-none' : 'cursor-pointer'"
|
|
92
98
|
>
|
|
93
|
-
<
|
|
99
|
+
<span class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
|
|
94
100
|
<Spinner v-if="isFetchingPasskey && addPasskeyMode === 'platform'" class="w-5 h-5" />
|
|
95
|
-
<IconPlusOutline v-else class="w-5 h-5
|
|
96
|
-
</
|
|
97
|
-
<
|
|
98
|
-
<
|
|
99
|
-
</
|
|
101
|
+
<IconPlusOutline v-else class="w-5 h-5" />
|
|
102
|
+
</span>
|
|
103
|
+
<span class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add local passkey')}}</span>
|
|
104
|
+
<span class="af-passkey-add-card-subtitle text-xs text-gray-500 dark:text-gray-400 text-center">{{$t('Register this device')}}</span>
|
|
105
|
+
</button>
|
|
100
106
|
|
|
101
|
-
<
|
|
107
|
+
<button
|
|
102
108
|
v-if="authenticatorAttachment === 'cross-platform' || authenticatorAttachment === 'both'"
|
|
109
|
+
type="button"
|
|
110
|
+
:disabled="isFetchingPasskey"
|
|
103
111
|
@click="addCrossDevicePasskey"
|
|
104
|
-
class="af-passkey-add-card af-passkey-add-cross flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-
|
|
112
|
+
class="af-passkey-add-card af-passkey-add-cross flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-lightPrimary hover:bg-lightPrimary/5 dark:hover:border-darkPrimary dark:hover:bg-darkPrimary/10 transition-colors w-full lg:w-72 min-h-36"
|
|
105
113
|
:class="isFetchingPasskey ? 'opacity-50 pointer-events-none' : 'cursor-pointer'"
|
|
106
114
|
>
|
|
107
|
-
<
|
|
115
|
+
<span class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
|
|
108
116
|
<Spinner v-if="isFetchingPasskey && addPasskeyMode === 'cross-platform'" class="w-5 h-5" />
|
|
109
|
-
<IconPlusOutline v-else class="w-5 h-5
|
|
110
|
-
</
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
</
|
|
117
|
+
<IconPlusOutline v-else class="w-5 h-5" />
|
|
118
|
+
</span>
|
|
119
|
+
<span class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add cross-device')}}</span>
|
|
120
|
+
<span class="af-passkey-add-card-subtitle text-xs text-gray-500 dark:text-gray-400 text-center">{{$t('Use phone or security key')}}</span>
|
|
121
|
+
</button>
|
|
114
122
|
</template>
|
|
115
123
|
</div>
|
|
116
124
|
|
|
117
|
-
<
|
|
118
|
-
<IconInfoCircleOutline class="af-passkeys-info-icon w-
|
|
119
|
-
|
|
120
|
-
</
|
|
125
|
+
<TwoFaHint class="af-passkeys-info mt-6 w-full md:w-fit">
|
|
126
|
+
<template #icon><IconInfoCircleOutline class="af-passkeys-info-icon w-5 h-5" /></template>
|
|
127
|
+
{{$t('Passkeys are a more secure alternative to passwords. They use your device\'s biometrics or PIN and are resistant to phishing attacks.')}}
|
|
128
|
+
</TwoFaHint>
|
|
121
129
|
|
|
122
130
|
</div>
|
|
123
131
|
</template>
|
|
@@ -127,8 +135,9 @@
|
|
|
127
135
|
import adminforth, { useAdminforth } from '@/adminforth';
|
|
128
136
|
import { onMounted, ref, Ref } from 'vue';
|
|
129
137
|
import { useI18n } from 'vue-i18n';
|
|
130
|
-
import { Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
131
|
-
import {
|
|
138
|
+
import { Button, Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
139
|
+
import { IconTrashBinOutline, IconPenOutline, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
|
|
140
|
+
import TwoFaHint from './TwoFaHint.vue';
|
|
132
141
|
import { useCoreStore } from '@/stores/core';
|
|
133
142
|
import { useTwoFactorsAuth, type TwoFaConfirmationResult } from './use2faApi';
|
|
134
143
|
|