feat(auth): integrate captcha validation and component for sign-in/up… (#15054)

… process

Added captcha token validation to the sign-in/up flow. Introduced
`StyledLoaderContainer` for better UI feedback during loading. Enhanced
error handling within `useSignInUp` to handle GraphQL errors
effectively.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-10-24 10:13:52 +02:00
committed by GitHub
parent 06a195af41
commit 4220bba69b
11 changed files with 185 additions and 38 deletions
@@ -42,6 +42,7 @@ import {
getFirstAvailableWorkspaces,
} from '@/auth/utils/availableWorkspacesUtils';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { captchaState } from '@/client-config/states/captchaState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
@@ -65,7 +66,6 @@ import { type AuthToken } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { loginTokenState } from '../states/loginTokenState';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
@@ -12,6 +12,7 @@ import {
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Trans, useLingui } from '@lingui/react/macro';
import { OTPInput, type SlotProps } from 'input-otp';
@@ -184,6 +185,7 @@ export const SignInUpTOTPVerification = () => {
const navigate = useNavigateApp();
const { readCaptchaToken } = useReadCaptchaToken();
const { isCaptchaReady } = useCaptcha();
const loginToken = useRecoilValue(loginTokenState);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const { t } = useLingui();
@@ -193,7 +195,15 @@ export const SignInUpTOTPVerification = () => {
const submitOTP = async (values: OTPFormValues) => {
setIsLoading(true);
try {
const captchaToken = await readCaptchaToken();
if (!isCaptchaReady) {
enqueueErrorSnackBar({
message: t`Captcha (anti-bot check) is still loading, try again`,
});
setIsLoading(false);
return;
}
const captchaToken = readCaptchaToken();
if (!loginToken) {
return navigate(AppPath.SignInUp);
@@ -10,16 +10,18 @@ import {
} from '@/auth/states/signInUpStepState';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { useBuildSearchParamsFromUrlSyncedStates } from '@/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAuth } from '../../hooks/useAuth';
import { useLingui } from '@lingui/react/macro';
export const useSignInUp = (form: UseFormReturn<Form>) => {
const { enqueueErrorSnackBar } = useSnackBar();
@@ -28,6 +30,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const { isCaptchaReady } = useCaptcha();
const location = useLocation();
@@ -59,49 +62,65 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const errorMsgUserAlreadyExist = t`An error occurred while checking user existence`;
const continueWithCredentials = useCallback(async () => {
const token = await readCaptchaToken();
if (!form.getValues('email')) {
return;
return enqueueErrorSnackBar({
message: t`Email is required`,
});
}
if (!isCaptchaReady) {
return enqueueErrorSnackBar({
message: t`Captcha (anti-bot check) is still loading, try again`,
});
}
try {
const { data } = await checkUserExistsQuery({
const token = readCaptchaToken();
const { data, error } = await checkUserExistsQuery({
variables: {
email: form.getValues('email').toLowerCase().trim(),
captchaToken: token,
},
});
if (isDefined(error)) {
return enqueueErrorSnackBar({ apolloError: error });
}
setSignInUpMode(
data?.checkUserExists.exists
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
setSignInUpStep(SignInUpStep.Password);
} catch (error) {
enqueueErrorSnackBar({
...(error instanceof ApolloError
? { apolloError: error }
: { message: errorMsgUserAlreadyExist }),
});
} catch {
enqueueErrorSnackBar({ message: errorMsgUserAlreadyExist });
}
}, [
readCaptchaToken,
form,
checkUserExistsQuery,
isCaptchaReady,
enqueueErrorSnackBar,
setSignInUpStep,
t,
checkUserExistsQuery,
setSignInUpMode,
setSignInUpStep,
errorMsgUserAlreadyExist,
]);
const submitCredentials: SubmitHandler<Form> = useCallback(
async (data) => {
const token = await readCaptchaToken();
try {
if (!data.email || !data.password) {
throw new Error('Email and password are required');
}
if (!data.email || !data.password) {
throw new Error('Email and password are required');
}
if (!isCaptchaReady) {
return enqueueErrorSnackBar({
message: t`Captcha (anti-bot check) is still loading, try again`,
});
}
const token = readCaptchaToken();
try {
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignIn &&
@@ -158,6 +177,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
}
},
[
isCaptchaReady,
readCaptchaToken,
signInUpMode,
isInviteMode,
@@ -170,6 +190,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
enqueueErrorSnackBar,
buildSearchParamsFromUrlSyncedStates,
isOnAWorkspace,
t,
],
);
@@ -1,20 +1,22 @@
import { useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { getCaptchaUrlByProvider } from '@/captcha/utils/getCaptchaUrlByProvider';
import { captchaState } from '@/client-config/states/captchaState';
import { CaptchaDriverType } from '~/generated/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { captchaState } from '@/client-config/states/captchaState';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { CaptchaDriverType } from '~/generated/graphql';
export const CaptchaProviderScriptLoaderEffect = () => {
const captcha = useRecoilValue(captchaState);
const [isCaptchaScriptLoaded, setIsCaptchaScriptLoaded] = useRecoilState(
const setIsCaptchaScriptLoaded = useSetRecoilState(
isCaptchaScriptLoadedState,
);
const { isCaptchaScriptLoaded, isCaptchaConfigured } = useCaptcha();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const location = useLocation();
@@ -60,10 +62,12 @@ export const CaptchaProviderScriptLoaderEffect = () => {
]);
useEffect(() => {
if (isUndefinedOrNull(captcha?.provider) || !isCaptchaScriptLoaded) {
if (!isCaptchaConfigured || !isCaptchaScriptLoaded) {
return;
}
assertIsDefinedOrThrow(captcha);
let refreshInterval: NodeJS.Timeout;
switch (captcha.provider) {
@@ -81,7 +85,13 @@ export const CaptchaProviderScriptLoaderEffect = () => {
}
return () => clearInterval(refreshInterval);
}, [captcha?.provider, requestFreshCaptchaToken, isCaptchaScriptLoaded]);
}, [
captcha,
captcha?.provider,
isCaptchaConfigured,
isCaptchaScriptLoaded,
requestFreshCaptchaToken,
]);
return <></>;
};
@@ -6,7 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
export const useReadCaptchaToken = () => {
const readCaptchaToken = useRecoilCallback(
({ snapshot }) =>
async () => {
() => {
const existingCaptchaToken = snapshot
.getLoadable(captchaTokenState)
.getValue();
@@ -4,8 +4,8 @@ import { captchaTokenState } from '@/captcha/states/captchaTokenState';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { captchaState } from '@/client-config/states/captchaState';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { CaptchaDriverType } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useRequestFreshCaptchaToken = () => {
const setCaptchaToken = useSetRecoilState(captchaTokenState);
@@ -22,14 +22,15 @@ export const useRequestFreshCaptchaToken = () => {
const captcha = snapshot.getLoadable(captchaState).getValue();
if (isUndefinedOrNull(captcha?.provider)) {
if (!isDefined(captcha)) {
return;
}
assertIsDefinedOrThrow(captcha);
setIsRequestingCaptchaToken(true);
let captchaWidget: any;
switch (captcha.provider) {
case CaptchaDriverType.GOOGLE_RECAPTCHA:
window.grecaptcha
@@ -0,0 +1,31 @@
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { captchaState } from '@/client-config/states/captchaState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useCaptcha = () => {
const captcha = useRecoilValue(captchaState);
const captchaToken = useRecoilValue(captchaTokenState);
const clientConfigApiStatus = useRecoilValue(clientConfigApiStatusState);
const isCaptchaScriptLoaded = useRecoilValue(isCaptchaScriptLoadedState);
const isClientConfigLoaded = clientConfigApiStatus.isLoadedOnce;
const isSiteKeyDefined = isDefined(captcha?.siteKey);
const isTokenAvailable = isDefined(captchaToken);
// Captcha is ready when:
// - Client config is loaded
// - And either captcha is not configured with a site key (no captcha required)
// - Or, when configured, a captcha token is available
const isCaptchaReady =
isClientConfigLoaded && (!isSiteKeyDefined || isTokenAvailable);
return {
isCaptchaScriptLoaded,
isCaptchaConfigured: !isUndefinedOrNull(captcha),
isCaptchaReady,
};
};