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 2fb30518d3..dd2b6a8d40 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 99c59c4096..268fd895ae 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 d443ae9061..d01a7c914c 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 cd4d507395..0000000000 --- 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 0000000000..71882210b6 --- /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 23896d9b59..640424baf8 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 0e99fe68df..ce60908d1c 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 a2baee372a..b04f39bc2f 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 5149ab2b5d..ac3ed8ed7c 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 f5b644309e..5344fe2977 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 2bdeb3ce8c..3d825b54b3 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 0000000000..13da7e0812 --- /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 0000000000..fb382b1c3e --- /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 27a439fa95..621d4f105f 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 a6a7f92cc2..21f1cb51fc 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 b849675431..274aeb8566 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 4ad512a6d3..c6805b1bbd 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'; +