@adminforth/two-factors-auth 4.5.0 → 4.7.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.
@@ -1,99 +1,58 @@
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
3
  v-show ="modelShow && (isLoading === false)">
4
- <div v-if="modalMode === 'totp'" class="af-two-factor-modal-totp flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
- <button
6
- type="button"
7
- class="af-2fa-close-btn 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"
8
- @click="onCancel"
9
- >
10
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
11
- <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"/>
12
- </svg>
13
- <span class="sr-only">{{$t('Close modal')}}</span>
14
- </button>
15
-
16
- <div class="af-2fa-totp-header flex flex-col items-center justify-center gap-3">
4
+ <div :class="`af-two-factor-modal-${modalMode}`" class="flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
+ <div class="af-2fa-header flex flex-col items-center justify-center gap-3">
17
6
  <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
18
7
  <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
19
8
  </div>
20
- <div id="mfaCode-label" class="af-2fa-totp-title-wrap">
21
- <p v-if="customDialogTitle" class="af-2fa-custom-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ customDialogTitle }}</p>
22
- <p class="af-2fa-totp-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ $t('Please enter your authenticator code') }}</p>
9
+ <div id="mfaCode-label" class="af-2fa-title-wrap">
10
+ <p class="af-2fa-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ dialogTitle }}</p>
11
+ <p class="af-2fa-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ subtitles[modalMode] }}</p>
23
12
  </div>
24
13
  </div>
25
14
 
26
- <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
27
- <v-otp-input
28
- ref="confirmationResult"
29
- container-class="grid grid-cols-6 gap-3"
30
- 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-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
31
- :num-inputs="6"
32
- inputType="number"
33
- inputmode="numeric"
34
- :should-auto-focus="true"
35
- :should-focus-order="true"
36
- v-model:value="bindValue"
37
- @on-complete="handleOnComplete"
38
- />
15
+ <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ dialogDetails }}</pre>
16
+
17
+ <ul v-if="confirmItems.length > 1" class="af-2fa-actions-list max-h-60 overflow-auto list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
18
+ <li v-for="(item, i) in confirmItems" :key="i">{{ item }}</li>
19
+ </ul>
20
+
21
+ <template v-if="modalMode === 'totp'">
22
+ <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
23
+ <v-otp-input
24
+ ref="confirmationResult"
25
+ container-class="grid grid-cols-6 gap-3"
26
+ 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-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
27
+ :num-inputs="6"
28
+ inputType="number"
29
+ inputmode="numeric"
30
+ :should-auto-focus="true"
31
+ :should-focus-order="true"
32
+ v-model:value="bindValue"
33
+ @on-complete="handleOnComplete"
34
+ />
35
+ </div>
36
+ <p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
37
+ </template>
38
+
39
+ <div class="af-2fa-buttons flex gap-2">
40
+ <Button variant="secondary" class="af-2fa-cancel-btn flex-1" @click="onCancel">{{ $t('Cancel') }}</Button>
41
+ <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn flex-1 flex items-center justify-center gap-2">
42
+ <IconShieldOutline class="w-4 h-4" />
43
+ {{ $t('Use passkey') }}
44
+ </Button>
45
+ <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn flex-1" @click="approveInGracePeriod">{{ $t('Approve') }}</Button>
39
46
  </div>
40
- <p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
47
+
48
+ <p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
41
49
  {{$t('Having trouble?')}}
42
50
  <button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
43
51
  </p>
