Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 44 additions & 59 deletions custom/TwoFAModal.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<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"
v-show ="modelShow && (isLoading === false)">
<div v-if="modalMode === 'totp'" class="af-two-factor-modal-totp flex flex-col items-center relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
v-show ="twofaApi.isOpened && (isLoading === false)">
<div v-if="twofaApi.modalMode === 'totp'" class="af-two-factor-modal-totp flex flex-col items-center relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
<div id="mfaCode-label" class="mb-4 text-gray-700 dark:text-gray-100 text-center">
<p> {{ customDialogTitle }} </p>
<p> {{ twofaApi.customDialogTitle }} </p>
<p>{{ $t('Please enter your authenticator code') }}</p>
</div>

Expand All @@ -23,8 +23,8 @@
/>
</div>

<div class="flex items-center w-full" :class="doesUserHavePasskeys ? 'justify-between' : 'justify-center' ">
<p v-if="doesUserHavePasskeys===true" class="underline hover:no-underline text-lightPrimary whitespace-nowrap hover:cursor-pointer" @click="modalMode = 'passkey'" >{{$t('use passkey')}}</p>
<div class="flex items-center w-full" :class="twofaApi.doesUserHavePasskeys ? 'justify-between' : 'justify-center' ">
<p v-if="twofaApi.doesUserHavePasskeys===true" class="underline hover:no-underline text-lightPrimary whitespace-nowrap hover:cursor-pointer" @click="twofaApi.setModalMode('passkey')" >{{$t('use passkey')}}</p>
<Button
class="px-4 py-2 rounded border"
@click="onCancel"
Expand All @@ -36,7 +36,7 @@



<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">
<div v-else-if="twofaApi.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">
<button
type="button"
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"
Expand All @@ -50,16 +50,16 @@
<IconShieldOutline class="af-2fa-shield-icon w-16 h-16 text-lightPrimary dark:text-darkPrimary"/>
<p class="text-4xl font-semibold mb-4 text:gray-900 dark:text-gray-200 ">{{$t('Passkey')}}</p>
<div class="mb-2 max-w-[300px] text:gray-900 dark:text-gray-200">
<p class="mb-2">{{customDialogTitle}} </p>
<p class="mb-2">{{twofaApi.customDialogTitle}} </p>
<p>{{$t('Authenticate yourself using the button below')}}</p>
</div>
<Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="w-full mx-16">
{{$t('Use passkey')}}
</Button>
<div v-if="modalMode === 'passkey'" class="af-2fa-passkey-issues-card 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">
<div v-if="twofaApi.modalMode === 'passkey'" class="af-2fa-passkey-issues-card 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">
<div class="mb-3 font-normal text-gray-700 dark:text-gray-400">
<p>{{$t('Have issues with passkey?')}}</p>
<p class="underline hover:no-underline text-lightPrimary whitespace-nowrap hover:cursor-pointer" @click="modalMode = 'totp'" >{{$t('use TOTP')}}</p>
<p class="underline hover:no-underline text-lightPrimary whitespace-nowrap hover:cursor-pointer" @click="twofaApi.setModalMode('totp')" >{{$t('use TOTP')}}</p>
</div>
</div>

Expand All @@ -80,7 +80,9 @@
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
import { getPasskey } from './utils.js'
import adminforth from '@/adminforth';
import { use2faApi } from './use2faApi';

const twofaApi = use2faApi();

