@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.
@@ -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
- <div class="relative w-full max-w-md">
12
-
13
- <div class="af-login-popup relative bg-white dark:bg-gray-700 rounded-lg shadow p-6" :class="codeError ? 'rounded-b-none' : ''">
14
-
15
- <div class="af-2fa-header flex flex-col items-center justify-center gap-3 mb-6">
16
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
17
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
18
- </div>
19
- <div class="af-2fa-title-wrap text-center">
20
- <p class="text-xl font-medium text-gray-900 dark:text-white">
21
- {{ confirmationMode === 'code' ? $t('Two-factor Auth') : $t('Passkey') }}
22
- </p>
23
- <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
24
- {{ confirmationMode === 'code'
25
- ? $t('Please enter your authenticator code')
26
- : $t('When you are ready, authenticate using the button below')
27
- }}
28
- </p>
29
- </div>
30
- </div>
31
-
32
- <div v-if="confirmationMode === 'code'" class="af-2fa-otp-root flex flex-col items-center gap-6" ref="otpRoot">
33
- <v-otp-input
34
- ref="code"
35
- container-class="grid grid-cols-6 gap-3"
36
- input-classes="bg-gray-50 text-center flex justify-center otp-input border 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:text-white"
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
- </div>
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, Link, Spinner } from '@/afcl';
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 { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
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 code = ref(null);
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
- removeEventListenerForOTPInput();
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 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm"
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 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg shrink-0">
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-gray-900 dark:text-white truncate" :title="item.name">{{ item.name }}</p>
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-100 text-green-800 dark:bg-green-900 dark:text-green-200">
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
- <button
41
+ <Button
42
+ type="button"
43
+ variant="secondary"
44
+ :shadow="false"
42
45
  @click="passkeysNewName = ''"
43
- class="af-passkey-btn-rename w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
46
+ class="af-passkey-btn-rename w-full gap-1.5 px-3 py-2 text-[13px]"
44
47
  >
45
- <IconPenSolid class="w-3.5 h-3.5" />
48
+ <IconPenOutline class="w-4 h-4" />
46
49
  {{$t('Rename')}}
47
- </button>
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-1.5 text-sm font-medium rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer"
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
- <IconTrashBinSolid class="w-3.5 h-3.5" />
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-gray-100 dark:bg-gray-700">
76
- <IconPlusOutline class="w-5 h-5 text-gray-500 dark:text-gray-400" />
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
- <div
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-blue-400 dark:hover:border-blue-500 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors w-full lg:w-72 min-h-36"
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
- <div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
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 text-gray-500 dark:text-gray-400" />
96
- </div>
97
- <p class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add local passkey')}}</p>
98
- <p class="af-passkey-add-card-subtitle text-xs text-gray-400 dark:text-gray-500 text-center">{{$t('Register this device')}}</p>
99
- </div>
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
- <div
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-purple-400 dark:hover:border-purple-500 hover:bg-purple-50/50 dark:hover:bg-purple-900/10 transition-colors w-full lg:w-72 min-h-36"
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
- <div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
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 text-gray-500 dark:text-gray-400" />
110
- </div>
111
- <p class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add cross-device')}}</p>
112
- <p class="af-passkey-add-card-subtitle text-xs text-gray-400 dark:text-gray-500 text-center">{{$t('Use phone or security key')}}</p>
113
- </div>
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
- <div class="af-passkeys-info mt-6 flex items-center gap-2 px-4 py-3 bg-blue-50 w-full md:w-fit dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-xl text-sm text-blue-700 dark:text-blue-300">
118
- <IconInfoCircleOutline class="af-passkeys-info-icon w-4 h-4 shrink-0" />
119
- <span class="af-passkeys-info-text">{{$t('Passkeys are a more secure alternative to passwords. They use your device\'s biometrics or PIN and are resistant to phishing attacks.')}}</span>
120
- </div>
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 { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
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