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
This commit is contained in:
Etienne
2025-05-28 19:20:31 +02:00
committed by GitHub
parent 9eeb50cb14
commit 081376f594
18 changed files with 164 additions and 64 deletions

View File

@ -1177,6 +1177,7 @@ export type MutationSignUpArgs = {
email: Scalars['String'];
locale?: InputMaybe<Scalars['String']>;
password: Scalars['String'];
verifyEmailNextPath?: InputMaybe<Scalars['String']>;
workspaceId?: InputMaybe<Scalars['String']>;
workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
@ -2672,6 +2673,7 @@ export type SignUpMutationVariables = Exact<{
captchaToken?: InputMaybe<Scalars['String']>;
workspaceId?: InputMaybe<Scalars['String']>;
locale?: InputMaybe<Scalars['String']>;
verifyEmailNextPath?: InputMaybe<Scalars['String']>;
}>;
@ -4057,7 +4059,7 @@ export type ResendEmailVerificationTokenMutationHookResult = ReturnType<typeof u
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, $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<SignUpMutation, SignUpMut
* captchaToken: // value for 'captchaToken'
* workspaceId: // value for 'workspaceId'
* locale: // value for 'locale'
* verifyEmailNextPath: // value for 'verifyEmailNextPath'
* },
* });
*/

View File

@ -3,6 +3,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { AppPath } from '@/types/AppPath';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { expect } from '@storybook/test';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
@ -57,10 +58,14 @@ const setupMockUseParams = (objectNamePlural?: string) => {
};
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,
);
});
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const verifyEmailNextPathState = createState<string | undefined>({
key: 'verifyEmailNextPathState',
defaultValue: undefined,
});

View File

@ -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 =

View File

@ -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

View File

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

View File

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

View File

@ -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<Form>) => {
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<Form>) => {
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<Form>) => {
workspacePersonalInviteToken,
enqueueSnackBar,
requestFreshCaptchaToken,
buildSearchParamsFromUrlSyncedStates,
],
);

View File

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

View File

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

View File

@ -0,0 +1,12 @@
import { AppPath } from '@/types/AppPath';
export const buildAppPathWithQueryParams = (
path: AppPath,
queryParams: Record<string, string>,
) => {
const searchParams = [];
for (const [key, value] of Object.entries(queryParams)) {
searchParams.push(`${key}=${value}`);
}
return `${path}?${searchParams.join('&')}`;
};

View File

@ -251,6 +251,7 @@ export class AuthResolver {
user.email,
workspace,
signUpInput.locale ?? SOURCE_LOCALE,
signUpInput.verifyEmailNextPath,
);
const loginToken = await this.loginTokenService.generateLoginToken(

View File

@ -39,4 +39,9 @@ export class SignUpInput {
@IsString()
@IsOptional()
locale?: keyof typeof APP_LOCALES;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
verifyEmailNextPath?: string;
}

View File

@ -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({

View File

@ -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';