From 081376f594a8533c33a41c2ff51f4af1ed0f18bf Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 28 May 2025 19:20:31 +0200 Subject: [PATCH] Onboarding - add nextPath logic after email verification (#12342) Context : Plan choice [on pricing page on website](https://twenty.com/pricing) should redirect you the right plan on app /plan-required page (after sign in), thanks to query parameters and BillingCheckoutSessionState sync. With email verification, an other session starts at CTA click in verification email. Initial BillingCheckoutSessionState is lost and user can't submit to the plan he choose. Solution : Pass a nextPath query parameter in email verification link To test : - Modify .env to add IS_BILLING_ENABLED (+ reset db + sync billing) + IS_EMAIL_VERIFICATION_REQUIRED - Start test from this page http://app.localhost:3001/welcome?billingCheckoutSession={%22plan%22:%22ENTERPRISE%22,%22interval%22:%22Year%22,%22requirePaymentMethod%22:true} - After verification, check you arrive on /plan-required page with Enterprise plan on a yearly interval (default is Pro/monthly). closes https://github.com/twentyhq/twenty/issues/12288 --- .../twenty-front/src/generated/graphql.tsx | 6 +- ...sePageChangeEffectNavigateLocation.test.ts | 18 ++++- .../usePageChangeEffectNavigateLocation.ts | 8 ++ .../app/hooks/useInitializeQueryParamState.ts | 75 +++++++++---------- .../states/isQueryParamInitializedState.ts | 6 -- .../app/states/verifyEmailNextPathState.ts | 6 ++ .../auth/components/VerifyEmailEffect.tsx | 11 +++ .../modules/auth/graphql/mutations/signUp.ts | 2 + .../auth/hooks/__tests__/useAuth.test.tsx | 2 +- .../src/modules/auth/hooks/useAuth.ts | 23 ++++-- .../auth/sign-in-up/hooks/useSignInUp.ts | 22 ++++-- .../src/pages/onboarding/ChooseYourPlan.tsx | 7 ++ .../buildAppPathWithQueryParams.test.ts | 13 ++++ .../src/utils/buildAppPathWithQueryParams.ts | 12 +++ .../engine/core-modules/auth/auth.resolver.ts | 1 + .../core-modules/auth/dto/sign-up.input.ts | 5 ++ .../services/email-verification.service.ts | 10 ++- packages/twenty-ui/src/utilities/index.ts | 1 + 18 files changed, 164 insertions(+), 64 deletions(-) delete mode 100644 packages/twenty-front/src/modules/app/states/isQueryParamInitializedState.ts create mode 100644 packages/twenty-front/src/modules/app/states/verifyEmailNextPathState.ts create mode 100644 packages/twenty-front/src/utils/__tests__/buildAppPathWithQueryParams.test.ts create mode 100644 packages/twenty-front/src/utils/buildAppPathWithQueryParams.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2fb30518d..dd2b6a8d4 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1177,6 +1177,7 @@ export type MutationSignUpArgs = { email: Scalars['String']; locale?: InputMaybe; password: Scalars['String']; + verifyEmailNextPath?: InputMaybe; workspaceId?: InputMaybe; workspaceInviteHash?: InputMaybe; workspacePersonalInviteToken?: InputMaybe; @@ -2672,6 +2673,7 @@ export type SignUpMutationVariables = Exact<{ captchaToken?: InputMaybe; workspaceId?: InputMaybe; locale?: InputMaybe; + verifyEmailNextPath?: InputMaybe; }>; @@ -4057,7 +4059,7 @@ export type ResendEmailVerificationTokenMutationHookResult = ReturnType; export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions; export const SignUpDocument = gql` - mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String) { + mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String, $verifyEmailNextPath: String) { signUp( email: $email password: $password @@ -4066,6 +4068,7 @@ export const SignUpDocument = gql` captchaToken: $captchaToken workspaceId: $workspaceId locale: $locale + verifyEmailNextPath: $verifyEmailNextPath ) { loginToken { ...AuthTokenFragment @@ -4102,6 +4105,7 @@ export type SignUpMutationFn = Apollo.MutationFunction { }; jest.mock('recoil'); -const setupMockRecoil = (objectNamePlural?: string) => { +const setupMockRecoil = ( + objectNamePlural?: string, + verifyEmailNextPath?: string, +) => { jest .mocked(useRecoilValue) - .mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]); + .mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]) + .mockReturnValueOnce(verifyEmailNextPath); }; // prettier-ignore @@ -72,6 +77,7 @@ const testCases: { res: string | undefined; objectNamePluralFromParams?: string; objectNamePluralFromMetadata?: string; + verifyEmailNextPath?: string; }[] = [ { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' }, @@ -110,7 +116,9 @@ const testCases: { { loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, { loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, verifyEmailNextPath: '/nextPath?key=value', res: '/nextPath?key=value' }, { loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' }, + { loc: AppPath.VerifyEmail, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, verifyEmailNextPath: '/nextPath?key=value', res: undefined }, { loc: AppPath.VerifyEmail, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, { loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace }, { loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, @@ -275,6 +283,7 @@ describe('usePageChangeEffectNavigateLocation', () => { isLoggedIn, objectNamePluralFromParams, objectNamePluralFromMetadata, + verifyEmailNextPath, res, }) => { setupMockIsMatchingLocation(loc); @@ -282,7 +291,7 @@ describe('usePageChangeEffectNavigateLocation', () => { setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended); setupMockIsLogged(isLoggedIn); setupMockUseParams(objectNamePluralFromParams); - setupMockRecoil(objectNamePluralFromMetadata); + setupMockRecoil(objectNamePluralFromMetadata, verifyEmailNextPath); expect(usePageChangeEffectNavigateLocation()).toEqual(res); }, @@ -294,7 +303,8 @@ describe('usePageChangeEffectNavigateLocation', () => { (Object.keys(OnboardingStatus).length + ['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false'] .length) + - ['nonExistingObjectInParam', 'existingObjectInParam:false'].length, + ['nonExistingObjectInParam', 'existingObjectInParam:false'].length + + ['caseWithRedirectionToVerifyEmailNextPath', 'caseWithout'].length, ); }); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 99c59c409..268fd895a 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -1,3 +1,4 @@ +import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; @@ -43,6 +44,7 @@ export const usePageChangeEffectNavigateLocation = () => { const objectMetadataItem = objectMetadataItems.find( (objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural, ); + const verifyEmailNextPath = useRecoilValue(verifyEmailNextPathState); if ( !isLoggedIn && @@ -58,6 +60,12 @@ export const usePageChangeEffectNavigateLocation = () => { onboardingStatus === OnboardingStatus.PLAN_REQUIRED && !someMatchingLocationOf([AppPath.PlanRequired, AppPath.PlanRequiredSuccess]) ) { + if ( + isMatchingLocation(location, AppPath.VerifyEmail) && + isDefined(verifyEmailNextPath) + ) { + return verifyEmailNextPath; + } return AppPath.PlanRequired; } diff --git a/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts b/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts index d443ae906..d01a7c914 100644 --- a/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts +++ b/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts @@ -1,9 +1,9 @@ import { useRecoilCallback } from 'recoil'; -import { isQueryParamInitializedState } from '@/app/states/isQueryParamInitializedState'; import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState'; import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type'; import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue'; +import deepEqual from 'deep-equal'; // Initialize state that are hydrated from query parameters // We used to use recoil-sync to do this, but it was causing issues with Firefox @@ -11,52 +11,49 @@ export const useInitializeQueryParamState = () => { const initializeQueryParamState = useRecoilCallback( ({ set, snapshot }) => () => { - const isInitialized = snapshot - .getLoadable(isQueryParamInitializedState) - .getValue(); + const handlers = { + billingCheckoutSession: (value: string) => { + const billingCheckoutSession = snapshot + .getLoadable(billingCheckoutSessionState) + .getValue(); - if (!isInitialized) { - const handlers = { - billingCheckoutSession: (value: string) => { - try { - const parsedValue = JSON.parse(decodeURIComponent(value)); + try { + const parsedValue = JSON.parse(decodeURIComponent(value)); - if ( - typeof parsedValue === 'object' && - parsedValue !== null && - 'plan' in parsedValue && - 'interval' in parsedValue && - 'requirePaymentMethod' in parsedValue - ) { - set( - billingCheckoutSessionState, - parsedValue as BillingCheckoutSession, - ); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error( - 'Failed to parse billingCheckoutSession from URL', - error, - ); + if ( + typeof parsedValue === 'object' && + parsedValue !== null && + 'plan' in parsedValue && + 'interval' in parsedValue && + 'requirePaymentMethod' in parsedValue && + !deepEqual(billingCheckoutSession, parsedValue) + ) { set( billingCheckoutSessionState, - BILLING_CHECKOUT_SESSION_DEFAULT_VALUE, + parsedValue as BillingCheckoutSession, ); } - }, - }; - - const queryParams = new URLSearchParams(window.location.search); - - for (const [paramName, handler] of Object.entries(handlers)) { - const value = queryParams.get(paramName); - if (value !== null) { - handler(value); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'Failed to parse billingCheckoutSession from URL', + error, + ); + set( + billingCheckoutSessionState, + BILLING_CHECKOUT_SESSION_DEFAULT_VALUE, + ); } - } + }, + }; - set(isQueryParamInitializedState, true); + const queryParams = new URLSearchParams(window.location.search); + + for (const [paramName, handler] of Object.entries(handlers)) { + const value = queryParams.get(paramName); + if (value !== null) { + handler(value); + } } }, [], diff --git a/packages/twenty-front/src/modules/app/states/isQueryParamInitializedState.ts b/packages/twenty-front/src/modules/app/states/isQueryParamInitializedState.ts deleted file mode 100644 index cd4d50739..000000000 --- a/packages/twenty-front/src/modules/app/states/isQueryParamInitializedState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createState } from 'twenty-ui/utilities'; - -export const isQueryParamInitializedState = createState({ - key: 'isQueryParamInitializedState', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/app/states/verifyEmailNextPathState.ts b/packages/twenty-front/src/modules/app/states/verifyEmailNextPathState.ts new file mode 100644 index 000000000..71882210b --- /dev/null +++ b/packages/twenty-front/src/modules/app/states/verifyEmailNextPathState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui/utilities'; + +export const verifyEmailNextPathState = createState({ + key: 'verifyEmailNextPathState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx index 23896d9b5..640424baf 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx @@ -4,12 +4,15 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ApolloError } from '@apollo/client'; +import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState'; import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { useLingui } from '@lingui/react/macro'; import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; import { useNavigateApp } from '~/hooks/useNavigateApp'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent'; @@ -22,8 +25,11 @@ export const VerifyEmailEffect = () => { const [searchParams] = useSearchParams(); const [isError, setIsError] = useState(false); + const setVerifyEmailNextPath = useSetRecoilState(verifyEmailNextPathState); + const email = searchParams.get('email'); const emailVerificationToken = searchParams.get('emailVerificationToken'); + const verifyEmailNextPath = searchParams.get('nextPath'); const navigate = useNavigateApp(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); @@ -58,6 +64,11 @@ export const VerifyEmailEffect = () => { loginToken: loginToken.token, }); } + + if (isDefined(verifyEmailNextPath)) { + setVerifyEmailNextPath(verifyEmailNextPath); + } + verifyLoginToken(loginToken.token); } catch (error) { const message: string = diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts index 0e99fe68d..ce60908d1 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts @@ -9,6 +9,7 @@ export const SIGN_UP = gql` $captchaToken: String $workspaceId: String $locale: String + $verifyEmailNextPath: String ) { signUp( email: $email @@ -18,6 +19,7 @@ export const SIGN_UP = gql` captchaToken: $captchaToken workspaceId: $workspaceId locale: $locale + verifyEmailNextPath: $verifyEmailNextPath ) { loginToken { ...AuthTokenFragment diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx index a2baee372..b04f39bc2 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx @@ -167,7 +167,7 @@ describe('useAuth', () => { const { result } = renderHooks(); await act(async () => { - await result.current.signUpWithCredentials(email, password); + await result.current.signUpWithCredentials({ email, password }); }); expect(mocks[2].result).toHaveBeenCalled(); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 5149ab2b5..ac3ed8ed7 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -397,13 +397,21 @@ export const useAuth = () => { }, [clearSession]); const handleCredentialsSignUp = useCallback( - async ( - email: string, - password: string, - workspaceInviteHash?: string, - workspacePersonalInviteToken?: string, - captchaToken?: string, - ) => { + async ({ + email, + password, + workspaceInviteHash, + workspacePersonalInviteToken, + captchaToken, + verifyEmailNextPath, + }: { + email: string; + password: string; + workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; + captchaToken?: string; + verifyEmailNextPath?: string; + }) => { const signUpResult = await signUp({ variables: { email, @@ -415,6 +423,7 @@ export const useAuth = () => { ...(workspacePublicData?.id ? { workspaceId: workspacePublicData.id } : {}), + verifyEmailNextPath, }, }); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts index f5b644309..5344fe297 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts @@ -11,10 +11,12 @@ import { import { SignInUpMode } from '@/auth/types/signInUpMode'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; +import { useBuildSearchParamsFromUrlSyncedStates } from '@/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates'; 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 { useRecoilState } from 'recoil'; +import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { useAuth } from '../../hooks/useAuth'; @@ -44,6 +46,9 @@ export const useSignInUp = (form: UseFormReturn
) => { const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken(); const { readCaptchaToken } = useReadCaptchaToken(); + const { buildSearchParamsFromUrlSyncedStates } = + useBuildSearchParamsFromUrlSyncedStates(); + const continueWithEmail = useCallback(() => { requestFreshCaptchaToken(); setSignInUpStep(SignInUpStep.Email); @@ -99,13 +104,19 @@ export const useSignInUp = (form: UseFormReturn) => { token, ); } else { - await signUpWithCredentials( - data.email.toLowerCase().trim(), - data.password, + const verifyEmailNextPath = buildAppPathWithQueryParams( + AppPath.PlanRequired, + await buildSearchParamsFromUrlSyncedStates(), + ); + + await signUpWithCredentials({ + email: data.email.toLowerCase().trim(), + password: data.password, workspaceInviteHash, workspacePersonalInviteToken, - token, - ); + captchaToken: token, + verifyEmailNextPath, + }); } } catch (err: any) { enqueueSnackBar(err?.message, { @@ -124,6 +135,7 @@ export const useSignInUp = (form: UseFormReturn) => { workspacePersonalInviteToken, enqueueSnackBar, requestFreshCaptchaToken, + buildSearchParamsFromUrlSyncedStates, ], ); diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx index 2bdeb3ce8..3d825b54b 100644 --- a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx +++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx @@ -1,3 +1,4 @@ +import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState'; import { SubTitle } from '@/auth/components/SubTitle'; import { Title } from '@/auth/components/Title'; import { useAuth } from '@/auth/hooks/useAuth'; @@ -96,6 +97,12 @@ export const ChooseYourPlan = () => { billingCheckoutSessionState, ); + const [verifyEmailNextPath, setVerifyEmailNextPath] = useRecoilState( + verifyEmailNextPathState, + ); + if (isDefined(verifyEmailNextPath)) { + setVerifyEmailNextPath(undefined); + } const { data: plans } = useBillingBaseProductPricesQuery(); const currentPlan = billingCheckoutSession.plan; diff --git a/packages/twenty-front/src/utils/__tests__/buildAppPathWithQueryParams.test.ts b/packages/twenty-front/src/utils/__tests__/buildAppPathWithQueryParams.test.ts new file mode 100644 index 000000000..13da7e081 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/buildAppPathWithQueryParams.test.ts @@ -0,0 +1,13 @@ +import { AppPath } from '@/types/AppPath'; +import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams'; + +describe('buildAppPathWithQueryParams', () => { + it('should build the correct path with query params', () => { + expect( + buildAppPathWithQueryParams(AppPath.PlanRequired, { + key1: 'value1', + key2: 'value2', + }), + ).toBe('/plan-required?key1=value1&key2=value2'); + }); +}); diff --git a/packages/twenty-front/src/utils/buildAppPathWithQueryParams.ts b/packages/twenty-front/src/utils/buildAppPathWithQueryParams.ts new file mode 100644 index 000000000..fb382b1c3 --- /dev/null +++ b/packages/twenty-front/src/utils/buildAppPathWithQueryParams.ts @@ -0,0 +1,12 @@ +import { AppPath } from '@/types/AppPath'; + +export const buildAppPathWithQueryParams = ( + path: AppPath, + queryParams: Record, +) => { + const searchParams = []; + for (const [key, value] of Object.entries(queryParams)) { + searchParams.push(`${key}=${value}`); + } + return `${path}?${searchParams.join('&')}`; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 27a439fa9..621d4f105 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -251,6 +251,7 @@ export class AuthResolver { user.email, workspace, signUpInput.locale ?? SOURCE_LOCALE, + signUpInput.verifyEmailNextPath, ); const loginToken = await this.loginTokenService.generateLoginToken( diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts index a6a7f92cc..21f1cb51f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts @@ -39,4 +39,9 @@ export class SignUpInput { @IsString() @IsOptional() locale?: keyof typeof APP_LOCALES; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + verifyEmailNextPath?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts index b84967543..274aeb856 100644 --- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts +++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts @@ -8,6 +8,7 @@ import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; import ms from 'ms'; import { SendEmailVerificationLinkEmail } from 'twenty-emails'; import { APP_LOCALES } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { @@ -45,6 +46,7 @@ export class EmailVerificationService { | WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType | undefined, locale: keyof typeof APP_LOCALES, + verifyEmailNextPath?: string, ) { if (!this.twentyConfigService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { return { success: false }; @@ -55,7 +57,13 @@ export class EmailVerificationService { const linkPathnameAndSearchParams = { pathname: 'verify-email', - searchParams: { emailVerificationToken, email }, + searchParams: { + emailVerificationToken, + email, + ...(isDefined(verifyEmailNextPath) + ? { nextPath: verifyEmailNextPath } + : {}), + }, }; const verificationLink = workspace ? this.domainManagerService.buildWorkspaceURL({ diff --git a/packages/twenty-ui/src/utilities/index.ts b/packages/twenty-ui/src/utilities/index.ts index 4ad512a6d..c6805b1bb 100644 --- a/packages/twenty-ui/src/utilities/index.ts +++ b/packages/twenty-ui/src/utilities/index.ts @@ -29,3 +29,4 @@ export { createState } from './state/utils/createState'; export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes'; export type { Nullable } from './types/Nullable'; export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType'; +