Add Email Verification for non-Microsoft/Google Emails (#9288)
Closes twentyhq/twenty#8240 This PR introduces email verification for non-Microsoft/Google Emails: ## Email Verification SignInUp Flow: https://github.com/user-attachments/assets/740e9714-5413-4fd8-b02e-ace728ea47ef The email verification link is sent as part of the `SignInUpStep.EmailVerification`. The email verification token validation is handled on a separate page (`AppPath.VerifyEmail`). A verification email resend can be triggered from both pages. ## Email Verification Flow Screenshots (In Order):    ## Sent Email Details (Subject & Template):   ### Successful Email Verification Redirect:  ### Unsuccessful Email Verification (invalid token, invalid email, token expired, user does not exist, etc.):  ### Force Sign In When Email Not Verified:  # TODOs: ## Sign Up Process - [x] Introduce server-level environment variable IS_EMAIL_VERIFICATION_REQUIRED (defaults to false) - [x] Ensure users joining an existing workspace through an invite are not required to validate their email - [x] Generate an email verification token - [x] Store the token in appToken - [x] Send email containing the verification link - [x] Create new email template for email verification - [x] Create a frontend page to handle verification requests ## Sign In Process - [x] After verifying user credentials, check if user's email is verified and prompt to to verify - [x] Show an option to resend the verification email ## Database - [x] Rename the `emailVerified` colum on `user` to to `isEmailVerified` for consistency ## During Deployment - [x] Run a script/sql query to set `isEmailVerified` to `true` for all users with a Google/Microsoft email and all users that show an indication of a valid subscription (e.g. linked credit card) - I have created a draft migration file below that shows one possible approach to implementing this change: ```typescript import { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateEmailVerifiedForActiveUsers1733318043628 implements MigrationInterface { name = 'UpdateEmailVerifiedForActiveUsers1733318043628'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` CREATE TABLE core."user_email_verified_backup" AS SELECT id, email, "isEmailVerified" FROM core."user" WHERE "deletedAt" IS NULL; `); await queryRunner.query(` -- Update isEmailVerified for users who have been part of workspaces with active subscriptions UPDATE core."user" u SET "isEmailVerified" = true WHERE EXISTS ( -- Check if user has been part of a workspace through userWorkspace table SELECT 1 FROM core."userWorkspace" uw JOIN core."workspace" w ON uw."workspaceId" = w.id WHERE uw."userId" = u.id -- Check for valid subscription indicators AND ( w."activationStatus" = 'ACTIVE' -- Add any other subscription-related conditions here ) ) AND u."deletedAt" IS NULL; `); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` UPDATE core."user" u SET "isEmailVerified" = b."isEmailVerified" FROM core."user_email_verified_backup" b WHERE u.id = b.id; `); await queryRunner.query(`DROP TABLE core."user_email_verified_backup";`); } } ``` --------- Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
55
packages/twenty-emails/src/components/Footer.tsx
Normal file
55
packages/twenty-emails/src/components/Footer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -16,7 +16,8 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
|
||||
const isMatchingOpenRoute =
|
||||
isMatchingLocation(AppPath.Invite) ||
|
||||
isMatchingLocation(AppPath.ResetPassword);
|
||||
isMatchingLocation(AppPath.ResetPassword) ||
|
||||
isMatchingLocation(AppPath.VerifyEmail);
|
||||
|
||||
const isMatchingOngoingUserCreationRoute =
|
||||
isMatchingOpenRoute ||
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RESEND_EMAIL_VERIFICATION_TOKEN = gql`
|
||||
mutation ResendEmailVerificationToken($email: String!) {
|
||||
resendEmailVerificationToken(email: $email) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -19,6 +19,7 @@ export const CHECK_USER_EXISTS = gql`
|
||||
status
|
||||
}
|
||||
}
|
||||
isEmailVerified
|
||||
}
|
||||
... on UserNotExists {
|
||||
exists
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -4,6 +4,7 @@ export enum SignInUpStep {
|
||||
Init = 'init',
|
||||
Email = 'email',
|
||||
Password = 'password',
|
||||
EmailVerification = 'emailVerification',
|
||||
WorkspaceSelection = 'workspaceSelection',
|
||||
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -22,6 +22,7 @@ export const GET_CLIENT_CONFIG = gql`
|
||||
}
|
||||
signInPrefilled
|
||||
isMultiWorkspaceEnabled
|
||||
isEmailVerificationRequired
|
||||
defaultSubdomain
|
||||
frontDomain
|
||||
debugMode
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isEmailVerificationRequiredState = createState<boolean>({
|
||||
key: 'isEmailVerificationRequired',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
export enum AppPath {
|
||||
// Not logged-in
|
||||
Verify = '/verify',
|
||||
VerifyEmail = '/verify-email',
|
||||
SignInUp = '/welcome',
|
||||
Invite = '/invite/:workspaceInviteHash',
|
||||
ResetPassword = '/reset-password/:passwordResetToken',
|
||||
|
||||
@ -35,6 +35,7 @@ export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
|
||||
onClose?: () => void;
|
||||
role?: 'alert' | 'status';
|
||||
variant?: SnackBarVariant;
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -28,6 +28,7 @@ export const useShowAuthModal = () => {
|
||||
if (
|
||||
isMatchingLocation(AppPath.Invite) ||
|
||||
isMatchingLocation(AppPath.ResetPassword) ||
|
||||
isMatchingLocation(AppPath.VerifyEmail) ||
|
||||
isMatchingLocation(AppPath.SignInUp)
|
||||
) {
|
||||
return isDefaultLayoutAuthModalVisible;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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' })
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -9,6 +9,9 @@ export class UserExists {
|
||||
|
||||
@Field(() => [AvailableWorkspaceOutput])
|
||||
availableWorkspaces: Array<AvailableWorkspaceOutput>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailVerified: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -65,6 +65,9 @@ export class ClientConfig {
|
||||
@Field(() => Boolean)
|
||||
isMultiWorkspaceEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailVerificationRequired: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
defaultSubdomain: string;
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
@ObjectType()
|
||||
export class ResendEmailVerificationTokenOutput {
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
success: boolean;
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'],
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ export class User {
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
emailVerified: boolean;
|
||||
isEmailVerified: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ default: false })
|
||||
|
||||
@ -48,6 +48,8 @@ export class GraphQLHydrateRequestFromTokenMiddleware
|
||||
'CheckUserExists',
|
||||
'Challenge',
|
||||
'Verify',
|
||||
'GetLoginTokenFromEmailVerificationToken',
|
||||
'ResendEmailVerificationToken',
|
||||
'SignUp',
|
||||
'RenewToken',
|
||||
'EmailPasswordResetLink',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user