Skip to content

Commit 73b68dc

Browse files
etiennejouanjordan-chalupka
authored andcommitted
Onboarding - add nextPath logic after email verification (twentyhq#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 twentyhq#12288
1 parent 53b189a commit 73b68dc

File tree

18 files changed

+165
-65
lines changed

18 files changed

+165
-65
lines changed

packages/twenty-front/src/generated/graphql.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,7 @@ export type MutationSignUpArgs = {
11771177
email: Scalars['String'];
11781178
locale?: InputMaybe<Scalars['String']>;
11791179
password: Scalars['String'];
1180+
verifyEmailNextPath?: InputMaybe<Scalars['String']>;
11801181
workspaceId?: InputMaybe<Scalars['String']>;
11811182
workspaceInviteHash?: InputMaybe<Scalars['String']>;
11821183
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
@@ -2672,6 +2673,7 @@ export type SignUpMutationVariables = Exact<{
26722673
captchaToken?: InputMaybe<Scalars['String']>;
26732674
workspaceId?: InputMaybe<Scalars['String']>;
26742675
locale?: InputMaybe<Scalars['String']>;
2676+
verifyEmailNextPath?: InputMaybe<Scalars['String']>;
26752677
}>;
26762678

26772679

@@ -4057,7 +4059,7 @@ export type ResendEmailVerificationTokenMutationHookResult = ReturnType<typeof u
40574059
export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult<ResendEmailVerificationTokenMutation>;
40584060
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
40594061
export const SignUpDocument = gql`
4060-
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String) {
4062+
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String, $locale: String, $verifyEmailNextPath: String) {
40614063
signUp(
40624064
email: $email
40634065
password: $password
@@ -4066,6 +4068,7 @@ export const SignUpDocument = gql`
40664068
captchaToken: $captchaToken
40674069
workspaceId: $workspaceId
40684070
locale: $locale
4071+
verifyEmailNextPath: $verifyEmailNextPath
40694072
) {
40704073
loginToken {
40714074
...AuthTokenFragment
@@ -4102,6 +4105,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
41024105
* captchaToken: // value for 'captchaToken'
41034106
* workspaceId: // value for 'workspaceId'
41044107
* locale: // value for 'locale'
4108+
* verifyEmailNextPath: // value for 'verifyEmailNextPath'
41054109
* },
41064110
* });
41074111
*/

packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
33
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
44
import { AppPath } from '@/types/AppPath';
55
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
6+
import { expect } from '@storybook/test';
67
import { useParams } from 'react-router-dom';
78
import { useRecoilValue } from 'recoil';
89

@@ -57,10 +58,14 @@ const setupMockUseParams = (objectNamePlural?: string) => {
5758
};
5859

5960
jest.mock('recoil');
60-
const setupMockRecoil = (objectNamePlural?: string) => {
61+
const setupMockRecoil = (
62+
objectNamePlural?: string,
63+
verifyEmailNextPath?: string,
64+
) => {
6165
jest
6266
.mocked(useRecoilValue)
63-
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]);
67+
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
68+
.mockReturnValueOnce(verifyEmailNextPath);
6469
};
6570

6671
// prettier-ignore
@@ -72,6 +77,7 @@ const testCases: {
7277
res: string | undefined;
7378
objectNamePluralFromParams?: string;
7479
objectNamePluralFromMetadata?: string;
80+
verifyEmailNextPath?: string;
7581
}[] = [
7682
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
7783
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
@@ -110,7 +116,9 @@ const testCases: {
110116
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
111117

112118
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
119+
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, verifyEmailNextPath: '/nextPath?key=value', res: '/nextPath?key=value' },
113120
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
121+
{ loc: AppPath.VerifyEmail, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, verifyEmailNextPath: '/nextPath?key=value', res: undefined },
114122
{ loc: AppPath.VerifyEmail, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
115123
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
116124
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
@@ -275,14 +283,15 @@ describe('usePageChangeEffectNavigateLocation', () => {
275283
isLoggedIn,
276284
objectNamePluralFromParams,
277285
objectNamePluralFromMetadata,
286+
verifyEmailNextPath,
278287
res,
279288
}) => {
280289
setupMockIsMatchingLocation(loc);
281290
setupMockOnboardingStatus(onboardingStatus);
282291
setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended);
283292
setupMockIsLogged(isLoggedIn);
284293
setupMockUseParams(objectNamePluralFromParams);
285-
setupMockRecoil(objectNamePluralFromMetadata);
294+
setupMockRecoil(objectNamePluralFromMetadata, verifyEmailNextPath);
286295

287296
expect(usePageChangeEffectNavigateLocation()).toEqual(res);
288297
},
@@ -294,7 +303,8 @@ describe('usePageChangeEffectNavigateLocation', () => {
294303
(Object.keys(OnboardingStatus).length +
295304
['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false']
296305
.length) +
297-
['nonExistingObjectInParam', 'existingObjectInParam:false'].length,
306+
['nonExistingObjectInParam', 'existingObjectInParam:false'].length +
307+
['caseWithRedirectionToVerifyEmailNextPath', 'caseWithout'].length,
298308
);
299309
});
300310
});

packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
12
import { useIsLogged } from '@/auth/hooks/useIsLogged';
23
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
34
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -43,6 +44,7 @@ export const usePageChangeEffectNavigateLocation = () => {
4344
const objectMetadataItem = objectMetadataItems.find(
4445
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
4546
);
47+
const verifyEmailNextPath = useRecoilValue(verifyEmailNextPathState);
4648

4749
if (
4850
!isLoggedIn &&
@@ -58,6 +60,12 @@ export const usePageChangeEffectNavigateLocation = () => {
5860
onboardingStatus === OnboardingStatus.PLAN_REQUIRED &&
5961
!someMatchingLocationOf([AppPath.PlanRequired, AppPath.PlanRequiredSuccess])
6062
) {
63+
if (
64+
isMatchingLocation(location, AppPath.VerifyEmail) &&
65+
isDefined(verifyEmailNextPath)
66+
) {
67+
return verifyEmailNextPath;
68+
}
6169
return AppPath.PlanRequired;
6270
}
6371

packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,59 @@
11
import { useRecoilCallback } from 'recoil';
22

3-
import { isQueryParamInitializedState } from '@/app/states/isQueryParamInitializedState';
43
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
54
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
65
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
6+
import deepEqual from 'deep-equal';
77

88
// Initialize state that are hydrated from query parameters
99
// We used to use recoil-sync to do this, but it was causing issues with Firefox
1010
export const useInitializeQueryParamState = () => {
1111
const initializeQueryParamState = useRecoilCallback(
1212
({ set, snapshot }) =>
1313
() => {
14-
const isInitialized = snapshot
15-
.getLoadable(isQueryParamInitializedState)
16-
.getValue();
17-
18-
if (!isInitialized) {
19-
const handlers = {
20-
billingCheckoutSession: (value: string) => {
21-
try {
22-
const parsedValue = JSON.parse(decodeURIComponent(value));
23-
24-
if (
25-
typeof parsedValue === 'object' &&
26-
parsedValue !== null &&
27-
'plan' in parsedValue &&
28-
'interval' in parsedValue &&
29-
'requirePaymentMethod' in parsedValue
30-
) {
31-
set(
32-
billingCheckoutSessionState,
33-
parsedValue as BillingCheckoutSession,
34-
);
35-
}
36-
} catch (error) {
37-
// eslint-disable-next-line no-console
38-
console.error(
39-
'Failed to parse billingCheckoutSession from URL',
40-
error,
41-
);
14+
const handlers = {
15+
billingCheckoutSession: (value: string) => {
16+
const billingCheckoutSession = snapshot
17+
.getLoadable(billingCheckoutSessionState)
18+
.getValue();
19+
20+
try {
21+
const parsedValue = JSON.parse(decodeURIComponent(value));
22+
23+
if (
24+
typeof parsedValue === 'object' &&
25+
parsedValue !== null &&
26+
'plan' in parsedValue &&
27+
'interval' in parsedValue &&
28+
'requirePaymentMethod' in parsedValue &&
29+
!deepEqual(billingCheckoutSession, parsedValue)
30+
) {
4231
set(
4332
billingCheckoutSessionState,
44-
BILLING_CHECKOUT_SESSION_DEFAULT_VALUE,
33+
parsedValue as BillingCheckoutSession,
4534
);
4635
}
47-
},
48-
};
36+
} catch (error) {
37+
// eslint-disable-next-line no-console
38+
console.error(
39+
'Failed to parse billingCheckoutSession from URL',
40+
error,
41+
);
42+
set(
43+
billingCheckoutSessionState,
44+
BILLING_CHECKOUT_SESSION_DEFAULT_VALUE,
45+
);
46+
}
47+
},
48+
};
4949

50-
const queryParams = new URLSearchParams(window.location.search);
50+
const queryParams = new URLSearchParams(window.location.search);
5151

52-
for (const [paramName, handler] of Object.entries(handlers)) {
53-
const value = queryParams.get(paramName);
54-
if (value !== null) {
55-
handler(value);
56-
}
52+
for (const [paramName, handler] of Object.entries(handlers)) {
53+
const value = queryParams.get(paramName);
54+
if (value !== null) {
55+
handler(value);
5756
}
58-
59-
set(isQueryParamInitializedState, true);
6057
}
6158
},
6259
[],

packages/twenty-front/src/modules/app/states/isQueryParamInitializedState.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createState } from 'twenty-ui/utilities';
2+
3+
export const verifyEmailNextPathState = createState<string | undefined>({
4+
key: 'verifyEmailNextPathState',
5+
defaultValue: undefined,
6+
});

packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
44
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
55
import { ApolloError } from '@apollo/client';
66

7+
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
78
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
89
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
910
import { Modal } from '@/ui/layout/modal/components/Modal';
1011
import { useLingui } from '@lingui/react/macro';
1112
import { useEffect, useState } from 'react';
1213
import { useSearchParams } from 'react-router-dom';
14+
import { useSetRecoilState } from 'recoil';
15+
import { isDefined } from 'twenty-shared/utils';
1316
import { useNavigateApp } from '~/hooks/useNavigateApp';
1417
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
1518
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
@@ -22,8 +25,11 @@ export const VerifyEmailEffect = () => {
2225
const [searchParams] = useSearchParams();
2326
const [isError, setIsError] = useState(false);
2427

28+
const setVerifyEmailNextPath = useSetRecoilState(verifyEmailNextPathState);
29+
2530
const email = searchParams.get('email');
2631
const emailVerificationToken = searchParams.get('emailVerificationToken');
32+
const verifyEmailNextPath = searchParams.get('nextPath');
2733

2834
const navigate = useNavigateApp();
2935
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
@@ -58,6 +64,11 @@ export const VerifyEmailEffect = () => {
5864
loginToken: loginToken.token,
5965
});
6066
}
67+
68+
if (isDefined(verifyEmailNextPath)) {
69+
setVerifyEmailNextPath(verifyEmailNextPath);
70+
}
71+
6172
verifyLoginToken(loginToken.token);
6273
} catch (error) {
6374
const message: string =

packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const SIGN_UP = gql`
99
$captchaToken: String
1010
$workspaceId: String
1111
$locale: String
12+
$verifyEmailNextPath: String
1213
) {
1314
signUp(
1415
email: $email
@@ -18,6 +19,7 @@ export const SIGN_UP = gql`
1819
captchaToken: $captchaToken
1920
workspaceId: $workspaceId
2021
locale: $locale
22+
verifyEmailNextPath: $verifyEmailNextPath
2123
) {
2224
loginToken {
2325
...AuthTokenFragment

packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ describe('useAuth', () => {
167167
const { result } = renderHooks();
168168

169169
await act(async () => {
170-
await result.current.signUpWithCredentials(email, password);
170+
await result.current.signUpWithCredentials({ email, password });
171171
});
172172

173173
expect(mocks[2].result).toHaveBeenCalled();

packages/twenty-front/src/modules/auth/hooks/useAuth.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -397,13 +397,21 @@ export const useAuth = () => {
397397
}, [clearSession]);
398398

399399
const handleCredentialsSignUp = useCallback(
400-
async (
401-
email: string,
402-
password: string,
403-
workspaceInviteHash?: string,
404-
workspacePersonalInviteToken?: string,
405-
captchaToken?: string,
406-
) => {
400+
async ({
401+
email,
402+
password,
403+
workspaceInviteHash,
404+
workspacePersonalInviteToken,
405+
captchaToken,
406+
verifyEmailNextPath,
407+
}: {
408+
email: string;
409+
password: string;
410+
workspaceInviteHash?: string;
411+
workspacePersonalInviteToken?: string;
412+
captchaToken?: string;
413+
verifyEmailNextPath?: string;
414+
}) => {
407415
const signUpResult = await signUp({
408416
variables: {
409417
email,
@@ -415,6 +423,7 @@ export const useAuth = () => {
415423
...(workspacePublicData?.id
416424
? { workspaceId: workspacePublicData.id }
417425
: {}),
426+
verifyEmailNextPath,
418427
},
419428
});
420429

0 commit comments

Comments
 (0)