44
- <p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
45
- {{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
46
- </p>
47
- </div>
48
-
49
- <div v-else-if="modalMode === 'passkey'" class="af-two-factor-modal-passkeys flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
50
- <button
51
- type="button"
52
- class="af-2fa-close-btn 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"
53
- @click="onCancel"
54
- >
55
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
56
- <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"/>
57
- </svg>
58
- <span class="sr-only">{{$t('Close modal')}}</span>
59
- </button>
60
-
61
- <div class="af-2fa-passkey-header flex flex-col items-center justify-center gap-3">
62
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
63
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
64
- </div>
65
- <div class="af-2fa-passkey-title-wrap">
66
- <p class="af-2fa-passkey-title text-xl text-center font-medium text-gray-900 dark:text-white">{{$t('Verify to add passkey')}}</p>
67
- <p class="af-2fa-passkey-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{$t("Confirm it's you before registering a new passkey on this device.")}}</p>
68
- </div>
69
- </div>
70
-
71
- <div class="af-2fa-passkey-steps flex flex-col gap-2 mt-2">
72
- <div
73
- v-for="(step, i) in [
74
- $t('Click the button below to begin'),
75
- $t('Authenticate with your device biometrics or PIN'),
76
- $t('Your passkey will be saved automatically'),
77
- ]"
78
- :key="i"
79
- class="af-2fa-passkey-step flex items-center gap-2.5 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-600/50 text-xs text-gray-600 dark:text-gray-300"
80
- >
81
- <span class="af-2fa-step-number shrink-0 w-5 h-5 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary text-white font-semibold text-xs">{{ i + 1 }}</span>
82
- {{ step }}
83
- </div>
84
- </div>
85
-
86
- <Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn w-full flex items-center justify-center gap-2 mt-2">
87
- <IconShieldOutline class="w-4 h-4" />
88
- {{$t('Use passkey to verify')}}
89
- </Button>
90
- <p class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
52
+ <p v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
91
53
  {{$t('Having trouble?')}}
92
54
  <button type="button" class="af-2fa-switch-to-totp text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'totp'">{{$t('Use TOTP instead')}}</button>
93
55
  </p>
94
- <p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
95
- {{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
96
- </p>
97
56
  </div>
98
57
  </div>
99
58
  </template>
@@ -101,7 +60,7 @@
101
60
  <script setup lang="ts">
102
61
 
103
62
  import VOtpInput from 'vue3-otp-input';
104
- import { ref, nextTick, watch } from 'vue';
63
+ import { ref, computed, nextTick, watch } from 'vue';
105
64
  import { useUserStore } from '@/stores/user';
106
65
  import { useI18n } from 'vue-i18n';
107
66
  import { callAdminForthApi } from '@/utils';
@@ -112,7 +71,7 @@
112
71
  import websocket from '@/websocket';
113
72
  import { getAdminForthClientId } from '@/utils/clientId';
114
73
  import type { AdminUser } from '@/types/Common';
115
- import { useTwoFactorsAuthApi, type TwoFaConfirmationResult } from './use2faApi.js';
74
+ import { useTwoFactorsAuthApi, type TwoFaConfirmationResult, type TwoFaDialogContent } from './use2faApi.js';
116
75
  const props = defineProps<{
117
76
  autoFinishLogin?: boolean
118
77
  adminUser?: AdminUser
@@ -121,10 +80,15 @@
121
80
  const { alert } = useAdminforth();
122
81
  const twoFactorsAuthApi = useTwoFactorsAuthApi();
123
82
 
83
+ type AutoVerifySession = { sessionId: string; actionDescription?: string };
84
+
124
85
  const isAwaiting2FAResult = ref(false);
125
86
  let allowAddNewSessions = true;
126
87
  const ALLOW_NEW_SESSIONS_PERIOD = 1000;
127
- const sessionsIdsToResolve = ref<string[]>([]);
88
+ const MAX_ACTIONS_PER_CONFIRMATION = 20;
89
+ const sessionsToResolve = ref<AutoVerifySession[]>([]);
90
+ // Sessions over the limit of one confirmation wait for the next one, so the user never confirms an action they did not see.
91
+ let queuedSessions: AutoVerifySession[] = [];
128
92
 
129
93
  watch(isAwaiting2FAResult, (awaiting) => {
130
94
  if (awaiting) {
@@ -141,54 +105,63 @@
141
105
  if (props.adminUser) {
142
106
  websocket.unsubscribeByPrefix(`/user2fa/`);
143
107
  const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
144
- websocket.subscribe(user2FaTopic, async (data: {sessionId: string}) => {
108
+ websocket.subscribe(user2FaTopic, async (session: AutoVerifySession) => {
145
109
  if (!allowAddNewSessions) {
146
110
  alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
147
111
  return;
148
112
  }
149
- sessionsIdsToResolve.value.push(data.sessionId);
150
- let confirmationResult;
113
+ if (sessionsToResolve.value.length < MAX_ACTIONS_PER_CONFIRMATION) {
114
+ sessionsToResolve.value.push(session);
115
+ } else {
116
+ queuedSessions.push(session);
117
+ }
151
118
  if (isAwaiting2FAResult.value) {
152
119
  return;
153
120
  }
154
- try {
155
- isAwaiting2FAResult.value = true;
156
- confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult();
157
- } catch (error) {
158
- if (!isTwoFaCancel(error)) {
159
- console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
160
- }
161
- }
162
- isAwaiting2FAResult.value = false;
163
- try {
164
- const response = await callAdminForthApi({
165
- method: "POST",
166
- path: "/plugin/passkeys/resolveVerifyAuto",
167
- body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value }
168
- });
169
- if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
170
- alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
171
- } else if (!response.ok) {
172
- alert({message: response.error || t('Verification failed'), variant: 'danger'});
173
- } else if (response.ok) {
174
- // alert({message: 'Verification successful', variant: 'success'});
175
- }
176
- sessionsIdsToResolve.value = [];
177
- } catch (error) {
178
- console.error('Error resolving automatic 2FA verification:', error);
121
+ while (sessionsToResolve.value.length) {
122
+ await confirmSessions();
123
+ sessionsToResolve.value = queuedSessions.splice(0, MAX_ACTIONS_PER_CONFIRMATION);
179
124
  }
180
125
  allowAddNewSessions = true;
181
126
  });
182
127
  websocket.subscribe(`${user2FaTopic}/resolve`, async (data: {sessionId: string}) => {
183
- if (sessionsIdsToResolve.value.includes(data.sessionId) && rejectFn && modelShow.value) {
128
+ queuedSessions = queuedSessions.filter((session) => session.sessionId !== data.sessionId);
129
+ if (sessionsToResolve.value.some((session) => session.sessionId === data.sessionId) && rejectFn && modelShow.value) {
184
130
  onCancel();
185
- sessionsIdsToResolve.value = sessionsIdsToResolve.value.filter(id => id !== data.sessionId);
131
+ sessionsToResolve.value = sessionsToResolve.value.filter((session) => session.sessionId !== data.sessionId);
186
132
  }
187
133
  });
188
134
  }
189
135
  }, { immediate: true });
190
136
 
191
137
 
138
+ async function confirmSessions() {
139
+ let confirmationResult;
140
+ try {
141
+ isAwaiting2FAResult.value = true;
142
+ confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(sessionsToResolve.value[0].actionDescription);
143
+ } catch (error) {
144
+ if (!isTwoFaCancel(error)) {
145
+ console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
146
+ }
147
+ }
148
+ isAwaiting2FAResult.value = false;
149
+ try {
150
+ const response = await callAdminForthApi({
151
+ method: "POST",
152
+ path: "/plugin/passkeys/resolveVerifyAuto",
153
+ body: { confirmationResult, sessionsIds: sessionsToResolve.value.map((session) => session.sessionId) }
154
+ });
155
+ if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
156
+ alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
157
+ } else if (!response.ok) {
158
+ alert({message: response.error || t('Verification failed'), variant: 'danger'});
159
+ }
160
+ } catch (error) {
161
+ console.error('Error resolving automatic 2FA verification:', error);
162
+ }
163
+ }
164
+
192
165
  const emit = defineEmits<{
193
166
  (e: 'resolved', payload: any): void
194
167
  (e: 'rejected', err?: any): void
@@ -225,22 +198,27 @@
225
198
 
226
199
  function get2FaConfirmationResult(
227
200
  title?: string,
228
- verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean
201
+ verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean,
202
+ { details, items }: TwoFaDialogContent = {},
229
203
  ): Promise<TwoFaConfirmationResult> {
230
204
  return new Promise((resolve, reject) => {
231
205
  (async () => {
232
206
  if (modelShow.value) {
233
207
  throw new Error(t('Modal is already open'));
234
208
  }
235
- const skipAllowModal = await checkIfSkipAllowModal();
236
- if (skipAllowModal) {
209
+ const { skipAllowed, gracePeriod } = await checkIfSkipAllowModal();
210
+ if (skipAllowed) {
237
211
  resolve({});
238
212
  return;
239
213
  }
240
- await checkIfUserHasPasskeys();
241
- if (title) {
242
- customDialogTitle.value = title;
214
+ if (gracePeriod) {
215
+ modalMode.value = 'grace';
216
+ } else {
217
+ await checkIfUserHasPasskeys();
243
218
  }
219
+ customDialogTitle.value = title ?? '';
220
+ dialogDetails.value = details ?? '';
221
+ dialogItems.value = items ?? [];
244
222
  resolveFn = resolve;
245
223
  rejectFn = reject;
246
224
  verifyFn = verifyingCallback ?? null;
@@ -267,11 +245,31 @@
267
245
  const otpRoot = ref<HTMLElement | null>(null);
268
246
  const bindValue = ref('');
269
247
  const doesUserHavePasskeys = ref(false);
270
- const modalMode = ref<"totp" | "passkey">("totp");
248
+ const modalMode = ref<"totp" | "passkey" | "grace">("totp");
271
249
  const isLoading = ref(false);
272
250
  const isFetchingPasskey = ref(false);
273
251
  const customDialogTitle = ref("");
274
-
252
+ const dialogDetails = ref("");
253
+ const dialogItems = ref<string[]>([]);
254
+ // verifyAuto calls that arrive together are confirmed with one code.
255
+ const confirmItems = computed(() => sessionsToResolve.value.length > 1
256
+ ? sessionsToResolve.value.map((session) => session.actionDescription ?? t('Action without a description'))
257
+ : dialogItems.value);
258
+ const dialogTitle = computed(() => confirmItems.value.length > 1
259
+ ? t('Confirm {count} actions', { count: confirmItems.value.length })
260
+ : customDialogTitle.value || t("Confirm it's you"));
261
+ const codeError = ref("");
262
+ const subtitles = computed(() => ({
263
+ totp: t('Please enter your authenticator code'),
264
+ passkey: t('Confirm with your passkey'),
265
+ grace: t('You verified recently, so no code is needed'),
266
+ }));
267
+
268
+ function approveInGracePeriod() {
269
+ modelShow.value = false;
270
+ resolveFn?.({});
271
+ }
272
+
275
273
  async function usePasskeyButtonClick() {
276
274
  isFetchingPasskey.value = true;
277
275
  let passkeyData;
@@ -322,32 +320,41 @@
322
320
 
323
321
  async function sendConfirmationResult(value: string) {
324
322
  if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
325
- if (verifyFn) {
326
- try {
327
- const ok = await verifyFn(value);
328
- if (!ok) {
329
- rejectFn?.(new Error(t('Invalid code')));
330
- return;
331
- }
332
- } catch (err) {
333
- rejectFn?.(err);
334
- return;
335
- }
323
+ let checked: { error: string } | { result: TwoFaConfirmationResult };
324
+ try {
325
+ checked = await checkCode(value);
326
+ } catch (err) {
327
+ rejectFn?.(err);
328
+ return;
329
+ }
330
+ if ('error' in checked) {
331
+ codeError.value = checked.error;
332
+ bindValue.value = '';
333
+ confirmationResult.value?.clearInput();
334
+ return;
336
335
  }
337
336
 
338
337
  modelShow.value = false;
339
- const dataToReturn = {
340
- mode: "totp",
341
- result: value
342
- }
338
+ codeError.value = "";
343
339
  customDialogTitle.value = "";
344
340
  removeEventListenerForOTPInput();
345
- resolveFn(dataToReturn);
341
+ resolveFn(checked.result);
346
342
  }
347
343
 
348
344
 
345
+ async function checkCode(code: string): Promise<{ error: string } | { result: TwoFaConfirmationResult }> {
346
+ if (verifyFn) {
347
+ return (await verifyFn(code)) ? { result: { mode: 'totp', result: code } } : { error: t('Wrong or expired OTP code') };
348
+ }
349
+ const response = await callAdminForthApi({ method: 'POST', path: '/plugin/twofa/verify', body: { code } });
350
+ return response?.ok
351
+ ? { result: { mode: 'verifiedTotp', result: response.verifiedCodeToken } }
352
+ : { error: response?.error ?? t('Wrong or expired OTP code') };
353
+ }
354
+
349
355
  function onCancel() {
350
356
  modelShow.value = false;
357
+ codeError.value = "";
351
358
  bindValue.value = '';
352
359
  confirmationResult.value?.clearInput();
353
360
  removeEventListenerForOTPInput();
@@ -401,15 +408,8 @@
401
408
  path: '/plugin/passkeys/getPasskeys',
402
409
  });
403
410
 
404
- if (response.ok) {
405
- if (response.data.length >= 1) {
406
- doesUserHavePasskeys.value = true;
407
- modalMode.value = "passkey";
408
- } else {
409
- doesUserHavePasskeys.value = false;
410
- modalMode.value = "totp";
411
- }
412
- }
411
+ doesUserHavePasskeys.value = Boolean(response.ok && response.data.length);
412
+ modalMode.value = doesUserHavePasskeys.value ? "passkey" : "totp";
413
413
  } catch (error) {
414
414
  console.error('Error checking passkeys:', error);
415
415
  // Fallback to TOTP if there's an error
@@ -461,20 +461,15 @@
461
461
  }
462
462
 
463
463
 
464
- async function checkIfSkipAllowModal(){
464
+ async function checkIfSkipAllowModal(): Promise<{ skipAllowed: boolean; gracePeriod?: boolean }> {
465
465
  try {
466
- const response = await callAdminForthApi({
466
+ return await callAdminForthApi({
467
467
  method: "GET",
468
468
  path: "/plugin/twofa/skip-allow-modal",
469
469
  });
470
- if ( response.skipAllowed === true ) {
471
- return true;
472
- } else {
473
- return false;
474
- }
475
470
  } catch (error) {
476
471
  console.error('Error checking skip allow modal:', error);
477
- return false;
472
+ return { skipAllowed: false };
478
473
  }
479
474
  }
480
475
 
@@ -130,7 +130,7 @@
130
130
  import { Dialog, Tooltip, Spinner, Input } from '@/afcl';
131
131
  import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
132
132
  import { useCoreStore } from '@/stores/core';
133
- import { useTwoFactorsAuth } from './use2faApi';
133
+ import { useTwoFactorsAuth, type TwoFaConfirmationResult } from './use2faApi';
134
134
 
135
135
  const { t } = useI18n();
136
136
  const coreStore = useCoreStore();
@@ -215,9 +215,7 @@
215
215
  }
216
216
  }
217
217
 
218
- type ConfirmationResult = { mode: 'totp'; result: string } | { mode: 'passkey'; result: Record<string, any> } | { mode?: undefined };
219
-
220
- async function requestPasskeyChallenge(confirmationResult: ConfirmationResult) {
218
+ async function requestPasskeyChallenge(confirmationResult: TwoFaConfirmationResult) {
221
219
  try {
222
220
  const response = await callAdminForthApi({
223
221
  path: '/plugin/passkeys/registrationOptions',
@@ -3,13 +3,17 @@ import { defineStore } from 'pinia';
3
3
 
4
4
  export type TwoFaConfirmationResult =
5
5
  | { mode: 'totp'; result: string }
6
+ | { mode: 'verifiedTotp'; result: string }
6
7
  | { mode: 'passkey'; result: Record<string, any> }
7
8
  | { mode?: undefined };
8
9
 
10
+ export type TwoFaDialogContent = { details?: string; items?: string[] };
11
+
9
12
  type VerifyFn = (confirmationResult: string) => Promise<boolean> | boolean;
10
13
  type Get2FaConfirmationResultHandler = (
11
14
  title?: string,
12
- verifyingCallback?: VerifyFn
15
+ verifyingCallback?: VerifyFn,
16
+ content?: TwoFaDialogContent,
13
17
  ) => Promise<TwoFaConfirmationResult>;
14
18
 
15
19
  declare global {
@@ -29,12 +33,13 @@ export const useTwoFactorsAuthApi = defineStore('twoFactorsAuthApi', () => {
29
33
 
30
34
  async function get2FaConfirmationResult(
31
35
  title?: string,
32
- verifyingCallback?: VerifyFn
36
+ verifyingCallback?: VerifyFn,
37
+ content?: TwoFaDialogContent,
33
38
  ): Promise<TwoFaConfirmationResult> {
34
39
  if (!get2FaConfirmationResultHandler.value) {
35
40
  throw new Error('Two factors auth modal API is not initialized');
36
41
  }
37
- return get2FaConfirmationResultHandler.value(title, verifyingCallback);
42
+ return get2FaConfirmationResultHandler.value(title, verifyingCallback, content);
38
43
  }
39
44
 
40
45
  function initGlobalApi() {
@@ -1,3 +1,4 @@
1
+ import { TwoFactorErrorCode } from "../utils/errors.js";
1
2
  export declare function createPasskeyHandlers(ctx: any): {
2
3
  createRegistrationOptions: ({ body, adminUser, response, cookies, headers }: {
3
4
  body: any;
@@ -41,12 +42,8 @@ export declare function createPasskeyHandlers(ctx: any): {
41
42
  headers: any;
42
43
  }) => Promise<{
43
44
  ok: boolean;
44
- code: string;
45
- error: any;
46
- } | {
47
- ok: boolean;
48
- error: any;
49
- code?: undefined;
45
+ code: TwoFactorErrorCode;
46
+ error: string;
50
47
  } | {
51
48
  ok: boolean;
52
49
  }>;
@@ -7,7 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { HttpStatus, errorMessage, respondWithStatus } from "../utils/errors.js";
10
+ import { HttpStatus, TwoFactorErrorCode, errorMessage, respondWithStatus } from "../utils/errors.js";
11
11
  export function createPasskeyHandlers(ctx) {
12
12
  return {
13
13
  createRegistrationOptions: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response, cookies, headers }) {
@@ -73,12 +73,11 @@ export function createPasskeyHandlers(ctx) {
73
73
  return checkResult;
74
74
  }),
75
75
  resolveVerifyAuto: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response, cookies, headers }) {
76
- var _b;
77
76
  const sessionsIds = body === null || body === void 0 ? void 0 : body.sessionsIds;
78
77
  const confirmationResult = body === null || body === void 0 ? void 0 : body.confirmationResult;
79
78
  const idsToResolve = Array.isArray(sessionsIds) ? sessionsIds : [];
80
79
  const resolveAllIdsAsFailed = (message, code) => {
81
- const payload = code ? { ok: false, code, error: message } : { ok: false, error: message };
80
+ const payload = { ok: false, code, error: message };
82
81
  for (const id of idsToResolve) {
83
82
  ctx.autoVerify.resolveResponse(id, payload);
84
83
  }
@@ -86,15 +85,15 @@ export function createPasskeyHandlers(ctx) {
86
85
  };
87
86
  try {
88
87
  if (!idsToResolve.length || !confirmationResult) {
89
- return respondWithStatus(resolveAllIdsAsFailed('Confirmation window was closed', 'VERIFICATION_CANCELLED'), response, HttpStatus.BAD_REQUEST);
88
+ return respondWithStatus(resolveAllIdsAsFailed('Confirmation window was closed', TwoFactorErrorCode.CANCELLED), response, HttpStatus.BAD_REQUEST);
90
89
  }
91
90
  for (const id of idsToResolve) {
92
91
  const validationResult = yield ctx.adminforth.auth.verify(id, 'auto2FA', false);
93
92
  if (!validationResult) {
94
- return respondWithStatus(resolveAllIdsAsFailed('Invalid session ID or confirmation result'), response, HttpStatus.FORBIDDEN);
93
+ return respondWithStatus(resolveAllIdsAsFailed('Invalid session ID or confirmation result', TwoFactorErrorCode.INVALID_SESSION), response, HttpStatus.FORBIDDEN);
95
94
  }
96
95
  if (validationResult.adminUserPk !== adminUser.pk) {
97
- return respondWithStatus(resolveAllIdsAsFailed('Session does not belong to the authenticated user'), response, HttpStatus.FORBIDDEN);
96
+ return respondWithStatus(resolveAllIdsAsFailed('Session does not belong to the authenticated user', TwoFactorErrorCode.INVALID_SESSION), response, HttpStatus.FORBIDDEN);
98
97
  }
99
98
  }
100
99
  const verificationResult = yield ctx.verifyMfaConfirmation(confirmationResult, {
@@ -106,20 +105,17 @@ export function createPasskeyHandlers(ctx) {
106
105
  headers: headers,
107
106
  }
108
107
  });
109
- if (!verificationResult || !('ok' in verificationResult)) {
110
- return respondWithStatus(resolveAllIdsAsFailed((_b = verificationResult === null || verificationResult === void 0 ? void 0 : verificationResult.error) !== null && _b !== void 0 ? _b : 'Verification failed'), response, HttpStatus.FORBIDDEN);
108
+ if ('error' in verificationResult) {
109
+ return respondWithStatus(resolveAllIdsAsFailed(verificationResult.error, verificationResult.code), response, HttpStatus.FORBIDDEN);
111
110
  }
112
- if ('ok' in verificationResult && verificationResult.ok) {
113
- for (const id of idsToResolve) {
114
- ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
115
- }
116
- return { ok: true };
111
+ for (const id of idsToResolve) {
112
+ ctx.autoVerify.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
117
113
  }
118
- return respondWithStatus(resolveAllIdsAsFailed('Verification failed'), response, HttpStatus.FORBIDDEN);
114
+ return { ok: true };
119
115
  }
120
116
  catch (error) {
121
117
  console.error('[AdminForth 2FA] Error resolving automatic 2FA verification', error);
122
- return respondWithStatus(resolveAllIdsAsFailed(errorMessage(error)), response, HttpStatus.INTERNAL_SERVER_ERROR);
118
+ return respondWithStatus(resolveAllIdsAsFailed(errorMessage(error), TwoFactorErrorCode.INTERNAL_ERROR), response, HttpStatus.INTERNAL_SERVER_ERROR);
123
119
  }
124
120
  }),
125
121
  };
@@ -44,6 +44,10 @@ export declare function createTwoFaHandlers(ctx: any): {
44
44
  cookies: any;
45
45
  }) => Promise<{
46
46
  skipAllowed: boolean;
47
+ gracePeriod?: undefined;
48
+ } | {
49
+ skipAllowed: boolean;
50
+ gracePeriod: any;
47
51
  }>;
48
52
  verifyTotp: ({ adminUser, body, response }: {
49
53
  adminUser: any;
@@ -160,13 +160,9 @@ export function createTwoFaHandlers(ctx) {
160
160
  return { skipAllowed: true };
161
161
  }
162
162
  }
163
- if (ctx.options.stepUpMfaGracePeriodSeconds) {
164
- const verificationResult = yield ctx.cookieService.isMfaGraceValid(headers, cookies, true);
165
- if (verificationResult === true) {
166
- return { skipAllowed: true };
167
- }
168
- }
169
- return { skipAllowed: false };
163
+ const gracePeriod = Boolean(ctx.options.stepUpMfaGracePeriodSeconds)
164
+ && (yield ctx.cookieService.isMfaGraceValid(headers, cookies, true));
165
+ return { skipAllowed: false, gracePeriod };
170
166
  }),
171
167
  verifyTotp: (_a) => __awaiter(this, [_a], void 0, function* ({ adminUser, body, response }) {
172
168
  if (!(body === null || body === void 0 ? void 0 : body.code))