@adminforth/two-factors-auth 5.0.0 → 7.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 +4 -3
- package/custom/TwoFAModal.vue +14 -17
- package/custom/TwoFaCard.vue +28 -26
- 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 +4 -3
- package/dist/custom/TwoFAModal.vue +14 -17
- package/dist/custom/TwoFaCard.vue +28 -26
- 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
|
@@ -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>
|
|
@@ -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>
|
|
@@ -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,37 +16,38 @@
|
|
|
16
16
|
:error="codeError"
|
|
17
17
|
>
|
|
18
18
|
<div>
|
|
19
|
-
<p class="mb-3 flex items-center gap-2.5 text-sm font-medium text-
|
|
19
|
+
<p class="mb-3 flex items-center gap-2.5 text-sm font-medium text-lightInputText dark:text-darkInputText">
|
|
20
20
|
<span class="flex w-6 h-6 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-xs font-semibold text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">1</span>
|
|
21
21
|
{{ $t('Scan the QR code with your authenticator app') }}
|
|
22
22
|
</p>
|
|
23
|
-
<div class="flex items-center gap-4 rounded-lg border border-lightDialogBreakLine bg-
|
|
23
|
+
<div class="flex items-center gap-4 rounded-lg border border-lightDialogBreakLine bg-lightSecondaryDarken p-3.5 dark:border-darkDialogBreakLine dark:bg-darkSecondaryLighten">
|
|
24
|
+
<!-- a QR code needs a white ground to be scanned, in the dark theme too -->
|
|
24
25
|
<div class="w-32 h-32 shrink-0 rounded-lg bg-white p-2">
|
|
25
26
|
<img v-if="totpQrCode" :src="totpQrCode" class="af-qr-code w-full h-full" alt="QR code" />
|
|
26
27
|
</div>
|
|
27
28
|
<div class="flex min-w-0 flex-col gap-2">
|
|
28
|
-
<p class="text-sm leading-5 text-
|
|
29
|
-
<
|
|
29
|
+
<p class="text-sm leading-5 text-lightInputIconColor dark:text-darkInputIconColor">{{ $t('Setting up on this device?') }}</p>
|
|
30
|
+
<Link :to="totpUri" class="inline-flex items-center gap-1.5 text-sm">
|
|
30
31
|
{{ $t('Open in authenticator app') }}
|
|
31
|
-
<
|
|
32
|
-
</
|
|
32
|
+
<IconExternalLink class="w-4 h-4" />
|
|
33
|
+
</Link>
|
|
33
34
|
</div>
|
|
34
35
|
</div>
|
|
35
36
|
</div>
|
|
36
37
|
|
|
37
38
|
<div>
|
|
38
|
-
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-
|
|
39
|
-
<div class="flex items-center gap-2 rounded-lg border border-lightDialogBreakLine bg-
|
|
40
|
-
<span class="af-2fa-secret flex-1 min-w-0 select-all break-all font-mono text-sm tracking-wider text-
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
</
|
|
39
|
+
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-lightInputIconColor dark:text-darkInputIconColor">{{ $t('Or enter the key manually') }}</p>
|
|
40
|
+
<div class="flex items-center gap-2 rounded-lg border border-lightDialogBreakLine bg-lightSecondaryDarken py-1.5 pl-3.5 pr-1.5 dark:border-darkDialogBreakLine dark:bg-darkSecondaryLighten">
|
|
41
|
+
<span class="af-2fa-secret flex-1 min-w-0 select-all break-all font-mono text-sm tracking-wider text-lightInputText dark:text-darkInputText">{{ totp.newSecret }}</span>
|
|
42
|
+
<Button type="button" variant="ghost" iconOnly :aria-label="$t('Copy key')" class="af-2fa-copy-btn" @click="onCopyClick">
|
|
43
|
+
<IconCopy aria-hidden="true" />
|
|
44
|
+
</Button>
|
|
44
45
|
</div>
|
|
45
46
|
</div>
|
|
46
47
|
|
|
47
48
|
<TwoFaCodeInput ref="codeInput" :invalid="Boolean(codeError)" @complete="handleOnComplete">
|
|
48
49
|
<template #label>
|
|
49
|
-
<p class="mb-3 flex items-center gap-2.5 text-sm font-medium text-
|
|
50
|
+
<p class="mb-3 flex items-center gap-2.5 text-sm font-medium text-lightInputText dark:text-darkInputText">
|
|
50
51
|
<span class="flex w-6 h-6 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-xs font-semibold text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">2</span>
|
|
51
52
|
{{ $t('Enter the 6-digit code from the app') }}
|
|
52
53
|
</p>
|
|
@@ -54,9 +55,9 @@
|
|
|
54
55
|
</TwoFaCodeInput>
|
|
55
56
|
|
|
56
57
|
<template #footer>
|
|
57
|
-
<
|
|
58
|
+
<Button v-if="skipAllowed" type="button" variant="ghost" class="af-2fa-skip-btn -ms-3.5" @click="handleSkip">
|
|
58
59
|
{{ $t('Skip for now') }}
|
|
59
|
-
</
|
|
60
|
+
</Button>
|
|
60
61
|
<LinkButton to="/login" variant="secondary" class="af-2fa-back-to-login ms-auto whitespace-nowrap">{{ $t('Back to login') }}</LinkButton>
|
|
61
62
|
</template>
|
|
62
63
|
</TwoFaCard>
|
|
@@ -70,19 +71,16 @@
|
|
|
70
71
|
import { onMounted, ref, computed, onBeforeMount } from 'vue';
|
|
71
72
|
import { useCoreStore } from '@/stores/core';
|
|
72
73
|
import { useUserStore } from '@/stores/user';
|
|
73
|
-
import {
|
|
74
|
+
import { IconCopy, IconExternalLink } from '@iconify-prerendered/vue-lucide';
|
|
74
75
|
import { callAdminForthApi, loadFile } from '@/utils';
|
|
75
76
|
import { useRouter } from 'vue-router';
|
|
76
|
-
import {
|
|
77
|
-
import { LinkButton } from '@/afcl';
|
|
78
|
-
import Vue2FACodeInput from '@loltech/vue3-2fa-code-input';
|
|
77
|
+
import { Button, Link, LinkButton } from '@/afcl';
|
|
79
78
|
import adminforth from '@/adminforth';
|
|
80
79
|
import TwoFaCard from './TwoFaCard.vue';
|
|
81
80
|
import TwoFaCodeInput from './TwoFaCodeInput.vue';
|
|
82
81
|
import { useI18n } from 'vue-i18n';
|
|
83
82
|
import { useRoute } from 'vue-router';
|
|
84
83
|
import { handlePasskeyAlert } from './utils.js';
|
|
85
|
-
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
86
84
|
import { useQRCode } from '@vueuse/integrations/useQRCode'
|
|
87
85
|
|
|
88
86
|
const { t } = useI18n();
|
|
@@ -113,9 +111,6 @@ const user = useUserStore();
|
|
|
113
111
|
|
|
114
112
|
const skipAllowed = ref(false);
|
|
115
113
|
|
|
116
|
-
// use this simple function to automatically focus on the next input
|
|
117
|
-
|
|
118
|
-
const showPw = ref(false);
|
|
119
114
|
|
|
120
115
|
const error = ref(null);
|
|
121
116
|
const totp = ref<{ issuer?: string; userName?: string; newSecret?: string }>({});
|