Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fad1636
fix: allow to resolve multiple 2fa calls for the verifyAuto calls
yaroslav8765 Apr 28, 2026
1a2c3e1
fix: improve verifyAuto multiple calls handling and security
yaroslav8765 Apr 28, 2026
298dcdd
fix: fix security problems of the verifyAuto call
yaroslav8765 Apr 29, 2026
bc6e07d
feat: enhance TwoFAModal and TwoFactorsPasskeysSettings for improved …
SerVitasik Apr 29, 2026
a9545a3
Merge branch 'main' of https://github.com/devforth/adminforth-two-fac…
SerVitasik Apr 29, 2026
30f5fc0
fix: update error messages and translations in 2FA components
kulikp1 Apr 29, 2026
0654780
fix: update translation logic for multiple actions in 2FA modal
kulikp1 Apr 30, 2026
a4e5d31
fix: update adminforth verion
yaroslav8765 Apr 30, 2026
8b78beb
Merge pull request #24 from devforth/feature/AdminForth/1539/check-tr…
SerVitasik May 1, 2026
80748d4
fix: update adminforth verion
SerVitasik May 1, 2026
dcf0bea
fix: improve error messaging in TwoFAModal for better user feedback
kulikp1 May 5, 2026
742d560
Merge branch 'main' into feature/AdminForth/1540/show-proper-error-me…
kulikp1 May 5, 2026
500becc
Merge pull request #25 from devforth/feature/AdminForth/1540/show-pro…
SerVitasik May 5, 2026
3efb1e7
feat: enhance TwoFactorsConfirmation modal with improved layout and u…
kulikp1 May 6, 2026
2ef7b76
Merge pull request #26 from devforth/feature/AdminForth/1565/improve-…
SerVitasik May 7, 2026
d66a81e
fix: change toast type to error for passkey authentication failures
kulikp1 May 7, 2026
3a70ea0
fix: streamline error handling for passkey authentication by removing…
kulikp1 May 7, 2026
ddc7710
Merge branch 'main' into feature/AdminForth/1566/change-toast-type-to…
kulikp1 May 7, 2026
89b51cb
fix: enhance waitForResponse method with timeout handling and error r…
NoOne7135 May 7, 2026
fe59bc3
fix: dont show success toast when user resolves 2fa modal successfully
yaroslav8765 May 8, 2026
4590cf8
Merge branch 'main' of https://github.com/devforth/adminforth-two-fac…
yaroslav8765 May 8, 2026
88fd9f2
fix: update error handling for authentication to provide more specifi…
kulikp1 May 8, 2026
d3e01d5
fix: enhance error handling for authentication by adding a generic er…
kulikp1 May 8, 2026
234c580
fix: enhance error handling for authentication by adding a generic er…
kulikp1 May 8, 2026
a8a1fb4
return comment
kulikp1 May 8, 2026
e15cc21
fix: remove redundant error handling for NotAllowedError in OTP input…
kulikp1 May 8, 2026
362db37
Merge pull request #27 from devforth/feature/AdminForth/1566/change-t…
SerVitasik May 8, 2026
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
182 changes: 119 additions & 63 deletions custom/TwoFAModal.vue
Original file line number Diff line number Diff line change
@@ -1,70 +1,99 @@
<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">
<div id="mfaCode-label" class="mb-4 text-gray-700 dark:text-gray-100 text-center">
<p> {{ customDialogTitle }} </p>
<p>{{ $t('Please enter your authenticator code') }}</p>
</div>

<div class="flex flex-col max-w-[calc(15rem_+_60px)]">
<div class="mb-4 w-full flex justify-center" ref="otpRoot">
<v-otp-input
ref="confirmationResult"
container-class="grid grid-cols-6 gap-3 w-full"
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"
:num-inputs="6"
inputType="number"
inputmode="numeric"
:should-auto-focus="true"
:should-focus-order="true"
v-model:value="bindValue"
@on-complete="handleOnComplete"
/>
<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">
<button
type="button"
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"
@click="onCancel"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<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"/>
</svg>
<span class="sr-only">{{$t('Close modal')}}</span>
</button>

<div class="af-2fa-totp-header flex flex-col items-center justify-center gap-3">
<div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
<IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
</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>
<Button
class="px-4 py-2 rounded border"
@click="onCancel"
:disabled="inProgress"
>{{ $t('Cancel') }}</Button>
<div id="mfaCode-label" class="af-2fa-totp-title-wrap">
<p v-if="customDialogTitle" class="af-2fa-custom-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ customDialogTitle }}</p>
<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>
</div>
</div>
</div>


