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):

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

## Sent Email Details (Subject & Template):
![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)

### Successful Email Verification Redirect:

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

### Unsuccessful Email Verification (invalid token, invalid email, token
expired, user does not exist, etc.):

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

### Force Sign In When Email Not Verified:

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

# 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:
Samyak Piya
2025-01-15 12:43:40 -05:00
committed by GitHub
parent 266b771a5b
commit f722a2d619
61 changed files with 1460 additions and 171 deletions

View File

@ -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>
</>
);
};

View File

@ -1,8 +1,7 @@
import { Column, Row } from '@react-email/components'; import { Footer } from 'src/components/Footer';
import { Link } from 'src/components/Link';
import { MainText } from 'src/components/MainText'; import { MainText } from 'src/components/MainText';
import { ShadowText } from 'src/components/ShadowText';
import { SubTitle } from 'src/components/SubTitle'; import { SubTitle } from 'src/components/SubTitle';
export const WhatIsTwenty = () => { export const WhatIsTwenty = () => {
return ( return (
<> <>
@ -11,35 +10,7 @@ export const WhatIsTwenty = () => {
It's a CRM, a software to help businesses manage their customer data and It's a CRM, a software to help businesses manage their customer data and
relationships efficiently. relationships efficiently.
</MainText> </MainText>
<Row> <Footer />
<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>
</> </>
); );
}; };

View File

@ -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>
);
};

View File

@ -2,4 +2,5 @@ export * from './emails/clean-inactive-workspaces.email';
export * from './emails/delete-inactive-workspaces.email'; export * from './emails/delete-inactive-workspaces.email';
export * from './emails/password-reset-link.email'; export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email'; export * from './emails/password-update-notify.email';
export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email'; export * from './emails/send-invite-link.email';

View File

