|
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