<div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
<v-otp-input
ref="confirmationResult"
container-class="grid grid-cols-6 gap-3"
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"
:num-inputs="6"
inputType="number"
inputmode="numeric"
:should-auto-focus="true"
:should-focus-order="true"
v-model:value="bindValue"
@on-complete="handleOnComplete"
/>
</div>
<p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
{{$t('Having trouble?')}}
<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>
</p>
<p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
{{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
</p>
</div>

<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="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">
<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"
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"
@click="onCancel"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<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"/>
</svg>
<span class="sr-only">{{$t('Close modal')}}</span>
</button>
<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>{{$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 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>

<div class="af-2fa-passkey-header flex flex-col items-center justify-center gap-3">
<div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
<IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
</div>
<div class="af-2fa-passkey-title-wrap">
<p class="af-2fa-passkey-title text-xl text-center font-medium text-gray-900 dark:text-white">{{$t('Verify to add passkey')}}</p>
<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>
</div>
</div>

<div class="af-2fa-passkey-steps flex flex-col gap-2 mt-2">
<div
v-for="(step, i) in [
$t('Click the button below to begin'),
$t('Authenticate with your device biometrics or PIN'),
$t('Your passkey will be saved automatically'),
]"
:key="i"
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"
>
<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>
{{ step }}
</div>
</div>


<Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn w-full flex items-center justify-center gap-2 mt-2">
<IconShieldOutline class="w-4 h-4" />
{{$t('Use passkey to verify')}}
</Button>
<p class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
{{$t('Having trouble?')}}
<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>
</p>
<p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
{{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
</p>
</div>
</div>
</template>
Expand All @@ -83,13 +112,15 @@
import websocket from '@/websocket';
import type { AdminUser } from '@/types/Common';

type TwoFaConfirmationResult = { mode: 'totp'; result: string } | { mode: 'passkey'; result: Record<string, any> };

declare global {
interface Window {
adminforthTwoFaModal: {
get2FaConfirmationResult: (
title?: string,
verifyingCallback?: (confirmationResult: string) => Promise<boolean>
) => Promise<any>;
) => Promise<TwoFaConfirmationResult>;
};
}
}
Expand All @@ -100,40 +131,65 @@

const { alert } = useAdminforth();

let currentSessionId: string | null = null;
const isAwaiting2FAResult = ref(false);
let allowAddNewSessions = true;
const ALLOW_NEW_SESSIONS_PERIOD = 1000;
const sessionsIdsToResolve = ref<string[]>([]);

watch(isAwaiting2FAResult, (awaiting) => {
if (awaiting) {
allowAddNewSessions = true;
setTimeout(() => {
if (isAwaiting2FAResult.value) {
allowAddNewSessions = false;
}
}, ALLOW_NEW_SESSIONS_PERIOD);
}
});

watch( props, () => {
if (props.adminUser) {
websocket.unsubscribeByPrefix(`/user2fa/`);
websocket.subscribe(`/user2fa/${props.adminUser.pk}`, async (data: {sessionId: string}) => {
currentSessionId = data.sessionId;
if (!allowAddNewSessions) {
alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
return;
}
sessionsIdsToResolve.value.push(data.sessionId);
let confirmationResult;
if (isAwaiting2FAResult.value) {
return;
}
try {
isAwaiting2FAResult.value = true;
confirmationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult();
} catch (error) {
console.error('Error during 2FA confirmation:', error);
console.error(t('Error during 2FA confirmation:', error));
}
isAwaiting2FAResult.value = false;
try {
const response = await callAdminForthApi({
method: "POST",
path: "/plugin/passkeys/resolveVerifyAuto",
body: { confirmationResult, sessionId: data.sessionId }
body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value }
});
if (!response.ok && response.error === 'No session ID or confirmation result'){
alert({message: 'Verification session finished or cancelled.', variant: 'warning'});
alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
} else if (!response.ok) {
alert({message: 'Verification failed', variant: 'danger'});
alert({message: response.error || t('Verification failed'), variant: 'danger'});
} else if (response.ok) {
alert({message: 'Verification successful', variant: 'success'});
// alert({message: 'Verification successful', variant: 'success'});
}
sessionsIdsToResolve.value = [];
} catch (error) {
console.error('Error resolving automatic 2FA verification:', error);
console.error(t('Error resolving automatic 2FA verification:', error));
}
currentSessionId = null;
allowAddNewSessions = true;
});
websocket.subscribe(`/user2fa/${props.adminUser.pk}-resolve`, async (data: {sessionId: string}) => {
if (currentSessionId === data.sessionId && rejectFn && modelShow.value) {
if (sessionsIdsToResolve.value.includes(data.sessionId) && rejectFn && modelShow.value) {
onCancel();
currentSessionId = null;
sessionsIdsToResolve.value = sessionsIdsToResolve.value.filter(id => id !== data.sessionId);
}
});
}
Expand Down Expand Up @@ -174,7 +230,7 @@
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 (modelShow.value) throw new Error(t('Modal is already open'));
const skipAllowModal = await checkIfSkipAllowModal();
if (skipAllowModal) {
resolve({ code: "123456" }); // dummy code
Expand Down Expand Up @@ -251,12 +307,12 @@
}

async function sendConfirmationResult(value: string) {
if (!resolveFn) throw new Error('Modal is not initialized properly');
if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
if (verifyFn) {
try {
const ok = await verifyFn(value);
if (!ok) {
rejectFn?.(new Error('Invalid code'));
rejectFn?.(new Error(t('Invalid code')));
return;
}
} catch (err) {
Expand Down Expand Up @@ -341,7 +397,7 @@
}
}
} catch (error) {
console.error('Error checking passkeys:', error);
console.error(t('Error checking passkeys:', error));
// Fallback to TOTP if there's an error
doesUserHavePasskeys.value = false;
modalMode.value = "totp";
Expand Down Expand Up @@ -403,7 +459,7 @@
return false;
}
} catch (error) {
console.error('Error checking skip allow modal:', error);
console.error(t('Error checking skip allow modal:', error));
return false;
}
}
Expand Down
Loading