Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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: t('Verification failed'), variant: 'danger'});
} else if (response.ok) {
alert({message: 'Verification successful', variant: 'success'});
alert({message: t('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
10 changes: 5 additions & 5 deletions custom/TwoFactorsConfirmation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
}
});
} catch (error) {
console.error('Error checking if user has passkeys:', error);
console.error(t('Error checking if user has passkeys:', error));
}
}

Expand All @@ -318,7 +318,7 @@
try {
options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
} catch (e) {
console.error('Error parsing request options:', e);
console.error(t('Error parsing request options:', e));
adminforth.alert({message: t('Error initiating passkey authentication.'), variant: 'warning'});
return;
}
Expand All @@ -345,14 +345,14 @@
method: 'POST',
});
} catch (error) {
console.error('Error creating sign-in request:', error);
console.error(t('Error creating sign-in request:', error));
return;
}
if (response.ok === true) {
return { _options: response.data, challengeId: response.challengeId };
} else {
adminforth.alert({message: t('Error creating sign-in request.'), variant: 'warning'});
codeError.value = 'Error creating sign-in request.';
codeError.value = t('Error creating sign-in request.');
}
}

Expand All @@ -378,7 +378,7 @@
});
return credential;
} catch (error) {
console.error('Error during authentication:', error);
console.error(t('Error during authentication:', error));
// Handle specific concurrent/pending request error cases gracefully
const name = (error && (error.name || error.constructor?.name)) || '';
const message = (error && error.message) || '';
Expand Down
Loading