@adminforth/two-factors-auth 1.10.6 → 1.11.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,106 @@
1
+ import { callAdminForthApi } from '@/utils';
2
+
3
+ export function handlePasskeyAlert(propSuggestionPeriod, router) {
4
+ const currentDate = Date.now();
5
+ window.localStorage.removeItem('suggestionPeriod');
6
+ window.localStorage.setItem('suggestionPeriod', propSuggestionPeriod);
7
+ let suggestionPeriod = window.localStorage.getItem('suggestionPeriod');
8
+ let lastSuggestionDate = window.localStorage.getItem('lastSuggestionDate');
9
+ let suggestPasskey = window.localStorage.getItem('suggestPasskey');
10
+ if ( !lastSuggestionDate ) {
11
+ window.localStorage.setItem('lastSuggestionDate', currentDate.toString());
12
+ lastSuggestionDate = window.localStorage.getItem('lastSuggestionDate');
13
+ }
14
+ if ( !suggestPasskey ) {
15
+ window.localStorage.setItem('suggestPasskey', 'true');
16
+ suggestPasskey = window.localStorage.getItem('suggestPasskey');
17
+ }
18
+ if ( currentDate - parseInt(lastSuggestionDate) > parseInt(suggestionPeriod) ) {
19
+ suggestPasskey = window.localStorage.getItem('suggestPasskey');
20
+ if (suggestPasskey !== 'true'){
21
+ if ( suggestPasskey === 'false' || !suggestPasskey ) {
22
+ window.localStorage.setItem('suggestPasskey', 'true');
23
+ } else if ( suggestPasskey !== 'never' ) {
24
+ window.localStorage.setItem('suggestPasskey', 'false');
25
+ }
26
+ }
27
+ }
28
+ suggestPasskey = window.localStorage.getItem('suggestPasskey');
29
+
30
+ if ( suggestPasskey === 'true' ) {
31
+ adminforth.alert({
32
+ message: 'Do you want to add passkey?',
33
+ variant: 'info',
34
+ buttons: [
35
+ { value: 'yes', label: 'Add passkey' },
36
+ { value: 'later', label: 'Later' },
37
+ { value: 'never', label: 'Never' },
38
+ ],
39
+ timeout: 'unlimited'
40
+ }).then((value) => {
41
+ switch (value) {
42
+ case 'yes':
43
+ router.push({ name: 'settings', params: { page: 'passkeys' } });
44
+ break;
45
+ case 'later':
46
+ window.localStorage.setItem('suggestPasskey', 'false');
47
+ break;
48
+ case 'never':
49
+ window.localStorage.setItem('suggestPasskey', 'never');
50
+ break;
51
+ default:
52
+ window.localStorage.setItem('suggestPasskey', 'false');
53
+ break;
54
+ }
55
+ });
56
+ }
57
+ }
58
+
59
+ export async function getPasskey() {
60
+ const { _options } = await createSignInRequest();
61
+ const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
62
+ const credential = await authenticate(options);
63
+ if (!credential) {
64
+ isFetchingPasskey.value = false;
65
+ return;
66
+ }
67
+ const result = JSON.stringify(credential);
68
+ const passkeyOptions = {
69
+ response: result,
70
+ origin: window.location.origin,
71
+ };
72
+ return passkeyOptions;
73
+ }
74
+
75
+ async function createSignInRequest() {
76
+ let response;
77
+ try {
78
+ response = await callAdminForthApi({
79
+ path: `/plugin/passkeys/signInRequest`,
80
+ method: 'POST',
81
+ });
82
+ } catch (error) {
83
+ console.error('Error creating sign-in request:', error);
84
+ return;
85
+ }
86
+ if (response.ok === true) {
87
+ return { _options: response.data, challengeId: response.challengeId };
88
+ } else {
89
+ adminforth.alert({message: 'Error creating sign-in request.', variant: 'warning'});
90
+ codeError.value = 'Error creating sign-in request.';
91
+ }
92
+ }
93
+
94
+ async function authenticate(options) {
95
+ try {
96
+ const abortController = new AbortController();
97
+ const credential = await navigator.credentials.get({
98
+ publicKey: options,
99
+ signal: abortController.signal,
100
+ });
101
+ return credential;
102
+ } catch (error) {
103
+ adminforth.alert({message: 'Error during authentication', variant: 'warning'});
104
+ codeError.value = 'Error during authentication.';
105
+ }
106
+ }
@@ -1,14 +1,15 @@
1
1
  <template>
