Add Email Verification for non-Microsoft/Google Emails (#9288)
Closes twentyhq/twenty#8240 This PR introduces email verification for non-Microsoft/Google Emails: ## Email Verification SignInUp Flow: https://github.com/user-attachments/assets/740e9714-5413-4fd8-b02e-ace728ea47ef The email verification link is sent as part of the `SignInUpStep.EmailVerification`. The email verification token validation is handled on a separate page (`AppPath.VerifyEmail`). A verification email resend can be triggered from both pages. ## Email Verification Flow Screenshots (In Order):    ## Sent Email Details (Subject & Template):   ### Successful Email Verification Redirect:  ### Unsuccessful Email Verification (invalid token, invalid email, token expired, user does not exist, etc.):  ### Force Sign In When Email Not Verified:  # TODOs: ## Sign Up Process - [x] Introduce server-level environment variable IS_EMAIL_VERIFICATION_REQUIRED (defaults to false) - [x] Ensure users joining an existing workspace through an invite are not required to validate their email - [x] Generate an email verification token - [x] Store the token in appToken - [x] Send email containing the verification link - [x] Create new email template for email verification - [x] Create a frontend page to handle verification requests ## Sign In Process - [x] After verifying user credentials, check if user's email is verified and prompt to to verify - [x] Show an option to resend the verification email ## Database - [x] Rename the `emailVerified` colum on `user` to to `isEmailVerified` for consistency ## During Deployment - [x] Run a script/sql query to set `isEmailVerified` to `true` for all users with a Google/Microsoft email and all users that show an indication of a valid subscription (e.g. linked credit card) - I have created a draft migration file below that shows one possible approach to implementing this change: ```typescript import { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateEmailVerifiedForActiveUsers1733318043628 implements MigrationInterface { name = 'UpdateEmailVerifiedForActiveUsers1733318043628'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` CREATE TABLE core."user_email_verified_backup" AS SELECT id, email, "isEmailVerified" FROM core."user" WHERE "deletedAt" IS NULL; `); await queryRunner.query(` -- Update isEmailVerified for users who have been part of workspaces with active subscriptions UPDATE core."user" u SET "isEmailVerified" = true WHERE EXISTS ( -- Check if user has been part of a workspace through userWorkspace table SELECT 1 FROM core."userWorkspace" uw JOIN core."workspace" w ON uw."workspaceId" = w.id WHERE uw."userId" = u.id -- Check for valid subscription indicators AND ( w."activationStatus" = 'ACTIVE' -- Add any other subscription-related conditions here ) ) AND u."deletedAt" IS NULL; `); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` UPDATE core."user" u SET "isEmailVerified" = b."isEmailVerified" FROM core."user_email_verified_backup" b WHERE u.id = b.id; `); await queryRunner.query(`DROP TABLE core."user_email_verified_backup";`); } } ``` --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -177,6 +177,7 @@ export type ClientConfig = {
|
||||
debugMode: Scalars['Boolean'];
|
||||
defaultSubdomain?: Maybe<Scalars['String']>;
|
||||
frontDomain: Scalars['String'];
|
||||
isEmailVerificationRequired: Scalars['Boolean'];
|
||||
isMultiWorkspaceEnabled: Scalars['Boolean'];
|
||||
sentry: Sentry;
|
||||
signInPrefilled: Scalars['Boolean'];
|
||||
@ -515,9 +516,11 @@ export type Mutation = {
|
||||
generateApiKeyToken: ApiKeyToken;
|
||||
generateTransientToken: TransientToken;
|
||||
getAuthorizationUrl: GetAuthorizationUrlOutput;
|
||||
getLoginTokenFromEmailVerificationToken: LoginToken;
|
||||
impersonate: ImpersonateOutput;
|
||||
publishServerlessFunction: ServerlessFunction;
|
||||
renewToken: AuthTokens;
|
||||
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
|
||||
resendWorkspaceInvitation: SendInvitationsOutput;
|
||||
runWorkflowVersion: WorkflowRun;
|
||||
sendInvitations: SendInvitationsOutput;
|
||||
@ -680,6 +683,12 @@ export type MutationGetAuthorizationUrlArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
emailVerificationToken: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationImpersonateArgs = {
|
||||
userId: Scalars['String'];
|
||||
workspaceId: Scalars['String'];
|
||||
@ -696,6 +705,11 @@ export type MutationRenewTokenArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationResendEmailVerificationTokenArgs = {
|
||||
email: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationResendWorkspaceInvitationArgs = {
|
||||
appTokenId: Scalars['String'];
|
||||
};
|
||||
@ -1062,6 +1076,11 @@ export enum RemoteTableStatus {
|
||||
Synced = 'SYNCED'
|
||||
}
|
||||
|
||||
export type ResendEmailVerificationTokenOutput = {
|
||||
__typename?: 'ResendEmailVerificationTokenOutput';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type RunWorkflowVersionInput = {
|
||||
/** Execution result in JSON format */
|
||||
payload?: InputMaybe<Scalars['JSON']>;
|
||||
@ -1396,9 +1415,9 @@ export type User = {
|
||||
deletedAt?: Maybe<Scalars['DateTime']>;
|
||||
disabled?: Maybe<Scalars['Boolean']>;
|
||||
email: Scalars['String'];
|
||||
emailVerified: Scalars['Boolean'];
|
||||
firstName: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
isEmailVerified: Scalars['Boolean'];
|
||||
lastName: Scalars['String'];
|
||||
onboardingStatus?: Maybe<OnboardingStatus>;
|
||||
passwordHash?: Maybe<Scalars['String']>;
|
||||
@ -1422,6 +1441,7 @@ export type UserExists = {
|
||||
__typename?: 'UserExists';
|
||||
availableWorkspaces: Array<AvailableWorkspaceOutput>;
|
||||
exists: Scalars['Boolean'];
|
||||
isEmailVerified: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type UserExistsOutput = UserExists | UserNotExists;
|
||||
@ -1957,6 +1977,14 @@ export type GetAuthorizationUrlMutationVariables = Exact<{
|
||||
|
||||
export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } };
|
||||
|
||||
export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
|
||||
emailVerificationToken: Scalars['String'];
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetLoginTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getLoginTokenFromEmailVerificationToken: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
|
||||
|
||||
export type ImpersonateMutationVariables = Exact<{
|
||||
userId: Scalars['String'];
|
||||
workspaceId: Scalars['String'];
|
||||
@ -1972,6 +2000,13 @@ export type RenewTokenMutationVariables = Exact<{
|
||||
|
||||
export type RenewTokenMutation = { __typename?: 'Mutation', renewToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
|
||||
|
||||
export type ResendEmailVerificationTokenMutationVariables = Exact<{
|
||||
email: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ResendEmailVerificationTokenMutation = { __typename?: 'Mutation', resendEmailVerificationToken: { __typename?: 'ResendEmailVerificationTokenOutput', success: boolean } };
|
||||
|
||||
export type SignUpMutationVariables = Exact<{
|
||||
email: Scalars['String'];
|
||||
password: Scalars['String'];
|
||||
@ -2012,7 +2047,7 @@ export type CheckUserExistsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
|
||||
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
|
||||
|
||||
export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@ -2058,7 +2093,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
|
||||
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, 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 }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, 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 }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
||||
|
||||
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@ -2915,6 +2950,45 @@ export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHook
|
||||
export type GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuthorizationUrlMutation>;
|
||||
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>;
|
||||
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
|
||||
export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
|
||||
mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $captchaToken: String) {
|
||||
getLoginTokenFromEmailVerificationToken(
|
||||
emailVerificationToken: $emailVerificationToken
|
||||
captchaToken: $captchaToken
|
||||
) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${AuthTokenFragmentFragmentDoc}`;
|
||||
export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useGetLoginTokenFromEmailVerificationTokenMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useGetLoginTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetLoginTokenFromEmailVerificationTokenMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
|
||||
* variables: {
|
||||
* emailVerificationToken: // value for 'emailVerificationToken'
|
||||
* captchaToken: // value for 'captchaToken'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetLoginTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>(GetLoginTokenFromEmailVerificationTokenDocument, options);
|
||||
}
|
||||
export type GetLoginTokenFromEmailVerificationTokenMutationHookResult = ReturnType<typeof useGetLoginTokenFromEmailVerificationTokenMutation>;
|
||||
export type GetLoginTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult<GetLoginTokenFromEmailVerificationTokenMutation>;
|
||||
export type GetLoginTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>;
|
||||
export const ImpersonateDocument = gql`
|
||||
mutation Impersonate($userId: String!, $workspaceId: String!) {
|
||||
impersonate(userId: $userId, workspaceId: $workspaceId) {
|
||||
@ -2990,6 +3064,39 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions<R
|
||||
export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>;
|
||||
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
|
||||
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
|
||||
export const ResendEmailVerificationTokenDocument = gql`
|
||||
mutation ResendEmailVerificationToken($email: String!) {
|
||||
resendEmailVerificationToken(email: $email) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type ResendEmailVerificationTokenMutationFn = Apollo.MutationFunction<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useResendEmailVerificationTokenMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useResendEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useResendEmailVerificationTokenMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [resendEmailVerificationTokenMutation, { data, loading, error }] = useResendEmailVerificationTokenMutation({
|
||||
* variables: {
|
||||
* email: // value for 'email'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useResendEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>(ResendEmailVerificationTokenDocument, options);
|
||||
}
|
||||
export type ResendEmailVerificationTokenMutationHookResult = ReturnType<typeof useResendEmailVerificationTokenMutation>;
|
||||
export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult<ResendEmailVerificationTokenMutation>;
|
||||
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
|
||||
export const SignUpDocument = gql`
|
||||
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) {
|
||||
signUp(
|
||||
@ -3179,6 +3286,7 @@ export const CheckUserExistsDocument = gql`
|
||||
status
|
||||
}
|
||||
}
|
||||
isEmailVerified
|
||||
}
|
||||
... on UserNotExists {
|
||||
exists
|
||||
@ -3471,6 +3579,7 @@ export const GetClientConfigDocument = gql`
|
||||
}
|
||||
signInPrefilled
|
||||
isMultiWorkspaceEnabled
|
||||
isEmailVerificationRequired
|
||||
defaultSubdomain
|
||||
frontDomain
|
||||
debugMode
|
||||
|
||||
Reference in New Issue
Block a user