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

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/password-reset-link.email';
export * from './emails/password-update-notify.email';
export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email';

View File

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

View File

@ -177,6 +177,7 @@ export type ClientConfig = {
debugMode: Scalars['Boolean'];
defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String'];
isEmailVerificationRequired: Scalars['Boolean'];
isMultiWorkspaceEnabled: Scalars['Boolean'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean'];
@ -515,9 +516,11 @@ export type Mutation = {
generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken;
getAuthorizationUrl: GetAuthorizationUrlOutput;
getLoginTokenFromEmailVerificationToken: LoginToken;
impersonate: ImpersonateOutput;
publishServerlessFunction: ServerlessFunction;
renewToken: AuthTokens;
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput;
runWorkflowVersion: WorkflowRun;
sendInvitations: SendInvitationsOutput;
@ -680,6 +683,12 @@ export type MutationGetAuthorizationUrlArgs = {
};
export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
emailVerificationToken: Scalars['String'];
};
export type MutationImpersonateArgs = {
userId: Scalars['String'];
workspaceId: Scalars['String'];
@ -696,6 +705,11 @@ export type MutationRenewTokenArgs = {
};
export type MutationResendEmailVerificationTokenArgs = {
email: Scalars['String'];
};
export type MutationResendWorkspaceInvitationArgs = {
appTokenId: Scalars['String'];
};
@ -1062,6 +1076,11 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}
export type ResendEmailVerificationTokenOutput = {
__typename?: 'ResendEmailVerificationTokenOutput';
success: Scalars['Boolean'];
};
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>;
@ -1396,9 +1415,9 @@ export type User = {
deletedAt?: Maybe<Scalars['DateTime']>;
disabled?: Maybe<Scalars['Boolean']>;
email: Scalars['String'];
emailVerified: Scalars['Boolean'];
firstName: Scalars['String'];
id: Scalars['UUID'];
isEmailVerified: Scalars['Boolean'];
lastName: Scalars['String'];
onboardingStatus?: Maybe<OnboardingStatus>;
passwordHash?: Maybe<Scalars['String']>;
@ -1422,6 +1441,7 @@ export type UserExists = {
__typename?: 'UserExists';
availableWorkspaces: Array<AvailableWorkspaceOutput>;
exists: Scalars['Boolean'];
isEmailVerified: Scalars['Boolean'];
};
export type UserExistsOutput = UserExists | UserNotExists;
@ -1957,6 +1977,14 @@ export type GetAuthorizationUrlMutationVariables = Exact<{
export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } };
export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
emailVerificationToken: Scalars['String'];
captchaToken?: InputMaybe<Scalars['String']>;
}>;
export type GetLoginTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getLoginTokenFromEmailVerificationToken: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
export type ImpersonateMutationVariables = Exact<{
userId: Scalars['String'];
workspaceId: Scalars['String'];
@ -1972,6 +2000,13 @@ export type RenewTokenMutationVariables = Exact<{
export type RenewTokenMutation = { __typename?: 'Mutation', renewToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type ResendEmailVerificationTokenMutationVariables = Exact<{
email: Scalars['String'];
}>;
export type ResendEmailVerificationTokenMutation = { __typename?: 'Mutation', resendEmailVerificationToken: { __typename?: 'ResendEmailVerificationTokenOutput', success: boolean } };
export type SignUpMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
@ -2012,7 +2047,7 @@ export type CheckUserExistsQueryVariables = Exact<{
}>;
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>;
@ -2058,7 +2093,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@ -2915,6 +2950,45 @@ export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHook
export type GetAuthorizationUrlMutationHookResult = ReturnType<typeof useGetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationResult = Apollo.MutationResult<GetAuthorizationUrlMutation>;
export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions<GetAuthorizationUrlMutation, GetAuthorizationUrlMutationVariables>;
export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $captchaToken: String) {
getLoginTokenFromEmailVerificationToken(
emailVerificationToken: $emailVerificationToken
captchaToken: $captchaToken
) {
loginToken {
...AuthTokenFragment
}
}
}
${AuthTokenFragmentFragmentDoc}`;
export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>;
/**
* __useGetLoginTokenFromEmailVerificationTokenMutation__
*
* To run a mutation, you first call `useGetLoginTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useGetLoginTokenFromEmailVerificationTokenMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
* variables: {
* emailVerificationToken: // value for 'emailVerificationToken'
* captchaToken: // value for 'captchaToken'
* },
* });
*/
export function useGetLoginTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>(GetLoginTokenFromEmailVerificationTokenDocument, options);
}
export type GetLoginTokenFromEmailVerificationTokenMutationHookResult = ReturnType<typeof useGetLoginTokenFromEmailVerificationTokenMutation>;
export type GetLoginTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult<GetLoginTokenFromEmailVerificationTokenMutation>;
export type GetLoginTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>;
export const ImpersonateDocument = gql`
mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) {
@ -2990,6 +3064,39 @@ export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions<R
export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutation>;
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
export const ResendEmailVerificationTokenDocument = gql`
mutation ResendEmailVerificationToken($email: String!) {
resendEmailVerificationToken(email: $email) {
success
}
}
`;
export type ResendEmailVerificationTokenMutationFn = Apollo.MutationFunction<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
/**
* __useResendEmailVerificationTokenMutation__
*
* To run a mutation, you first call `useResendEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useResendEmailVerificationTokenMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [resendEmailVerificationTokenMutation, { data, loading, error }] = useResendEmailVerificationTokenMutation({
* variables: {
* email: // value for 'email'
* },
* });
*/
export function useResendEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>(ResendEmailVerificationTokenDocument, options);
}
export type ResendEmailVerificationTokenMutationHookResult = ReturnType<typeof useResendEmailVerificationTokenMutation>;
export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult<ResendEmailVerificationTokenMutation>;
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) {
signUp(
@ -3179,6 +3286,7 @@ export const CheckUserExistsDocument = gql`
status
}
}
isEmailVerified
}
... on UserNotExists {
exists
@ -3471,6 +3579,7 @@ export const GetClientConfigDocument = gql`
}
signInPrefilled
isMultiWorkspaceEnabled
isEmailVerificationRequired
defaultSubdomain
frontDomain
debugMode

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.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: SubscriptionStatus.Canceled, 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 =
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword);
isMatchingLocation(AppPath.ResetPassword) ||
isMatchingLocation(AppPath.VerifyEmail);
const isMatchingOngoingUserCreationRoute =
isMatchingOpenRoute ||