2
2
  <div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0"
3
- v-show ="modelShow">
4
- <div class="relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
3
+ v-show ="modelShow && (isLoading === false)">
4
+ <div v-if="modalMode === 'totp'" class="af-two-factor-modal-totp relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
5
  <div id="mfaCode-label" class="mb-4 text-gray-700 dark:text-gray-100 text-center">
6
- {{ $t('Please enter your authenticator code') }}
6
+ <p> {{ customDialogTitle }} </p>
7
+ <p>{{ $t('Please enter your authenticator code') }}</p>
7
8
  </div>
8
9
 
9
10
  <div class="my-4 w-full flex justify-center" ref="otpRoot">
10
11
  <v-otp-input
11
- ref="code"
12
+ ref="confirmationResult"
12
13
  container-class="grid grid-cols-6 gap-3 w-full"
13
14
  input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
14
15
  :num-inputs="6"
@@ -21,7 +22,8 @@
21
22
  />
22
23
  </div>
23
24
 
24
- <div class="mt-6 flex justify-center gap-3">
25
+ <div class="mt-6 flex justify-center items-center gap-32 w-full">
26
+ <p v-if="doesUserHavePasskeys===true" class="underline hover:no-underline text-lightPrimary whitespace-nowrap hover:cursor-pointer" @click="modalMode = 'passkey'" >use passkey</p>
25
27
  <button
26
28
  class="px-4 py-2 rounded border bg-gray-100 dark:bg-gray-600"
27
29
  @click="onCancel"
@@ -29,18 +31,60 @@
29
31
  >{{ $t('Cancel') }}</button>
30
32
  </div>
31
33
  </div>
34
+
35
+
36
+
37
+ <div v-else-if="modalMode === 'passkey'" class="af-two-factor-modal-passkeys flex flex-col items-center justify-center py-4 gap-6 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6">
38
+ <button
39
+ type="button"
40
+ class="text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
41
+ @click="onCancel"
42
+ >
43
+ <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
44
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
45
+ </svg>
46
+ <span class="sr-only">Close modal</span>
47
+ </button>
48
+ <IconShieldOutline class="w-16 h-16 text-lightPrimary dark:text-darkPrimary"/>
49
+ <p class="text-4xl font-semibold mb-4 text:gray-900 dark:text-gray-200 ">Passkey</p>
50
+ <div class="mb-2 max-w-[300px] text:gray-900 dark:text-gray-200">
51
+ <p class="mb-2">{{customDialogTitle}} </p>
52
+ <p>Authenticate yourself using the button below</p>
53
+ </div>
54
+ <Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="w-full mx-16">
55
+ Use passkey
56
+ </Button>
57
+ <div v-if="modalMode === 'passkey'" class="max-w-sm px-6 pt-3 w-full bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
58
+ <div class="mb-3 font-normal text-gray-700 dark:text-gray-400">
59
+ <p> Have issues with passkey? </p>
60
+ <p class="underline hover:no-underline text-lightPrimary whitespace-nowrap hover:cursor-pointer" @click="modalMode = 'totp'" >use TOTP</p>
61
+ </div>
62
+ </div>
63
+
64
+
65
+
66
+ </div>
32
67
  </div>
33
68
  </template>
34
69
 
35
70
  <script setup lang="ts">
36
71
  import VOtpInput from 'vue3-otp-input';
37
- import { ref, nextTick, watch } from 'vue';
72
+ import { ref, nextTick, watch, onMounted } from 'vue';
38
73
  import { useUserStore } from '@/stores/user';
39
74
  import { useI18n } from 'vue-i18n';
