@adminforth/two-factors-auth 1.9.2 → 1.10.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 +3 -2
- package/custom/TwoFactorsConfirmation.vue +241 -55
- package/custom/TwoFactorsPasskeysSettings.vue +334 -0
- package/custom/TwoFactorsSetup.vue +15 -5
- package/custom/package-lock.json +7 -0
- package/custom/package.json +1 -0
- package/dist/custom/TwoFactorsConfirmation.vue +241 -55
- package/dist/custom/TwoFactorsPasskeysSettings.vue +334 -0
- package/dist/custom/TwoFactorsSetup.vue +15 -5
- package/dist/custom/package-lock.json +7 -0
- package/dist/custom/package.json +1 -0
- package/dist/index.js +449 -35
- package/index.ts +463 -37
- package/package.json +3 -1
- package/types.ts +62 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="text-3xl text-gray-900 font-semibold mt-2 max-w-2xl mr-6 flex-col justify-center items-center">
|
|
3
|
+
<p>Passkeys</p>
|
|
4
|
+
<div class="flex flex-col items-end">
|
|
5
|
+
<Table
|
|
6
|
+
class="mt-4 w-full"
|
|
7
|
+
:columns="[
|
|
8
|
+
{ label: 'Passkey name', fieldName: 'name' },
|
|
9
|
+
{ label: 'Last used', fieldName: 'last_used_at' },
|
|
10
|
+
{ label: 'Actions', fieldName: 'actions'}
|
|
11
|
+
]"
|
|
12
|
+
:data="passkeys"
|
|
13
|
+
>
|
|
14
|
+
<template #cell:actions="{item}">
|
|
15
|
+
<div class="flex items-center justify-start space-x-2">
|
|
16
|
+
<Dialog
|
|
17
|
+
class="w-96"
|
|
18
|
+
:buttons="[
|
|
19
|
+
{ label: 'Save', onclick: (dialog) => { renamePasskey(item.id, passkeysNewName); dialog.hide(); } },
|
|
20
|
+
{ label: 'Cancel', options: {class: 'bg-white focus:!ring-gray-200 !text-gray-900 hover:!bg-gray-200 dark:!bg-gray-800 dark:!text-gray-300 dark:hover:!bg-gray-700 dark:border-gray-900'}, onclick: (dialog) => {passkeysNewName = ''; dialog.hide();} },
|
|
21
|
+
]"
|
|
22
|
+
header="Edit Passkey"
|
|
23
|
+
>
|
|
24
|
+
<template #trigger>
|
|
25
|
+
<div
|
|
26
|
+
@click="passkeysNewName = ''"
|
|
27
|
+
class="w-7 h-7 flex items-center justify-center hover:border rounded-md hover:shadow-md text-blue-500 hover:text-blue-700 cursor-pointer"
|
|
28
|
+
>
|
|
29
|
+
<IconPenSolid class="w-5 h-5" />
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
<div>
|
|
33
|
+
<p>Enter new passkey name:</p>
|
|
34
|
+
<input
|
|
35
|
+
v-model="passkeysNewName"
|
|
36
|
+
type="text"
|
|
37
|
+
class="w-full mt-2 p-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-gray-500 "
|
|
38
|
+
placeholder="Enter new passkey name"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</Dialog>
|
|
42
|
+
<Dialog
|
|
43
|
+
class="w-96"
|
|
44
|
+
:buttons="[
|
|
45
|
+
{ label: 'Delete', options: {class: 'bg-red-700 !text-white hover:!bg-red-600 focus:ring-2 focus:ring-red-500'}, onclick: (dialog) => { deletePasskey(item.id); dialog.hide(); } },
|
|
46
|
+
{ label: 'Cancel', options: {class: 'bg-white focus:!ring-gray-200 !text-gray-900 hover:!bg-gray-200'}, onclick: (dialog) => dialog.hide() },
|
|
47
|
+
]"
|
|
48
|
+
header="Delete Passkey"
|
|
49
|
+
>
|
|
50
|
+
<template #trigger>
|
|
51
|
+
<div class="w-7 h-7 flex items-center justify-center hover:border rounded-md hover:shadow-md text-red-500 hover:text-red-700 cursor-pointer">
|
|
52
|
+
<IconTrashBinSolid
|
|
53
|
+
class="w-5 h-5"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
<div>
|
|
58
|
+
<p>Are you sure you want to delete this passkey?</p>
|
|
59
|
+
</div>
|
|
60
|
+
</Dialog>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
<template #cell:last_used_at="{item}">
|
|
64
|
+
<span v-if="item.last_used_at">{{ formatDateTime(item.last_used_at) }}</span>
|
|
65
|
+
<span v-else class="text-gray-400">Never</span>
|
|
66
|
+
</template>
|
|
67
|
+
</Table>
|
|
68
|
+
<div class="flex space-x-4 mt-4" v-if="isInitialFinished">
|
|
69
|
+
<ButtonGroup :solidColor="true">
|
|
70
|
+
<template #button:Profile>
|
|
71
|
+
<div class="flex px-4 py-2" @click="addPasskey()">
|
|
72
|
+
<IconPlusOutline class="w-5 h-5 me-2"/>
|
|
73
|
+
<p>{{ addPasskeyMode === 'platform' ? 'Add Local Passkey' : 'Add External Passkey' }}</p>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
<template #button:Dropdown v-if="authenticatorAttachment === 'both'">
|
|
77
|
+
<div id="dropdown-button" class="flex px-2 py-2" @click="isCardsVisible = !isCardsVisible">
|
|
78
|
+
<IconCaretDownSolid class="w-5 h-5"/>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
</ButtonGroup>
|
|
82
|
+
</div>
|
|
83
|
+
<div v-if="isCardsVisible" id="cards-container" class="w-80 mt-2 border-gray-400 p-2 bg-white rounded-lg shadow-md flex flex-col space-y-2">
|
|
84
|
+
<div v-if="isPasskeySupported" class="flex justify-between gap-4" :class="!isPasskeySupported ? 'opacity-50 pointer-events-none' : ''">
|
|
85
|
+
<div class="shrink-0 mt-1 w-4 h-4 z-10"><IconCheckOutline v-if="addPasskeyMode === 'platform'"/></div>
|
|
86
|
+
<Card
|
|
87
|
+
@click="addPasskeyMode = 'platform'; isCardsVisible = false;"
|
|
88
|
+
class="h-20"
|
|
89
|
+
title="Use this device"
|
|
90
|
+
size="sm"
|
|
91
|
+
description="Create a passkey using the built-in authenticator on this device."
|
|
92
|
+
hideBorder="true"
|
|
93
|
+
>
|
|
94
|
+
</Card>
|
|
95
|
+
</div>
|
|
96
|
+
<Tooltip v-else >
|
|
97
|
+
<div class="flex justify-between gap-4" :class="!isPasskeySupported ? 'opacity-50 pointer-events-none' : ''">
|
|
98
|
+
<div class="shrink-0 mt-1 w-4 h-4 z-10"><IconCheckOutline v-if="addPasskeyMode === 'platform'"/></div>
|
|
99
|
+
<Card
|
|
100
|
+
@click="addPasskeyMode = 'platform'; isCardsVisible = false;"
|
|
101
|
+
class="h-20"
|
|
102
|
+
title="Use this device"
|
|
103
|
+
size="sm"
|
|
104
|
+
description="Create a passkey using the built-in authenticator on this device."
|
|
105
|
+
hideBorder="true"
|
|
106
|
+
>
|
|
107
|
+
</Card>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<template #tooltip>
|
|
111
|
+
<p class="max-w-64">This browser or device is reporting partial passkey support.</p>
|
|
112
|
+
<p class="max-w-64"> To fix it try to install extention or change browser.</p>
|
|
113
|
+
</template>
|
|
114
|
+
</Tooltip>
|
|
115
|
+
<div class="border-t border-gray-300"></div>
|
|
116
|
+
<div class="flex justify-between gap-4">
|
|
117
|
+
<div class="shrink-0 mt-1 w-4 h-4 z-10"><IconCheckOutline v-if="addPasskeyMode === 'cross-platform'"/></div>
|
|
118
|
+
<Card
|
|
119
|
+
@click="addPasskeyMode = 'cross-platform'; isCardsVisible = false;"
|
|
120
|
+
size="sm"
|
|
121
|
+
class="h-20"
|
|
122
|
+
title="Use a external device"
|
|
123
|
+
description="Create a passkey using a phone, tablet or an external security key."
|
|
124
|
+
hideBorder="true"
|
|
125
|
+
>
|
|
126
|
+
</Card>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<script setup lang="ts">
|
|
134
|
+
import { Table } from '@/afcl'
|
|
135
|
+
import { callAdminForthApi } from '@/utils';
|
|
136
|
+
import adminforth from '@/adminforth';
|
|
137
|
+
import { onMounted, ref, Ref, onBeforeUnmount } from 'vue';
|
|
138
|
+
import { Card, Dialog, ButtonGroup, Tooltip } from '@/afcl'
|
|
139
|
+
import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconCaretDownSolid, IconCheckOutline } from '@iconify-prerendered/vue-flowbite';
|
|
140
|
+
import dayjs from 'dayjs';
|
|
141
|
+
import utc from 'dayjs/plugin/utc';
|
|
142
|
+
import timezone from 'dayjs/plugin/timezone';
|
|
143
|
+
import { useCoreStore } from '@/stores/core';
|
|
144
|
+
|
|
145
|
+
dayjs.extend(utc);
|
|
146
|
+
dayjs.extend(timezone);
|
|
147
|
+
const coreStore = useCoreStore();
|
|
148
|
+
const passkeys = ref([]);
|
|
149
|
+
const isPasskeySupported = ref(false);
|
|
150
|
+
const passkeysNewName = ref('');
|
|
151
|
+
const isCardsVisible = ref(false);
|
|
152
|
+
const addPasskeyMode: Ref<'platform' | 'cross-platform'> = ref('platform');
|
|
153
|
+
const authenticatorAttachment = ref<'platform' | 'cross-platform' | 'both'>('platform');
|
|
154
|
+
const isInitialFinished = ref(false);
|
|
155
|
+
|
|
156
|
+
onMounted(async () => {
|
|
157
|
+
await getPasskeys();
|
|
158
|
+
await checkForCompatibility();
|
|
159
|
+
if (authenticatorAttachment.value === "cross-platform") {
|
|
160
|
+
addPasskeyMode.value = 'cross-platform';
|
|
161
|
+
}
|
|
162
|
+
isInitialFinished.value = true;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
onMounted(() => {
|
|
166
|
+
document.addEventListener('click', handleClickOutside);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
onBeforeUnmount(() => {
|
|
170
|
+
document.removeEventListener('click', handleClickOutside);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
function handleClickOutside(event: MouseEvent) {
|
|
174
|
+
const cards = document.getElementById('cards-container');
|
|
175
|
+
const dropdownButton = document.getElementById('dropdown-button');
|
|
176
|
+
|
|
177
|
+
if (!cards?.contains(event.target as Node) && !dropdownButton?.contains(event.target as Node)) {
|
|
178
|
+
isCardsVisible.value = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function addPasskey() {
|
|
183
|
+
const { options, challengeId } = await fetchInformationFromTheBackend();
|
|
184
|
+
const creationResult = await callWebAuthn(options);
|
|
185
|
+
if (!creationResult) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
finishRegisteringPasskey(creationResult, challengeId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function getPasskeys() {
|
|
192
|
+
try {
|
|
193
|
+
const response = await callAdminForthApi({
|
|
194
|
+
path: `/plugin/passkeys/getPasskeys`,
|
|
195
|
+
method: 'GET',
|
|
196
|
+
});
|
|
197
|
+
passkeys.value = response.data;
|
|
198
|
+
authenticatorAttachment.value = response.authenticatorAttachment;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error('Error fetching passkeys:', error);
|
|
201
|
+
adminforth.alert({message: 'Error fetching passkeys.', variant: 'warning'});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function deletePasskey(passkeyId: string) {
|
|
206
|
+
let response;
|
|
207
|
+
try {
|
|
208
|
+
response = await callAdminForthApi({
|
|
209
|
+
path: `/plugin/passkeys/deletePasskey`,
|
|
210
|
+
method: 'DELETE',
|
|
211
|
+
body: {
|
|
212
|
+
passkeyId: passkeyId
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error('Error deleting passkey:', error);
|
|
217
|
+
adminforth.alert({message: 'Error deleting passkey.', variant: 'warning'});
|
|
218
|
+
}
|
|
219
|
+
if (response.ok === true) {
|
|
220
|
+
adminforth.alert({message: 'Passkey deleted successfully!', variant: 'success'});
|
|
221
|
+
getPasskeys();
|
|
222
|
+
} else {
|
|
223
|
+
console.error('Error deleting passkey:', response?.error);
|
|
224
|
+
adminforth.alert({message: 'Error deleting passkey.', variant: 'warning'});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function renamePasskey(passkeyId: string, name: string) {
|
|
229
|
+
let response;
|
|
230
|
+
try {
|
|
231
|
+
response = await callAdminForthApi({
|
|
232
|
+
path: `/plugin/passkeys/renamePasskey`,
|
|
233
|
+
method: 'POST',
|
|
234
|
+
body: {
|
|
235
|
+
passkeyId: passkeyId,
|
|
236
|
+
newName: name
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error('Error updating passkey:', error);
|
|
241
|
+
adminforth.alert({message: 'Error updating passkey.', variant: 'warning'});
|
|
242
|
+
}
|
|
243
|
+
if (response.ok === true) {
|
|
244
|
+
adminforth.alert({message: 'Passkey updated successfully!', variant: 'success'});
|
|
245
|
+
getPasskeys();
|
|
246
|
+
} else {
|
|
247
|
+
console.error('Error updating passkey:', response.error);
|
|
248
|
+
adminforth.alert({message: 'Error updating passkey.', variant: 'warning'});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function checkForCompatibility() {
|
|
253
|
+
if (window.PublicKeyCredential &&
|
|
254
|
+
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
|
|
255
|
+
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
256
|
+
Promise.all([
|
|
257
|
+
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
|
|
258
|
+
PublicKeyCredential.isConditionalMediationAvailable(),
|
|
259
|
+
]).then(results => {
|
|
260
|
+
if (results.every(r => r === true)) {
|
|
261
|
+
isPasskeySupported.value = true;
|
|
262
|
+
} else {
|
|
263
|
+
isPasskeySupported.value = false;
|
|
264
|
+
addPasskeyMode.value = 'cross-platform';
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function fetchInformationFromTheBackend() {
|
|
271
|
+
let response;
|
|
272
|
+
try {
|
|
273
|
+
response = await callAdminForthApi({
|
|
274
|
+
path: `/plugin/passkeys/registerPasskeyRequest`,
|
|
275
|
+
method: 'POST',
|
|
276
|
+
body: {
|
|
277
|
+
mode: addPasskeyMode.value
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error('Error fetching passkeys info:', error);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const _options = response.data;
|
|
285
|
+
const challengeId = response.challengeId;
|
|
286
|
+
const options = PublicKeyCredential.parseCreationOptionsFromJSON(_options);
|
|
287
|
+
return { options, challengeId };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function callWebAuthn(options: any) {
|
|
291
|
+
let credential;
|
|
292
|
+
try {
|
|
293
|
+
credential = await navigator.credentials.create({
|
|
294
|
+
publicKey: options
|
|
295
|
+
});
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error('Error creating credential:', error);
|
|
298
|
+
adminforth.alert({message: 'Error creating passkey.', variant: 'warning'});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const _result = (credential as PublicKeyCredential).toJSON();
|
|
302
|
+
const result = JSON.stringify(_result);
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function finishRegisteringPasskey(credential: any, challengeId: string) {
|
|
307
|
+
let res
|
|
308
|
+
try {
|
|
309
|
+
res = await callAdminForthApi({
|
|
310
|
+
path: `/plugin/passkeys/finishRegisteringPasskey`,
|
|
311
|
+
method: 'POST',
|
|
312
|
+
body: {
|
|
313
|
+
credential: credential,
|
|
314
|
+
origin: window.location.origin,
|
|
315
|
+
challengeId: challengeId,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error('Error finishing registering passkey:', error);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (res.ok === true) {
|
|
323
|
+
adminforth.alert({message: 'Passkey registered successfully!', variant: 'success'});
|
|
324
|
+
getPasskeys();
|
|
325
|
+
} else {
|
|
326
|
+
adminforth.alert({message: 'Error registering passkey.', variant: 'warning'});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function formatDateTime(date: string) {
|
|
331
|
+
if (!date) return '';
|
|
332
|
+
return dayjs.utc(date).local().format(`${coreStore.config?.datesFormat} ${coreStore.config?.timeFormat}` || 'YYYY-MM-DD HH:mm:ss');
|
|
333
|
+
}
|
|
334
|
+
</script>
|
|
@@ -79,9 +79,13 @@ import Vue2FACodeInput from '@loltech/vue3-2fa-code-input';
|
|
|
79
79
|
import VOtpInput from "vue3-otp-input";
|
|
80
80
|
import adminforth from '@/adminforth';
|
|
81
81
|
import { useI18n } from 'vue-i18n';
|
|
82
|
+
import { useRoute } from 'vue-router';
|
|
83
|
+
import { handlePasskeyAlert } from './TwoFactorsConfirmation.vue';
|
|
82
84
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
83
85
|
|
|
84
86
|
const { t } = useI18n();
|
|
87
|
+
const route = useRoute()
|
|
88
|
+
|
|
85
89
|
|
|
86
90
|
const code = ref(null);
|
|
87
91
|
const codeError = ref(null);
|
|
@@ -225,14 +229,17 @@ async function sendCode (value) {
|
|
|
225
229
|
|
|
226
230
|
const resp = await callAdminForthApi({
|
|
227
231
|
method: 'POST',
|
|
228
|
-
path: '/plugin/twofa/
|
|
232
|
+
path: '/plugin/twofa/confirmLogin',
|
|
229
233
|
body: {
|
|
230
234
|
code: value,
|
|
231
235
|
secret: totp.value.newSecret,
|
|
232
236
|
}
|
|
233
237
|
})
|
|
234
|
-
if (resp.allowedLogin){
|
|
235
|
-
|
|
238
|
+
if (resp.allowedLogin) {
|
|
239
|
+
if ( route.meta.isPasskeysEnabled ) {
|
|
240
|
+
handlePasskeyAlert(route.meta.suggestionPeriod, router);
|
|
241
|
+
}
|
|
242
|
+
await user.finishLogin();
|
|
236
243
|
} else {
|
|
237
244
|
codeError.value = 'Invalid code';
|
|
238
245
|
}
|
|
@@ -253,13 +260,16 @@ function handlePaste(event) {
|
|
|
253
260
|
const handleSkip = async () => {
|
|
254
261
|
const resp = await callAdminForthApi({
|
|
255
262
|
method: 'POST',
|
|
256
|
-
path: '/plugin/twofa/
|
|
263
|
+
path: '/plugin/twofa/confirmLogin',
|
|
257
264
|
body: {
|
|
258
265
|
skip: true,
|
|
259
266
|
}
|
|
260
267
|
});
|
|
261
268
|
if (resp.allowedLogin){
|
|
262
|
-
|
|
269
|
+
if ( route.meta.isPasskeysEnabled ) {
|
|
270
|
+
handlePasskeyAlert();
|
|
271
|
+
}
|
|
272
|
+
await user.finishLogin();
|
|
263
273
|
} else {
|
|
264
274
|
codeError.value = 'Something went wrong';
|
|
265
275
|
}
|
package/custom/package-lock.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@loltech/vue3-2fa-code-input": "^1.1.0",
|
|
13
|
+
"dayjs": "^1.11.18",
|
|
13
14
|
"vue3-otp-input": "^0.5.21"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
@@ -176,6 +177,12 @@
|
|
|
176
177
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
|
177
178
|
"license": "MIT"
|
|
178
179
|
},
|
|
180
|
+
"node_modules/dayjs": {
|
|
181
|
+
"version": "1.11.18",
|
|
182
|
+
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
|
183
|
+
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
|
184
|
+
"license": "MIT"
|
|
185
|
+
},
|
|
179
186
|
"node_modules/entities": {
|
|
180
187
|
"version": "4.5.0",
|
|
181
188
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|