|  | 
| 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 | 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" | 
| 28 | 30 |             :disabled="inProgress" | 
| 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 | 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 | 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 | 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 | 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 | 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> | 
|  | 
0 commit comments