@ -183,6 +183,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean']['output']; debugMode: Scalars['Boolean']['output'];
defaultSubdomain?: Maybe<Scalars['String']['output']>; defaultSubdomain?: Maybe<Scalars['String']['output']>;
frontDomain: Scalars['String']['output']; frontDomain: Scalars['String']['output'];
isEmailVerificationRequired: Scalars['Boolean']['output'];
isMultiWorkspaceEnabled: Scalars['Boolean']['output']; isMultiWorkspaceEnabled: Scalars['Boolean']['output'];
isSSOEnabled: Scalars['Boolean']['output']; isSSOEnabled: Scalars['Boolean']['output'];
sentry: Sentry; sentry: Sentry;
@ -404,7 +405,6 @@ export enum FeatureFlagKey {
IsSsoEnabled = 'IsSSOEnabled', IsSsoEnabled = 'IsSSOEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled', IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled', IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
IsViewGroupsEnabled = 'IsViewGroupsEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled' IsWorkflowEnabled = 'IsWorkflowEnabled'
} }
@ -612,9 +612,11 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput; getAuthorizationUrl: GetAuthorizationUrlOutput;
getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput; impersonate: ImpersonateOutput;
publishServerlessFunction: ServerlessFunction; publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens; renewToken: AuthTokens;
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput; resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun; runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput; sendInvitations: SendInvitationsOutput;
@ -811,6 +813,12 @@ export type MutationGetAuthorizationUrlArgs = {
}; };
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
emailVerificationToken: Scalars['String']['input'];
};
export type MutationImpersonateArgs = { export type MutationImpersonateArgs = {
userId: Scalars['String']['input']; userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
@ -827,6 +835,11 @@ export type MutationRenewTokenArgs = {
}; };
export type MutationResendEmailVerificationTokenArgs = {
email: Scalars['String']['input'];
};
export type MutationResendWorkspaceInvitationArgs = { export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String']['input']; appTokenId: Scalars['String']['input'];
}; };
@ -1289,6 +1302,11 @@ export enum RemoteTableStatus {
Synced = 'SYNCED' Synced = 'SYNCED'
} }
export type ResendEmailVerificationTokenOutput = {
__typename?: 'ResendEmailVerificationTokenOutput';
success: Scalars['Boolean']['output'];
};
export type RunWorkflowVersionInput = { export type RunWorkflowVersionInput = {
/** Execution result in JSON format */ /** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']['input']>; payload?: InputMaybe<Scalars['JSON']['input']>;
@ -1631,9 +1649,9 @@ export type User = {
deletedAt?: Maybe<Scalars['DateTime']['output']>; deletedAt?: Maybe<Scalars['DateTime']['output']>;
disabled?: Maybe<Scalars['Boolean']['output']>; disabled?: Maybe<Scalars['Boolean']['output']>;
email: Scalars['String']['output']; email: Scalars['String']['output'];
emailVerified: Scalars['Boolean']['output'];
firstName: Scalars['String']['output']; firstName: Scalars['String']['output'];
id: Scalars['UUID']['output']; id: Scalars['UUID']['output'];
isEmailVerified: Scalars['Boolean']['output'];
lastName: Scalars['String']['output']; lastName: Scalars['String']['output'];
onboardingStatus?: Maybe<OnboardingStatus>; onboardingStatus?: Maybe<OnboardingStatus>;
passwordHash?: Maybe<Scalars['String']['output']>; passwordHash?: Maybe<Scalars['String']['output']>;
@ -1657,6 +1675,7 @@ export type UserExists = {
__typename?: 'UserExists'; __typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>; availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean']['output']; exists: Scalars['Boolean']['output'];
isEmailVerified: Scalars['Boolean']['output'];
}; };
export type UserExistsOutput = UserExists | UserNotExists; export type UserExistsOutput = UserExists | UserNotExists;

View File

@ -177,6 +177,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean']; debugMode: Scalars['Boolean'];
defaultSubdomain?: Maybe<Scalars['String']>; defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String']; frontDomain: Scalars['String'];
isEmailVerificationRequired: Scalars['Boolean'];
isMultiWorkspaceEnabled: Scalars['Boolean']; isMultiWorkspaceEnabled: Scalars['Boolean'];
sentry: Sentry; sentry: Sentry;
signInPrefilled: Scalars['Boolean']; signInPrefilled: Scalars['Boolean'];
@ -515,9 +516,11 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken; generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken; generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput; getAuthorizationUrl: GetAuthorizationUrlOutput;
getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput; impersonate: ImpersonateOutput;
publishServerlessFunction: ServerlessFunction; publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens; renewToken: AuthTokens;
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput; resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun; runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput; sendInvitations: SendInvitationsOutput;
@ -680,6 +683,12 @@ export type MutationGetAuthorizationUrlArgs = {
}; };
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
emailVerificationToken: Scalars['String'];
};
export type MutationImpersonateArgs = { export type MutationImpersonateArgs = {
userId: Scalars['String']; userId: Scalars['String'];
workspaceId: Scalars['String']; workspaceId: Scalars['String'];
@ -696,6 +705,11 @@ export type MutationRenewTokenArgs = {
}; };
export type MutationResendEmailVerificationTokenArgs = {
email: Scalars['String'];
};
export type MutationResendWorkspaceInvitationArgs = { export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String']; appTokenId: Scalars['String'];
}; };
@ -1062,6 +1076,11 @@ export enum RemoteTableStatus {
Synced = 'SYNCED' Synced = 'SYNCED'
} }
export type ResendEmailVerificationTokenOutput = {
__typename?: 'ResendEmailVerificationTokenOutput';
success: Scalars['Boolean'];
};
export type RunWorkflowVersionInput = { export type RunWorkflowVersionInput = {
/** Execution result in JSON format */ /** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>; payload?: InputMaybe<Scalars['JSON']>;
@ -1396,9 +1415,9 @@ export type User = {
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
disabled?: Maybe<Scalars['Boolean']>; disabled?: Maybe<Scalars['Boolean']>;
email: Scalars['String']; email: Scalars['String'];
emailVerified: Scalars['Boolean'];
firstName: Scalars['String']; firstName: Scalars['String'];
id: Scalars['UUID']; id: Scalars['UUID'];
isEmailVerified: Scalars['Boolean'];
lastName: Scalars['String']; lastName: Scalars['String'];
onboardingStatus?: Maybe<OnboardingStatus>; onboardingStatus?: Maybe<OnboardingStatus>;
passwordHash?: Maybe<Scalars['String']>; passwordHash?: Maybe<Scalars['String']>;
@ -1422,6 +1441,7 @@ export type UserExists = {
__typename?: 'UserExists'; __typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>; availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean']; exists: Scalars['Boolean'];
isEmailVerified: Scalars['Boolean'];
}; };
export type UserExistsOutput = UserExists | UserNotExists; 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 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<{ export type ImpersonateMutationVariables = Exact<{
userId: Scalars['String']; userId: Scalars['String'];
workspaceId: 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 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<{ export type SignUpMutationVariables = Exact<{
email: Scalars['String']; email: Scalars['String'];
password: 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; }>; 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 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; }>; 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 GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>; export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>; 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` export const ImpersonateDocument = gql`
mutation Impersonate($userId: String!, $workspaceId: String!) { mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) { impersonate(userId: $userId, workspaceId: $workspaceId) {
@ -2990,6 +3064,39 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions<R
export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>; export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>;
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>; export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>; 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` export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) { mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) {
signUp( signUp(
@ -3179,6 +3286,7 @@ export const CheckUserExistsDocument = gql`
status status
} }
} }
isEmailVerified
} }
... on UserNotExists { ... on UserNotExists {
exists exists
@ -3471,6 +3579,7 @@ export const GetClientConfigDocument = gql`
} }
signInPrefilled signInPrefilled
isMultiWorkspaceEnabled isMultiWorkspaceEnabled
isEmailVerificationRequired
defaultSubdomain defaultSubdomain
frontDomain frontDomain
debugMode debugMode

View File

@ -58,6 +58,17 @@ const testCases = [
{ loc: AppPath.Verify, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Verify, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
{ loc: AppPath.Verify, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.Verify, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: undefined },
{ loc: AppPath.VerifyEmail, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, { loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired },
{ loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, { loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
{ loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, { loc: AppPath.SignInUp, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },

View File

@ -16,7 +16,8 @@ export const usePageChangeEffectNavigateLocation = () => {
const isMatchingOpenRoute = const isMatchingOpenRoute =
isMatchingLocation(AppPath.Invite) || isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword); isMatchingLocation(AppPath.ResetPassword) ||
isMatchingLocation(AppPath.VerifyEmail);
const isMatchingOngoingUserCreationRoute = const isMatchingOngoingUserCreationRoute =
isMatchingOpenRoute || isMatchingOpenRoute ||

View File

@ -1,6 +1,8 @@
import { AppRouterProviders } from '@/app/components/AppRouterProviders'; import { AppRouterProviders } from '@/app/components/AppRouterProviders';
import { SettingsRoutes } from '@/app/components/SettingsRoutes'; import { SettingsRoutes } from '@/app/components/SettingsRoutes';
import { VerifyEffect } from '@/auth/components/VerifyEffect'; import { VerifyEffect } from '@/auth/components/VerifyEffect';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath'; import indexAppPath from '@/navigation/utils/indexAppPath';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout'; import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
@ -40,6 +42,7 @@ export const useCreateAppRouter = (
> >
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyEffect />} /> <Route path={AppPath.Verify} element={<VerifyEffect />} />
<Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} />
<Route path={AppPath.SignInUp} element={<SignInUp />} /> <Route path={AppPath.SignInUp} element={<SignInUp />} />
<Route path={AppPath.Invite} element={<Invite />} /> <Route path={AppPath.Invite} element={<Invite />} />
<Route path={AppPath.ResetPassword} element={<PasswordReset />} /> <Route path={AppPath.ResetPassword} element={<PasswordReset />} />

View File

@ -5,9 +5,9 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState'; import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useSetRecoilState } from 'recoil';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
export const VerifyEffect = () => { export const VerifyEffect = () => {
@ -29,6 +29,7 @@ export const VerifyEffect = () => {
useEffect(() => { useEffect(() => {
if (isDefined(errorMessage)) { if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, { enqueueSnackBar(errorMessage, {
dedupeKey: 'verify-failed-dedupe-key',
variant: SnackBarVariant.Error, variant: SnackBarVariant.Error,
}); });
} }

View File

@ -0,0 +1,68 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
export const VerifyEmailEffect = () => {
const { getLoginTokenFromEmailVerificationToken } = useAuth();
const { enqueueSnackBar } = useSnackBar();
const [searchParams] = useSearchParams();
const [isError, setIsError] = useState(false);
const email = searchParams.get('email');
const emailVerificationToken = searchParams.get('emailVerificationToken');
const navigate = useNavigate();
const { readCaptchaToken } = useReadCaptchaToken();
useEffect(() => {
const verifyEmailToken = async () => {
if (!email || !emailVerificationToken) {
enqueueSnackBar(`Invalid email verification link.`, {
dedupeKey: 'email-verification-link-dedupe-key',
variant: SnackBarVariant.Error,
});
return navigate(AppPath.SignInUp);
}
const captchaToken = await readCaptchaToken();
try {
const { loginToken } = await getLoginTokenFromEmailVerificationToken(
emailVerificationToken,
captchaToken,
);
enqueueSnackBar('Email verified.', {
dedupeKey: 'email-verification-dedupe-key',
variant: SnackBarVariant.Success,
});
navigate(`${AppPath.Verify}?loginToken=${loginToken.token}`);
} catch (error) {
enqueueSnackBar('Email verification failed.', {
dedupeKey: 'email-verification-dedupe-key',
variant: SnackBarVariant.Error,
});
setIsError(true);
}
};
verifyEmailToken();
// Verify email only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isError) {
return <EmailVerificationSent email={email} isError={true} />;
}
return <></>;
};

View File

@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
mutation GetLoginTokenFromEmailVerificationToken(
$emailVerificationToken: String!
$captchaToken: String
) {
getLoginTokenFromEmailVerificationToken(
emailVerificationToken: $emailVerificationToken
captchaToken: $captchaToken
) {
loginToken {
...AuthTokenFragment
}
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const RESEND_EMAIL_VERIFICATION_TOKEN = gql`
mutation ResendEmailVerificationToken($email: String!) {
resendEmailVerificationToken(email: $email) {
success
}
}
`;

View File

@ -19,6 +19,7 @@ export const CHECK_USER_EXISTS = gql`
status status
} }
} }
isEmailVerified
} }
... on UserNotExists { ... on UserNotExists {
exists exists

View File

@ -2,6 +2,7 @@ import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { expect } from '@storybook/test'; import { expect } from '@storybook/test';
import { ReactNode, act } from 'react'; import { ReactNode, act } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil'; import { RecoilRoot, useRecoilValue } from 'recoil';
import { iconsState } from 'twenty-ui'; import { iconsState } from 'twenty-ui';
@ -13,12 +14,14 @@ import { supportChatState } from '@/client-config/states/supportChatState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
const Wrapper = ({ children }: { children: ReactNode }) => ( const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={mocks} addTypename={false}> <MockedProvider mocks={mocks} addTypename={false}>
<RecoilRoot>{children}</RecoilRoot> <RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
</RecoilRoot>
</MockedProvider> </MockedProvider>
); );

View File

@ -1,4 +1,4 @@
import { useApolloClient } from '@apollo/client'; import { ApolloError, useApolloClient } from '@apollo/client';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
snapshot_UNSTABLE, snapshot_UNSTABLE,
@ -26,6 +26,7 @@ import {
useChallengeMutation, useChallengeMutation,
useCheckUserExistsLazyQuery, useCheckUserExistsLazyQuery,
useGetCurrentUserLazyQuery, useGetCurrentUserLazyQuery,
useGetLoginTokenFromEmailVerificationTokenMutation,
useSignUpMutation, useSignUpMutation,
useVerifyMutation, useVerifyMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
@ -44,7 +45,12 @@ import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTi
import { currentUserState } from '../states/currentUserState'; import { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState'; import { tokenPairState } from '../states/tokenPairState';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type'; import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
@ -54,6 +60,7 @@ import { domainConfigurationState } from '@/domain-manager/states/domainConfigur
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState'; import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { useSearchParams } from 'react-router-dom';
export const useAuth = () => { export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
@ -68,7 +75,11 @@ export const useAuth = () => {
currentWorkspaceMembersState, currentWorkspaceMembersState,
); );
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const isEmailVerificationRequired = useRecoilValue(
isEmailVerificationRequiredState,
);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState); const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
const setWorkspaces = useSetRecoilState(workspacesState); const setWorkspaces = useSetRecoilState(workspacesState);
@ -78,6 +89,8 @@ export const useAuth = () => {
const [challenge] = useChallengeMutation(); const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation(); const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation(); const [verify] = useVerifyMutation();
const [getLoginTokenFromEmailVerificationToken] =
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery(); const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } = const { isOnAWorkspaceSubdomain } =
@ -96,6 +109,8 @@ export const useAuth = () => {
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
const [, setSearchParams] = useSearchParams();
const clearSession = useRecoilCallback( const clearSession = useRecoilCallback(
({ snapshot }) => ({ snapshot }) =>
async () => { async () => {
@ -154,25 +169,59 @@ export const useAuth = () => {
const handleChallenge = useCallback( const handleChallenge = useCallback(
async (email: string, password: string, captchaToken?: string) => { async (email: string, password: string, captchaToken?: string) => {
const challengeResult = await challenge({ try {
const challengeResult = await challenge({
variables: {
email,
password,
captchaToken,
},
});
if (isDefined(challengeResult.errors)) {
throw challengeResult.errors;
}
if (!challengeResult.data?.challenge) {
throw new Error('No login token');
}
return challengeResult.data.challenge;
} catch (error) {
// TODO: Get intellisense for graphql error extensions code (codegen?)
if (
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.code === 'EMAIL_NOT_VERIFIED'
) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
}
throw error;
}
},
[challenge, setSearchParams, setSignInUpStep],
);
const handleGetLoginTokenFromEmailVerificationToken = useCallback(
async (emailVerificationToken: string, captchaToken?: string) => {
const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
variables: { variables: {
email, emailVerificationToken,
password,
captchaToken, captchaToken,
}, },
}); });
if (isDefined(challengeResult.errors)) { if (isDefined(loginTokenResult.errors)) {
throw challengeResult.errors; throw loginTokenResult.errors;
} }
if (!challengeResult.data?.challenge) { if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) {
throw new Error('No login token'); throw new Error('No login token');
} }
return challengeResult.data.challenge; return loginTokenResult.data.getLoginTokenFromEmailVerificationToken;
}, },
[challenge], [getLoginTokenFromEmailVerificationToken],
); );
const loadCurrentUser = useCallback(async () => { const loadCurrentUser = useCallback(async () => {
@ -343,12 +392,21 @@ export const useAuth = () => {
throw new Error('No login token'); throw new Error('No login token');
} }
if (isEmailVerificationRequired) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
return null;
}
if (isMultiWorkspaceEnabled) { if (isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain( return redirectToWorkspaceDomain(
signUpResult.data.signUp.workspace.subdomain, signUpResult.data.signUp.workspace.subdomain,
AppPath.Verify, isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{ {
loginToken: signUpResult.data.signUp.loginToken.token, ...(!isEmailVerificationRequired && {
loginToken: signUpResult.data.signUp.loginToken.token,
}),
email,
}, },
); );
} }
@ -361,6 +419,9 @@ export const useAuth = () => {
workspacePublicData, workspacePublicData,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
handleVerify, handleVerify,
setSignInUpStep,
setSearchParams,
isEmailVerificationRequired,
redirectToWorkspaceDomain, redirectToWorkspaceDomain,
], ],
); );
@ -424,6 +485,8 @@ export const useAuth = () => {
return { return {
challenge: handleChallenge, challenge: handleChallenge,
getLoginTokenFromEmailVerificationToken:
handleGetLoginTokenFromEmailVerificationToken,
verify: handleVerify, verify: handleVerify,
loadCurrentUser, loadCurrentUser,

View File

@ -0,0 +1,79 @@
import styled from '@emotion/styled';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { useHandleResendEmailVerificationToken } from '@/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken';
import { useTheme } from '@emotion/react';
import { AnimatedEaseIn, IconMail, Loader, MainButton, RGBA } from 'twenty-ui';
const StyledMailContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
border: 2px solid ${(props) => props.color};
border-radius: ${({ theme }) => theme.border.radius.rounded};
box-shadow: ${(props) =>
props.color && `-4px 4px 0 -2px ${RGBA(props.color, 1)}`};
height: 36px;
width: 36px;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledEmail = styled.span`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(8)};
`;
export const EmailVerificationSent = ({
email,
isError = false,
}: {
email: string | null;
isError?: boolean;
}) => {
const theme = useTheme();
const color =
theme.name === 'light' ? theme.grayScale.gray90 : theme.grayScale.gray10;
const { handleResendEmailVerificationToken, loading: isLoading } =
useHandleResendEmailVerificationToken();
return (
<>
<AnimatedEaseIn>
<StyledMailContainer color={color}>
<IconMail color={color} size={24} stroke={3} />
</StyledMailContainer>
</AnimatedEaseIn>
<Title animate>
{isError ? 'Email Verification Failed' : 'Confirm Your Email Address'}
</Title>
<SubTitle>
{isError ? (
<>
Oops! We encountered an issue verifying{' '}
<StyledEmail>{email}</StyledEmail>. Please request a new
verification email and try again.
</>
) : (
<>
A verification email has been sent to{' '}
<StyledEmail>{email}</StyledEmail>. Please check your inbox and
click the link in the email to activate your account.
</>
)}
</SubTitle>
<StyledButtonContainer>
<MainButton
title="Click to resend"
onClick={handleResendEmailVerificationToken(email)}
Icon={() => (isLoading ? <Loader /> : undefined)}
width={200}
/>
</StyledButtonContainer>
</>
);
};

View File

@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useResendEmailVerificationTokenMutation } from '~/generated/graphql';
export const useHandleResendEmailVerificationToken = () => {
const { enqueueSnackBar } = useSnackBar();
const [resendEmailVerificationToken, { loading }] =
useResendEmailVerificationTokenMutation();
const handleResendEmailVerificationToken = useCallback(
(email: string | null) => {
return async () => {
if (!email) {
enqueueSnackBar('Invalid email', {
variant: SnackBarVariant.Error,
});
return;
}
try {
const { data } = await resendEmailVerificationToken({
variables: { email },
});
if (data?.resendEmailVerificationToken?.success === true) {
enqueueSnackBar('Email verification link resent!', {
variant: SnackBarVariant.Success,
});
} else {
enqueueSnackBar('There was some issue', {
variant: SnackBarVariant.Error,
});
}
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
},
[enqueueSnackBar, resendEmailVerificationToken],
);
return { handleResendEmailVerificationToken, loading };
};

View File

@ -4,6 +4,7 @@ export enum SignInUpStep {
Init = 'init', Init = 'init',
Email = 'email', Email = 'email',
Password = 'password', Password = 'password',
EmailVerification = 'emailVerification',
WorkspaceSelection = 'workspaceSelection', WorkspaceSelection = 'workspaceSelection',
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection', SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
} }

View File

@ -8,6 +8,7 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState'; import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { supportChatState } from '@/client-config/states/supportChatState'; import { supportChatState } from '@/client-config/states/supportChatState';
@ -29,6 +30,9 @@ export const ClientConfigProviderEffect = () => {
const setIsMultiWorkspaceEnabled = useSetRecoilState( const setIsMultiWorkspaceEnabled = useSetRecoilState(
isMultiWorkspaceEnabledState, isMultiWorkspaceEnabledState,
); );
const setIsEmailVerificationRequired = useSetRecoilState(
isEmailVerificationRequiredState,
);
const setBilling = useSetRecoilState(billingState); const setBilling = useSetRecoilState(billingState);
const setSupportChat = useSetRecoilState(supportChatState); const setSupportChat = useSetRecoilState(supportChatState);
@ -89,6 +93,9 @@ export const ClientConfigProviderEffect = () => {
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled); setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled); setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled);
setIsEmailVerificationRequired(
data?.clientConfig.isEmailVerificationRequired,
);
setBilling(data?.clientConfig.billing); setBilling(data?.clientConfig.billing);
setSupportChat(data?.clientConfig.support); setSupportChat(data?.clientConfig.support);
@ -115,6 +122,7 @@ export const ClientConfigProviderEffect = () => {
setIsDebugMode, setIsDebugMode,
setIsDeveloperDefaultSignInPrefilled, setIsDeveloperDefaultSignInPrefilled,
setIsMultiWorkspaceEnabled, setIsMultiWorkspaceEnabled,
setIsEmailVerificationRequired,
setSupportChat, setSupportChat,
setBilling, setBilling,
setSentryConfig, setSentryConfig,

View File

@ -22,6 +22,7 @@ export const GET_CLIENT_CONFIG = gql`
} }
signInPrefilled signInPrefilled
isMultiWorkspaceEnabled isMultiWorkspaceEnabled
isEmailVerificationRequired
defaultSubdomain defaultSubdomain
frontDomain frontDomain
debugMode debugMode

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isEmailVerificationRequiredState = createState<boolean>({
key: 'isEmailVerificationRequired',
defaultValue: false,
});

View File

@ -1,6 +1,7 @@
export enum AppPath { export enum AppPath {
// Not logged-in // Not logged-in
Verify = '/verify', Verify = '/verify',
VerifyEmail = '/verify-email',
SignInUp = '/welcome', SignInUp = '/welcome',
Invite = '/invite/:workspaceInviteHash', Invite = '/invite/:workspaceInviteHash',
ResetPassword = '/reset-password/:passwordResetToken', ResetPassword = '/reset-password/:passwordResetToken',

View File

@ -35,6 +35,7 @@ export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
onClose?: () => void; onClose?: () => void;
role?: 'alert' | 'status'; role?: 'alert' | 'status';
variant?: SnackBarVariant; variant?: SnackBarVariant;
dedupeKey?: string;
}; };
const StyledContainer = styled.div` const StyledContainer = styled.div`

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { isDefined } from '~/utils/isDefined';
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext'; import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import { import {
@ -27,8 +28,17 @@ export const useSnackBar = () => {
const setSnackBarQueue = useRecoilCallback( const setSnackBarQueue = useRecoilCallback(
({ set }) => ({ set }) =>
(newValue) => (newValue: SnackBarOptions) =>
set(snackBarInternalScopedState({ scopeId }), (prev) => { set(snackBarInternalScopedState({ scopeId }), (prev) => {
if (
isDefined(newValue.dedupeKey) &&
prev.queue.some(
(snackBar) => snackBar.dedupeKey === newValue.dedupeKey,
)
) {
return prev;
}
if (prev.queue.length >= prev.maxQueue) { if (prev.queue.length >= prev.maxQueue) {
return { return {
...prev, ...prev,

View File

@ -67,6 +67,17 @@ const testCases = [
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: false },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: true },
{ loc: AppPath.VerifyEmail, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: true },
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, { loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true }, { loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
{ loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true }, { loc: AppPath.SignInUp, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: true },

View File

@ -28,6 +28,7 @@ export const useShowAuthModal = () => {
if ( if (
isMatchingLocation(AppPath.Invite) || isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword) || isMatchingLocation(AppPath.ResetPassword) ||
isMatchingLocation(AppPath.VerifyEmail) ||
isMatchingLocation(AppPath.SignInUp) isMatchingLocation(AppPath.SignInUp)
) { ) {
return isDefaultLayoutAuthModalVisible; return isDefaultLayoutAuthModalVisible;

View File

@ -1,12 +1,12 @@
import { useRecoilValue } from 'recoil';
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { SignInUpStep } from '@/auth/states/signInUpStepState'; import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { useRecoilValue } from 'recoil';
import { Logo } from '@/auth/components/Logo'; import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title'; import { Title } from '@/auth/components/Title';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection';
@ -21,6 +21,37 @@ import { useMemo } from 'react';
import { AnimatedEaseIn } from 'twenty-ui'; import { AnimatedEaseIn } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useSearchParams } from 'react-router-dom';
import { PublicWorkspaceDataOutput } from '~/generated-metadata/graphql';
const StandardContent = ({
workspacePublicData,
signInUpForm,
signInUpStep,
}: {
workspacePublicData: PublicWorkspaceDataOutput | null;
signInUpForm: JSX.Element | null;
signInUpStep: SignInUpStep;
}) => {
return (
<>
<AnimatedEaseIn>
<Logo secondaryLogo={workspacePublicData?.logo} />
</AnimatedEaseIn>
<Title animate>
Welcome to{' '}
{!isDefined(workspacePublicData?.displayName)
? DEFAULT_WORKSPACE_NAME
: workspacePublicData?.displayName === ''
? 'Your Workspace'
: workspacePublicData?.displayName}
</Title>
{signInUpForm}
{signInUpStep !== SignInUpStep.Password && <FooterNote />}
</>
);
};
export const SignInUp = () => { export const SignInUp = () => {
const { form } = useSignInUpForm(); const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form); const { signInUpStep } = useSignInUp(form);
@ -31,6 +62,8 @@ export const SignInUp = () => {
const { loading } = useGetPublicWorkspaceDataBySubdomain(); const { loading } = useGetPublicWorkspaceDataBySubdomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const [searchParams] = useSearchParams();
const signInUpForm = useMemo(() => { const signInUpForm = useMemo(() => {
if (loading) return null; if (loading) return null;
@ -68,16 +101,15 @@ export const SignInUp = () => {
workspacePublicData, workspacePublicData,
]); ]);
if (signInUpStep === SignInUpStep.EmailVerification) {
return <EmailVerificationSent email={searchParams.get('email')} />;
}
return ( return (
<> <StandardContent
<AnimatedEaseIn> workspacePublicData={workspacePublicData}
<Logo secondaryLogo={workspacePublicData?.logo} /> signInUpForm={signInUpForm}
</AnimatedEaseIn> signInUpStep={signInUpStep}
<Title animate> />
{`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`}
</Title>
{signInUpForm}
{signInUpStep !== SignInUpStep.Password && <FooterNote />}
</>
); );
}; };

View File

@ -3,6 +3,7 @@ import { CaptchaDriverType, ClientConfig } from '~/generated/graphql';
export const mockedClientConfig: ClientConfig = { export const mockedClientConfig: ClientConfig = {
signInPrefilled: true, signInPrefilled: true,
isMultiWorkspaceEnabled: false, isMultiWorkspaceEnabled: false,
isEmailVerificationRequired: false,
authProviders: { authProviders: {
google: true, google: true,
magicLink: false, magicLink: false,

View File

@ -56,6 +56,8 @@ FRONT_PORT=3001
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30 # WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60 # WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60
# Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email # Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email
# IS_EMAIL_VERIFICATION_REQUIRED=false
# EMAIL_VERIFICATION_TOKEN_EXPIRES_IN=1h
# EMAIL_FROM_ADDRESS=contact@yourdomain.com # EMAIL_FROM_ADDRESS=contact@yourdomain.com
# EMAIL_SYSTEM_ADDRESS=system@yourdomain.com # EMAIL_SYSTEM_ADDRESS=system@yourdomain.com
# EMAIL_FROM_NAME='John from YourDomain' # EMAIL_FROM_NAME='John from YourDomain'

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameEmailVerifiedColumn1736050161854
implements MigrationInterface
{
name = 'RenameEmailVerifiedColumn1736050161854';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" RENAME COLUMN "emailVerified" TO "isEmailVerified"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" RENAME COLUMN "isEmailVerified" TO "emailVerified"`,
);
}
}

View File

@ -1,21 +1,21 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Relation,
} from 'typeorm';
import { BeforeCreateOne, IDField } from '@ptc-org/nestjs-query-graphql'; import { BeforeCreateOne, IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BeforeCreateOneAppToken } from 'src/engine/core-modules/app-token/hooks/before-create-one-app-token.hook'; import { BeforeCreateOneAppToken } from 'src/engine/core-modules/app-token/hooks/before-create-one-app-token.hook';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
export enum AppTokenType { export enum AppTokenType {
RefreshToken = 'REFRESH_TOKEN', RefreshToken = 'REFRESH_TOKEN',
CodeChallenge = 'CODE_CHALLENGE', CodeChallenge = 'CODE_CHALLENGE',
@ -23,6 +23,7 @@ export enum AppTokenType {
PasswordResetToken = 'PASSWORD_RESET_TOKEN', PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN', InvitationToken = 'INVITATION_TOKEN',
OIDCCodeVerifier = 'OIDC_CODE_VERIFIER', OIDCCodeVerifier = 'OIDC_CODE_VERIFIER',
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
} }
@Entity({ name: 'appToken', schema: 'core' }) @Entity({ name: 'appToken', schema: 'core' })

View File

@ -9,6 +9,7 @@ export class AuthException extends CustomException {
export enum AuthExceptionCode { export enum AuthExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND',
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND', CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
INVALID_INPUT = 'INVALID_INPUT', INVALID_INPUT = 'INVALID_INPUT',

View File

@ -25,6 +25,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
@ -80,6 +81,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
WorkspaceSSOModule, WorkspaceSSOModule,
FeatureFlagModule, FeatureFlagModule,
WorkspaceInvitationModule, WorkspaceInvitationModule,
EmailVerificationModule,
], ],
controllers: [ controllers: [
GoogleAuthController, GoogleAuthController,

View File

@ -3,11 +3,12 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { AuthResolver } from './auth.resolver'; import { AuthResolver } from './auth.resolver';
@ -16,6 +17,7 @@ import { AuthService } from './services/auth.service';
// import { OAuthService } from './services/oauth.service'; // import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service'; import { ResetPasswordService } from './services/reset-password.service';
import { SwitchWorkspaceService } from './services/switch-workspace.service'; import { SwitchWorkspaceService } from './services/switch-workspace.service';
import { EmailVerificationTokenService } from './token/services/email-verification-token.service';
import { LoginTokenService } from './token/services/login-token.service'; import { LoginTokenService } from './token/services/login-token.service';
import { RenewTokenService } from './token/services/renew-token.service'; import { RenewTokenService } from './token/services/renew-token.service';
import { TransientTokenService } from './token/services/transient-token.service'; import { TransientTokenService } from './token/services/transient-token.service';
@ -80,6 +82,14 @@ describe('AuthResolver', () => {
provide: TransientTokenService, provide: TransientTokenService,
useValue: {}, useValue: {},
}, },
{
provide: EmailVerificationService,
useValue: {},
},
{
provide: EmailVerificationTokenService,
useValue: {},
},
// { // {
// provide: OAuthService, // provide: OAuthService,
// useValue: {}, // useValue: {},

View File

@ -18,30 +18,34 @@ import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dt
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output'; import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ChallengeInput } from './dto/challenge.input'; import { ChallengeInput } from './dto/challenge.input';
import { LoginToken } from './dto/login-token.entity'; import { LoginToken } from './dto/login-token.entity';
@ -68,8 +72,11 @@ export class AuthResolver {
private loginTokenService: LoginTokenService, private loginTokenService: LoginTokenService,
private switchWorkspaceService: SwitchWorkspaceService, private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService, private transientTokenService: TransientTokenService,
private emailVerificationService: EmailVerificationService,
// private oauthService: OAuthService, // private oauthService: OAuthService,
private domainManagerService: DomainManagerService, private domainManagerService: DomainManagerService,
private userWorkspaceService: UserWorkspaceService,
private emailVerificationTokenService: EmailVerificationTokenService,
) {} ) {}
@UseGuards(CaptchaGuard) @UseGuards(CaptchaGuard)
@ -116,7 +123,6 @@ export class AuthResolver {
AuthExceptionCode.WORKSPACE_NOT_FOUND, AuthExceptionCode.WORKSPACE_NOT_FOUND,
), ),
); );
const user = await this.authService.challenge(challengeInput, workspace); const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
@ -126,6 +132,41 @@ export class AuthResolver {
return { loginToken }; return { loginToken };
} }
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async getLoginTokenFromEmailVerificationToken(
@Args()
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
@OriginHeader() origin: string,
) {
const workspace =
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
const user =
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
getLoginTokenFromEmailVerificationTokenInput.emailVerificationToken,
);
await this.userService.markEmailAsVerified(user.id);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return { loginToken };
}
@UseGuards(CaptchaGuard) @UseGuards(CaptchaGuard)
@Mutation(() => SignUpOutput) @Mutation(() => SignUpOutput)
async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> { async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> {
@ -170,6 +211,12 @@ export class AuthResolver {
}, },
}); });
await this.emailVerificationService.sendVerificationEmail(
user.id,
user.email,
workspace.subdomain,
);
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
workspace.id, workspace.id,
@ -333,6 +380,6 @@ export class AuthResolver {
async findAvailableWorkspacesByEmail( async findAvailableWorkspacesByEmail(
@Args('email') email: string, @Args('email') email: string,
): Promise<AvailableWorkspaceOutput[]> { ): Promise<AvailableWorkspaceOutput[]> {
return this.authService.findAvailableWorkspacesByEmail(email); return this.userWorkspaceService.findAvailableWorkspacesByEmail(email);
} }
} }

View File

@ -0,0 +1,16 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
@ArgsType()
export class GetLoginTokenFromEmailVerificationTokenInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
emailVerificationToken: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
captchaToken?: string;
}

View File

@ -9,6 +9,9 @@ export class UserExists {
@Field(() => [AvailableWorkspaceOutput]) @Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>; availableWorkspaces: Array<AvailableWorkspaceOutput>;
@Field(() => Boolean)
isEmailVerified: boolean;
} }
@ObjectType() @ObjectType()

View File

@ -6,6 +6,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { import {
AuthenticationError, AuthenticationError,
EmailNotVerifiedError,
ForbiddenError, ForbiddenError,
InternalServerError, InternalServerError,
NotFoundError, NotFoundError,
@ -20,6 +21,8 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
throw new NotFoundError(exception.message); throw new NotFoundError(exception.message);
case AuthExceptionCode.INVALID_INPUT: case AuthExceptionCode.INVALID_INPUT:
throw new UserInputError(exception.message); throw new UserInputError(exception.message);
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
throw new EmailNotVerifiedError(exception.message);
case AuthExceptionCode.FORBIDDEN_EXCEPTION: case AuthExceptionCode.FORBIDDEN_EXCEPTION:
throw new ForbiddenError(exception.message); throw new ForbiddenError(exception.message);
case AuthExceptionCode.UNAUTHENTICATED: case AuthExceptionCode.UNAUTHENTICATED:

View File

@ -1,23 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals'; import { expect, jest } from '@jest/globals';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import bcrypt from 'bcrypt';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -31,9 +31,10 @@ const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
const workspaceInvitationValidateInvitationMock = jest.fn(); const workspaceInvitationValidateInvitationMock = jest.fn();
const userWorkspaceAddUserToWorkspaceMock = jest.fn(); const userWorkspaceAddUserToWorkspaceMock = jest.fn();
const environmentServiceGetMock = jest.fn();
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
let appTokenRepository: Repository<AppToken>;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -66,7 +67,9 @@ describe('AuthService', () => {
}, },
{ {
provide: EnvironmentService, provide: EnvironmentService,
useValue: {}, useValue: {
get: environmentServiceGetMock,
},
}, },
{ {
provide: DomainManagerService, provide: DomainManagerService,
@ -112,10 +115,10 @@ describe('AuthService', () => {
}).compile(); }).compile();
service = module.get<AuthService>(AuthService); service = module.get<AuthService>(AuthService);
});
appTokenRepository = module.get<Repository<AppToken>>( beforeEach(() => {
getRepositoryToken(AppToken, 'core'), environmentServiceGetMock.mockReturnValue(false);
);
}); });
it('should be defined', async () => { it('should be defined', async () => {

View File

@ -26,7 +26,6 @@ import {
} from 'src/engine/core-modules/auth/auth.util'; } from 'src/engine/core-modules/auth/auth.util';
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity'; import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input'; import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity'; import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
@ -157,6 +156,17 @@ export class AuthService {
); );
} }
const isEmailVerificationRequired = this.environmentService.get(
'IS_EMAIL_VERIFICATION_REQUIRED',
);
if (isEmailVerificationRequired && !user.isEmailVerified) {
throw new AuthException(
'Email is not verified',
AuthExceptionCode.EMAIL_NOT_VERIFIED,
);
}
return user; return user;
} }
@ -260,7 +270,9 @@ export class AuthService {
if (userValidator.isDefined(user)) { if (userValidator.isDefined(user)) {
return { return {
exists: true, exists: true,
availableWorkspaces: await this.findAvailableWorkspacesByEmail(email), availableWorkspaces:
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
isEmailVerified: user.isEmailVerified,
}; };
} }
@ -463,48 +475,6 @@ export class AuthService {
return url.toString(); return url.toString();
} }
async findAvailableWorkspacesByEmail(email: string) {
const user = await this.userRepository.findOne({
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
],
});
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
id: userWorkspace.workspaceId,
displayName: userWorkspace.workspace.displayName,
subdomain: userWorkspace.workspace.subdomain,
logo: userWorkspace.workspace.logo,
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
(acc, identityProvider) =>
acc.concat(
identityProvider.status === 'Inactive'
? []
: [
{
id: identityProvider.id,
name: identityProvider.name,
issuer: identityProvider.issuer,
type: identityProvider.type,
status: identityProvider.status,
},
],
),
[] as AvailableWorkspaceOutput['sso'],
),
}));
}
async findInvitationForSignInUp({ async findInvitationForSignInUp({
currentWorkspace, currentWorkspace,
workspacePersonalInviteToken, workspacePersonalInviteToken,

View File

@ -22,6 +22,7 @@ import {
WorkspaceActivationStatus, WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity'; } from 'src/engine/core-modules/workspace/workspace.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
jest.mock('src/utils/image', () => { jest.mock('src/utils/image', () => {
return { return {
@ -36,8 +37,6 @@ describe('SignInUpService', () => {
let fileUploadService: FileUploadService; let fileUploadService: FileUploadService;
let workspaceInvitationService: WorkspaceInvitationService; let workspaceInvitationService: WorkspaceInvitationService;
let userWorkspaceService: UserWorkspaceService; let userWorkspaceService: UserWorkspaceService;
let onboardingService: OnboardingService;
let httpService: HttpService;
let environmentService: EnvironmentService; let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService; let domainManagerService: DomainManagerService;
@ -98,6 +97,16 @@ describe('SignInUpService', () => {
get: jest.fn(), get: jest.fn(),
}, },
}, },
{
provide: UserService,
useValue: {
markEmailAsVerified: jest.fn().mockReturnValue({
id: 'test-user-id',
email: 'test@test.com',
isEmailVerified: true,
} as User),
},
},
{ {
provide: DomainManagerService, provide: DomainManagerService,
useValue: { useValue: {
@ -116,8 +125,6 @@ describe('SignInUpService', () => {
); );
userWorkspaceService = userWorkspaceService =
module.get<UserWorkspaceService>(UserWorkspaceService); module.get<UserWorkspaceService>(UserWorkspaceService);
onboardingService = module.get<OnboardingService>(OnboardingService);
httpService = module.get<HttpService>(HttpService);
environmentService = module.get<EnvironmentService>(EnvironmentService); environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService = domainManagerService =
module.get<DomainManagerService>(DomainManagerService); module.get<DomainManagerService>(DomainManagerService);

View File

@ -41,6 +41,7 @@ import {
SignInUpBaseParams, SignInUpBaseParams,
SignInUpNewUserPayload, SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type'; } from 'src/engine/core-modules/auth/types/signInUp.type';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -57,6 +58,7 @@ export class SignInUpService {
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly userService: UserService,
) {} ) {}
async computeParamsForNewUser( async computeParamsForNewUser(
@ -194,6 +196,8 @@ export class SignInUpService {
email, email,
); );
await this.userService.markEmailAsVerified(updatedUser.id);
return updatedUser; return updatedUser;
} }

View File

@ -0,0 +1,187 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import crypto from 'crypto';
import { Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
EmailVerificationException,
EmailVerificationExceptionCode,
} from 'src/engine/core-modules/email-verification/email-verification.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { EmailVerificationTokenService } from './email-verification-token.service';
describe('EmailVerificationTokenService', () => {
let service: EmailVerificationTokenService;
let appTokenRepository: Repository<AppToken>;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailVerificationTokenService,
{
provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository,
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<EmailVerificationTokenService>(
EmailVerificationTokenService,
);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
describe('generateToken', () => {
it('should generate a verification token successfully', async () => {
const userId = 'test-user-id';
const email = 'test@example.com';
const mockExpiresIn = '24h';
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(appTokenRepository, 'create').mockReturnValue({} as AppToken);
jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
const result = await service.generateToken(userId, email);
expect(result).toHaveProperty('token');
expect(result).toHaveProperty('expiresAt');
expect(result.token).toHaveLength(64); // 32 bytes in hex = 64 characters
expect(appTokenRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
userId,
type: AppTokenType.EmailVerificationToken,
context: { email },
}),
);
expect(appTokenRepository.save).toHaveBeenCalled();
});
});
describe('validateEmailVerificationTokenOrThrow', () => {
it('should validate token successfully and return user', async () => {
const plainToken = 'plain-token';
const hashedToken = crypto
.createHash('sha256')
.update(plainToken)
.digest('hex');
const mockUser = { id: 'user-id', email: 'test@example.com' };
const mockAppToken = {
type: AppTokenType.EmailVerificationToken,
expiresAt: new Date(Date.now() + 86400000), // 24h from now
context: { email: 'test@example.com' },
user: mockUser,
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
jest
.spyOn(appTokenRepository, 'remove')
.mockResolvedValue(mockAppToken as AppToken);
const result =
await service.validateEmailVerificationTokenOrThrow(plainToken);
expect(result).toEqual(mockUser);
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
where: {
value: hashedToken,
type: AppTokenType.EmailVerificationToken,
},
relations: ['user'],
});
expect(appTokenRepository.remove).toHaveBeenCalledWith(mockAppToken);
});
it('should throw exception for invalid token', async () => {
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validateEmailVerificationTokenOrThrow('invalid-token'),
).rejects.toThrow(
new EmailVerificationException(
'Invalid email verification token',
EmailVerificationExceptionCode.INVALID_TOKEN,
),
);
});
it('should throw exception for wrong token type', async () => {
const mockAppToken = {
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 86400000),
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
await expect(
service.validateEmailVerificationTokenOrThrow('wrong-type-token'),
).rejects.toThrow(
new EmailVerificationException(
'Invalid email verification token type',
EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE,
),
);
});
it('should throw exception for expired token', async () => {
const mockAppToken = {
type: AppTokenType.EmailVerificationToken,
expiresAt: new Date(Date.now() - 86400000), // 24h ago
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
await expect(
service.validateEmailVerificationTokenOrThrow('expired-token'),
).rejects.toThrow(
new EmailVerificationException(
'Email verification token expired',
EmailVerificationExceptionCode.TOKEN_EXPIRED,
),
);
});
it('should throw exception when email is missing in context', async () => {
const mockAppToken = {
type: AppTokenType.EmailVerificationToken,
expiresAt: new Date(Date.now() + 86400000),
context: {},
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockAppToken as AppToken);
await expect(
service.validateEmailVerificationTokenOrThrow('valid-token'),
).rejects.toThrow(
new EmailVerificationException(
'Email missing in email verification token context',
EmailVerificationExceptionCode.EMAIL_MISSING,
),
);
});
});
});

View File

@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import {
EmailVerificationException,
EmailVerificationExceptionCode,
} from 'src/engine/core-modules/email-verification/email-verification.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class EmailVerificationTokenService {
constructor(
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly environmentService: EnvironmentService,
) {}
async generateToken(userId: string, email: string): Promise<AuthToken> {
const expiresIn = this.environmentService.get(
'EMAIL_VERIFICATION_TOKEN_EXPIRES_IN',
);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const plainToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto
.createHash('sha256')
.update(plainToken)
.digest('hex');
const verificationToken = this.appTokenRepository.create({
userId,
expiresAt,
type: AppTokenType.EmailVerificationToken,
value: hashedToken,
context: { email },
});
await this.appTokenRepository.save(verificationToken);
return {
token: plainToken,
expiresAt,
};
}
async validateEmailVerificationTokenOrThrow(emailVerificationToken: string) {
const hashedToken = crypto
.createHash('sha256')
.update(emailVerificationToken)
.digest('hex');
const appToken = await this.appTokenRepository.findOne({
where: {
value: hashedToken,
type: AppTokenType.EmailVerificationToken,
},
relations: ['user'],
});
if (!appToken) {
throw new EmailVerificationException(
'Invalid email verification token',
EmailVerificationExceptionCode.INVALID_TOKEN,
);
}
if (appToken.type !== AppTokenType.EmailVerificationToken) {
throw new EmailVerificationException(
'Invalid email verification token type',
EmailVerificationExceptionCode.INVALID_APP_TOKEN_TYPE,
);
}
if (new Date() > appToken.expiresAt) {
throw new EmailVerificationException(
'Email verification token expired',
EmailVerificationExceptionCode.TOKEN_EXPIRED,
);
}
if (!appToken.context?.email) {
throw new EmailVerificationException(
'Email missing in email verification token context',
EmailVerificationExceptionCode.EMAIL_MISSING,
);
}
await this.appTokenRepository.remove(appToken);
return appToken.user;
}
}

View File

@ -65,6 +65,9 @@ export class ClientConfig {
@Field(() => Boolean) @Field(() => Boolean)
isMultiWorkspaceEnabled: boolean; isMultiWorkspaceEnabled: boolean;
@Field(() => Boolean)
isEmailVerificationRequired: boolean;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
defaultSubdomain: string; defaultSubdomain: string;

View File

@ -33,6 +33,9 @@ export class ClientConfigResolver {
isMultiWorkspaceEnabled: this.environmentService.get( isMultiWorkspaceEnabled: this.environmentService.get(
'IS_MULTIWORKSPACE_ENABLED', 'IS_MULTIWORKSPACE_ENABLED',
), ),
isEmailVerificationRequired: this.environmentService.get(
'IS_EMAIL_VERIFICATION_REQUIRED',
),
defaultSubdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), defaultSubdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
frontDomain: this.domainManagerService.getFrontUrl().hostname, frontDomain: this.domainManagerService.getFrontUrl().hostname,
debugMode: this.environmentService.get('DEBUG_MODE'), debugMode: this.environmentService.get('DEBUG_MODE'),

View File

@ -59,6 +59,22 @@ export class DomainManagerService {
return baseUrl; return baseUrl;
} }
buildEmailVerificationURL({
emailVerificationToken,
email,
workspaceSubdomain,
}: {
emailVerificationToken: string;
email: string;
workspaceSubdomain?: string;
}) {
return this.buildWorkspaceURL({
subdomain: workspaceSubdomain,
pathname: 'verify-email',
searchParams: { emailVerificationToken, email },
});
}
buildWorkspaceURL({ buildWorkspaceURL({
subdomain, subdomain,
pathname, pathname,

View File

@ -0,0 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator';
@ArgsType()
export class ResendEmailVerificationTokenInput {
@Field(() => String)
@IsEmail()
@IsNotEmpty()
email: string;
}

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean } from 'class-validator';
@ObjectType()
export class ResendEmailVerificationTokenOutput {
@IsBoolean()
@Field(() => Boolean)
success: boolean;
}

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class EmailVerificationException extends CustomException {
constructor(message: string, code: EmailVerificationExceptionCode) {
super(message, code);
}
}
export enum EmailVerificationExceptionCode {
EMAIL_VERIFICATION_NOT_REQUIRED = 'EMAIL_VERIFICATION_NOT_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
EMAIL_MISSING = 'EMAIL_MISSING',
EMAIL_ALREADY_VERIFIED = 'EMAIL_ALREADY_VERIFIED',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { EmailVerificationResolver } from 'src/engine/core-modules/email-verification/email-verification.resolver';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([AppToken], 'core'),
EmailModule,
EnvironmentModule,
DomainManagerModule,
UserModule,
UserWorkspaceModule,
],
providers: [
EmailVerificationService,
EmailVerificationResolver,
EmailVerificationTokenService,
],
exports: [EmailVerificationService, EmailVerificationTokenService],
})
export class EmailVerificationModule {}

View File

@ -0,0 +1,35 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { ResendEmailVerificationTokenInput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input';
import { ResendEmailVerificationTokenOutput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
@Resolver()
export class EmailVerificationResolver {
constructor(
private readonly emailVerificationService: EmailVerificationService,
private readonly domainManagerService: DomainManagerService,
) {}
@Mutation(() => ResendEmailVerificationTokenOutput)
async resendEmailVerificationToken(
@Args()
resendEmailVerificationTokenInput: ResendEmailVerificationTokenInput,
@OriginHeader() origin: string,
): Promise<ResendEmailVerificationTokenOutput> {
const workspace =
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
return await this.emailVerificationService.resendEmailVerificationToken(
resendEmailVerificationTokenInput.email,
workspace.subdomain,
);
}
}

View File

@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
import { Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import {
EmailVerificationException,
EmailVerificationExceptionCode,
} from 'src/engine/core-modules/email-verification/email-verification.exception';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class EmailVerificationService {
constructor(
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly domainManagerService: DomainManagerService,
private readonly emailService: EmailService,
private readonly environmentService: EnvironmentService,
private readonly userService: UserService,
private readonly emailVerificationTokenService: EmailVerificationTokenService,
) {}
async sendVerificationEmail(
userId: string,
email: string,
workspaceSubdomain?: string,
) {
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
return { success: false };
}
const { token: emailVerificationToken } =
await this.emailVerificationTokenService.generateToken(userId, email);
const verificationLink =
this.domainManagerService.buildEmailVerificationURL({
emailVerificationToken,
email,
workspaceSubdomain,
});
const emailData = {
link: verificationLink.toString(),
};
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
await this.emailService.send({
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
to: email,
subject: 'Welcome to Twenty: Please Confirm Your Email',
text,
html,
});
return { success: true };
}
async resendEmailVerificationToken(
email: string,
workspaceSubdomain?: string,
) {
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
throw new EmailVerificationException(
'Email verification token cannot be sent because email verification is not required',
EmailVerificationExceptionCode.EMAIL_VERIFICATION_NOT_REQUIRED,
);
}
const user = await this.userService.getUserByEmail(email);
if (user.isEmailVerified) {
throw new EmailVerificationException(
'Email already verified',
EmailVerificationExceptionCode.EMAIL_ALREADY_VERIFIED,
);
}
const existingToken = await this.appTokenRepository.findOne({
where: {
userId: user.id,
type: AppTokenType.EmailVerificationToken,
},
});
if (existingToken) {
const cooldownDuration = ms('1m');
const timeToWaitMs = differenceInMilliseconds(
addMilliseconds(existingToken.createdAt, cooldownDuration),
new Date(),
);
if (timeToWaitMs > 0) {
throw new EmailVerificationException(
`Please wait ${ms(timeToWaitMs, { long: true })} before requesting another verification email`,
EmailVerificationExceptionCode.RATE_LIMIT_EXCEEDED,
);
}
await this.appTokenRepository.delete(existingToken.id);
}
await this.sendVerificationEmail(user.id, email, workspaceSubdomain);
return { success: true };
}
}

View File

@ -413,6 +413,15 @@ export class EnvironmentVariables {
MESSAGE_QUEUE_TYPE: string = MessageQueueDriverType.BullMQ; MESSAGE_QUEUE_TYPE: string = MessageQueueDriverType.BullMQ;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_EMAIL_VERIFICATION_REQUIRED = false;
@IsDuration()
@IsOptional()
EMAIL_VERIFICATION_TOKEN_EXPIRES_IN = '1h';
EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com'; EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com';
EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com'; EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com';

View File

@ -24,6 +24,7 @@ export enum ErrorCode {
PERSISTED_QUERY_NOT_SUPPORTED = 'PERSISTED_QUERY_NOT_SUPPORTED', PERSISTED_QUERY_NOT_SUPPORTED = 'PERSISTED_QUERY_NOT_SUPPORTED',
BAD_USER_INPUT = 'BAD_USER_INPUT', BAD_USER_INPUT = 'BAD_USER_INPUT',
NOT_FOUND = 'NOT_FOUND', NOT_FOUND = 'NOT_FOUND',
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
CONFLICT = 'CONFLICT', CONFLICT = 'CONFLICT',
TIMEOUT = 'TIMEOUT', TIMEOUT = 'TIMEOUT',
@ -160,6 +161,14 @@ export class NotFoundError extends BaseGraphQLError {
} }
} }
export class EmailNotVerifiedError extends BaseGraphQLError {
constructor(message: string) {
super(message, ErrorCode.EMAIL_NOT_VERIFIED);
Object.defineProperty(this, 'name', { value: 'EmailNotVerifiedError' });
}
}
export class MethodNotAllowedError extends BaseGraphQLError { export class MethodNotAllowedError extends BaseGraphQLError {
constructor(message: string) { constructor(message: string) {
super(message, ErrorCode.METHOD_NOT_ALLOWED); super(message, ErrorCode.METHOD_NOT_ALLOWED);

View File

@ -7,8 +7,14 @@ import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants'; import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@ -161,4 +167,46 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
}, },
}); });
} }
async findAvailableWorkspacesByEmail(email: string) {
const user = await this.userRepository.findOne({
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
],
});
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
id: userWorkspace.workspaceId,
displayName: userWorkspace.workspace.displayName,
subdomain: userWorkspace.workspace.subdomain,
logo: userWorkspace.workspace.logo,
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
(acc, identityProvider) =>
acc.concat(
identityProvider.status === 'Inactive'
? []
: [
{
id: identityProvider.id,
name: identityProvider.name,
issuer: identityProvider.issuer,
type: identityProvider.type,
status: identityProvider.status,
},
],
),
[] as AvailableWorkspaceOutput['sso'],
),
}));
}
} }

View File

@ -165,4 +165,30 @@ export class UserService extends TypeOrmQueryService<User> {
), ),
); );
} }
async getUserByEmail(email: string) {
const user = await this.userRepository.findOne({
where: {
email,
},
});
userValidator.assertIsDefinedOrThrow(user);
return user;
}
async markEmailAsVerified(userId: string) {
const user = await this.userRepository.findOne({
where: {
id: userId,
},
});
userValidator.assertIsDefinedOrThrow(user);
user.isEmailVerified = true;
return await this.userRepository.save(user);
}
} }

View File

@ -55,7 +55,7 @@ export class User {
@Field() @Field()
@Column({ default: false }) @Column({ default: false })
emailVerified: boolean; isEmailVerified: boolean;
@Field({ nullable: true }) @Field({ nullable: true })
@Column({ default: false }) @Column({ default: false })

View File

@ -48,6 +48,8 @@ export class GraphQLHydrateRequestFromTokenMiddleware
'CheckUserExists', 'CheckUserExists',
'Challenge', 'Challenge',
'Verify', 'Verify',
'GetLoginTokenFromEmailVerificationToken',
'ResendEmailVerificationToken',
'SignUp', 'SignUp',
'RenewToken', 'RenewToken',
'EmailPasswordResetLink', 'EmailPasswordResetLink',

View File

@ -185,6 +185,8 @@ yarn command:prod cron:calendar:ongoing-stale
### Email ### Email
<ArticleTable options={[ <ArticleTable options={[
['IS_EMAIL_VERIFICATION_REQUIRED', 'false', 'If enabled, users must verify their email address before signing in. When true, users will receive a verification email after registration'],
['EMAIL_VERIFICATION_TOKEN_EXPIRES_IN', '1h', 'How long email verification tokens remain valid before requiring a new verification email'],
['EMAIL_FROM_ADDRESS', 'contact@yourdomain.com', 'Global email From: header used to send emails'], ['EMAIL_FROM_ADDRESS', 'contact@yourdomain.com', 'Global email From: header used to send emails'],
['EMAIL_FROM_NAME', 'John from YourDomain', 'Global name From: header used to send emails'], ['EMAIL_FROM_NAME', 'John from YourDomain', 'Global name From: header used to send emails'],
['EMAIL_SYSTEM_ADDRESS', 'system@yourdomain.com', 'Email address used as a destination to send internal system notification'], ['EMAIL_SYSTEM_ADDRESS', 'system@yourdomain.com', 'Email address used as a destination to send internal system notification'],
@ -212,24 +214,24 @@ yarn command:prod cron:calendar:ongoing-stale
<ArticleTab> <ArticleTab>
Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9). Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
- EMAIL_DRIVER=smtp - EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=smtp.office365.com - EMAIL_SMTP_HOST=smtp.office365.com
- EMAIL_SMTP_PORT=587 - EMAIL_SMTP_PORT=587
- EMAIL_SMTP_USER=office365_email_address - EMAIL_SMTP_USER=office365_email_address
- EMAIL_SMTP_PASSWORD='office365_password' - EMAIL_SMTP_PASSWORD='office365_password'
</ArticleTab> </ArticleTab>
<ArticleTab> <ArticleTab>
**smtp4dev** is a fake SMTP email server for development and testing. **smtp4dev** is a fake SMTP email server for development and testing.
- Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev` - Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090) - Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
- Set the following env variables: - Set the following env variables:
- EMAIL_DRIVER=smtp - EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=localhost - EMAIL_SMTP_HOST=localhost
- EMAIL_SMTP_PORT=2525 - EMAIL_SMTP_PORT=2525
</ArticleTab> </ArticleTab>