75
+ import { callAdminForthApi } from '@/utils';
76
+ import { Link, Button } from '@/afcl';
77
+ import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
78
+ import { getPasskey } from './utils.js'
79
+
80
+
40
81
  declare global {
41
82
  interface Window {
42
83
  adminforthTwoFaModal: {
43
- getCode: () => Promise<any>;
84
+ get2FaConfirmationResult: (
85
+ verifyingCallback?: (confirmationResult: string) => Promise<boolean>,
86
+ title?: string
87
+ ) => Promise<any>;
44
88
  };
45
89
  }
46
90
  }
@@ -54,15 +98,20 @@
54
98
  }>();
55
99
 
56
100
  const modelShow = ref(false);
57
- let resolveFn: ((code: string) => void) | null = null;
58
- let verifyingCallback: ((code: string) => boolean) | null = null;
59
- let verifyFn: null | ((code: string) => Promise<boolean> | boolean) = null;
101
+ let resolveFn: ((confirmationResult: string) => void) | null = null;
102
+ let verifyingCallback: ((confirmationResult: string) => boolean) | null = null;
103
+ let verifyFn: null | ((confirmationResult: string) => Promise<boolean> | boolean) = null;
60
104
  let rejectFn: ((err?: any) => void) | null = null;
61
105
 
