From 9080981990005e510a7b041172bc278da3112b18 Mon Sep 17 00:00:00 2001 From: martmull Date: Sat, 25 May 2024 10:36:59 +0200 Subject: [PATCH] 5509 remove flash on intermediate verify step when sign in with sso (#5526) - remove flash on /verify - remove flash on signInUp - remove useless redirections and hooks - Remove DefaultHomePage component - Move redirections to /objects/companies in PageChangeEffect - add useShowAuthModal hooks and tests - add usePageChangeEffectNaviteLocation hooks and tests - fix refresh token expired produces blank screen --- packages/twenty-front/src/App.tsx | 6 +- .../effect-components/PageChangeEffect.tsx | 89 +----- .../__tests__/useDefaultHomePagePath.test.ts | 72 +++++ ...sePageChangeEffectNavigateLocation.test.ts | 238 ++++++++++++++++ .../src/hooks/useDefaultHomePagePath.tsx | 18 +- .../usePageChangeEffectNavigateLocation.ts | 96 +++++++ .../modules/apollo/hooks/useApolloFactory.ts | 23 +- .../modules/auth/components/VerifyEffect.tsx | 9 - .../modules/auth/hooks/useOnboardingStatus.ts | 2 +- .../auth/hooks/useSignOutAndRedirect.ts | 15 - .../hooks/useNavigateAfterSignInUp.ts | 46 --- .../auth/sign-in-up/hooks/useSignInUp.tsx | 34 +-- .../hooks/useWorkspaceFromInviteHash.ts | 41 ++- .../__test__/getOnboardingStatus.test.ts | 5 + .../modules/auth/utils/getOnboardingStatus.ts | 24 +- .../SettingsNavigationDrawerItems.tsx | 6 +- .../profile/components/DeleteAccount.tsx | 6 +- .../profile/components/DeleteWorkspace.tsx | 6 +- .../hooks/__tests__/useShowAuthModal.test.tsx | 269 ++++++++++++++++++ .../ui/layout/hooks/useShowAuthModal.ts | 42 +++ .../modules/ui/layout/page/DefaultLayout.tsx | 29 +- .../isDefaultLayoutAuthModalVisibleState.ts | 6 + .../src/pages/DefaultHomePage.tsx | 9 - .../src/pages/auth/ChooseYourPlan.tsx | 6 +- .../twenty-front/src/pages/auth/Invite.tsx | 108 +++---- .../src/pages/auth/PasswordReset.tsx | 189 ++++++------ 26 files changed, 976 insertions(+), 418 deletions(-) create mode 100644 packages/twenty-front/src/hooks/__tests__/useDefaultHomePagePath.test.ts create mode 100644 packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts create mode 100644 packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts delete mode 100644 packages/twenty-front/src/modules/auth/hooks/useSignOutAndRedirect.ts delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/states/isDefaultLayoutAuthModalVisibleState.ts delete mode 100644 packages/twenty-front/src/pages/DefaultHomePage.tsx diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index 3d0b1646ca56..ed890f4806dd 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -43,7 +43,6 @@ import { Invite } from '~/pages/auth/Invite'; import { PasswordReset } from '~/pages/auth/PasswordReset'; import { PaymentSuccess } from '~/pages/auth/PaymentSuccess'; import { SignInUp } from '~/pages/auth/SignInUp'; -import { DefaultHomePage } from '~/pages/DefaultHomePage'; import { ImpersonateEffect } from '~/pages/impersonate/ImpersonateEffect'; import { NotFound } from '~/pages/not-found/NotFound'; import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage'; @@ -139,10 +138,7 @@ const createRouter = (isBillingEnabled?: boolean) => path={AppPath.PlanRequiredSuccess} element={} /> - } - /> + } /> } /> } /> } /> diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 9f818634373a..d7b4a58ed1f4 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -5,11 +5,8 @@ import { IconCheckbox } from 'twenty-ui'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { useEventTracker } from '@/analytics/hooks/useEventTracker'; -import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus'; -import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; -import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { CommandType } from '@/command-menu/types/Command'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; @@ -17,36 +14,32 @@ import { AppBasePath } from '@/types/AppBasePath'; import { AppPath } from '@/types/AppPath'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { SettingsPath } from '@/types/SettingsPath'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; -import { useGetWorkspaceFromInviteHashLazyQuery } from '~/generated/graphql'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; +import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { isDefined } from '~/utils/isDefined'; // TODO: break down into smaller functions and / or hooks +// - moved usePageChangeEffectNavigateLocation into dedicated hook export const PageChangeEffect = () => { const navigate = useNavigate(); const isMatchingLocation = useIsMatchingLocation(); - const { enqueueSnackBar } = useSnackBar(); const [previousLocation, setPreviousLocation] = useState(''); - const onboardingStatus = useOnboardingStatus(); - const setHotkeyScope = useSetHotkeyScope(); const location = useLocation(); + const pageChangeEffectNavigateLocation = + usePageChangeEffectNavigateLocation(); + const eventTracker = useEventTracker(); - const [workspaceFromInviteHashQuery] = - useGetWorkspaceFromInviteHashLazyQuery(); const { addToCommandMenu, setToInitialCommandMenu } = useCommandMenu(); const openCreateActivity = useOpenCreateActivityDrawer(); - const isSignUpDisabled = useRecoilValue(isSignUpDisabledState); - useEffect(() => { if (!previousLocation || previousLocation !== location.pathname) { setPreviousLocation(location.pathname); @@ -56,76 +49,10 @@ export const PageChangeEffect = () => { }, [location, previousLocation]); useEffect(() => { - const isMatchingOngoingUserCreationRoute = - isMatchingLocation(AppPath.SignInUp) || - isMatchingLocation(AppPath.Invite) || - isMatchingLocation(AppPath.Verify); - - const isMatchingOnboardingRoute = - isMatchingOngoingUserCreationRoute || - isMatchingLocation(AppPath.CreateWorkspace) || - isMatchingLocation(AppPath.CreateProfile) || - isMatchingLocation(AppPath.PlanRequired) || - isMatchingLocation(AppPath.PlanRequiredSuccess); - - if ( - onboardingStatus === OnboardingStatus.OngoingUserCreation && - !isMatchingOngoingUserCreationRoute && - !isMatchingLocation(AppPath.ResetPassword) - ) { - navigate(AppPath.SignInUp); - } else if ( - isDefined(onboardingStatus) && - onboardingStatus === OnboardingStatus.Incomplete && - !isMatchingLocation(AppPath.PlanRequired) - ) { - navigate(AppPath.PlanRequired); - } else if ( - isDefined(onboardingStatus) && - [OnboardingStatus.Unpaid, OnboardingStatus.Canceled].includes( - onboardingStatus, - ) && - !( - isMatchingLocation(AppPath.SettingsCatchAll) || - isMatchingLocation(AppPath.PlanRequired) - ) - ) { - navigate( - `${AppPath.SettingsCatchAll.replace('/*', '')}/${SettingsPath.Billing}`, - ); - } else if ( - onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation && - !isMatchingLocation(AppPath.CreateWorkspace) && - !isMatchingLocation(AppPath.PlanRequiredSuccess) - ) { - navigate(AppPath.CreateWorkspace); - } else if ( - onboardingStatus === OnboardingStatus.OngoingProfileCreation && - !isMatchingLocation(AppPath.CreateProfile) - ) { - navigate(AppPath.CreateProfile); - } else if ( - onboardingStatus === OnboardingStatus.Completed && - isMatchingOnboardingRoute && - !isMatchingLocation(AppPath.Invite) - ) { - navigate(AppPath.Index); - } else if ( - onboardingStatus === OnboardingStatus.CompletedWithoutSubscription && - isMatchingOnboardingRoute && - !isMatchingLocation(AppPath.PlanRequired) - ) { - navigate(AppPath.Index); + if (isDefined(pageChangeEffectNavigateLocation)) { + navigate(pageChangeEffectNavigateLocation); } - }, [ - enqueueSnackBar, - isMatchingLocation, - isSignUpDisabled, - location.pathname, - navigate, - onboardingStatus, - workspaceFromInviteHashQuery, - ]); + }, [navigate, pageChangeEffectNavigateLocation]); useEffect(() => { switch (true) { diff --git a/packages/twenty-front/src/hooks/__tests__/useDefaultHomePagePath.test.ts b/packages/twenty-front/src/hooks/__tests__/useDefaultHomePagePath.test.ts new file mode 100644 index 000000000000..1d2834937e35 --- /dev/null +++ b/packages/twenty-front/src/hooks/__tests__/useDefaultHomePagePath.test.ts @@ -0,0 +1,72 @@ +import { renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { AppPath } from '@/types/AppPath'; +import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath'; +import { mockedUsersData } from '~/testing/mock-data/users'; + +const objectMetadataItem = getObjectMetadataItemsMock()[0]; +jest.mock('@/object-metadata/hooks/useObjectMetadataItem'); +jest.mocked(useObjectMetadataItem).mockReturnValue({ + objectMetadataItem, +}); + +jest.mock('@/prefetch/hooks/usePrefetchedData'); +const setupMockPrefetchedData = (viewId?: string) => { + jest.mocked(usePrefetchedData).mockReturnValue({ + isDataPrefetched: true, + records: viewId + ? [ + { + id: viewId, + __typename: 'object', + objectMetadataId: objectMetadataItem.id, + }, + ] + : [], + }); +}; + +const renderHooks = (withCurrentUser: boolean) => { + const { result } = renderHook( + () => { + const setCurrentUser = useSetRecoilState(currentUserState); + if (withCurrentUser) { + setCurrentUser(mockedUsersData[0]); + } + return useDefaultHomePagePath(); + }, + { + wrapper: RecoilRoot, + }, + ); + return { result }; +}; +describe('useDefaultHomePagePath', () => { + it('should return proper path when no currentUser', () => { + setupMockPrefetchedData(); + const { result } = renderHooks(false); + expect(result.current.defaultHomePagePath).toEqual(AppPath.SignInUp); + }); + it('should return proper path when no currentUser and existing view', () => { + setupMockPrefetchedData('viewId'); + const { result } = renderHooks(false); + expect(result.current.defaultHomePagePath).toEqual(AppPath.SignInUp); + }); + it('should return proper path when currentUser is defined', () => { + setupMockPrefetchedData(); + const { result } = renderHooks(true); + expect(result.current.defaultHomePagePath).toEqual('/objects/companies'); + }); + it('should return proper path when currentUser is defined and view exists', () => { + setupMockPrefetchedData('viewId'); + const { result } = renderHooks(true); + expect(result.current.defaultHomePagePath).toEqual( + '/objects/companies?view=viewId', + ); + }); +}); diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts new file mode 100644 index 000000000000..af15879288ae --- /dev/null +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -0,0 +1,238 @@ +import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus'; +import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; +import { AppPath } from '@/types/AppPath'; +import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; +import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; + +jest.mock('@/auth/hooks/useOnboardingStatus'); +const setupMockOnboardingStatus = (onboardingStatus: OnboardingStatus) => { + jest.mocked(useOnboardingStatus).mockReturnValueOnce(onboardingStatus); +}; + +jest.mock('~/hooks/useIsMatchingLocation'); +const mockUseIsMatchingLocation = jest.mocked(useIsMatchingLocation); + +const setupMockIsMatchingLocation = (pathname: string) => { + mockUseIsMatchingLocation.mockReturnValueOnce( + (path: string) => path === pathname, + ); +}; + +const defaultHomePagePath = '/objects/companies'; + +jest.mock('~/hooks/useDefaultHomePagePath'); +jest.mocked(useDefaultHomePagePath).mockReturnValue({ + defaultHomePagePath: '/objects/companies', +}); + +// prettier-ignore +const testCases = [ + { loc: AppPath.Verify, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.Verify, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.Verify, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.Verify, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingUserCreation, res: undefined }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.Verify, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.Verify, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + + { loc: AppPath.SignInUp, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.SignInUp, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.SignInUp, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.SignInUp, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingUserCreation, res: undefined }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.SignInUp, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.SignInUp, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + + { loc: AppPath.Invite, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.Invite, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.Invite, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.Invite, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingUserCreation, res: undefined }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.Invite, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.Invite, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.ResetPassword, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingUserCreation, res: undefined }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingWorkspaceActivation, res: undefined }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + + { loc: AppPath.CreateProfile, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingProfileCreation, res: undefined }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + + { loc: AppPath.PlanRequired, status: OnboardingStatus.Incomplete, res: undefined }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Canceled, res: undefined }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Unpaid, res: undefined }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingWorkspaceActivation, res: undefined }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + + { loc: AppPath.Index, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.Index, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.Index, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.Index, status: OnboardingStatus.PastDue, res: defaultHomePagePath }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.Index, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.Index, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + + { loc: AppPath.TasksPage, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.TasksPage, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.TasksPage, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.TasksPage, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.TasksPage, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.TasksPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Canceled, res: undefined }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Unpaid, res: undefined }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.Impersonate, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.Impersonate, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.Impersonate, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.Impersonate, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.Impersonate, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.Impersonate, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.Authorize, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.Authorize, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.Authorize, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.Authorize, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.Authorize, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.Authorize, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, + + { loc: AppPath.NotFound, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.NotFound, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.NotFound, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.NotFound, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.NotFound, status: OnboardingStatus.Completed, res: undefined }, + { loc: AppPath.NotFound, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, +]; + +describe('usePageChangeEffectNavigateLocation', () => { + testCases.forEach((testCase) => { + it(`with location ${testCase.loc} and onboardingStatus ${testCase.status} should return ${testCase.res}`, () => { + setupMockIsMatchingLocation(testCase.loc); + setupMockOnboardingStatus(testCase.status); + expect(usePageChangeEffectNavigateLocation()).toEqual(testCase.res); + }); + }); + + describe('tests should be exhaustive', () => { + it('all location and onboarding status should be tested', () => { + expect(testCases.length).toEqual( + Object.keys(AppPath).length * Object.keys(OnboardingStatus).length, + ); + }); + }); +}); diff --git a/packages/twenty-front/src/hooks/useDefaultHomePagePath.tsx b/packages/twenty-front/src/hooks/useDefaultHomePagePath.tsx index 8ed52fe8cff4..8332f5113e6a 100644 --- a/packages/twenty-front/src/hooks/useDefaultHomePagePath.tsx +++ b/packages/twenty-front/src/hooks/useDefaultHomePagePath.tsx @@ -1,21 +1,31 @@ +import { useRecoilValue } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { AppPath } from '@/types/AppPath'; +import { isDefined } from '~/utils/isDefined'; export const useDefaultHomePagePath = () => { + const currentUser = useRecoilValue(currentUserState); const { objectMetadataItem: companyObjectMetadataItem } = useObjectMetadataItem({ objectNameSingular: CoreObjectNameSingular.Company, }); - const { records } = usePrefetchedData(PrefetchKey.AllViews); + if (!isDefined(currentUser)) { + return { defaultHomePagePath: AppPath.SignInUp }; + } + const companyViewId = records.find( (view: any) => view?.objectMetadataId === companyObjectMetadataItem.id, )?.id; - const defaultHomePagePath = - '/objects/companies' + (companyViewId ? `?view=${companyViewId}` : ''); - return { defaultHomePagePath }; + return { + defaultHomePagePath: + '/objects/companies' + (companyViewId ? `?view=${companyViewId}` : ''), + }; }; diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts new file mode 100644 index 000000000000..b31df0a3a855 --- /dev/null +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -0,0 +1,96 @@ +import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus'; +import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; +import { AppPath } from '@/types/AppPath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; +import { isDefined } from '~/utils/isDefined'; + +export const usePageChangeEffectNavigateLocation = () => { + const isMatchingLocation = useIsMatchingLocation(); + const onboardingStatus = useOnboardingStatus(); + const { defaultHomePagePath } = useDefaultHomePagePath(); + + const isMatchingOpenRoute = + isMatchingLocation(AppPath.Invite) || + isMatchingLocation(AppPath.ResetPassword); + + const isMatchingOngoingUserCreationRoute = + isMatchingOpenRoute || + isMatchingLocation(AppPath.SignInUp) || + isMatchingLocation(AppPath.Verify); + + const isMatchingOnboardingRoute = + isMatchingOngoingUserCreationRoute || + isMatchingLocation(AppPath.CreateWorkspace) || + isMatchingLocation(AppPath.CreateProfile) || + isMatchingLocation(AppPath.PlanRequired) || + isMatchingLocation(AppPath.PlanRequiredSuccess); + + if ( + onboardingStatus === OnboardingStatus.OngoingUserCreation && + !isMatchingOngoingUserCreationRoute + ) { + return AppPath.SignInUp; + } + + if ( + onboardingStatus === OnboardingStatus.Incomplete && + !isMatchingLocation(AppPath.PlanRequired) + ) { + return AppPath.PlanRequired; + } + + if ( + isDefined(onboardingStatus) && + [OnboardingStatus.Unpaid, OnboardingStatus.Canceled].includes( + onboardingStatus, + ) && + !( + isMatchingLocation(AppPath.SettingsCatchAll) || + isMatchingLocation(AppPath.PlanRequired) + ) + ) { + return `${AppPath.SettingsCatchAll.replace('/*', '')}/${ + SettingsPath.Billing + }`; + } + + if ( + onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation && + !isMatchingLocation(AppPath.CreateWorkspace) && + !isMatchingLocation(AppPath.PlanRequiredSuccess) + ) { + return AppPath.CreateWorkspace; + } + + if ( + onboardingStatus === OnboardingStatus.OngoingProfileCreation && + !isMatchingLocation(AppPath.CreateProfile) + ) { + return AppPath.CreateProfile; + } + + if ( + onboardingStatus === OnboardingStatus.Completed && + isMatchingOnboardingRoute && + !isMatchingOpenRoute + ) { + return defaultHomePagePath; + } + + if ( + onboardingStatus === OnboardingStatus.CompletedWithoutSubscription && + isMatchingOnboardingRoute && + !isMatchingOpenRoute && + !isMatchingLocation(AppPath.PlanRequired) + ) { + return defaultHomePagePath; + } + + if (isMatchingLocation(AppPath.Index)) { + return defaultHomePagePath; + } + + return; +}; diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index d0c3c6eda91b..351ac8289350 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -1,11 +1,14 @@ import { useMemo, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { InMemoryCache, NormalizedCacheObject } from '@apollo/client'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { previousUrlState } from '@/auth/states/previousUrlState'; import { tokenPairState } from '@/auth/states/tokenPairState'; +import { workspacesState } from '@/auth/states/workspaces'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { AppPath } from '@/types/AppPath'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; @@ -18,12 +21,20 @@ import { ApolloFactory, Options } from '../services/apollo.factory'; export const useApolloFactory = (options: Partial> = {}) => { // eslint-disable-next-line @nx/workspace-no-state-useref const apolloRef = useRef | null>(null); - const currentWorkspace = useRecoilValue(currentWorkspaceState); const [isDebugMode] = useRecoilState(isDebugModeState); const navigate = useNavigate(); const isMatchingLocation = useIsMatchingLocation(); const [tokenPair, setTokenPair] = useRecoilState(tokenPairState); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + const setCurrentUser = useSetRecoilState(currentUserState); + const setCurrentWorkspaceMember = useSetRecoilState( + currentWorkspaceMemberState, + ); + + const setWorkspaces = useSetRecoilState(workspacesState); const [, setPreviousUrl] = useRecoilState(previousUrlState); const location = useLocation(); @@ -55,6 +66,10 @@ export const useApolloFactory = (options: Partial> = {}) => { }, onUnauthenticatedError: () => { setTokenPair(null); + setCurrentUser(null); + setCurrentWorkspaceMember(null); + setCurrentWorkspace(null); + setWorkspaces(null); if ( !isMatchingLocation(AppPath.Verify) && !isMatchingLocation(AppPath.SignInUp) && @@ -75,6 +90,10 @@ export const useApolloFactory = (options: Partial> = {}) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ setTokenPair, + setCurrentUser, + setCurrentWorkspaceMember, + setCurrentWorkspace, + setWorkspaces, isDebugMode, currentWorkspace?.currentCacheVersion, setPreviousUrl, diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx index 81cb710659a3..a066ddf7ed76 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx @@ -1,16 +1,13 @@ import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; import { useAuth } from '@/auth/hooks/useAuth'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { AppPath } from '@/types/AppPath'; export const VerifyEffect = () => { const [searchParams] = useSearchParams(); const loginToken = searchParams.get('loginToken'); - const currentWorkspace = useRecoilValue(currentWorkspaceState); const isLogged = useIsLogged(); const navigate = useNavigate(); @@ -23,12 +20,6 @@ export const VerifyEffect = () => { navigate(AppPath.SignInUp); } else { await verify(loginToken); - - if (currentWorkspace?.activationStatus === 'active') { - navigate(AppPath.Index); - } else { - navigate(AppPath.CreateWorkspace); - } } }; diff --git a/packages/twenty-front/src/modules/auth/hooks/useOnboardingStatus.ts b/packages/twenty-front/src/modules/auth/hooks/useOnboardingStatus.ts index 91a8a60e84e9..63c5d7add8a3 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useOnboardingStatus.ts @@ -20,6 +20,6 @@ export const useOnboardingStatus = (): OnboardingStatus | undefined => { isLoggedIn, currentWorkspaceMember, currentWorkspace, - isBillingEnabled: billing?.isBillingEnabled, + isBillingEnabled: billing?.isBillingEnabled || false, }); }; diff --git a/packages/twenty-front/src/modules/auth/hooks/useSignOutAndRedirect.ts b/packages/twenty-front/src/modules/auth/hooks/useSignOutAndRedirect.ts deleted file mode 100644 index a9c2c7baf0cb..000000000000 --- a/packages/twenty-front/src/modules/auth/hooks/useSignOutAndRedirect.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { useAuth } from '@/auth/hooks/useAuth'; -import { AppPath } from '@/types/AppPath'; - -export const useSignOutAndRedirect = () => { - const { signOut } = useAuth(); - const navigate = useNavigate(); - - return useCallback(() => { - signOut(); - navigate(AppPath.SignInUp); - }, [signOut, navigate]); -}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts deleted file mode 100644 index 195a4e44daee..000000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; - -import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; -import { previousUrlState } from '@/auth/states/previousUrlState'; -import { billingState } from '@/client-config/states/billingState'; -import { AppPath } from '@/types/AppPath'; -import { WorkspaceMember } from '~/generated/graphql'; - -export const useNavigateAfterSignInUp = () => { - const navigate = useNavigate(); - const billing = useRecoilValue(billingState); - const previousUrl = useRecoilValue(previousUrlState); - const navigateAfterSignInUp = useCallback( - ( - currentWorkspace: CurrentWorkspace, - currentWorkspaceMember: WorkspaceMember | null, - ) => { - if ( - billing?.isBillingEnabled === true && - !['active', 'trialing'].includes(currentWorkspace.subscriptionStatus) - ) { - navigate(AppPath.PlanRequired); - return; - } - - if (currentWorkspace.activationStatus !== 'active') { - navigate(AppPath.CreateWorkspace); - return; - } - - if ( - !currentWorkspaceMember?.name.firstName || - !currentWorkspaceMember?.name.lastName - ) { - navigate(AppPath.CreateProfile); - return; - } - if (previousUrl !== '') navigate(previousUrl); - else navigate(AppPath.Index); - }, - [billing, previousUrl, navigate], - ); - return { navigateAfterSignInUp }; -}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 0c23b372b25a..ba8ddd471c62 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -2,7 +2,6 @@ import { useCallback, useState } from 'react'; import { SubmitHandler, UseFormReturn } from 'react-hook-form'; import { useParams } from 'react-router-dom'; -import { useNavigateAfterSignInUp } from '@/auth/sign-in-up/hooks/useNavigateAfterSignInUp'; import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; @@ -31,8 +30,6 @@ export const useSignInUp = (form: UseFormReturn
) => { const workspaceInviteHash = useParams().workspaceInviteHash; - const { navigateAfterSignInUp } = useNavigateAfterSignInUp(); - const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); const [signInUpStep, setSignInUpStep] = useState( @@ -105,24 +102,18 @@ export const useSignInUp = (form: UseFormReturn) => { throw new Error('Email and password are required'); } - const { - workspace: currentWorkspace, - workspaceMember: currentWorkspaceMember, - } = - signInUpMode === SignInUpMode.SignIn && !isInviteMode - ? await signInWithCredentials( - data.email.toLowerCase().trim(), - data.password, - token, - ) - : await signUpWithCredentials( - data.email.toLowerCase().trim(), - data.password, - workspaceInviteHash, - token, - ); - - navigateAfterSignInUp(currentWorkspace, currentWorkspaceMember); + signInUpMode === SignInUpMode.SignIn && !isInviteMode + ? await signInWithCredentials( + data.email.toLowerCase().trim(), + data.password, + token, + ) + : await signUpWithCredentials( + data.email.toLowerCase().trim(), + data.password, + workspaceInviteHash, + token, + ); } catch (err: any) { enqueueSnackBar(err?.message, { variant: SnackBarVariant.Error, @@ -136,7 +127,6 @@ export const useSignInUp = (form: UseFormReturn) => { signInWithCredentials, signUpWithCredentials, workspaceInviteHash, - navigateAfterSignInUp, enqueueSnackBar, ], ); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts index 391730ddefaa..ee6234feb343 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceFromInviteHash.ts @@ -1,12 +1,51 @@ -import { useParams } from 'react-router-dom'; +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +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 { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState'; import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; export const useWorkspaceFromInviteHash = () => { + const { enqueueSnackBar } = useSnackBar(); + const navigate = useNavigate(); const workspaceInviteHash = useParams().workspaceInviteHash; + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const [initiallyLoggedIn] = useState(isDefined(currentWorkspace)); + const setIsDefaultLayoutAuthModalVisible = useSetRecoilState( + isDefaultLayoutAuthModalVisibleState, + ); const { data: workspaceFromInviteHash, loading } = useGetWorkspaceFromInviteHashQuery({ variables: { inviteHash: workspaceInviteHash || '' }, + onError: () => { + enqueueSnackBar('workspace does not exist', { + variant: SnackBarVariant.Error, + }); + navigate(AppPath.Index); + }, + onCompleted: (data) => { + if ( + isDefined(currentWorkspace) && + data?.findWorkspaceFromInviteHash && + currentWorkspace.id === data.findWorkspaceFromInviteHash.id + ) { + initiallyLoggedIn && + enqueueSnackBar( + `You already belong to ${data?.findWorkspaceFromInviteHash?.displayName} workspace`, + { + variant: SnackBarVariant.Info, + }, + ); + navigate(AppPath.Index); + } else { + setIsDefaultLayoutAuthModalVisible(true); + } + }, }); return { workspace: workspaceFromInviteHash?.findWorkspaceFromInviteHash, diff --git a/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts b/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts index c2547ecf4787..1ac681ac3e8a 100644 --- a/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts @@ -9,6 +9,7 @@ describe('getOnboardingStatus', () => { isLoggedIn: false, currentWorkspaceMember: null, currentWorkspace: null, + isBillingEnabled: false, }); const ongoingWorkspaceActivation = getOnboardingStatus({ @@ -18,6 +19,7 @@ describe('getOnboardingStatus', () => { id: '1', activationStatus: 'inactive', } as CurrentWorkspace, + isBillingEnabled: false, }); const ongoingProfileCreation = getOnboardingStatus({ @@ -30,6 +32,7 @@ describe('getOnboardingStatus', () => { id: '1', activationStatus: 'active', } as CurrentWorkspace, + isBillingEnabled: false, }); const completed = getOnboardingStatus({ @@ -45,6 +48,7 @@ describe('getOnboardingStatus', () => { id: '1', activationStatus: 'active', } as CurrentWorkspace, + isBillingEnabled: false, }); const incomplete = getOnboardingStatus({ @@ -78,6 +82,7 @@ describe('getOnboardingStatus', () => { activationStatus: 'active', subscriptionStatus: 'incomplete', } as CurrentWorkspace, + isBillingEnabled: false, }); const canceled = getOnboardingStatus({ diff --git a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts index a88ba60ba3b5..82b05648a59d 100644 --- a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts @@ -25,7 +25,7 @@ export const getOnboardingStatus = ({ 'createdAt' | 'updatedAt' | 'userId' | 'userEmail' | '__typename' > | null; currentWorkspace: CurrentWorkspace | null; - isBillingEnabled?: boolean; + isBillingEnabled: boolean; }) => { if (!isLoggedIn) { return OnboardingStatus.OngoingUserCreation; @@ -38,7 +38,7 @@ export const getOnboardingStatus = ({ } if ( - isBillingEnabled === true && + isBillingEnabled && currentWorkspace.subscriptionStatus === 'incomplete' ) { return OnboardingStatus.Incomplete; @@ -55,31 +55,19 @@ export const getOnboardingStatus = ({ return OnboardingStatus.OngoingProfileCreation; } - if ( - isBillingEnabled === true && - currentWorkspace.subscriptionStatus === 'canceled' - ) { + if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'canceled') { return OnboardingStatus.Canceled; } - if ( - isBillingEnabled === true && - currentWorkspace.subscriptionStatus === 'past_due' - ) { + if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'past_due') { return OnboardingStatus.PastDue; } - if ( - isBillingEnabled === true && - currentWorkspace.subscriptionStatus === 'unpaid' - ) { + if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'unpaid') { return OnboardingStatus.Unpaid; } - if ( - isBillingEnabled === true && - !currentWorkspace.currentBillingSubscription - ) { + if (isBillingEnabled && !currentWorkspace.currentBillingSubscription) { return OnboardingStatus.CompletedWithoutSubscription; } diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 1a91d922a022..78de9f9007e2 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -15,7 +15,7 @@ import { IconUsers, } from 'twenty-ui'; -import { useSignOutAndRedirect } from '@/auth/hooks/useSignOutAndRedirect'; +import { useAuth } from '@/auth/hooks/useAuth'; import { billingState } from '@/client-config/states/billingState'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; import { SettingsPath } from '@/types/SettingsPath'; @@ -25,7 +25,7 @@ import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/compo import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; export const SettingsNavigationDrawerItems = () => { - const handleLogout = useSignOutAndRedirect(); + const { signOut } = useAuth(); const billing = useRecoilValue(billingState); @@ -113,7 +113,7 @@ export const SettingsNavigationDrawerItems = () => { /> diff --git a/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx b/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx index 6f6326dcdd4f..11c246173397 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { H2Title } from 'twenty-ui'; -import { useSignOutAndRedirect } from '@/auth/hooks/useSignOutAndRedirect'; +import { useAuth } from '@/auth/hooks/useAuth'; import { currentUserState } from '@/auth/states/currentUserState'; import { Button } from '@/ui/input/button/components/Button'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; @@ -15,11 +15,11 @@ export const DeleteAccount = () => { const [deleteUserAccount] = useDeleteUserAccountMutation(); const currentUser = useRecoilValue(currentUserState); const userEmail = currentUser?.email; - const handleLogout = useSignOutAndRedirect(); + const { signOut } = useAuth(); const deleteAccount = async () => { await deleteUserAccount(); - handleLogout(); + await signOut(); }; return ( diff --git a/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx b/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx index a0c02cd38c20..f63782fa35e5 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { H2Title } from 'twenty-ui'; -import { useSignOutAndRedirect } from '@/auth/hooks/useSignOutAndRedirect'; +import { useAuth } from '@/auth/hooks/useAuth'; import { currentUserState } from '@/auth/states/currentUserState'; import { ConfirmationModal, @@ -18,11 +18,11 @@ export const DeleteWorkspace = () => { const currentUser = useRecoilValue(currentUserState); const userEmail = currentUser?.email; - const handleLogout = useSignOutAndRedirect(); + const { signOut } = useAuth(); const deleteWorkspace = async () => { await deleteCurrentWorkspace(); - handleLogout(); + await signOut(); }; return ( diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx new file mode 100644 index 000000000000..5b7450da69cf --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx @@ -0,0 +1,269 @@ +import { renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus'; +import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; +import { AppPath } from '@/types/AppPath'; +import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; +import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; + +jest.mock('@/auth/hooks/useOnboardingStatus'); +const setupMockOnboardingStatus = (onboardingStatus: OnboardingStatus) => { + jest.mocked(useOnboardingStatus).mockReturnValueOnce(onboardingStatus); +}; + +jest.mock('~/hooks/useIsMatchingLocation'); +const mockUseIsMatchingLocation = jest.mocked(useIsMatchingLocation); + +const setupMockIsMatchingLocation = (pathname: string) => { + mockUseIsMatchingLocation.mockReturnValueOnce( + (path: string) => path === pathname, + ); +}; + +const getResult = (isDefaultLayoutAuthModalVisible = true) => + renderHook( + () => { + const setIsDefaultLayoutAuthModalVisible = useSetRecoilState( + isDefaultLayoutAuthModalVisibleState, + ); + setIsDefaultLayoutAuthModalVisible(isDefaultLayoutAuthModalVisible); + + return useShowAuthModal(); + }, + { + wrapper: RecoilRoot, + }, + ); + +// prettier-ignore +const testCases = [ + { loc: AppPath.Verify, status: OnboardingStatus.Incomplete, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingUserCreation, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingWorkspaceActivation, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingProfileCreation, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.SignInUp, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.SignInUp, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.SignInUp, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.SignInUp, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.SignInUp, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.SignInUp, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.Invite, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.Canceled, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.Unpaid, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.PastDue, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.Completed, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.CompletedWithoutSubscription, res: true }, + + { loc: AppPath.ResetPassword, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.Canceled, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.Unpaid, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.PastDue, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.Completed, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.CompletedWithoutSubscription, res: true }, + + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.CreateProfile, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.PlanRequired, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Canceled, res: true }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.CompletedWithoutSubscription, res: true }, + + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.Index, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.Index, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.Index, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.Index, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.Index, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.Index, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.TasksPage, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.TasksPage, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.TasksPage, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.TasksPage, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.TasksPage, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.TasksPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.Impersonate, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.Impersonate, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.Impersonate, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.Impersonate, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.Impersonate, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.Impersonate, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.Authorize, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.Authorize, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.Authorize, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.Authorize, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.Authorize, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.Authorize, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + + { loc: AppPath.NotFound, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.NotFound, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.NotFound, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.NotFound, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.NotFound, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.NotFound, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, +]; + +describe('useShowAuthModal', () => { + testCases.forEach((testCase) => { + it(`testCase for location ${testCase.loc} with onboardingStatus ${testCase.status} should return ${testCase.res}`, () => { + setupMockOnboardingStatus(testCase.status); + setupMockIsMatchingLocation(testCase.loc); + const { result } = getResult(); + if (testCase.res) { + expect(result.current).toBeTruthy(); + } else { + expect(result.current).toBeFalsy(); + } + }); + }); + + describe('test with token validation loading', () => { + it(`with appPath ${AppPath.Invite} and isDefaultLayoutAuthModalVisible=false`, () => { + setupMockOnboardingStatus(OnboardingStatus.Completed); + setupMockIsMatchingLocation(AppPath.Invite); + const { result } = getResult(false); + expect(result.current).toBeFalsy(); + }); + it(`with appPath ${AppPath.ResetPassword} and isDefaultLayoutAuthModalVisible=false`, () => { + setupMockOnboardingStatus(OnboardingStatus.Completed); + setupMockIsMatchingLocation(AppPath.ResetPassword); + const { result } = getResult(false); + expect(result.current).toBeFalsy(); + }); + }); + + describe('tests should be exhaustive', () => { + it('all location and onboarding status should be tested', () => { + expect(testCases.length).toEqual( + Object.keys(AppPath).length * Object.keys(OnboardingStatus).length, + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts new file mode 100644 index 000000000000..212b382a4c26 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus'; +import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; +import { AppPath } from '@/types/AppPath'; +import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; + +export const useShowAuthModal = () => { + const isMatchingLocation = useIsMatchingLocation(); + const onboardingStatus = useOnboardingStatus(); + const isDefaultLayoutAuthModalVisible = useRecoilValue( + isDefaultLayoutAuthModalVisibleState, + ); + return useMemo(() => { + if (isMatchingLocation(AppPath.Verify)) { + return false; + } + if ( + isMatchingLocation(AppPath.Invite) || + isMatchingLocation(AppPath.ResetPassword) + ) { + return isDefaultLayoutAuthModalVisible; + } + if ( + OnboardingStatus.Incomplete === onboardingStatus || + OnboardingStatus.OngoingUserCreation === onboardingStatus || + OnboardingStatus.OngoingProfileCreation === onboardingStatus || + OnboardingStatus.OngoingWorkspaceActivation === onboardingStatus + ) { + return true; + } + if (isMatchingLocation(AppPath.PlanRequired)) { + return ( + OnboardingStatus.CompletedWithoutSubscription === onboardingStatus || + OnboardingStatus.Canceled === onboardingStatus + ); + } + return false; + }, [isDefaultLayoutAuthModalVisible, isMatchingLocation, onboardingStatus]); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx index 0b3ad1bcdd44..d347a23d9976 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx @@ -1,12 +1,9 @@ -import { useMemo } from 'react'; import { Outlet } from 'react-router-dom'; import { css, Global, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import { AuthModal } from '@/auth/components/Modal'; -import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus'; -import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus'; import { CommandMenu } from '@/command-menu/components/CommandMenu'; import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary'; import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu'; @@ -15,11 +12,10 @@ import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings'; import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage'; -import { AppPath } from '@/types/AppPath'; +import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; import { DESKTOP_NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/DesktopNavDrawerWidths'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useScreenSize } from '@/ui/utilities/screen-size/hooks/useScreenSize'; -import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; const StyledLayout = styled.div` background: ${({ theme }) => theme.background.noisy}; @@ -64,28 +60,11 @@ const StyledMainContainer = styled.div` `; export const DefaultLayout = () => { - const onboardingStatus = useOnboardingStatus(); const isMobile = useIsMobile(); const isSettingsPage = useIsSettingsPage(); const theme = useTheme(); - const widowsWidth = useScreenSize().width; - const isMatchingLocation = useIsMatchingLocation(); - const showAuthModal = useMemo(() => { - return ( - (onboardingStatus && - [ - OnboardingStatus.Incomplete, - OnboardingStatus.OngoingUserCreation, - OnboardingStatus.OngoingProfileCreation, - OnboardingStatus.OngoingWorkspaceActivation, - ].includes(onboardingStatus)) || - isMatchingLocation(AppPath.ResetPassword) || - isMatchingLocation(AppPath.Invite) || - (isMatchingLocation(AppPath.PlanRequired) && - (OnboardingStatus.CompletedWithoutSubscription || - OnboardingStatus.Canceled)) - ); - }, [isMatchingLocation, onboardingStatus]); + const windowsWidth = useScreenSize().width; + const showAuthModal = useShowAuthModal(); return ( <> @@ -104,7 +83,7 @@ export const DefaultLayout = () => { animate={{ marginLeft: isSettingsPage && !isMobile - ? (widowsWidth - + ? (windowsWidth - (OBJECT_SETTINGS_WIDTH + DESKTOP_NAV_DRAWER_WIDTHS.menu + 64)) / diff --git a/packages/twenty-front/src/modules/ui/layout/states/isDefaultLayoutAuthModalVisibleState.ts b/packages/twenty-front/src/modules/ui/layout/states/isDefaultLayoutAuthModalVisibleState.ts new file mode 100644 index 000000000000..95c41986949e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/states/isDefaultLayoutAuthModalVisibleState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isDefaultLayoutAuthModalVisibleState = createState({ + key: 'isDefaultLayoutAuthModalVisibleState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/pages/DefaultHomePage.tsx b/packages/twenty-front/src/pages/DefaultHomePage.tsx deleted file mode 100644 index 5fe09abdc37c..000000000000 --- a/packages/twenty-front/src/pages/DefaultHomePage.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Navigate } from 'react-router-dom'; - -import { useDefaultHomePagePath } from '~/hooks/useDefaultHomePagePath'; - -export const DefaultHomePage = () => { - const { defaultHomePagePath } = useDefaultHomePagePath(); - - return ; -}; diff --git a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx index 4f00f56d1661..58fa0dfc64cb 100644 --- a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx +++ b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx @@ -5,7 +5,7 @@ import { useRecoilValue } from 'recoil'; import { SubTitle } from '@/auth/components/SubTitle'; import { Title } from '@/auth/components/Title'; -import { useSignOutAndRedirect } from '@/auth/hooks/useSignOutAndRedirect'; +import { useAuth } from '@/auth/hooks/useAuth'; import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit'; import { SubscriptionCard } from '@/billing/components/SubscriptionCard'; import { billingState } from '@/client-config/states/billingState'; @@ -95,7 +95,7 @@ export const ChooseYourPlan = () => { }; }; - const handleLogout = useSignOutAndRedirect(); + const { signOut } = useAuth(); const computeInfo = ( price: ProductPriceEntity, @@ -175,7 +175,7 @@ export const ChooseYourPlan = () => { disabled={isSubmitting} /> - Log out + Log out Book a Call diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index eb6baa700925..b0558f97171e 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -1,5 +1,4 @@ -import { useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useMemo } from 'react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; @@ -10,10 +9,7 @@ import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { AppPath } from '@/types/AppPath'; import { Loader } from '@/ui/feedback/loader/components/Loader'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { MainButton } from '@/ui/input/button/components/MainButton'; import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; @@ -26,13 +22,8 @@ const StyledContentContainer = styled.div` `; export const Invite = () => { - const { enqueueSnackBar } = useSnackBar(); - const navigate = useNavigate(); - const { - workspace: workspaceFromInviteHash, - loading: workspaceFromInviteHashLoading, - workspaceInviteHash, - } = useWorkspaceFromInviteHash(); + const { workspace: workspaceFromInviteHash, workspaceInviteHash } = + useWorkspaceFromInviteHash(); const { form } = useSignInUpForm(); const currentWorkspace = useRecoilValue(currentWorkspaceState); const [addUserToWorkspace] = useAddUserToWorkspaceMutation(); @@ -56,68 +47,41 @@ export const Invite = () => { await switchWorkspace(workspaceFromInviteHash.id); }; - useEffect(() => { - if ( - !isDefined(workspaceFromInviteHash) && - !workspaceFromInviteHashLoading - ) { - enqueueSnackBar('workspace does not exist', { - variant: SnackBarVariant.Error, - }); - if (isDefined(currentWorkspace)) { - navigate(AppPath.Index); - } else { - navigate(AppPath.SignInUp); - } - } - if ( + if ( + !isDefined(workspaceFromInviteHash) || + (isDefined(workspaceFromInviteHash) && isDefined(currentWorkspace) && - currentWorkspace.id === workspaceFromInviteHash?.id - ) { - enqueueSnackBar( - `You already belong to ${workspaceFromInviteHash?.displayName} workspace`, - { - variant: SnackBarVariant.Info, - }, - ); - navigate(AppPath.Index); - } - }, [ - navigate, - enqueueSnackBar, - currentWorkspace, - workspaceFromInviteHash, - workspaceFromInviteHashLoading, - ]); + workspaceFromInviteHash.id === currentWorkspace.id) + ) { + return <>; + } return ( - !workspaceFromInviteHashLoading && ( - <> - - - - {title} - {isDefined(currentWorkspace) && workspaceFromInviteHash ? ( - <> - - form.formState.isSubmitting && } - fullWidth - /> - - - By using Twenty, you agree to the Terms of Service and Privacy - Policy. - - - ) : ( - - )} - - ) + <> + + + + {title} + {isDefined(currentWorkspace) ? ( + <> + + form.formState.isSubmitting && } + fullWidth + /> + + + By using Twenty, you agree to the Terms of Service and Privacy + Policy. + + + ) : ( + + )} + ); }; diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index 01e917b4a437..7b247fa2c853 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -7,19 +7,20 @@ import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; import { isNonEmptyString } from '@sniptt/guards'; import { motion } from 'framer-motion'; +import { useSetRecoilState } from 'recoil'; import { z } from 'zod'; import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { useAuth } from '@/auth/hooks/useAuth'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; -import { useNavigateAfterSignInUp } from '@/auth/sign-in-up/hooks/useNavigateAfterSignInUp'; import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; 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 { MainButton } from '@/ui/input/button/components/MainButton'; import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; import { useUpdatePasswordViaResetTokenMutation, @@ -73,6 +74,7 @@ export const PasswordReset = () => { const navigate = useNavigate(); const [email, setEmail] = useState(''); + const [isTokenValid, setIsTokenValid] = useState(false); const theme = useTheme(); @@ -80,6 +82,9 @@ export const PasswordReset = () => { const isLoggedIn = useIsLogged(); + const setIsDefaultLayoutAuthModalVisibleState = useSetRecoilState( + isDefaultLayoutAuthModalVisibleState, + ); const { control, handleSubmit } = useForm({ mode: 'onChange', defaultValues: { @@ -89,7 +94,7 @@ export const PasswordReset = () => { resolver: zodResolver(validationSchema), }); - const { loading: isValidatingToken } = useValidatePasswordResetTokenQuery({ + useValidatePasswordResetTokenQuery({ variables: { token: passwordResetToken ?? '', }, @@ -98,13 +103,11 @@ export const PasswordReset = () => { enqueueSnackBar(error?.message ?? 'Token Invalid', { variant: SnackBarVariant.Error, }); - if (!isLoggedIn) { - navigate(AppPath.SignInUp); - } else { - navigate(AppPath.Index); - } + navigate(AppPath.Index); }, onCompleted: (data) => { + setIsTokenValid(true); + setIsDefaultLayoutAuthModalVisibleState(true); if (isNonEmptyString(data?.validatePasswordResetToken?.email)) { setEmail(data.validatePasswordResetToken.email); } @@ -116,8 +119,6 @@ export const PasswordReset = () => { const { signInWithCredentials } = useAuth(); - const { navigateAfterSignInUp } = useNavigateAfterSignInUp(); - const onSubmit = async (formData: Form) => { try { const { data } = await updatePasswordViaToken({ @@ -142,12 +143,7 @@ export const PasswordReset = () => { return; } - const { - workspace: currentWorkspace, - workspaceMember: currentWorkspaceMember, - } = await signInWithCredentials(email || '', formData.newPassword); - - navigateAfterSignInUp(currentWorkspace, currentWorkspaceMember); + await signInWithCredentials(email || '', formData.newPassword); } catch (err) { logError(err); enqueueSnackBar( @@ -160,89 +156,90 @@ export const PasswordReset = () => { }; return ( - - - - - Reset Password - - {isValidatingToken && ( - - - - )} - {email && ( - - + + + + Reset Password + + {!email ? ( + - - + + ) : ( + + + + + + + + ( + + + + )} /> - - - - ( - - - - )} + + + - - - - - )} - - + + )} + + + ) ); };