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:
@ -1177,6 +1177,7 @@ export type MutationSignUpArgs = {
|
|||||||
email: Scalars['String'];
|
email: Scalars['String'];
|
||||||
locale?: InputMaybe<Scalars['String']>;
|
locale?: InputMaybe<Scalars['String']>;
|
||||||
password: Scalars['String'];
|
password: Scalars['String'];
|
||||||
|
verifyEmailNextPath?: InputMaybe<Scalars['String']>;
|
||||||
workspaceId?: InputMaybe<Scalars['String']>;
|
workspaceId?: InputMaybe<Scalars['String']>;
|
||||||
workspaceInviteHash?: InputMaybe<Scalars['String']>;
|
workspaceInviteHash?: InputMaybe<Scalars['String']>;
|
||||||
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
|
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
|
||||||
@ -2672,6 +2673,7 @@ export type SignUpMutationVariables = Exact<{
|
|||||||
captchaToken?: InputMaybe<Scalars['String']>;
|
captchaToken?: InputMaybe<Scalars['String']>;
|
||||||
workspaceId?: InputMaybe<Scalars['String']>;
|
workspaceId?: InputMaybe<Scalars['String']>;
|
||||||
locale?: 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 ResendEmailVerificationTokenMutationResult = Apollo.MutationResult<ResendEmailVerificationTokenMutation>;
|
||||||
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
|
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
|
||||||
export const SignUpDocument = gql`
|
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(
|
signUp(
|
||||||
email: $email
|
email: $email
|
||||||
password: $password
|
password: $password
|
||||||
@ -4066,6 +4068,7 @@ export const SignUpDocument = gql`
|
|||||||
captchaToken: $captchaToken
|
captchaToken: $captchaToken
|
||||||
workspaceId: $workspaceId
|
workspaceId: $workspaceId
|
||||||
locale: $locale
|
locale: $locale
|
||||||
|
verifyEmailNextPath: $verifyEmailNextPath
|
||||||
) {
|
) {
|
||||||
loginToken {
|
loginToken {
|
||||||
...AuthTokenFragment
|
...AuthTokenFragment
|
||||||
@ -4102,6 +4105,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
|
|||||||
* captchaToken: // value for 'captchaToken'
|
* captchaToken: // value for 'captchaToken'
|
||||||
* workspaceId: // value for 'workspaceId'
|
* workspaceId: // value for 'workspaceId'
|
||||||
* locale: // value for 'locale'
|
* locale: // value for 'locale'
|
||||||
|
* verifyEmailNextPath: // value for 'verifyEmailNextPath'
|
||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
|
|||||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||||
import { AppPath } from '@/types/AppPath';
|
import { AppPath } from '@/types/AppPath';
|
||||||
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
||||||
|
import { expect } from '@storybook/test';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
@ -57,10 +58,14 @@ const setupMockUseParams = (objectNamePlural?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('recoil');
|
jest.mock('recoil');
|
||||||
const setupMockRecoil = (objectNamePlural?: string) => {
|
const setupMockRecoil = (
|
||||||
|
objectNamePlural?: string,
|
||||||
|
verifyEmailNextPath?: string,
|
||||||
|
) => {
|
||||||
jest
|
jest
|
||||||
.mocked(useRecoilValue)
|
.mocked(useRecoilValue)
|
||||||
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]);
|
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
|
||||||
|
.mockReturnValueOnce(verifyEmailNextPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@ -72,6 +77,7 @@ const testCases: {
|
|||||||
res: string | undefined;
|
res: string | undefined;
|
||||||
objectNamePluralFromParams?: string;
|
objectNamePluralFromParams?: string;
|
||||||
objectNamePluralFromMetadata?: 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: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
|
{ 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.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, 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: 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: 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.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
|
||||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||||
@ -275,6 +283,7 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
|||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
objectNamePluralFromParams,
|
objectNamePluralFromParams,
|
||||||
objectNamePluralFromMetadata,
|
objectNamePluralFromMetadata,
|
||||||
|
verifyEmailNextPath,
|
||||||
res,
|
res,
|
||||||
}) => {
|
}) => {
|
||||||
setupMockIsMatchingLocation(loc);
|
setupMockIsMatchingLocation(loc);
|
||||||
@ -282,7 +291,7 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
|||||||
setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended);
|
setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended);
|
||||||
setupMockIsLogged(isLoggedIn);
|
setupMockIsLogged(isLoggedIn);
|
||||||
setupMockUseParams(objectNamePluralFromParams);
|
setupMockUseParams(objectNamePluralFromParams);
|
||||||
setupMockRecoil(objectNamePluralFromMetadata);
|
setupMockRecoil(objectNamePluralFromMetadata, verifyEmailNextPath);
|
||||||
|
|
||||||
expect(usePageChangeEffectNavigateLocation()).toEqual(res);
|
expect(usePageChangeEffectNavigateLocation()).toEqual(res);
|
||||||
},
|
},
|
||||||
@ -294,7 +303,8 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
|||||||
(Object.keys(OnboardingStatus).length +
|
(Object.keys(OnboardingStatus).length +
|
||||||
['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false']
|
['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false']
|
||||||
.length) +
|
.length) +
|
||||||
['nonExistingObjectInParam', 'existingObjectInParam:false'].length,
|
['nonExistingObjectInParam', 'existingObjectInParam:false'].length +
|
||||||
|
['caseWithRedirectionToVerifyEmailNextPath', 'caseWithout'].length,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
|
||||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||||
@ -43,6 +44,7 @@ export const usePageChangeEffectNavigateLocation = () => {
|
|||||||
const objectMetadataItem = objectMetadataItems.find(
|
const objectMetadataItem = objectMetadataItems.find(
|
||||||
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
|
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
|
||||||
);
|
);
|
||||||
|
const verifyEmailNextPath = useRecoilValue(verifyEmailNextPathState);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isLoggedIn &&
|
!isLoggedIn &&
|
||||||
@ -58,6 +60,12 @@ export const usePageChangeEffectNavigateLocation = () => {
|
|||||||
onboardingStatus === OnboardingStatus.PLAN_REQUIRED &&
|
onboardingStatus === OnboardingStatus.PLAN_REQUIRED &&
|
||||||
!someMatchingLocationOf([AppPath.PlanRequired, AppPath.PlanRequiredSuccess])
|
!someMatchingLocationOf([AppPath.PlanRequired, AppPath.PlanRequiredSuccess])
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
isMatchingLocation(location, AppPath.VerifyEmail) &&
|
||||||
|
isDefined(verifyEmailNextPath)
|
||||||
|
) {
|
||||||
|
return verifyEmailNextPath;
|
||||||
|
}
|
||||||
return AppPath.PlanRequired;
|
return AppPath.PlanRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useRecoilCallback } from 'recoil';
|
import { useRecoilCallback } from 'recoil';
|
||||||
|
|
||||||
import { isQueryParamInitializedState } from '@/app/states/isQueryParamInitializedState';
|
|
||||||
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
|
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
|
||||||
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
|
import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
|
||||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
|
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||||
|
import deepEqual from 'deep-equal';
|
||||||
|
|
||||||
// Initialize state that are hydrated from query parameters
|
// Initialize state that are hydrated from query parameters
|
||||||
// We used to use recoil-sync to do this, but it was causing issues with Firefox
|
// 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(
|
const initializeQueryParamState = useRecoilCallback(
|
||||||
({ set, snapshot }) =>
|
({ set, snapshot }) =>
|
||||||
() => {
|
() => {
|
||||||
const isInitialized = snapshot
|
const handlers = {
|
||||||
.getLoadable(isQueryParamInitializedState)
|
billingCheckoutSession: (value: string) => {
|
||||||
.getValue();
|
const billingCheckoutSession = snapshot
|
||||||
|
.getLoadable(billingCheckoutSessionState)
|
||||||
|
.getValue();
|
||||||
|
|
||||||
if (!isInitialized) {
|
try {
|
||||||
const handlers = {
|
const parsedValue = JSON.parse(decodeURIComponent(value));
|
||||||
billingCheckoutSession: (value: string) => {
|
|
||||||
try {
|
|
||||||
const parsedValue = JSON.parse(decodeURIComponent(value));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof parsedValue === 'object' &&
|
typeof parsedValue === 'object' &&
|
||||||
parsedValue !== null &&
|
parsedValue !== null &&
|
||||||
'plan' in parsedValue &&
|
'plan' in parsedValue &&
|
||||||
'interval' in parsedValue &&
|
'interval' in parsedValue &&
|
||||||
'requirePaymentMethod' in parsedValue
|
'requirePaymentMethod' in parsedValue &&
|
||||||
) {
|
!deepEqual(billingCheckoutSession, parsedValue)
|
||||||
set(
|
) {
|
||||||
billingCheckoutSessionState,
|
|
||||||
parsedValue as BillingCheckoutSession,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(
|
|
||||||
'Failed to parse billingCheckoutSession from URL',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
set(
|
set(
|
||||||
billingCheckoutSessionState,
|
billingCheckoutSessionState,
|
||||||
BILLING_CHECKOUT_SESSION_DEFAULT_VALUE,
|
parsedValue as BillingCheckoutSession,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
};
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
const queryParams = new URLSearchParams(window.location.search);
|
'Failed to parse billingCheckoutSession from URL',
|
||||||
|
error,
|
||||||
for (const [paramName, handler] of Object.entries(handlers)) {
|
);
|
||||||
const value = queryParams.get(paramName);
|
set(
|
||||||
if (value !== null) {
|
billingCheckoutSessionState,
|
||||||
handler(value);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import { createState } from 'twenty-ui/utilities';
|
|
||||||
|
|
||||||
export const isQueryParamInitializedState = createState<boolean>({
|
|
||||||
key: 'isQueryParamInitializedState',
|
|
||||||
defaultValue: false,
|
|
||||||
});
|
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createState } from 'twenty-ui/utilities';
|
||||||
|
|
||||||
|
export const verifyEmailNextPathState = createState<string | undefined>({
|
||||||
|
key: 'verifyEmailNextPathState',
|
||||||
|
defaultValue: undefined,
|
||||||
|
});
|
||||||
@ -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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { ApolloError } from '@apollo/client';
|
import { ApolloError } from '@apollo/client';
|
||||||
|
|
||||||
|
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
|
||||||
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
|
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
|
||||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||||
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
||||||
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
|
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
|
||||||
@ -22,8 +25,11 @@ export const VerifyEmailEffect = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const setVerifyEmailNextPath = useSetRecoilState(verifyEmailNextPathState);
|
||||||
|
|
||||||
const email = searchParams.get('email');
|
const email = searchParams.get('email');
|
||||||
const emailVerificationToken = searchParams.get('emailVerificationToken');
|
const emailVerificationToken = searchParams.get('emailVerificationToken');
|
||||||
|
const verifyEmailNextPath = searchParams.get('nextPath');
|
||||||
|
|
||||||
const navigate = useNavigateApp();
|
const navigate = useNavigateApp();
|
||||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||||
@ -58,6 +64,11 @@ export const VerifyEmailEffect = () => {
|
|||||||
loginToken: loginToken.token,
|
loginToken: loginToken.token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDefined(verifyEmailNextPath)) {
|
||||||
|
setVerifyEmailNextPath(verifyEmailNextPath);
|
||||||
|
}
|
||||||
|
|
||||||
verifyLoginToken(loginToken.token);
|
verifyLoginToken(loginToken.token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message: string =
|
const message: string =
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const SIGN_UP = gql`
|
|||||||
$captchaToken: String
|
$captchaToken: String
|
||||||
$workspaceId: String
|
$workspaceId: String
|
||||||
$locale: String
|
$locale: String
|
||||||
|
$verifyEmailNextPath: String
|
||||||
) {
|
) {
|
||||||
signUp(
|
signUp(
|
||||||
email: $email
|
email: $email
|
||||||
@ -18,6 +19,7 @@ export const SIGN_UP = gql`
|
|||||||
captchaToken: $captchaToken
|
captchaToken: $captchaToken
|
||||||
workspaceId: $workspaceId
|
workspaceId: $workspaceId
|
||||||
locale: $locale
|
locale: $locale
|
||||||
|
verifyEmailNextPath: $verifyEmailNextPath
|
||||||
) {
|
) {
|
||||||
loginToken {
|
loginToken {
|
||||||
...AuthTokenFragment
|
...AuthTokenFragment
|
||||||
|
|||||||
@ -167,7 +167,7 @@ describe('useAuth', () => {
|
|||||||
const { result } = renderHooks();
|
const { result } = renderHooks();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.signUpWithCredentials(email, password);
|
await result.current.signUpWithCredentials({ email, password });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks[2].result).toHaveBeenCalled();
|
expect(mocks[2].result).toHaveBeenCalled();
|
||||||
|
|||||||
@ -397,13 +397,21 @@ export const useAuth = () => {
|
|||||||
}, [clearSession]);
|
}, [clearSession]);
|
||||||
|
|
||||||
const handleCredentialsSignUp = useCallback(
|
const handleCredentialsSignUp = useCallback(
|
||||||
async (
|
async ({
|
||||||
email: string,
|
email,
|
||||||
password: string,
|
password,
|
||||||
workspaceInviteHash?: string,
|
workspaceInviteHash,
|
||||||
workspacePersonalInviteToken?: string,
|
workspacePersonalInviteToken,
|
||||||
captchaToken?: string,
|
captchaToken,
|
||||||
) => {
|
verifyEmailNextPath,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
workspaceInviteHash?: string;
|
||||||
|
workspacePersonalInviteToken?: string;
|
||||||
|
captchaToken?: string;
|
||||||
|
verifyEmailNextPath?: string;
|
||||||
|
}) => {
|
||||||
const signUpResult = await signUp({
|
const signUpResult = await signUp({
|
||||||
variables: {
|
variables: {
|
||||||
email,
|
email,
|
||||||
@ -415,6 +423,7 @@ export const useAuth = () => {
|
|||||||
...(workspacePublicData?.id
|
...(workspacePublicData?.id
|
||||||
? { workspaceId: workspacePublicData.id }
|
? { workspaceId: workspacePublicData.id }
|
||||||
: {}),
|
: {}),
|
||||||
|
verifyEmailNextPath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import {
|
|||||||
import { SignInUpMode } from '@/auth/types/signInUpMode';
|
import { SignInUpMode } from '@/auth/types/signInUpMode';
|
||||||
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||||
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
||||||
|
import { useBuildSearchParamsFromUrlSyncedStates } from '@/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates';
|
||||||
import { AppPath } from '@/types/AppPath';
|
import { AppPath } from '@/types/AppPath';
|
||||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
|
import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams';
|
||||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
@ -44,6 +46,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
|||||||
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
||||||
const { readCaptchaToken } = useReadCaptchaToken();
|
const { readCaptchaToken } = useReadCaptchaToken();
|
||||||
|
|
||||||
|
const { buildSearchParamsFromUrlSyncedStates } =
|
||||||
|
useBuildSearchParamsFromUrlSyncedStates();
|
||||||
|
|
||||||
const continueWithEmail = useCallback(() => {
|
const continueWithEmail = useCallback(() => {
|
||||||
requestFreshCaptchaToken();
|
requestFreshCaptchaToken();
|
||||||
setSignInUpStep(SignInUpStep.Email);
|
setSignInUpStep(SignInUpStep.Email);
|
||||||
@ -99,13 +104,19 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
|||||||
token,
|
token,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await signUpWithCredentials(
|
const verifyEmailNextPath = buildAppPathWithQueryParams(
|
||||||
data.email.toLowerCase().trim(),
|
AppPath.PlanRequired,
|
||||||
data.password,
|
await buildSearchParamsFromUrlSyncedStates(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await signUpWithCredentials({
|
||||||
|
email: data.email.toLowerCase().trim(),
|
||||||
|
password: data.password,
|
||||||
workspaceInviteHash,
|
workspaceInviteHash,
|
||||||
workspacePersonalInviteToken,
|
workspacePersonalInviteToken,
|
||||||
token,
|
captchaToken: token,
|
||||||
);
|
verifyEmailNextPath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
enqueueSnackBar(err?.message, {
|
enqueueSnackBar(err?.message, {
|
||||||
@ -124,6 +135,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
|||||||
workspacePersonalInviteToken,
|
workspacePersonalInviteToken,
|
||||||
enqueueSnackBar,
|
enqueueSnackBar,
|
||||||
requestFreshCaptchaToken,
|
requestFreshCaptchaToken,
|
||||||
|
buildSearchParamsFromUrlSyncedStates,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
|
||||||
import { SubTitle } from '@/auth/components/SubTitle';
|
import { SubTitle } from '@/auth/components/SubTitle';
|
||||||
import { Title } from '@/auth/components/Title';
|
import { Title } from '@/auth/components/Title';
|
||||||
import { useAuth } from '@/auth/hooks/useAuth';
|
import { useAuth } from '@/auth/hooks/useAuth';
|
||||||
@ -96,6 +97,12 @@ export const ChooseYourPlan = () => {
|
|||||||
billingCheckoutSessionState,
|
billingCheckoutSessionState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [verifyEmailNextPath, setVerifyEmailNextPath] = useRecoilState(
|
||||||
|
verifyEmailNextPathState,
|
||||||
|
);
|
||||||
|
if (isDefined(verifyEmailNextPath)) {
|
||||||
|
setVerifyEmailNextPath(undefined);
|
||||||
|
}
|
||||||
const { data: plans } = useBillingBaseProductPricesQuery();
|
const { data: plans } = useBillingBaseProductPricesQuery();
|
||||||
|
|
||||||
const currentPlan = billingCheckoutSession.plan;
|
const currentPlan = billingCheckoutSession.plan;
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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('&')}`;
|
||||||
|
};
|
||||||
@ -251,6 +251,7 @@ export class AuthResolver {
|
|||||||
user.email,
|
user.email,
|
||||||
workspace,
|
workspace,
|
||||||
signUpInput.locale ?? SOURCE_LOCALE,
|
signUpInput.locale ?? SOURCE_LOCALE,
|
||||||
|
signUpInput.verifyEmailNextPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||||
|
|||||||
@ -39,4 +39,9 @@ export class SignUpInput {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: keyof typeof APP_LOCALES;
|
locale?: keyof typeof APP_LOCALES;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
verifyEmailNextPath?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
|
|||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
|
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
|
||||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -45,6 +46,7 @@ export class EmailVerificationService {
|
|||||||
| WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType
|
| WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType
|
||||||
| undefined,
|
| undefined,
|
||||||
locale: keyof typeof APP_LOCALES,
|
locale: keyof typeof APP_LOCALES,
|
||||||
|
verifyEmailNextPath?: string,
|
||||||
) {
|
) {
|
||||||
if (!this.twentyConfigService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
if (!this.twentyConfigService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||||
return { success: false };
|
return { success: false };
|
||||||
@ -55,7 +57,13 @@ export class EmailVerificationService {
|
|||||||
|
|
||||||
const linkPathnameAndSearchParams = {
|
const linkPathnameAndSearchParams = {
|
||||||
pathname: 'verify-email',
|
pathname: 'verify-email',
|
||||||
searchParams: { emailVerificationToken, email },
|
searchParams: {
|
||||||
|
emailVerificationToken,
|
||||||
|
email,
|
||||||
|
...(isDefined(verifyEmailNextPath)
|
||||||
|
? { nextPath: verifyEmailNextPath }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const verificationLink = workspace
|
const verificationLink = workspace
|
||||||
? this.domainManagerService.buildWorkspaceURL({
|
? this.domainManagerService.buildWorkspaceURL({
|
||||||
|
|||||||
@ -29,3 +29,4 @@ export { createState } from './state/utils/createState';
|
|||||||
export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes';
|
export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes';
|
||||||
export type { Nullable } from './types/Nullable';
|
export type { Nullable } from './types/Nullable';
|
||||||
export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType';
|
export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user