diff --git a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
index 5efa3dc0e..44ad97cf7 100644
--- a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
+++ b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
@@ -201,3 +201,11 @@ import TabItem from '@theme/TabItem';
['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'],
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before deleting workspace'],
]}>
+
+### Captcha
+
+
\ No newline at end of file
diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
index ea0b1a2e4..dba2d07ab 100644
--- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
@@ -7,6 +7,8 @@ import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateAct
import { useEventTracker } from '@/analytics/hooks/useEventTracker';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
+import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
+import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandType } from '@/command-menu/types/Command';
@@ -248,5 +250,17 @@ export const PageChangeEffect = () => {
}, 500);
}, [eventTracker, location.pathname]);
+ const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
+ const isCaptchaScriptLoaded = useRecoilValue(isCaptchaScriptLoadedState);
+
+ useEffect(() => {
+ if (
+ isCaptchaScriptLoaded &&
+ isMatchingLocation(AppPath.SignInUp || AppPath.Invite)
+ ) {
+ requestFreshCaptchaToken();
+ }
+ }, [isCaptchaScriptLoaded, isMatchingLocation, requestFreshCaptchaToken]);
+
return <>>;
};
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 1f656f653..ed8400877 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -122,10 +122,22 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe;
};
+export type Captcha = {
+ __typename?: 'Captcha';
+ provider?: Maybe;
+ siteKey?: Maybe;
+};
+
+export enum CaptchaDriverType {
+ GoogleRecatpcha = 'GoogleRecatpcha',
+ Turnstile = 'Turnstile'
+}
+
export type ClientConfig = {
__typename?: 'ClientConfig';
authProviders: AuthProviders;
billing: Billing;
+ captcha: Captcha;
debugMode: Scalars['Boolean']['output'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean']['output'];
@@ -386,6 +398,7 @@ export type MutationAuthorizeAppArgs = {
export type MutationChallengeArgs = {
+ captchaToken?: InputMaybe;
email: Scalars['String']['input'];
password: Scalars['String']['input'];
};
@@ -469,6 +482,7 @@ export type MutationRenewTokenArgs = {
export type MutationSignUpArgs = {
+ captchaToken?: InputMaybe;
email: Scalars['String']['input'];
password: Scalars['String']['input'];
workspaceInviteHash?: InputMaybe;
@@ -614,6 +628,7 @@ export type QueryBillingPortalSessionArgs = {
export type QueryCheckUserExistsArgs = {
+ captchaToken?: InputMaybe;
email: Scalars['String']['input'];
};
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index 100824406..295303497 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -117,10 +117,22 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe;
};
+export type Captcha = {
+ __typename?: 'Captcha';
+ provider?: Maybe;
+ siteKey?: Maybe;
+};
+
+export enum CaptchaDriverType {
+ GoogleRecatpcha = 'GoogleRecatpcha',
+ Turnstile = 'Turnstile'
+}
+
export type ClientConfig = {
__typename?: 'ClientConfig';
authProviders: AuthProviders;
billing: Billing;
+ captcha: Captcha;
debugMode: Scalars['Boolean'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean'];
@@ -289,6 +301,7 @@ export type MutationAuthorizeAppArgs = {
export type MutationChallengeArgs = {
+ captchaToken?: InputMaybe;
email: Scalars['String'];
password: Scalars['String'];
};
@@ -339,6 +352,7 @@ export type MutationRenewTokenArgs = {
export type MutationSignUpArgs = {
+ captchaToken?: InputMaybe;
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe;
@@ -456,6 +470,7 @@ export type QueryBillingPortalSessionArgs = {
export type QueryCheckUserExistsArgs = {
+ captchaToken?: InputMaybe;
email: Scalars['String'];
};
@@ -999,6 +1014,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __
export type ChallengeMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
+ captchaToken?: InputMaybe;
}>;
@@ -1049,6 +1065,7 @@ export type SignUpMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe;
+ captchaToken?: InputMaybe;
}>;
@@ -1071,6 +1088,7 @@ export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: '
export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String'];
+ captchaToken?: InputMaybe;
}>;
@@ -1113,7 +1131,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null } } };
+export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null } } };
export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload'];
@@ -1559,8 +1577,8 @@ export type AuthorizeAppMutationHookResult = ReturnType;
export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions;
export const ChallengeDocument = gql`
- mutation Challenge($email: String!, $password: String!) {
- challenge(email: $email, password: $password) {
+ mutation Challenge($email: String!, $password: String!, $captchaToken: String) {
+ challenge(email: $email, password: $password, captchaToken: $captchaToken) {
loginToken {
...AuthTokenFragment
}
@@ -1584,6 +1602,7 @@ export type ChallengeMutationFn = Apollo.MutationFunction;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions;
export const SignUpDocument = gql`
- mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String) {
+ mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $captchaToken: String) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
+ captchaToken: $captchaToken
) {
loginToken {
...AuthTokenFragment
@@ -1835,6 +1855,7 @@ export type SignUpMutationFn = Apollo.MutationFunction;
export type VerifyMutationResult = Apollo.MutationResult;
export type VerifyMutationOptions = Apollo.BaseMutationOptions;
export const CheckUserExistsDocument = gql`
- query CheckUserExists($email: String!) {
- checkUserExists(email: $email) {
+ query CheckUserExists($email: String!, $captchaToken: String) {
+ checkUserExists(email: $email, captchaToken: $captchaToken) {
exists
}
}
@@ -1942,6 +1963,7 @@ export const CheckUserExistsDocument = gql`
* const { data, loading, error } = useCheckUserExistsQuery({
* variables: {
* email: // value for 'email'
+ * captchaToken: // value for 'captchaToken'
* },
* });
*/
@@ -2165,6 +2187,10 @@ export const GetClientConfigDocument = gql`
environment
release
}
+ captcha {
+ provider
+ siteKey
+ }
}
}
`;
diff --git a/packages/twenty-front/src/index.css b/packages/twenty-front/src/index.css
index 5c9b489dc..2d696388b 100644
--- a/packages/twenty-front/src/index.css
+++ b/packages/twenty-front/src/index.css
@@ -8,3 +8,8 @@ body {
html {
font-size: 13px;
}
+
+/* https://stackoverflow.com/questions/44543157/how-to-hide-the-google-invisible-recaptcha-badge */
+.grecaptcha-badge {
+ visibility: hidden !important;
+}
\ No newline at end of file
diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx
index 83e4c55e5..dfdc65277 100644
--- a/packages/twenty-front/src/index.tsx
+++ b/packages/twenty-front/src/index.tsx
@@ -6,6 +6,7 @@ import { RecoilRoot } from 'recoil';
import { IconsProvider } from 'twenty-ui';
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
+import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
@@ -39,45 +40,47 @@ const root = ReactDOM.createRoot(
root.render(
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
);
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts
index 7bf299695..be775eb1f 100644
--- a/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts
@@ -1,8 +1,12 @@
import { gql } from '@apollo/client';
export const CHALLENGE = gql`
- mutation Challenge($email: String!, $password: String!) {
- challenge(email: $email, password: $password) {
+ mutation Challenge(
+ $email: String!
+ $password: String!
+ $captchaToken: String
+ ) {
+ challenge(email: $email, password: $password, captchaToken: $captchaToken) {
loginToken {
...AuthTokenFragment
}
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts
index 43dca085f..85285b776 100644
--- a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts
@@ -5,11 +5,13 @@ export const SIGN_UP = gql`
$email: String!
$password: String!
$workspaceInviteHash: String
+ $captchaToken: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
+ captchaToken: $captchaToken
) {
loginToken {
...AuthTokenFragment
diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts
index 2b35abb13..ddf5505bb 100644
--- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const CHECK_USER_EXISTS = gql`
- query CheckUserExists($email: String!) {
- checkUserExists(email: $email) {
+ query CheckUserExists($email: String!, $captchaToken: String) {
+ checkUserExists(email: $email, captchaToken: $captchaToken) {
exists
}
}
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index ea204c95a..da9f421c8 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -16,6 +16,7 @@ import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState';
import { workspacesState } from '@/auth/states/workspaces';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
+import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
@@ -56,11 +57,12 @@ export const useAuth = () => {
const goToRecoilSnapshot = useGotoRecoilSnapshot();
const handleChallenge = useCallback(
- async (email: string, password: string) => {
+ async (email: string, password: string, captchaToken?: string) => {
const challengeResult = await challenge({
variables: {
email,
password,
+ captchaToken,
},
});
@@ -133,8 +135,12 @@ export const useAuth = () => {
);
const handleCrendentialsSignIn = useCallback(
- async (email: string, password: string) => {
- const { loginToken } = await handleChallenge(email, password);
+ async (email: string, password: string, captchaToken?: string) => {
+ const { loginToken } = await handleChallenge(
+ email,
+ password,
+ captchaToken,
+ );
setIsVerifyPendingState(true);
const { user, workspaceMember, workspace } = await handleVerify(
@@ -167,6 +173,9 @@ export const useAuth = () => {
const supportChat = snapshot.getLoadable(supportChatState).getValue();
const telemetry = snapshot.getLoadable(telemetryState).getValue();
const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue();
+ const captchaProvider = snapshot
+ .getLoadable(captchaProviderState)
+ .getValue();
const isClientConfigLoaded = snapshot
.getLoadable(isClientConfigLoadedState)
.getValue();
@@ -175,8 +184,6 @@ export const useAuth = () => {
.getValue();
const initialSnapshot = emptySnapshot.map(({ set }) => {
- set(isClientConfigLoadedState, isClientConfigLoaded);
- set(isCurrentUserLoadedState, isCurrentUserLoaded);
set(iconsState, iconsValue);
set(authProvidersState, authProvidersValue);
set(billingState, billing);
@@ -184,6 +191,9 @@ export const useAuth = () => {
set(supportChatState, supportChat);
set(telemetryState, telemetry);
set(isDebugModeState, isDebugMode);
+ set(captchaProviderState, captchaProvider);
+ set(isClientConfigLoadedState, isClientConfigLoaded);
+ set(isCurrentUserLoadedState, isCurrentUserLoaded);
return undefined;
});
@@ -196,7 +206,12 @@ export const useAuth = () => {
);
const handleCredentialsSignUp = useCallback(
- async (email: string, password: string, workspaceInviteHash?: string) => {
+ async (
+ email: string,
+ password: string,
+ workspaceInviteHash?: string,
+ captchaToken?: string,
+ ) => {
setIsVerifyPendingState(true);
const signUpResult = await signUp({
@@ -204,6 +219,7 @@ export const useAuth = () => {
email,
password,
workspaceInviteHash,
+ captchaToken,
},
});
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx
index 9c6ce8de8..ccbd87b70 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx
@@ -3,7 +3,7 @@ import { Controller } from 'react-hook-form';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
-import { useRecoilState } from 'recoil';
+import { useRecoilState, useRecoilValue } from 'recoil';
import { IconGoogle, IconMicrosoft } from 'twenty-ui';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
@@ -11,6 +11,7 @@ import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
+import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton';
@@ -46,6 +47,9 @@ const StyledInputContainer = styled.div`
`;
export const SignInUpForm = () => {
+ const isRequestingCaptchaToken = useRecoilValue(
+ isRequestingCaptchaTokenState,
+ );
const [authProviders] = useRecoilState(authProvidersState);
const [showErrors, setShowErrors] = useState(false);
const { handleResetPassword } = useHandleResetPassword();
@@ -63,7 +67,9 @@ export const SignInUpForm = () => {
submitCredentials,
} = useSignInUp(form);
- const handleKeyDown = (event: React.KeyboardEvent) => {
+ const handleKeyDown = async (
+ event: React.KeyboardEvent,
+ ) => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -222,12 +228,11 @@ export const SignInUpForm = () => {
/>
)}
-
{
+ onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
@@ -243,11 +248,13 @@ export const SignInUpForm = () => {
disabled={
signInUpStep === SignInUpStep.Init
? false
- : signInUpStep === SignInUpStep.Email
- ? !form.watch('email')
- : !form.watch('email') ||
- !form.watch('password') ||
- form.formState.isSubmitting
+ : isRequestingCaptchaToken
+ ? true
+ : signInUpStep === SignInUpStep.Email
+ ? !form.watch('email')
+ : !form.watch('email') ||
+ !form.watch('password') ||
+ form.formState.isSubmitting
}
fullWidth
/>
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx
index 6c4bf6198..3c9edd5cb 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx
@@ -4,6 +4,8 @@ import { useParams } from 'react-router-dom';
import { useNavigateAfterSignInUp } from '@/auth/sign-in-up/hooks/useNavigateAfterSignInUp';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
+import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
+import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
@@ -48,24 +50,36 @@ export const useSignInUp = (form: UseFormReturn