Skip to content

Commit

Permalink
Add Email Verification for non-Microsoft/Google Emails (twentyhq#9288)
Browse files Browse the repository at this point in the history
Closes twentyhq#8240

This PR introduces email verification for non-Microsoft/Google Emails:

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.

![image](https://github.com/user-attachments/assets/d52237dc-fcc6-4754-a40f-b7d6294eebad)

![image](https://github.com/user-attachments/assets/263a4b6b-db49-406b-9e43-6c0f90488bb8)

![image](https://github.com/user-attachments/assets/0343ae51-32ef-48b8-8167-a96deb7db99e)

![Screenshot 2025-01-05 at 11 56
56 PM](https://github.com/user-attachments/assets/475840d1-7d47-4792-b8c6-5c9ef5e02229)

![image](https://github.com/user-attachments/assets/a41b3b36-a36f-4a8e-b1f9-beeec7fe23e4)

![image](https://github.com/user-attachments/assets/e2fad9e2-f4b1-485e-8f4a-32163c2718e7)

expired, user does not exist, etc.):

![image](https://github.com/user-attachments/assets/92f4b65e-2971-4f26-a9fa-7aafadd2b305)

![image](https://github.com/user-attachments/assets/86d0f188-cded-49a6-bde9-9630fd18d71e)

- [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

- [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

- [x] Rename the `emailVerified` colum on `user` to to `isEmailVerified`
for consistency

- [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>
  • Loading branch information
3 people authored and pacyL2K19 committed Jan 16, 2025
1 parent d23bbca commit a85297d
Show file tree
Hide file tree
Showing 61 changed files with 1,449 additions and 160 deletions.
55 changes: 55 additions & 0 deletions packages/twenty-emails/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Column, Row } from '@react-email/components';
import { Link } from 'src/components/Link';
import { ShadowText } from 'src/components/ShadowText';

export const Footer = () => {
return (
<>
<Row>
<Column>
<ShadowText>
<Link
href="https://twenty.com/"
value="Website"
aria-label="Visit Twenty's website"
/>
</ShadowText>
</Column>
<Column>
<ShadowText>
<Link
href="https://github.com/twentyhq/twenty"
value="Github"
aria-label="Visit Twenty's GitHub repository"
/>
</ShadowText>
</Column>
<Column>
<ShadowText>
<Link
href="https://twenty.com/user-guide"
value="User guide"
aria-label="Read Twenty's user guide"
/>
</ShadowText>
</Column>
<Column>
<ShadowText>
<Link
href="https://docs.twenty.com/"
value="Developers"
aria-label="Visit Twenty's developer documentation"
/>
</ShadowText>
</Column>
</Row>
<ShadowText>
Twenty.com Public Benefit Corporation
<br />
2261 Market Street #5275
<br />
San Francisco, CA 94114
</ShadowText>
</>
);
};
35 changes: 3 additions & 32 deletions packages/twenty-emails/src/components/WhatIsTwenty.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Column, Row } from '@react-email/components';
import { Link } from 'src/components/Link';
import { Footer } from 'src/components/Footer';
import { MainText } from 'src/components/MainText';
import { ShadowText } from 'src/components/ShadowText';
import { SubTitle } from 'src/components/SubTitle';

export const WhatIsTwenty = () => {
return (
<>
Expand All @@ -11,35 +10,7 @@ export const WhatIsTwenty = () => {
It's a CRM, a software to help businesses manage their customer data and
relationships efficiently.
</MainText>
<Row>
<Column>
<ShadowText>
<Link href="https://twenty.com/" value="Website" />
</ShadowText>
</Column>
<Column>
<ShadowText>
<Link href="https://github.com/twentyhq/twenty" value="Github" />
</ShadowText>
</Column>
<Column>
<ShadowText>
<Link href="https://twenty.com/user-guide" value="User guide" />
</ShadowText>
</Column>
<Column>
<ShadowText>
<Link href="https://docs.twenty.com/" value="Developers" />
</ShadowText>
</Column>
</Row>
<ShadowText>
Twenty.com Public Benefit Corporation
<br />
2261 Market Street #5275
<br />
San Francisco, CA 94114
</ShadowText>
<Footer />
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { Footer } from 'src/components/Footer';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';

type SendEmailVerificationLinkEmailProps = {
link: string;
};

export const SendEmailVerificationLinkEmail = ({
link,
}: SendEmailVerificationLinkEmailProps) => {
return (
<BaseEmail width={333}>
<Title value="Confirm your email address" />
<CallToAction href={link} value="Verify Email" />
<br />
<br />
<MainText>
Thanks for registering for an account on Twenty! Before we get started,
we just need to confirm that this is you. Click above to verify your
email address.
</MainText>
<Footer />
</BaseEmail>
);
};
1 change: 1 addition & 0 deletions packages/twenty-emails/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './emails/clean-inactive-workspaces.email';
export * from './emails/delete-inactive-workspaces.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email';
23 changes: 21 additions & 2 deletions packages/twenty-front/src/generated-metadata/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean']['output'];
defaultSubdomain?: Maybe<Scalars['String']['output']>;
frontDomain: Scalars['String']['output'];
isEmailVerificationRequired: Scalars['Boolean']['output'];
isMultiWorkspaceEnabled: Scalars['Boolean']['output'];
isSSOEnabled: Scalars['Boolean']['output'];
sentry: Sentry;
Expand Down Expand Up @@ -404,7 +405,6 @@ export enum FeatureFlagKey {
IsSsoEnabled = 'IsSSOEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
IsViewGroupsEnabled = 'IsViewGroupsEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled'
}

Expand Down Expand Up @@ -612,9 +612,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;
Expand Down Expand Up @@ -811,6 +813,12 @@ export type MutationGetAuthorizationUrlArgs = {
};


export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
emailVerificationToken: Scalars['String']['input'];
};


export type MutationImpersonateArgs = {
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
Expand All @@ -827,6 +835,11 @@ export type MutationRenewTokenArgs = {
};


export type MutationResendEmailVerificationTokenArgs = {
email: Scalars['String']['input'];
};


export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String']['input'];
};
Expand Down Expand Up @@ -1289,6 +1302,11 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}

export type ResendEmailVerificationTokenOutput = {
__typename?: 'ResendEmailVerificationTokenOutput';
success: Scalars['Boolean']['output'];
};

export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']['input']>;
Expand Down Expand Up @@ -1631,9 +1649,9 @@ export type User = {
deletedAt?: Maybe<Scalars['DateTime']['output']>;
disabled?: Maybe<Scalars['Boolean']['output']>;
email: Scalars['String']['output'];
emailVerified: Scalars['Boolean']['output'];
firstName: Scalars['String']['output'];
id: Scalars['UUID']['output'];
isEmailVerified: Scalars['Boolean']['output'];
lastName: Scalars['String']['output'];
onboardingStatus?: Maybe<OnboardingStatus>;
passwordHash?: Maybe<Scalars['String']['output']>;
Expand All @@ -1657,6 +1675,7 @@ export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean']['output'];
isEmailVerified: Scalars['Boolean']['output'];
};

export type UserExistsOutput = UserExists | UserNotExists;
Expand Down
Loading

0 comments on commit a85297d

Please sign in to comment.