diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 3e3b558cfb0..a6010194585 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -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); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx index 1823339e472..d3bd2e59fd9 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts index 3fe4ee8fc36..a73a15b23c1 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts @@ -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
) => { const { enqueueErrorSnackBar } = useSnackBar(); @@ -28,6 +30,7 @@ export const useSignInUp = (form: UseFormReturn) => { 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) => { 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 = 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) => { } }, [ + isCaptchaReady, readCaptchaToken, signInUpMode, isInviteMode, @@ -170,6 +190,7 @@ export const useSignInUp = (form: UseFormReturn) => { enqueueErrorSnackBar, buildSearchParamsFromUrlSyncedStates, isOnAWorkspace, + t, ], ); diff --git a/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx b/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx index 95d6f396039..65b8350a78c 100644 --- a/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx @@ -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 <>; }; diff --git a/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts b/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts index 6abb6dea91d..347a84297a3 100644 --- a/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts +++ b/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts @@ -6,7 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; export const useReadCaptchaToken = () => { const readCaptchaToken = useRecoilCallback( ({ snapshot }) => - async () => { + () => { const existingCaptchaToken = snapshot .getLoadable(captchaTokenState) .getValue(); diff --git a/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts b/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts index 35c3b9dcfef..959b60c8326 100644 --- a/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts +++ b/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts @@ -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 diff --git a/packages/twenty-front/src/modules/client-config/hooks/useCaptcha.ts b/packages/twenty-front/src/modules/client-config/hooks/useCaptcha.ts new file mode 100644 index 00000000000..bff40df4779 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/hooks/useCaptcha.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index b5685e3cc5b..b8d1028ecdc 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -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 || '', diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 1fb070d358d..0e3a5115e0d 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -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 ( + + + + ); + } if (isDefaultDomain && isMultiWorkspaceEnabled) { return ( @@ -163,10 +183,11 @@ export const SignInUp = () => { ); }, [ + clientConfigApiStatus.isLoadedOnce, isDefaultDomain, isMultiWorkspaceEnabled, isOnAWorkspace, - loading, + getPublicWorkspaceDataLoading, signInUpStep, workspacePublicData, ]); diff --git a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx index 4407df2dae1..1ef2fb832ec 100644 --- a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx +++ b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx @@ -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 ( + <> + + + + ); +}; + const meta: Meta = { title: 'Pages/Auth/SignInUp', - component: SignInUp, + component: SignInUpWithCaptcha, decorators: [PageDecorator], args: { routePath: AppPath.SignInUp }, parameters: { @@ -47,7 +69,7 @@ const meta: Meta = { export default meta; -export type Story = StoryObj; +export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { diff --git a/packages/twenty-front/src/pages/auth/__stories__/SignInUpWithInvite.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/SignInUpWithInvite.stories.tsx index 3bb140889d5..c65d62040a4 100644 --- a/packages/twenty-front/src/pages/auth/__stories__/SignInUpWithInvite.stories.tsx +++ b/packages/twenty-front/src/pages/auth/__stories__/SignInUpWithInvite.stories.tsx @@ -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 ( + <> + + + + ); +}; + const meta: Meta = { title: 'Pages/Auth/Invite', - component: SignInUp, + component: SignInUpWithCaptcha, decorators: [PageDecorator], args: { routePath: AppPath.Invite, @@ -67,7 +89,7 @@ const meta: Meta = { export default meta; -export type Story = StoryObj; +export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => {