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
co-authored by Félix Malfait Félix Malfait
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,
};
};
@@ -6,6 +6,7 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
@@ -126,6 +127,7 @@ export const PasswordReset = () => {
const { signInWithCredentialsInWorkspace } = useAuth();
const { readCaptchaToken } = useReadCaptchaToken();
const { isCaptchaReady } = useCaptcha();
const onSubmit = async (formData: Form) => {
try {
@@ -151,7 +153,14 @@ export const PasswordReset = () => {
return;
}
const token = await readCaptchaToken();
if (!isCaptchaReady) {
enqueueErrorSnackBar({
message: t`Captcha (anti-bot check) is still loading, try again`,
});
return;
}
const token = readCaptchaToken();
await signInWithCredentialsInWorkspace(
email || '',
@@ -5,6 +5,7 @@ import {
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Logo } from '@/auth/components/Logo';
@@ -26,13 +27,24 @@ import { SignInUpGlobalScopeFormEffect } from '@/auth/sign-in-up/components/inte
import { SignInUpTwoFactorAuthenticationProvision } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision';
import { SignInUpTOTPVerification } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useLingui } from '@lingui/react/macro';
import { useSearchParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { AnimatedEaseIn } from 'twenty-ui/utilities';
import { type PublicWorkspaceDataOutput } from '~/generated/graphql';
const StyledLoaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
margin-top: ${({ theme }) => theme.spacing(8)};
width: 100%;
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StandardContent = ({
workspacePublicData,
signInUpForm,
@@ -70,13 +82,15 @@ const StandardContent = ({
export const SignInUp = () => {
const { t } = useLingui();
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const clientConfigApiStatus = useRecoilValue(clientConfigApiStatusState);
const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form);
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { loading } = useGetPublicWorkspaceDataByDomain();
const { loading: getPublicWorkspaceDataLoading } =
useGetPublicWorkspaceDataByDomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { workspaceInviteHash, workspace: workspaceFromInviteHash } =
useWorkspaceFromInviteHash();
@@ -121,7 +135,13 @@ export const SignInUp = () => {
]);
const signInUpForm = useMemo(() => {
if (loading) return null;
if (getPublicWorkspaceDataLoading || !clientConfigApiStatus.isLoadedOnce) {
return (
<StyledLoaderContainer>
<Loader color="gray" />
</StyledLoaderContainer>
);
}
if (isDefaultDomain && isMultiWorkspaceEnabled) {
return (
@@ -163,10 +183,11 @@ export const SignInUp = () => {
</>
);
}, [
clientConfigApiStatus.isLoadedOnce,
isDefaultDomain,
isMultiWorkspaceEnabled,
isOnAWorkspace,
loading,
getPublicWorkspaceDataLoading,
signInUpStep,
workspacePublicData,
]);
@@ -2,7 +2,10 @@ import { getOperationName } from '@apollo/client/utilities';
import { type Meta, type StoryObj } from '@storybook/react';
import { fireEvent, within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import {
PageDecorator,
@@ -13,9 +16,28 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { AppPath } from 'twenty-shared/types';
import { SignInUp } from '../SignInUp';
const CaptchaTokenSetterEffect = () => {
const setCaptchaToken = useSetRecoilState(captchaTokenState);
useEffect(() => {
setCaptchaToken('MOCKED_CAPTCHA_TOKEN');
}, [setCaptchaToken]);
return null;
};
const SignInUpWithCaptcha = () => {
return (
<>
<CaptchaTokenSetterEffect />
<SignInUp />
</>
);
};
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Auth/SignInUp',
component: SignInUp,
component: SignInUpWithCaptcha,
decorators: [PageDecorator],
args: { routePath: AppPath.SignInUp },
parameters: {
@@ -47,7 +69,7 @@ const meta: Meta<PageDecoratorArgs> = {
export default meta;
export type Story = StoryObj<typeof SignInUp>;
export type Story = StoryObj<typeof SignInUpWithCaptcha>;
export const Default: Story = {
play: async ({ canvasElement }) => {
@@ -2,7 +2,10 @@ import { getOperationName } from '@apollo/client/utilities';
import { type Meta, type StoryObj } from '@storybook/react';
import { fireEvent, within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { GET_WORKSPACE_FROM_INVITE_HASH } from '@/workspace/graphql/queries/getWorkspaceFromInviteHash';
import {
@@ -14,9 +17,28 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { AppPath } from 'twenty-shared/types';
import { SignInUp } from '../SignInUp';
const CaptchaTokenSetterEffect = () => {
const setCaptchaToken = useSetRecoilState(captchaTokenState);
useEffect(() => {
setCaptchaToken('MOCKED_CAPTCHA_TOKEN');
}, [setCaptchaToken]);
return null;
};
const SignInUpWithCaptcha = () => {
return (
<>
<CaptchaTokenSetterEffect />
<SignInUp />
</>
);
};
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Auth/Invite',
component: SignInUp,
component: SignInUpWithCaptcha,
decorators: [PageDecorator],
args: {
routePath: AppPath.Invite,
@@ -67,7 +89,7 @@ const meta: Meta<PageDecoratorArgs> = {
export default meta;
export type Story = StoryObj<typeof SignInUp>;
export type Story = StoryObj<typeof SignInUpWithCaptcha>;
export const Default: Story = {
play: async ({ canvasElement }) => {