@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.
@@ -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/confirmSetup',
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
- await user.finishLogin()
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/confirmSetup',
263
+ path: '/plugin/twofa/confirmLogin',
257
264
  body: {
258
265
  skip: true,
259
266
  }
260
267
  });
261
268
  if (resp.allowedLogin){
262
- await user.finishLogin()
269
+ if ( route.meta.isPasskeysEnabled ) {
270
+ handlePasskeyAlert();
271
+ }
272
+ await user.finishLogin();
263
273
  } else {
264
274
  codeError.value = 'Something went wrong';
265
275
  }
@@ -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",
@@ -11,6 +11,7 @@
11
11
  "description": "",
12
12
  "dependencies": {
13
13
  "@loltech/vue3-2fa-code-input": "^1.1.0",
14
+ "dayjs": "^1.11.18",
14
15
  "vue3-otp-input": "^0.5.21"
15
16
  }
16
17
  }