declare global {
interface Window {
Expand All @@ -101,6 +103,10 @@
(e: 'closed'): void
}>();

onMounted(() => {
twofaApi.registerAddEventListenerForOTPInput(addEventListenerForOTPInput);
});

async function addEventListenerForOTPInput(){
document.addEventListener('focusin', handleGlobalFocusIn, true);
focusFirstAvailableOtpInput();
Expand All @@ -120,32 +126,32 @@


const modelShow = ref(false);
let resolveFn: ((confirmationResult: any) => void) | null = null;
let verifyingCallback: ((confirmationResult: string) => boolean) | null = null;
let verifyFn: null | ((confirmationResult: string) => Promise<boolean> | boolean) = null;
let rejectFn: ((err?: any) => void) | null = null;
// let resolveFn: ((confirmationResult: any) => void) | null = null;
// let verifyingCallback: ((confirmationResult: string) => boolean) | null = null;
// let verifyFn: null | ((confirmationResult: string) => Promise<boolean> | boolean) = null;
// let rejectFn: ((err?: any) => void) | null = null;
Comment thread
yaroslav8765 marked this conversation as resolved.
Outdated


window.adminforthTwoFaModal = {
get2FaConfirmationResult: (title?: string, verifyingCallback?: (confirmationResult: string) => Promise<boolean>) =>
new Promise(async (resolve, reject) => {
if (modelShow.value) throw new Error('Modal is already open');
if (twofaApi.isOpened) throw new Error('Modal is already open');
const skipAllowModal = await checkIfSkipAllowModal();
Comment thread
yaroslav8765 marked this conversation as resolved.
Outdated
if (skipAllowModal) {
resolve({ code: "123456" }); // dummy code
return;
}
await checkIfUserHasPasskeys();
if (title) {
customDialogTitle.value = title;
twofaApi.setCustomDialogTitle(title);
}
modelShow.value = true;
if (modalMode.value === 'totp') {
twofaApi.setIsOpened(true);
if (twofaApi.modalMode === 'totp') {
Comment thread
yaroslav8765 marked this conversation as resolved.
Outdated
await addEventListenerForOTPInput();
}
resolveFn = resolve;
rejectFn = reject;
verifyFn = verifyingCallback ?? null;
twofaApi.registerResolveFn(resolve);
twofaApi.registerRejectFn(reject);
twofaApi.registerVerifyFn(verifyingCallback ?? null);
}),
};

Expand All @@ -168,14 +174,14 @@
onCancel();
return null;
}
modelShow.value = false;
twofaApi.setIsOpened(false);
const dataToReturn = {
mode: "passkey",
result: passkeyData
}
customDialogTitle.value = "";
twofaApi.setCustomDialogTitle("");
removeEventListenerForOTPInput();
resolveFn(dataToReturn);
twofaApi.resolveFn?.(dataToReturn);
}

function tagOtpInputs() {
Expand Down Expand Up @@ -206,50 +212,50 @@
}

async function sendConfirmationResult(value: string) {
if (!resolveFn) throw new Error('Modal is not initialized properly');
if (verifyFn) {
if (!twofaApi.resolveFn) throw new Error('Modal is not initialized properly');
if (twofaApi.verifyFn) {
try {
const ok = await verifyFn(value);
const ok = await twofaApi.verifyFn(value);
if (!ok) {
rejectFn?.(new Error('Invalid code'));
twofaApi.rejectFn?.(new Error('Invalid code'));
return;
}
} catch (err) {
rejectFn?.(err);
twofaApi.rejectFn?.(err);
return;
}
}

modelShow.value = false;
twofaApi.setIsOpened(false);
const dataToReturn = {
mode: "totp",
result: value
}
customDialogTitle.value = "";
twofaApi.setCustomDialogTitle("");
removeEventListenerForOTPInput();
resolveFn(dataToReturn);
twofaApi.resolveFn?.(dataToReturn);
}


function onCancel() {
modelShow.value = false;
twofaApi.setIsOpened(false);
bindValue.value = '';
confirmationResult.value?.clearInput();
removeEventListenerForOTPInput();
rejectFn("Cancel");
twofaApi.rejectFn?.(new Error('Cancel'));
emit('rejected', new Error('cancelled'));
emit('closed');
}

watch(modalMode, async (newMode) => {
watch(() => twofaApi.modalMode, async (newMode) => {
if (newMode === 'totp') {
await addEventListenerForOTPInput();
} else {
removeEventListenerForOTPInput();
}
});

watch(modelShow, async (open) => {
watch(() => twofaApi.isOpened, async (open) => {
if (open) {
await nextTick();
const htmlRef = document.querySelector('html');
Expand All @@ -258,7 +264,7 @@
}

// Wait for conditional rendering to complete
if (modalMode.value === 'totp' && !isLoading.value) {
if (twofaApi.modalMode === 'totp' && !isLoading.value) {
await nextTick();
setTimeout(() => {
tagOtpInputs();
Expand All @@ -280,29 +286,8 @@

async function checkIfUserHasPasskeys() {
isLoading.value = true;
try {
const response = await callAdminForthApi({
method: 'GET',
path: '/plugin/passkeys/getPasskeys',
});

if (response.ok) {
if (response.data.length >= 1) {
doesUserHavePasskeys.value = true;
modalMode.value = "passkey";
} else {
doesUserHavePasskeys.value = false;
modalMode.value = "totp";
}
}
} catch (error) {
console.error('Error checking passkeys:', error);
// Fallback to TOTP if there's an error
doesUserHavePasskeys.value = false;
modalMode.value = "totp";
} finally {
isLoading.value = false;
}
await twofaApi.checkIfUserHasPasskeys();
isLoading.value = false;
}

function getOtpInputs() {
Expand Down
130 changes: 130 additions & 0 deletions custom/use2faApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { ref } from 'vue';
import { defineStore } from 'pinia'
import { callAdminForthApi } from '@/utils/utils';
Comment thread
yaroslav8765 marked this conversation as resolved.

export const use2faApi = defineStore('2fa', () => {
const isOpened = ref(false);
const customDialogTitle = ref('');
const resolveFn = ref<((confirmationResult: any) => void) | null>(null);
const verifyFn = ref<null | ((confirmationResult: string) => Promise<boolean> | boolean)>(null);
const rejectFn = ref<((err?: any) => void) | null>(null);
const addEventListenerForOTPInput = ref<null | (() => Promise<void>)>(null);
const doesUserHavePasskeys = ref(false);
const modalMode = ref<"totp" | "passkey">("totp");

function setDoesUserHavePasskeys(value: boolean) {
doesUserHavePasskeys.value = value;
}

function setModalMode(mode: "totp" | "passkey") {
modalMode.value = mode;
}

function registerVerifyFn(fn: (confirmationResult: string) => Promise<boolean> | boolean) {
verifyFn.value = fn;
}

function registerRejectFn(fn: (err?: any) => void) {
rejectFn.value = fn;
}

function registerResolveFn(fn: (confirmationResult: any) => void) {
resolveFn.value = fn;
}

function registerAddEventListenerForOTPInput(fn: () => Promise<void>) {
addEventListenerForOTPInput.value = fn;
}

async function setCustomDialogTitle(title: string) {
Comment thread
yaroslav8765 marked this conversation as resolved.
Outdated
customDialogTitle.value = title;
}

async function checkIfSkipAllowModal(){
try {
const response = await callAdminForthApi({
method: "GET",
path: "/plugin/twofa/skip-allow-modal",
});
if ( response.skipAllowed === true ) {
return true;
} else {
return false;
}
} catch (error) {
console.error('Error checking skip allow modal:', error);
return false;
}
}

async function checkIfUserHasPasskeys() {
try {
const response = await callAdminForthApi({
method: 'GET',
path: '/plugin/passkeys/getPasskeys',
});

if (response.ok) {
if (response.data.length >= 1) {
doesUserHavePasskeys.value = true;
modalMode.value = "passkey";
} else {
doesUserHavePasskeys.value = false;
modalMode.value = "totp";
}
}
Comment thread
yaroslav8765 marked this conversation as resolved.
Comment thread
yaroslav8765 marked this conversation as resolved.
} catch (error) {
console.error('Error checking passkeys:', error);
doesUserHavePasskeys.value = false;
modalMode.value = "totp";
return false;
}
}

async function get2FaConfirmationResult(title?: string, verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean) {
return new Promise(async (resolve, reject) => {
if (isOpened.value) throw new Error('Modal is already open');
const skipAllowModal = await checkIfSkipAllowModal();
if (skipAllowModal) {
resolve({ code: "123456" }); // dummy code
return;
}
await checkIfUserHasPasskeys();
if (title) {
customDialogTitle.value = title;
}
isOpened.value = true;
Comment thread
yaroslav8765 marked this conversation as resolved.
if (!doesUserHavePasskeys.value && addEventListenerForOTPInput.value && typeof addEventListenerForOTPInput.value === 'function') {
await addEventListenerForOTPInput.value();
}
Comment thread
yaroslav8765 marked this conversation as resolved.
resolveFn.value = resolve;
rejectFn.value = reject;
verifyFn.value = verifyingCallback ?? null;
Comment thread
yaroslav8765 marked this conversation as resolved.
});
}

function setIsOpened(value: boolean) {
isOpened.value = value;
}

return {
isOpened,
customDialogTitle,
get2FaConfirmationResult,
setIsOpened,
setCustomDialogTitle,
checkIfSkipAllowModal,
registerAddEventListenerForOTPInput,
resolveFn,
verifyFn,
rejectFn,
registerVerifyFn,
registerRejectFn,
registerResolveFn,
checkIfUserHasPasskeys,
setDoesUserHavePasskeys,
setModalMode,
doesUserHavePasskeys,
modalMode,

@ivictbor ivictbor Apr 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yaroslav8765 this is over-exposed and this is a critical issue, for users and LLM Agentic stability. In all plugins, always we need to expose from useXXAPI only methods which might be used by someone, very limited set (same method we had before exposed in window, nothing more then it), we should hide all internal implementation, e.g. setIsOpene can brake internal state, it is only get2FaConfirmationResult and maybe some couple of other methods?

Please never expose "just in case someone might use it" - it will cause a lot of issues, every exposed API method should be carefully planned, otherwise we mislead. Even before LLMs plain Ctrl+Space will show user all avaialble methods, user will think he can use it and by using some internal state method he might achieven unexpected unclear behaviour

Please read, ask chat to explain and remember these very basic principles:

  1. https://en.wikipedia.org/wiki/Encapsulation_%28computer_programming%29
  2. https://en.wikipedia.org/wiki/Information_hiding
  3. https://en.wikipedia.org/wiki/Law_of_Demeter
  4. https://en.wikipedia.org/wiki/Interface_segregation_principle

Lets discuss all 4 principles later

};
});