106
+
62
107
  window.adminforthTwoFaModal = {
63
- getCode: (verifyingCallback?: (code: string) => Promise<boolean>) =>
64
- new Promise((resolve, reject) => {
108
+ get2FaConfirmationResult: (verifyingCallback?: (confirmationResult: string) => Promise<boolean>, title?: string) =>
109
+ new Promise(async (resolve, reject) => {
65
110
  if (modelShow.value) throw new Error('Modal is already open');
111
+ await checkIfUserHasPasskeys();
112
+ if (title) {
113
+ customDialogTitle.value = title;
114
+ }
66
115
  modelShow.value = true;
67
116
  resolveFn = resolve;
68
117
  rejectFn = reject;
@@ -73,10 +122,30 @@
73
122
  const { t } = useI18n();
74
123
  const user = useUserStore();
75
124
 
76
- const code = ref<any>(null);
125
+ const confirmationResult = ref<any>(null);
77
126
  const otpRoot = ref<HTMLElement | null>(null);
78
127
  const bindValue = ref('');
128
+ const doesUserHavePasskeys = ref(false);
129
+ const modalMode = ref<"totp" | "passkey">("totp");
130
+ const isLoading = ref(false);
131
+ const customDialogTitle = ref("");
79
132
 
133
+ async function usePasskeyButtonClick() {
134
+ let passkeyData;
135
+ try {
136
+ passkeyData = await getPasskey();
137
+ } catch (e) {
138
+ adminforth.alert({message: 'Failed to get passkey', variant: 'danger'});
139
+ onCancel();
140
+ }
141
+ modelShow.value = false;
142
+ const dataToReturn = {
143
+ mode: "passkey",
144
+ result: passkeyData
145
+ }
146
+ resolveFn(dataToReturn);
147
+ }
148
+
80
149
  function tagOtpInputs() {
81
150
  const root = otpRoot.value;
82
151
  if (!root) return;
@@ -96,15 +165,15 @@
96
165
  event.preventDefault();
97
166
  const pastedText = event.clipboardData?.getData('text') || '';
98
167
  if (pastedText.length === 6) {
99
- code.value?.fillInput(pastedText);
168
+ confirmationResult.value?.fillInput(pastedText);
100
169
  }
101
170
  }
102
171
 
103
172
  async function handleOnComplete(value: string) {
104
- await sendCode(value);
173
+ await sendConfirmationResult(value);
105
174
  }
106
175
 
107
- async function sendCode(value: string) {
176
+ async function sendConfirmationResult(value: string) {
108
177
  if (!resolveFn) throw new Error('Modal is not initialized properly');
109
178
  if (verifyFn) {
110
179
  try {
@@ -120,14 +189,19 @@
120
189
  }
121
190
 
122
191
  modelShow.value = false;
123
- resolveFn(value);
192
+ const dataToReturn = {
193
+ mode: "totp",
194
+ result: value
195
+ }
196
+ resolveFn(dataToReturn);
124
197
  }
125
198
 
126
199
 
127
200
  function onCancel() {
128
201
  modelShow.value = false;
129
202
  bindValue.value = '';
130
- code.value?.clearInput();
203
+ confirmationResult.value?.clearInput();
204
+ rejectFn("Cancel");
131
205
  emit('rejected', new Error('cancelled'));
132
206
  emit('closed');
133
207
  }
@@ -148,10 +222,30 @@
148
222
  htmlRef.style.overflow = '';
149
223
  }
150
224
  bindValue.value = '';
151
- code.value?.clearInput();
225
+ confirmationResult.value?.clearInput();
152
226
  }
153
227
  });
154
228
 
229
+ async function checkIfUserHasPasskeys() {
230
+ isLoading.value = true;
231
+ callAdminForthApi({
232
+ method: 'GET',
233
+ path: '/plugin/passkeys/getPasskeys',
234
+ }).then((response) => {
235
+ if (response.ok) {
236
+ if (response.data.length >= 1) {
237
+ doesUserHavePasskeys.value = true;
238
+ modalMode.value = "passkey";
239
+ isLoading.value = false;
240
+ } else {
241
+ doesUserHavePasskeys.value = false;
242
+ modalMode.value = "totp";
243
+ isLoading.value = false;
244
+ }
245
+ }
246
+ });
247
+ }
248
+
155
249
  </script>
156
250
 
157
251
  <style scoped>
@@ -8,7 +8,7 @@
8
8
  }: {}"
9
9
  >
10
10
 
11
- <div id="authentication-modal" tabindex="-1" class="af-two-factors-confirmation overflow-y-auto overflow-x-hidden z-50 min-w-[00px] justify-center items-center md:inset-0 h-[calc(100%-1rem)] max-h-full">
11
+ <div v-if="isLoading===false" id="authentication-modal" tabindex="-1" class="af-two-factors-confirmation overflow-y-auto overflow-x-hidden z-50 min-w-[00px] justify-center items-center md:inset-0 h-[calc(100%-1rem)] max-h-full">
12
12
  <div class="relative p-4 w-full max-w-md max-h-full">
13
13
  <!-- Modal content -->
14
14
  <div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
@@ -44,7 +44,7 @@
44
44
  <IconShieldOutline class="w-16 h-16 text-lightPrimary dark:text-darkPrimary"/>
45
45
  <p class="text-4xl font-semibold mb-4">Passkey</p>
46
46
  <p class="mb-2 max-w-[300px]">When you are ready, authenticate using the button below</p>
47
- <Button @click="usePasskeyButton" class="w-full mx-16">
47
+ <Button @click="usePasskeyButton" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="w-full mx-16">
48
48
  Use passkey
49
49
  </Button>
50
50
  <div v-if="confirmationMode === 'passkey'" class="max-w-sm px-6 pt-3 w-full bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
@@ -68,6 +68,9 @@
68
68
  </div>
69
69
  </div>
70
70
  </div>
71
+ <div v-else>
72
+ <Spinner class="w-10 h-10" />
73
+ </div>
71
74
  </div>
72
75
  </template>
73
76
 
@@ -79,13 +82,14 @@
79
82
  import { useUserStore } from '@/stores/user';
80
83
  import { callAdminForthApi, loadFile } from '@/utils';
81
84
  import { showErrorTost } from '@/composables/useFrontendApi';
82
- import { Button, Link } from '@/afcl';
85
+ import { Button, Link, Spinner } from '@/afcl';
83
86
  import VOtpInput from "vue3-otp-input";
84
87
  import { useI18n } from 'vue-i18n';
85
88
  import { useRoute } from 'vue-router'
86
89
  import { useRouter } from 'vue-router';
87
90
  import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
88
91
  import ErrorMessage from '@/components/ErrorMessage.vue';
92
+ import { handlePasskeyAlert } from './utils.js';
89
93
 
90
94
  const { t } = useI18n();
91
95
  const code = ref(null);
@@ -94,6 +98,7 @@
94
98
  const route = useRoute();
95
99
  const router = useRouter();
96
100
  const codeError = ref(null);
101
+ const isFetchingPasskey = ref(false);
97
102
 
98
103
  onBeforeMount(() => {
99
104
  if (localStorage.getItem('isAuthorized') === 'true') {
@@ -130,6 +135,7 @@
130
135
  const doesUserHavePasskeys = ref(false);
131
136
  const confirmationMode = ref("code");
132
137
  const isPasskeysSupported = ref(false);
138
+ const isLoading = ref(true);
133
139
 
134
140
  onMounted(async () => {
135
141
  if (localStorage.getItem('isAuthorized') !== 'true') {
@@ -144,6 +150,7 @@
144
150
  const rootEl = otpRoot.value;
145
151
  rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
146
152
  }
153
+ isLoading.value = false;
147
154
  });
148
155
 
149
156
  watch(route, (newRoute) => {
@@ -230,10 +237,12 @@
230
237
  }
231
238
 
232
239
  async function usePasskeyButton() {
240
+ isFetchingPasskey.value = true;
233
241
  const { _options, challengeId } = await createSignInRequest();
234
242
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
235
243
  const credential = await authenticate(options);
236
244
  if (!credential) {
245
+ isFetchingPasskey.value = false;
237
246
  return;
238
247
  }
239
248
  const result = JSON.stringify(credential);
@@ -243,6 +252,7 @@
243
252
  origin: window.location.origin,
244
253
  };
245
254
  sendCode('', 'passkey', passkeyOptions);
255
+ isFetchingPasskey.value = false;
246
256
  }
247
257
 
248
258
  async function createSignInRequest() {
@@ -278,65 +288,6 @@
278
288
  }
279
289
  }
280
290
 
281
- </script>
282
-
283
- <script lang="ts">
284
-
285
- export function handlePasskeyAlert(propSuggestionPeriod, router) {
286
- const currentDate = Date.now();
287
- window.localStorage.removeItem('suggestionPeriod');
288
- window.localStorage.setItem('suggestionPeriod', propSuggestionPeriod);
289
- let suggestionPeriod = window.localStorage.getItem('suggestionPeriod');
290
- let lastSuggestionDate = window.localStorage.getItem('lastSuggestionDate');
291
- let suggestPasskey = window.localStorage.getItem('suggestPasskey');
292
- if ( !lastSuggestionDate ) {
293
- window.localStorage.setItem('lastSuggestionDate', currentDate.toString());
294
- lastSuggestionDate = window.localStorage.getItem('lastSuggestionDate');
295
- }
296
- if ( !suggestPasskey ) {
297
- window.localStorage.setItem('suggestPasskey', 'true');
298
- suggestPasskey = window.localStorage.getItem('suggestPasskey');
299
- }
300
- if ( currentDate - parseInt(lastSuggestionDate) > parseInt(suggestionPeriod) ) {
301
- suggestPasskey = window.localStorage.getItem('suggestPasskey');
302
- if (suggestPasskey !== 'true'){
303
- if ( suggestPasskey === 'false' || !suggestPasskey ) {
304
- window.localStorage.setItem('suggestPasskey', 'true');
305
- } else if ( suggestPasskey !== 'never' ) {
306
- window.localStorage.setItem('suggestPasskey', 'false');
307
- }
308
- }
309
- }
310
- suggestPasskey = window.localStorage.getItem('suggestPasskey');
311
-
312
- if ( suggestPasskey === 'true' ) {
313
- adminforth.alert({
314
- message: 'Do you want to add passkey?',
315
- variant: 'info',
316
- buttons: [
317
- { value: 'yes', label: 'Add passkey' },
318
- { value: 'later', label: 'Later' },
319
- { value: 'never', label: 'Never' },
320
- ],
321
- timeout: 'unlimited'
322
- }).then((value) => {
323
- switch (value) {
324
- case 'yes':
325
- router.push({ name: 'settings', params: { page: 'passkeys' } });
326
- break;
327
- case 'later':
328
- window.localStorage.setItem('suggestPasskey', 'false');
329
- break;
330
- case 'never':
331
- window.localStorage.setItem('suggestPasskey', 'never');
332
- break;
333
- default:
334
- window.localStorage.setItem('suggestPasskey', 'false');
335
- break;
336
- }
337
- });
338
- }
339
- }
340
291
 
341
292
  function getOtpInputs() {
342
293
  const root = otpRoot.value;