@adminforth/two-factors-auth 4.8.3 → 6.0.0
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 +2 -2
- package/custom/LoginWithPasskeyButton.vue +2 -2
- package/custom/TwoFAModal.vue +13 -18
- package/custom/TwoFaCard.vue +20 -10
- package/custom/TwoFaCodeInput.vue +18 -104
- package/custom/TwoFaHint.vue +2 -2
- package/custom/TwoFaLinkButton.vue +7 -2
- package/custom/TwoFactorsConfirmation.vue +6 -7
- package/custom/TwoFactorsPasskeysSettings.vue +37 -53
- package/custom/TwoFactorsSetup.vue +19 -24
- package/custom/package-lock.json +29 -26
- package/custom/package.json +1 -3
- package/custom/pnpm-lock.yaml +0 -20
- package/dist/custom/LoginWithPasskeyButton.vue +2 -2
- package/dist/custom/TwoFAModal.vue +13 -18
- package/dist/custom/TwoFaCard.vue +20 -10
- package/dist/custom/TwoFaCodeInput.vue +18 -104
- package/dist/custom/TwoFaHint.vue +2 -2
- package/dist/custom/TwoFaLinkButton.vue +7 -2
- package/dist/custom/TwoFactorsConfirmation.vue +6 -7
- package/dist/custom/TwoFactorsPasskeysSettings.vue +37 -53
- package/dist/custom/TwoFactorsSetup.vue +19 -24
- package/dist/custom/package-lock.json +29 -26
- package/dist/custom/package.json +1 -3
- package/dist/custom/pnpm-lock.yaml +0 -20
- package/package.json +2 -2
package/build.log
CHANGED
|
@@ -21,5 +21,5 @@ custom/tsconfig.json
|
|
|
21
21
|
custom/use2faApi.ts
|
|
22
22
|
custom/utils.js
|
|
23
23
|
|
|
24
|
-
sent
|
|
25
|
-
total size is
|
|
24
|
+
sent 301,180 bytes received 324 bytes 603,008.00 bytes/sec
|
|
25
|
+
total size is 299,946 speedup is 0.99
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
class="af-login-with-passkey-btn w-full gap-2"
|
|
6
6
|
@click="handleLoginWithPasskey"
|
|
7
7
|
>
|
|
8
|
-
<
|
|
8
|
+
<IconFingerprint class="w-5 h-5 text-lightPrimary dark:text-darkPrimary" />
|
|
9
9
|
{{ $t('Continue with Passkey') }}
|
|
10
10
|
</Button>
|
|
11
11
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
<script setup lang="ts">
|
|
17
|
-
import {
|
|
17
|
+
import { IconFingerprint } from '@iconify-prerendered/vue-lucide';
|
|
18
18
|
import { getPasskey } from "./utils.js";
|
|
19
19
|
import { callAdminForthApi } from '@/utils';
|
|
20
20
|
import { useUserStore } from '@/stores/user';
|
package/custom/TwoFAModal.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-
|
|
2
|
+
<div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-lightScrim dark:bg-darkScrim top-0 bottom-0 left-0 right-0 px-4"
|
|
3
3
|
v-if="modelShow && (isLoading === false)">
|
|
4
4
|
<TwoFaCard
|
|
5
5
|
:class="`af-two-factor-modal-${modalMode}`"
|
|
@@ -9,45 +9,45 @@
|
|
|
9
9
|
closable
|
|
10
10
|
@close="onCancel"
|
|
11
11
|
>
|
|
12
|
-
<pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-lg border border-lightDialogBreakLine bg-
|
|
12
|
+
<pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-lg border border-lightDialogBreakLine bg-lightSecondaryDarken p-3 text-xs text-lightInputText dark:border-darkDialogBreakLine dark:bg-darkSecondaryLighten dark:text-darkInputText">{{ dialogDetails }}</pre>
|
|
13
13
|
|
|
14
14
|
<div v-if="confirmItems.length" class="af-2fa-actions">
|
|
15
|
-
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-
|
|
16
|
-
<ul class="af-2fa-actions-list max-h-60 overflow-auto divide-y divide-lightDialogBreakLine rounded-lg border border-lightDialogBreakLine bg-
|
|
15
|
+
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-lightInputIconColor dark:text-darkInputIconColor">{{ $t('You are approving') }}</p>
|
|
16
|
+
<ul class="af-2fa-actions-list max-h-60 overflow-auto divide-y divide-lightDialogBreakLine rounded-lg border border-lightDialogBreakLine bg-lightSecondaryDarken dark:divide-darkDialogBreakLine dark:border-darkDialogBreakLine dark:bg-darkSecondaryLighten">
|
|
17
17
|
<li v-for="(item, i) in confirmItems" :key="i" class="flex gap-3 px-3.5 py-3">
|
|
18
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-base leading-6 break-words text-
|
|
19
|
+
<span class="min-w-0 text-base leading-6 break-words text-lightInputText dark:text-darkInputText">{{ item }}</span>
|
|
20
20
|
</li>
|
|
21
21
|
</ul>
|
|
22
22
|
</div>
|
|
23
23
|
|
|
24
24
|
<TwoFaCodeInput v-if="modalMode === 'totp'" ref="codeInput" :invalid="Boolean(codeError)" @complete="sendConfirmationResult" />
|
|
25
25
|
<TwoFaHint v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-hint">
|
|
26
|
-
<template #icon><
|
|
26
|
+
<template #icon><IconFingerprint class="w-5 h-5" /></template>
|
|
27
27
|
{{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}
|
|
28
28
|
</TwoFaHint>
|
|
29
|
-
<p v-else role="status" class="af-2fa-grace-notice flex items-start gap-2.5 rounded-lg border border-
|
|
30
|
-
<
|
|
29
|
+
<p v-else role="status" class="af-2fa-grace-notice flex items-start gap-2.5 rounded-lg border border-lightSuccessBorder bg-lightSuccessSoft px-3.5 py-3 text-sm leading-5 text-lightSuccess dark:border-darkSuccessBorder dark:bg-darkSuccessSoft dark:text-darkSuccess">
|
|
30
|
+
<IconCircleCheck class="w-5 h-5 shrink-0" />
|
|
31
31
|
{{ $t('You verified recently, so no code is needed.') }}
|
|
32
32
|
</p>
|
|
33
33
|
|
|
34
34
|
<template #footer>
|
|
35
35
|
<TwoFaLinkButton v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-switch-to-passkey" @click="modalMode = 'passkey'">
|
|
36
|
-
<
|
|
36
|
+
<IconFingerprint class="w-4 h-4" />
|
|
37
37
|
{{ $t('Use passkey instead') }}
|
|
38
38
|
</TwoFaLinkButton>
|
|
39
39
|
<TwoFaLinkButton v-else-if="modalMode === 'passkey'" class="af-2fa-switch-to-totp" @click="modalMode = 'totp'">
|
|
40
|
-
<
|
|
40
|
+
<IconSmartphone class="w-4 h-4" />
|
|
41
41
|
{{ $t('Use code instead') }}
|
|
42
42
|
</TwoFaLinkButton>
|
|
43
43
|
<div class="af-2fa-buttons ms-auto flex gap-2 whitespace-nowrap">
|
|
44
44
|
<Button variant="secondary" class="af-2fa-cancel-btn" @click="onCancel">{{ $t('Cancel') }}</Button>
|
|
45
45
|
<Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
|
|
46
|
-
<
|
|
46
|
+
<IconFingerprint class="w-4 h-4" />
|
|
47
47
|
{{ $t('Use passkey') }}
|
|
48
48
|
</Button>
|
|
49
49
|
<Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn gap-2" @click="approveInGracePeriod">
|
|
50
|
-
<
|
|
50
|
+
<IconCheck class="w-4 h-4" />
|
|
51
51
|
{{ approveLabel }}
|
|
52
52
|
</Button>
|
|
53
53
|
</div>
|
|
@@ -63,12 +63,7 @@
|
|
|
63
63
|
import { useI18n } from 'vue-i18n';
|
|
64
64
|
import { callAdminForthApi } from '@/utils';
|
|
65
65
|
import { Link, Button } from '@/afcl';
|
|
66
|
-
import {
|
|
67
|
-
IconCheckCircleOutline,
|
|
68
|
-
IconCheckOutline,
|
|
69
|
-
IconFingerprintOutline,
|
|
70
|
-
IconMobilePhoneOutline,
|
|
71
|
-
} from '@iconify-prerendered/vue-flowbite';
|
|
66
|
+
import { IconCheck, IconCircleCheck, IconFingerprint, IconSmartphone } from '@iconify-prerendered/vue-lucide';
|
|
72
67
|
import { getPasskey } from './utils.js'
|
|
73
68
|
import TwoFaCard from './TwoFaCard.vue';
|
|
74
69
|
import TwoFaCodeInput from './TwoFaCodeInput.vue';
|
package/custom/TwoFaCard.vue
CHANGED
|
@@ -1,34 +1,44 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="af-2fa-card relative flex flex-col w-full max-w-md overflow-hidden rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund shadow-
|
|
2
|
+
<div class="af-2fa-card relative flex flex-col w-full max-w-md overflow-hidden rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund shadow-popover dark:border-darkDialogBreakLine dark:bg-darkDialogBackgorund dark:shadow-darkPopover">
|
|
3
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
4
|
<div class="af-2fa-icon-wrap flex w-10 h-10 shrink-0 items-center justify-center rounded-lg bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
|
|
5
|
-
<
|
|
5
|
+
<IconShieldCheck class="af-2fa-shield-icon w-5 h-5" />
|
|
6
6
|
</div>
|
|
7
7
|
<div class="af-2fa-title-wrap flex-1 min-w-0">
|
|
8
8
|
<p class="af-2fa-title break-words text-lg leading-6 font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ title }}</p>
|
|
9
|
-
<p class="af-2fa-subtitle mt-0.5 text-sm leading-5 text-
|
|
9
|
+
<p class="af-2fa-subtitle mt-0.5 text-sm leading-5 text-lightInputIconColor dark:text-darkInputIconColor">{{ subtitle }}</p>
|
|
10
10
|
</div>
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
<Button
|
|
12
|
+
v-if="closable"
|
|
13
|
+
type="button"
|
|
14
|
+
variant="ghost"
|
|
15
|
+
size="sm"
|
|
16
|
+
iconOnly
|
|
17
|
+
:aria-label="$t('Cancel')"
|
|
18
|
+
class="af-2fa-close-btn -mt-0.5 text-lightDialogCloseButton hover:text-lightDialogCloseButtonHover hover:bg-lightDialogCloseButtonHoverBackground dark:text-darkDialogCloseButton dark:hover:text-darkDialogCloseButtonHover dark:hover:bg-darkDialogCloseButtonHoverBackground"
|
|
19
|
+
@click="emit('close')"
|
|
20
|
+
>
|
|
21
|
+
<IconX aria-hidden="true" />
|
|
22
|
+
</Button>
|
|
14
23
|
</div>
|
|
15
24
|
|
|
16
25
|
<div class="af-2fa-body flex flex-col gap-5 px-6 pt-5 pb-6">
|
|
17
26
|
<slot />
|
|
18
|
-
<p v-if="error" role="alert" class="af-2fa-code-error -mt-2.5 flex items-center gap-1.5 text-sm text-
|
|
19
|
-
<
|
|
27
|
+
<p v-if="error" role="alert" class="af-2fa-code-error -mt-2.5 flex items-center gap-1.5 text-sm text-lightDanger dark:text-darkDanger">
|
|
28
|
+
<IconCircleAlert class="w-4 h-4 shrink-0" />
|
|
20
29
|
{{ error }}
|
|
21
30
|
</p>
|
|
22
31
|
</div>
|
|
23
32
|
|
|
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-
|
|
33
|
+
<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-lightFormHeading py-3 pl-6 pr-5 dark:border-darkDialogBreakLine dark:bg-darkFormHeading">
|
|
25
34
|
<slot name="footer" />
|
|
26
35
|
</div>
|
|
27
36
|
</div>
|
|
28
37
|
</template>
|
|
29
38
|
|
|
30
39
|
<script setup lang="ts">
|
|
31
|
-
import {
|
|
40
|
+
import { IconCircleAlert, IconShieldCheck, IconX } from '@iconify-prerendered/vue-lucide';
|
|
41
|
+
import { Button } from '@/afcl';
|
|
32
42
|
|
|
33
43
|
defineProps<{
|
|
34
44
|
title: string;
|
|
@@ -2,29 +2,31 @@
|
|
|
2
2
|
<div class="af-2fa-code">
|
|
3
3
|
<div :id="LABEL_ID">
|
|
4
4
|
<slot name="label">
|
|
5
|
-
<p class="mb-2 text-sm font-medium text-
|
|
5
|
+
<p class="mb-2 text-sm font-medium text-lightInputText dark:text-darkInputText">{{ $t('Authenticator code') }}</p>
|
|
6
6
|
</slot>
|
|
7
7
|
</div>
|
|
8
|
-
<div
|
|
9
|
-
<
|
|
10
|
-
ref="
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
:
|
|
16
|
-
:
|
|
17
|
-
|
|
8
|
+
<div class="af-2fa-otp-root flex justify-center">
|
|
9
|
+
<CodeInput
|
|
10
|
+
ref="codeInput"
|
|
11
|
+
id="mfaCode"
|
|
12
|
+
name="mfaCode"
|
|
13
|
+
:aria-labelledby="LABEL_ID"
|
|
14
|
+
:length="CODE_LENGTH"
|
|
15
|
+
:groupSize="3"
|
|
16
|
+
:invalid="invalid"
|
|
17
|
+
autofocus
|
|
18
|
+
keepFocus
|
|
19
|
+
@complete="emit('complete', $event)"
|
|
18
20
|
/>
|
|
19
21
|
</div>
|
|
20
22
|
</div>
|
|
21
23
|
</template>
|
|
22
24
|
|
|
23
25
|
<script setup lang="ts">
|
|
24
|
-
import
|
|
25
|
-
import {
|
|
26
|
+
import { ref } from 'vue';
|
|
27
|
+
import { CodeInput } from '@/afcl';
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
defineProps<{
|
|
28
30
|
invalid?: boolean;
|
|
29
31
|
}>();
|
|
30
32
|
|
|
@@ -34,98 +36,10 @@ const emit = defineEmits<{
|
|
|
34
36
|
|
|
35
37
|
const CODE_LENGTH = 6;
|
|
36
38
|
const LABEL_ID = 'mfaCode-label';
|
|
37
|
-
const INPUT_CLASS = 'otp-input';
|
|
38
39
|
|
|
39
|
-
const
|
|
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-lg border-2 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
|
-
});
|
|
40
|
+
const codeInput = ref<InstanceType<typeof CodeInput> | null>(null);
|
|
108
41
|
|
|
109
42
|
defineExpose({
|
|
110
|
-
clear: () =>
|
|
43
|
+
clear: () => codeInput.value!.clear(),
|
|
111
44
|
});
|
|
112
45
|
</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>
|
package/custom/TwoFaHint.vue
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="af-2fa-hint flex items-center gap-3.5 rounded-lg border border-lightDialogBreakLine bg-
|
|
2
|
+
<div class="af-2fa-hint flex items-center gap-3.5 rounded-lg border border-lightDialogBreakLine bg-lightSecondaryDarken px-4 py-3.5 dark:border-darkDialogBreakLine dark:bg-darkSecondaryLighten">
|
|
3
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
4
|
<slot name="icon" />
|
|
5
5
|
</span>
|
|
6
|
-
<p class="af-2fa-hint-text text-sm leading-5 text-
|
|
6
|
+
<p class="af-2fa-hint-text text-sm leading-5 text-lightInputIconColor dark:text-darkInputIconColor">
|
|
7
7
|
<slot />
|
|
8
8
|
</p>
|
|
9
9
|
</div>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
2
|
+
<!-- the negative margin lines the label up with the card content -->
|
|
3
|
+
<Button type="button" variant="ghost" class="af-2fa-link-btn -ms-3.5">
|
|
3
4
|
<slot />
|
|
4
|
-
</
|
|
5
|
+
</Button>
|
|
5
6
|
</template>
|
|
7
|
+
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { Button } from '@/afcl';
|
|
10
|
+
</script>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="relative flex items-center justify-center min-h-screen bg-
|
|
2
|
+
<div class="relative flex items-center justify-center min-h-screen bg-lightHtml dark:bg-darkHtml"
|
|
3
3
|
:style="(coreStore.config?.loginBackgroundImage && coreStore.config?.loginBackgroundPosition === 'over') ? {
|
|
4
4
|
'background-image': 'url(' + loadFile(coreStore.config?.loginBackgroundImage) + ')',
|
|
5
5
|
'background-size': 'cover',
|
|
@@ -16,23 +16,23 @@
|
|
|
16
16
|
>
|
|
17
17
|
<TwoFaCodeInput v-if="confirmationMode === 'code'" ref="codeInput" :invalid="Boolean(codeError)" @complete="handleOnComplete" />
|
|
18
18
|
<TwoFaHint v-else class="af-2fa-passkey-hint">
|
|
19
|
-
<template #icon><
|
|
19
|
+
<template #icon><IconFingerprint class="w-5 h-5" /></template>
|
|
20
20
|
{{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}
|
|
21
21
|
</TwoFaHint>
|
|
22
22
|
|
|
23
23
|
<template #footer>
|
|
24
24
|
<TwoFaLinkButton v-if="confirmationMode === 'code' && doesUserHavePasskeys" class="af-2fa-switch-to-passkey" @click="router.push({ hash: '#passkey' })">
|
|
25
|
-
<
|
|
25
|
+
<IconFingerprint class="w-4 h-4" />
|
|
26
26
|
{{ $t('Use passkey instead') }}
|
|
27
27
|
</TwoFaLinkButton>
|
|
28
28
|
<TwoFaLinkButton v-else-if="confirmationMode === 'passkey'" class="af-2fa-switch-to-totp" @click="router.push({ hash: '#code' })">
|
|
29
|
-
<
|
|
29
|
+
<IconSmartphone class="w-4 h-4" />
|
|
30
30
|
{{ $t('Use code instead') }}
|
|
31
31
|
</TwoFaLinkButton>
|
|
32
32
|
<div class="af-2fa-buttons ms-auto flex gap-2 whitespace-nowrap">
|
|
33
33
|
<LinkButton to="/login" variant="secondary" class="af-2fa-back-to-login">{{ $t('Back to login') }}</LinkButton>
|
|
34
34
|
<Button v-if="confirmationMode === 'passkey'" @click="usePasskeyButton" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
|
|
35
|
-
<
|
|
35
|
+
<IconFingerprint class="w-4 h-4" />
|
|
36
36
|
{{ $t('Use passkey') }}
|
|
37
37
|
</Button>
|
|
38
38
|
</div>
|
|
@@ -57,8 +57,7 @@
|
|
|
57
57
|
import { useI18n } from 'vue-i18n';
|
|
58
58
|
import { useRoute } from 'vue-router'
|
|
59
59
|
import { useRouter } from 'vue-router';
|
|
60
|
-
import {
|
|
61
|
-
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
60
|
+
import { IconFingerprint, IconSmartphone } from '@iconify-prerendered/vue-lucide';
|
|
62
61
|
import { handlePasskeyAlert } from './utils.js';
|
|
63
62
|
import TwoFaCard from './TwoFaCard.vue';
|
|
64
63
|
import TwoFaCodeInput from './TwoFaCodeInput.vue';
|
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="af-passkeys flex flex-col justify-center mr-6 md:mr-12">
|
|
3
|
-
<h2 class="af-passkeys-title
|
|
4
|
-
<p class="af-passkeys-subtitle text-sm
|
|
3
|
+
<h2 class="af-passkeys-title text-lg font-semibold tracking-tight text-lightCardTitle dark:text-darkCardTitle">{{$t('Passkeys')}}</h2>
|
|
4
|
+
<p class="af-passkeys-subtitle mt-1 text-sm text-lightCardDescription dark:text-darkCardDescription"> {{$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
|
+
<Card
|
|
8
8
|
v-for="item in passkeys"
|
|
9
9
|
:key="item.id"
|
|
10
|
-
|
|
10
|
+
size="md"
|
|
11
|
+
class="af-passkey-card flex flex-col w-full h-42 lg:w-72"
|
|
11
12
|
>
|
|
12
13
|
<div class="af-passkey-card-header flex items-center gap-3 mb-3">
|
|
13
|
-
<div class="af-passkey-card-icon flex w-10 h-10 shrink-0 items-center justify-center rounded-lg bg-
|
|
14
|
+
<div class="af-passkey-card-icon flex w-10 h-10 shrink-0 items-center justify-center rounded-lg bg-lightSecondaryDarken dark:bg-darkSecondaryLighten">
|
|
14
15
|
<img class="max-h-6 max-w-6" :src="coreStore.theme === 'light' ? item.light_icon : item.dark_icon" />
|
|
15
16
|
</div>
|
|
16
17
|
<div class="af-passkey-card-info flex-1 min-w-0">
|
|
17
18
|
<p class="af-passkey-card-name font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText truncate" :title="item.name">{{ item.name }}</p>
|
|
18
|
-
<p class="af-passkey-card-type text-xs text-
|
|
19
|
+
<p class="af-passkey-card-type text-xs text-lightInputIconColor dark:text-darkInputIconColor">{{$t('Synced passkey')}}</p>
|
|
19
20
|
</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-
|
|
21
|
+
<span class="af-passkey-card-badge shrink-0 inline-flex items-center px-2 py-0.5 rounded-full border text-xs font-medium border-lightSuccessBorder bg-lightSuccessSoft text-lightSuccess dark:border-darkSuccessBorder dark:bg-darkSuccessSoft dark:text-darkSuccess">
|
|
21
22
|
{{$t('Active')}}
|
|
22
23
|
</span>
|
|
23
24
|
</div>
|
|
24
25
|
|
|
25
|
-
<div class="af-passkey-card-last-used text-xs text-
|
|
26
|
-
<span class="af-passkey-card-last-used-label font-medium text-
|
|
26
|
+
<div class="af-passkey-card-last-used text-xs text-lightInputIconColor dark:text-darkInputIconColor mb-4">
|
|
27
|
+
<span class="af-passkey-card-last-used-label font-medium text-lightInputText dark:text-darkInputText">{{$t('Last used')}}:</span>
|
|
27
28
|
<span v-if="item.last_used_at" class="af-passkey-card-last-used-date ml-1">{{ formatDateTime(item.last_used_at) }}</span>
|
|
28
29
|
<span v-else class="af-passkey-card-last-used-never ml-1 italic">{{$t('Never')}}</span>
|
|
29
30
|
</div>
|
|
@@ -32,26 +33,25 @@
|
|
|
32
33
|
<Dialog
|
|
33
34
|
class="edit-passkey-confirmation-dialog w-96"
|
|
34
35
|
:buttons="[
|
|
35
|
-
{ label: 'Cancel', options: { class: 'dialog-cancel-button w-full' }, onclick: (dialog) => { passkeysNewName = ''; dialog.hide(); } },
|
|
36
|
-
{ label: 'Save', options: { class: 'dialog-save-button w-full' }, onclick: (dialog) => { renamePasskey(item.id, passkeysNewName); dialog.hide(); } },
|
|
36
|
+
{ label: t('Cancel'), options: { class: 'dialog-cancel-button w-full', variant: 'secondary' }, onclick: (dialog) => { passkeysNewName = ''; dialog.hide(); } },
|
|
37
|
+
{ label: t('Save'), options: { class: 'dialog-save-button w-full' }, onclick: (dialog) => { renamePasskey(item.id, passkeysNewName); dialog.hide(); } },
|
|
37
38
|
]"
|
|
38
|
-
header="Edit Passkey"
|
|
39
|
+
:header="t('Edit Passkey')"
|
|
39
40
|
>
|
|
40
41
|
<template #trigger>
|
|
41
42
|
<Button
|
|
42
43
|
type="button"
|
|
43
44
|
variant="secondary"
|
|
44
|
-
:shadow="false"
|
|
45
45
|
@click="passkeysNewName = ''"
|
|
46
|
-
class="af-passkey-btn-rename w-full
|
|
46
|
+
class="af-passkey-btn-rename w-full"
|
|
47
47
|
>
|
|
48
|
-
<
|
|
48
|
+
<IconPencil class="w-4 h-4" />
|
|
49
49
|
{{$t('Rename')}}
|
|
50
50
|
</Button>
|
|
51
51
|
</template>
|
|
52
52
|
<div class="flex flex-col gap-2">
|
|
53
53
|
<label class="text-sm font-medium">{{$t('Current name:')}}</label>
|
|
54
|
-
<p class="font-medium text-sm text-
|
|
54
|
+
<p class="font-medium text-sm text-lightInputText dark:text-darkInputText pb-2 truncate" :title="item.name">{{ item.name }}</p>
|
|
55
55
|
<label class="text-sm font-medium">{{$t('Enter new passkey name:')}}</label>
|
|
56
56
|
<Input
|
|
57
57
|
v-model="passkeysNewName"
|
|
@@ -62,25 +62,26 @@
|
|
|
62
62
|
</div>
|
|
63
63
|
</Dialog>
|
|
64
64
|
|
|
65
|
-
<
|
|
65
|
+
<Button
|
|
66
66
|
type="button"
|
|
67
|
+
variant="danger-soft"
|
|
67
68
|
@click="confirmAndDelete(item.id)"
|
|
68
|
-
class="af-passkey-btn-remove
|
|
69
|
+
class="af-passkey-btn-remove w-full"
|
|
69
70
|
>
|
|
70
|
-
<
|
|
71
|
+
<IconTrash2 class="w-4 h-4" />
|
|
71
72
|
{{$t('Remove')}}
|
|
72
|
-
</
|
|
73
|
+
</Button>
|
|
73
74
|
</div>
|
|
74
|
-
</
|
|
75
|
+
</Card>
|
|
75
76
|
|
|
76
77
|
<template v-if="isInitialFinished">
|
|
77
78
|
<Tooltip v-if="!isPasskeySupported && (authenticatorAttachment === 'platform' || authenticatorAttachment === 'both')">
|
|
78
|
-
<div class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-
|
|
79
|
+
<div class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-lg border border-dashed border-lightInputBorder dark:border-darkInputBorder w-full lg:w-72 min-h-36 opacity-50 cursor-not-allowed">
|
|
79
80
|
<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
|
-
<
|
|
81
|
+
<IconPlus class="w-5 h-5" />
|
|
81
82
|
</div>
|
|
82
|
-
<p class="af-passkey-add-card-title font-medium text-
|
|
83
|
-
<p class="af-passkey-add-card-subtitle text-xs text-
|
|
83
|
+
<p class="af-passkey-add-card-title font-medium text-lightInputText dark:text-darkInputText text-sm">{{$t('Add local passkey')}}</p>
|
|
84
|
+
<p class="af-passkey-add-card-subtitle text-xs text-lightInputIconColor dark:text-darkInputIconColor text-center">{{$t('Register this device')}}</p>
|
|
84
85
|
</div>
|
|
85
86
|
<template #tooltip>
|
|
86
87
|
<p class="max-w-64">{{$t('This browser or device is reporting partial passkey support.')}}</p>
|
|
@@ -93,15 +94,14 @@
|
|
|
93
94
|
type="button"
|
|
94
95
|
:disabled="isFetchingPasskey"
|
|
95
96
|
@click="addLocalPasskey"
|
|
96
|
-
class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-
|
|
97
|
-
:class="isFetchingPasskey ? 'opacity-50 pointer-events-none' : 'cursor-pointer'"
|
|
97
|
+
class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-lg border border-dashed border-lightInputBorder dark:border-darkInputBorder enabled:hover:border-lightPrimary enabled:hover:bg-lightPrimary/5 dark:enabled:hover:border-darkPrimary dark:enabled:hover:bg-darkPrimary/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors w-full lg:w-72 min-h-36"
|
|
98
98
|
>
|
|
99
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">
|
|
100
100
|
<Spinner v-if="isFetchingPasskey && addPasskeyMode === 'platform'" class="w-5 h-5" />
|
|
101
|
-
<
|
|
101
|
+
<IconPlus v-else class="w-5 h-5" />
|
|
102
102
|
</span>
|
|
103
|
-
<span class="af-passkey-add-card-title font-medium text-
|
|
104
|
-
<span class="af-passkey-add-card-subtitle text-xs text-
|
|
103
|
+
<span class="af-passkey-add-card-title font-medium text-lightInputText dark:text-darkInputText text-sm">{{$t('Add local passkey')}}</span>
|
|
104
|
+
<span class="af-passkey-add-card-subtitle text-xs text-lightInputIconColor dark:text-darkInputIconColor text-center">{{$t('Register this device')}}</span>
|
|
105
105
|
</button>
|
|
106
106
|
|
|
107
107
|
<button
|
|
@@ -109,21 +109,20 @@
|
|
|
109
109
|
type="button"
|
|
110
110
|
:disabled="isFetchingPasskey"
|
|
111
111
|
@click="addCrossDevicePasskey"
|
|
112
|
-
class="af-passkey-add-card af-passkey-add-cross flex flex-col items-center justify-center gap-2 p-4 rounded-
|
|
113
|
-
:class="isFetchingPasskey ? 'opacity-50 pointer-events-none' : 'cursor-pointer'"
|
|
112
|
+
class="af-passkey-add-card af-passkey-add-cross flex flex-col items-center justify-center gap-2 p-4 rounded-lg border border-dashed border-lightInputBorder dark:border-darkInputBorder enabled:hover:border-lightPrimary enabled:hover:bg-lightPrimary/5 dark:enabled:hover:border-darkPrimary dark:enabled:hover:bg-darkPrimary/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors w-full lg:w-72 min-h-36"
|
|
114
113
|
>
|
|
115
114
|
<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">
|
|
116
115
|
<Spinner v-if="isFetchingPasskey && addPasskeyMode === 'cross-platform'" class="w-5 h-5" />
|
|
117
|
-
<
|
|
116
|
+
<IconPlus v-else class="w-5 h-5" />
|
|
118
117
|
</span>
|
|
119
|
-
<span class="af-passkey-add-card-title font-medium text-
|
|
120
|
-
<span class="af-passkey-add-card-subtitle text-xs text-
|
|
118
|
+
<span class="af-passkey-add-card-title font-medium text-lightInputText dark:text-darkInputText text-sm">{{$t('Add cross-device')}}</span>
|
|
119
|
+
<span class="af-passkey-add-card-subtitle text-xs text-lightInputIconColor dark:text-darkInputIconColor text-center">{{$t('Use phone or security key')}}</span>
|
|
121
120
|
</button>
|
|
122
121
|
</template>
|
|
123
122
|
</div>
|
|
124
123
|
|
|
125
124
|
<TwoFaHint class="af-passkeys-info mt-6 w-full md:w-fit">
|
|
126
|
-
<template #icon><
|
|
125
|
+
<template #icon><IconInfo class="af-passkeys-info-icon w-5 h-5" /></template>
|
|
127
126
|
{{$t('Passkeys are a more secure alternative to passwords. They use your device\'s biometrics or PIN and are resistant to phishing attacks.')}}
|
|
128
127
|
</TwoFaHint>
|
|
129
128
|
|
|
@@ -135,8 +134,8 @@
|
|
|
135
134
|
import adminforth, { useAdminforth } from '@/adminforth';
|
|
136
135
|
import { onMounted, ref, Ref } from 'vue';
|
|
137
136
|
import { useI18n } from 'vue-i18n';
|
|
138
|
-
import { Button, Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
139
|
-
import {
|
|
137
|
+
import { Button, Card, Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
138
|
+
import { IconTrash2, IconPencil, IconPlus, IconInfo } from '@iconify-prerendered/vue-lucide';
|
|
140
139
|
import TwoFaHint from './TwoFaHint.vue';
|
|
141
140
|
import { useCoreStore } from '@/stores/core';
|
|
142
141
|
import { useTwoFactorsAuth, type TwoFaConfirmationResult } from './use2faApi';
|
|
@@ -356,18 +355,3 @@
|
|
|
356
355
|
}
|
|
357
356
|
}
|
|
358
357
|
</script>
|
|
359
|
-
|
|
360
|
-
<style>
|
|
361
|
-
/* Scoped styles for dialog buttons, because there is no different way to style them */
|
|
362
|
-
.dialog-delete-button.dialog-delete-button {
|
|
363
|
-
background-color: rgb(179, 27, 27);
|
|
364
|
-
color: white;
|
|
365
|
-
border: 1px solid rgb(153, 27, 27);
|
|
366
|
-
}
|
|
367
|
-
.dialog-delete-button.dialog-delete-button:hover { background-color: rgb(153, 27, 27); }
|
|
368
|
-
.dialog-delete-button.dialog-delete-button:active { background-color: rgb(127, 20, 20); }
|
|
369
|
-
|
|
370
|
-
.dark .dialog-delete-button.dialog-delete-button { background-color: rgb(220, 38, 38); color: white; }
|
|
371
|
-
.dark .dialog-delete-button.dialog-delete-button:hover { background-color: rgb(185, 28, 28); }
|
|
372
|
-
.dark .dialog-delete-button.dialog-delete-button:active { background-color: rgb(153, 27, 27); }
|
|
373
|
-
</style>
|