View File

@ -1,6 +1,8 @@
import { AppRouterProviders } from '@/app/components/AppRouterProviders';
import { SettingsRoutes } from '@/app/components/SettingsRoutes';
import { VerifyEffect } from '@/auth/components/VerifyEffect';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { AppPath } from '@/types/AppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
@ -40,6 +42,7 @@ export const useCreateAppRouter = (
>
<Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyEffect />} />
<Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} />
<Route path={AppPath.SignInUp} element={<SignInUp />} />
<Route path={AppPath.Invite} element={<Invite />} />
<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 { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const VerifyEffect = () => {
@ -29,6 +29,7 @@ export const VerifyEffect = () => {
useEffect(() => {
if (isDefined(errorMessage)) {
enqueueSnackBar(errorMessage, {
dedupeKey: 'verify-failed-dedupe-key',
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
}
}
isEmailVerified
}
... on UserNotExists {
exists

View File

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

View File

@ -1,4 +1,4 @@
import { useApolloClient } from '@apollo/client';
import { ApolloError, useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import {
snapshot_UNSTABLE,
@ -26,6 +26,7 @@ import {
useChallengeMutation,
useCheckUserExistsLazyQuery,
useGetCurrentUserLazyQuery,
useGetLoginTokenFromEmailVerificationTokenMutation,
useSignUpMutation,
useVerifyMutation,
} from '~/generated/graphql';
@ -44,7 +45,12 @@ import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTi
import { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
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 { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { useSearchParams } from 'react-router-dom';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
@ -68,7 +75,11 @@ export const useAuth = () => {
currentWorkspaceMembersState,
);
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const isEmailVerificationRequired = useRecoilValue(
isEmailVerificationRequiredState,
);
const setSignInUpStep = useSetRecoilState(signInUpStepState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
const setWorkspaces = useSetRecoilState(workspacesState);
@ -78,6 +89,8 @@ export const useAuth = () => {
const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation();
const [verify] = useVerifyMutation();
const [getLoginTokenFromEmailVerificationToken] =
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } =
@ -96,6 +109,8 @@ export const useAuth = () => {
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
const [, setSearchParams] = useSearchParams();
const clearSession = useRecoilCallback(
({ snapshot }) =>
async () => {
@ -154,25 +169,59 @@ export const useAuth = () => {
const handleChallenge = useCallback(
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: {
email,
password,
emailVerificationToken,
captchaToken,
},
});
if (isDefined(challengeResult.errors)) {
throw challengeResult.errors;
if (isDefined(loginTokenResult.errors)) {
throw loginTokenResult.errors;
}
if (!challengeResult.data?.challenge) {
if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) {
throw new Error('No login token');
}
return challengeResult.data.challenge;
return loginTokenResult.data.getLoginTokenFromEmailVerificationToken;
},
[challenge],
[getLoginTokenFromEmailVerificationToken],
);
const loadCurrentUser = useCallback(async () => {
@ -343,12 +392,21 @@ export const useAuth = () => {
throw new Error('No login token');
}
if (isEmailVerificationRequired) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
return null;
}
if (isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain(
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,
isMultiWorkspaceEnabled,
handleVerify,
setSignInUpStep,
setSearchParams,
isEmailVerificationRequired,
redirectToWorkspaceDomain,
],
);
@ -424,6 +485,8 @@ export const useAuth = () => {
return {
challenge: handleChallenge,
getLoginTokenFromEmailVerificationToken:
handleGetLoginTokenFromEmailVerificationToken,
verify: handleVerify,
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',
Email = 'email',
Password = 'password',
EmailVerification = 'emailVerification',
WorkspaceSelection = 'workspaceSelection',
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
}

View File

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

View File

@ -22,6 +22,7 @@ export const GET_CLIENT_CONFIG = gql`
}
signInPrefilled
isMultiWorkspaceEnabled
isEmailVerificationRequired
defaultSubdomain
frontDomain
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 {
// Not logged-in
Verify = '/verify',
VerifyEmail = '/verify-email',
SignInUp = '/welcome',
Invite = '/invite/:workspaceInviteHash',
ResetPassword = '/reset-password/:passwordResetToken',

View File

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

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
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 {
@ -27,8 +28,17 @@ export const useSnackBar = () => {
const setSnackBarQueue = useRecoilCallback(
({ set }) =>
(newValue) =>
(newValue: SnackBarOptions) =>
set(snackBarInternalScopedState({ scopeId }), (prev) => {
if (
isDefined(newValue.dedupeKey) &&
prev.queue.some(
(snackBar) => snackBar.dedupeKey === newValue.dedupeKey,
)
) {
return prev;
}
if (prev.queue.length >= prev.maxQueue) {
return {
...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.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: SubscriptionStatus.Canceled, 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 (
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword) ||
isMatchingLocation(AppPath.VerifyEmail) ||
isMatchingLocation(AppPath.SignInUp)
) {
return isDefaultLayoutAuthModalVisible;

View File

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

View File

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

View File

@ -56,6 +56,8 @@ FRONT_PORT=3001
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60
# 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_SYSTEM_ADDRESS=system@yourdomain.com
# 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 {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Relation,
} from 'typeorm';
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 { User } from 'src/engine/core-modules/user/user.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 {
RefreshToken = 'REFRESH_TOKEN',
CodeChallenge = 'CODE_CHALLENGE',
@ -23,6 +23,7 @@ export enum AppTokenType {
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN',
OIDCCodeVerifier = 'OIDC_CODE_VERIFIER',
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
}
@Entity({ name: 'appToken', schema: 'core' })

View File

@ -9,6 +9,7 @@ export class AuthException extends CustomException {
export enum AuthExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
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 { TokenModule } from 'src/engine/core-modules/auth/token/token.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 { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.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,
FeatureFlagModule,
WorkspaceInvitationModule,
EmailVerificationModule,
],
controllers: [
GoogleAuthController,

View File

@ -3,11 +3,12 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
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 { 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';
@ -16,6 +17,7 @@ import { AuthService } from './services/auth.service';
// import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.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 { RenewTokenService } from './token/services/renew-token.service';
import { TransientTokenService } from './token/services/transient-token.service';
@ -80,6 +82,14 @@ describe('AuthResolver', () => {
provide: TransientTokenService,
useValue: {},
},
{
provide: EmailVerificationService,
useValue: {},
},
{
provide: EmailVerificationTokenService,
useValue: {},
},
// {
// provide: OAuthService,
// 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 { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.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 {
AuthException,
AuthExceptionCode,
} 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 { 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 { 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 { 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 { LoginToken } from './dto/login-token.entity';
@ -68,8 +72,11 @@ export class AuthResolver {
private loginTokenService: LoginTokenService,
private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService,
private emailVerificationService: EmailVerificationService,
// private oauthService: OAuthService,
private domainManagerService: DomainManagerService,
private userWorkspaceService: UserWorkspaceService,
private emailVerificationTokenService: EmailVerificationTokenService,
) {}
@UseGuards(CaptchaGuard)
@ -116,7 +123,6 @@ export class AuthResolver {
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
@ -126,6 +132,41 @@ export class AuthResolver {
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)
@Mutation(() => 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(
user.email,
workspace.id,
@ -333,6 +380,6 @@ export class AuthResolver {
async findAvailableWorkspacesByEmail(
@Args('email') email: string,
): 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])
availableWorkspaces: Array<AvailableWorkspaceOutput>;
@Field(() => Boolean)
isEmailVerified: boolean;
}
@ObjectType()

View File

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

View File

@ -1,23 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals';
import { Repository } from 'typeorm';
import bcrypt from 'bcrypt';
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 { 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 { 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 { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthService } from './auth.service';
@ -31,9 +31,10 @@ const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
const workspaceInvitationValidateInvitationMock = jest.fn();
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
const environmentServiceGetMock = jest.fn();
describe('AuthService', () => {
let service: AuthService;
let appTokenRepository: Repository<AppToken>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -66,7 +67,9 @@ describe('AuthService', () => {
},
{
provide: EnvironmentService,
useValue: {},
useValue: {
get: environmentServiceGetMock,
},
},
{
provide: DomainManagerService,
@ -112,10 +115,10 @@ describe('AuthService', () => {
}).compile();
service = module.get<AuthService>(AuthService);
});
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
beforeEach(() => {
environmentServiceGetMock.mockReturnValue(false);
});
it('should be defined', async () => {

View File

@ -26,7 +26,6 @@ import {
} from 'src/engine/core-modules/auth/auth.util';
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 { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.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;
}
@ -260,7 +270,9 @@ export class AuthService {
if (userValidator.isDefined(user)) {
return {
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();
}
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({
currentWorkspace,
workspacePersonalInviteToken,

View File

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

View File

@ -41,6 +41,7 @@ import {
SignInUpBaseParams,
SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -57,6 +58,7 @@ export class SignInUpService {
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
private readonly userService: UserService,
) {}
async computeParamsForNewUser(
@ -194,6 +196,8 @@ export class SignInUpService {
email,
);
await this.userService.markEmailAsVerified(updatedUser.id);
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)
isMultiWorkspaceEnabled: boolean;
@Field(() => Boolean)
isEmailVerificationRequired: boolean;
@Field(() => String, { nullable: true })
defaultSubdomain: string;

View File

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

View File

@ -59,6 +59,22 @@ export class DomainManagerService {
return baseUrl;
}
buildEmailVerificationURL({
emailVerificationToken,
email,
workspaceSubdomain,
}: {
emailVerificationToken: string;
email: string;
workspaceSubdomain?: string;
}) {
return this.buildWorkspaceURL({
subdomain: workspaceSubdomain,
pathname: 'verify-email',
searchParams: { emailVerificationToken, email },
});
}
buildWorkspaceURL({
subdomain,
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;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_EMAIL_VERIFICATION_REQUIRED = false;
@IsDuration()
@IsOptional()
EMAIL_VERIFICATION_TOKEN_EXPIRES_IN = '1h';
EMAIL_FROM_ADDRESS = 'noreply@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',
BAD_USER_INPUT = 'BAD_USER_INPUT',
NOT_FOUND = 'NOT_FOUND',
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
CONFLICT = 'CONFLICT',
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 {
constructor(message: string) {
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 { 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 {
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 { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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()
@Column({ default: false })
emailVerified: boolean;
isEmailVerified: boolean;
@Field({ nullable: true })
@Column({ default: false })

View File

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

View File

@ -185,6 +185,8 @@ yarn command:prod cron:calendar:ongoing-stale
### Email
<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_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'],
@ -212,24 +214,24 @@ yarn command:prod cron:calendar:ongoing-stale
<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).
- EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=smtp.office365.com
- EMAIL_SMTP_PORT=587
- EMAIL_SMTP_USER=office365_email_address
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_SMTP_HOST=smtp.office365.com
- EMAIL_SMTP_PORT=587
- EMAIL_SMTP_USER=office365_email_address
- EMAIL_SMTP_PASSWORD='office365_password'
</ArticleTab>
<ArticleTab>
**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`
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
- Set the following env variables:
- EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=localhost
- EMAIL_SMTP_PORT=2525
**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`
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
- Set the following env variables:
- EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=localhost
- EMAIL_SMTP_PORT=2525
</ArticleTab>