Fixing Singup sequence FLASHING💥 (#11371)

After investiagting the different options ([see related
issue](https://github.com/twentyhq/core-team-issues/issues/660#issuecomment-2766030972))
I decided to add a "Verify Component" and a to build a custom Layout for
this route.

Reason I cannot use the default one is to have all preloaded once the
user changes website and lands on the verify route.

Reason I did not modify the DefaultLayout to match our need is that is
would require many changes in order to avoid preloading states for our
specific usecase.

Fixes https://github.com/twentyhq/core-team-issues/issues/660

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-04-04 17:25:15 +02:00
committed by GitHub
parent 609e06fd14
commit 10e140495c
22 changed files with 302 additions and 143 deletions

View File

@ -16,8 +16,8 @@ import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager'; import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider'; import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/AppThemeProvider';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider'; import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon'; import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { ServerPreconnect } from '@/ui/utilities/server-preconnect/components/ServerPreconnect'; import { ServerPreconnect } from '@/ui/utilities/server-preconnect/components/ServerPreconnect';

View File

@ -4,6 +4,7 @@ import {
useLocation, useLocation,
useNavigate, useNavigate,
useParams, useParams,
useSearchParams,
} from 'react-router-dom'; } from 'react-router-dom';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
@ -13,6 +14,7 @@ import {
} from '@/analytics/hooks/useEventTracker'; } from '@/analytics/hooks/useEventTracker';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
@ -21,10 +23,9 @@ import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { isDefined } from 'twenty-shared/utils';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation'; import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { isDefined } from 'twenty-shared/utils';
// TODO: break down into smaller functions and / or hooks // TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook // - moved usePageChangeEffectNavigateLocation into dedicated hook
@ -58,11 +59,16 @@ export const PageChangeEffect = () => {
} }
}, [location, previousLocation]); }, [location, previousLocation]);
const [searchParams] = useSearchParams();
const navigationParams = searchParams.get('animateModal')
? `?animateModal=${searchParams.get('animateModal')}`
: '';
useEffect(() => { useEffect(() => {
if (isDefined(pageChangeEffectNavigateLocation)) { if (isDefined(pageChangeEffectNavigateLocation)) {
navigate(pageChangeEffectNavigateLocation); navigate(pageChangeEffectNavigateLocation + navigationParams);
} }
}, [navigate, pageChangeEffectNavigateLocation]); }, [navigate, pageChangeEffectNavigateLocation, navigationParams]);
useEffect(() => { useEffect(() => {
const isLeavingRecordIndexPage = !!matchPath( const isLeavingRecordIndexPage = !!matchPath(

View File

@ -1,12 +1,13 @@
import { AppRouterProviders } from '@/app/components/AppRouterProviders'; import { AppRouterProviders } from '@/app/components/AppRouterProviders';
import { SettingsRoutes } from '@/app/components/SettingsRoutes'; import { SettingsRoutes } from '@/app/components/SettingsRoutes';
import { Verify } from '@/auth/components/Verify';
import { VerifyEffect } from '@/auth/components/VerifyEffect';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect'; import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath'; import indexAppPath from '@/navigation/utils/indexAppPath';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout'; import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout'; import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { import {
createBrowserRouter, createBrowserRouter,
createRoutesFromElements, createRoutesFromElements,
@ -38,7 +39,7 @@ export const useCreateAppRouter = (
loader={async () => Promise.resolve(null)} loader={async () => Promise.resolve(null)}
> >
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyEffect />} /> <Route path={AppPath.Verify} element={<Verify />} />
<Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} /> <Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} />
<Route path={AppPath.SignInUp} element={<SignInUp />} /> <Route path={AppPath.SignInUp} element={<SignInUp />} />
<Route path={AppPath.Invite} element={<SignInUp />} /> <Route path={AppPath.Invite} element={<SignInUp />} />

View File

@ -8,10 +8,20 @@ const StyledContent = styled(Modal.Content)`
justify-content: center; justify-content: center;
`; `;
type AuthModalProps = { children: React.ReactNode }; type AuthModalProps = {
children: React.ReactNode;
isOpenAnimated?: boolean;
};
export const AuthModal = ({ children }: AuthModalProps) => ( export const AuthModal = ({
<Modal padding={'none'} modalVariant="primary"> children,
isOpenAnimated = true,
}: AuthModalProps) => (
<Modal
padding={'none'}
modalVariant="primary"
isOpenAnimated={isOpenAnimated}
>
<ScrollWrapper componentInstanceId="scroll-wrapper-modal-content"> <ScrollWrapper componentInstanceId="scroll-wrapper-modal-content">
<StyledContent>{children}</StyledContent> <StyledContent>{children}</StyledContent>
</ScrollWrapper> </ScrollWrapper>

View File

@ -6,10 +6,11 @@ import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
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 { useNavigateApp } from '~/hooks/useNavigateApp';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { SignInUpLoading } from '~/pages/auth/SignInUpLoading';
export const VerifyEffect = () => { export const Verify = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const loginToken = searchParams.get('loginToken'); const loginToken = searchParams.get('loginToken');
const errorMessage = searchParams.get('errorMessage'); const errorMessage = searchParams.get('errorMessage');
@ -36,5 +37,5 @@ export const VerifyEffect = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return <></>; return <SignInUpLoading />;
}; };

View File

@ -3,14 +3,14 @@ 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 { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
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 { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateApp } from '~/hooks/useNavigateApp';
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificationSent';
export const VerifyEmailEffect = () => { export const VerifyEmailEffect = () => {
const { getLoginTokenFromEmailVerificationToken } = useAuth(); const { getLoginTokenFromEmailVerificationToken } = useAuth();
@ -50,6 +50,7 @@ export const VerifyEmailEffect = () => {
if (workspaceUrl.slice(0, -1) !== window.location.origin) { if (workspaceUrl.slice(0, -1) !== window.location.origin) {
return redirectToWorkspaceDomain(workspaceUrl, AppPath.Verify, { return redirectToWorkspaceDomain(workspaceUrl, AppPath.Verify, {
loginToken: loginToken.token, loginToken: loginToken.token,
animateModal: false,
}); });
} }
verifyLoginToken(loginToken.token); verifyLoginToken(loginToken.token);

View File

@ -416,6 +416,7 @@ export const useAuth = () => {
{ {
...(!isEmailVerificationRequired && { ...(!isEmailVerificationRequired && {
loginToken: signUpResult.data.signUp.loginToken.token, loginToken: signUpResult.data.signUp.loginToken.token,
animateModal: false,
}), }),
email, email,
}, },

View File

@ -2,6 +2,8 @@ import { useRecoilValue } from 'recoil';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback'; import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { AppPath } from '@/types/AppPath';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
children, children,
@ -10,8 +12,15 @@ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
clientConfigApiStatusState, clientConfigApiStatusState,
); );
const { isMatchingLocation } = useIsMatchingLocation();
// TODO: Implement a better loading strategy // TODO: Implement a better loading strategy
if (!isLoaded) return null; if (
!isLoaded &&
!isMatchingLocation(AppPath.Verify) &&
!isMatchingLocation(AppPath.VerifyEmail)
)
return null;
return isErrored && error instanceof Error ? ( return isErrored && error instanceof Error ? (
<AppFullScreenErrorFallback <AppFullScreenErrorFallback

View File

@ -10,7 +10,6 @@ export const ObjectMetadataItemsProvider = ({
children, children,
}: React.PropsWithChildren) => { }: React.PropsWithChildren) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const shouldDisplayChildren = objectMetadataItems.length > 0; const shouldDisplayChildren = objectMetadataItems.length > 0;
return ( return (

View File

@ -13,7 +13,7 @@ export const useImpersonationRedirect = () => {
return redirectToWorkspaceDomain( return redirectToWorkspaceDomain(
getWorkspaceUrl(workspaceUrls), getWorkspaceUrl(workspaceUrls),
AppPath.Verify, AppPath.Verify,
{ loginToken }, { loginToken, animateModal: false },
); );
}; };

View File

@ -56,16 +56,16 @@ const getResult = (isDefaultLayoutAuthModalVisible = true) =>
// prettier-ignore // prettier-ignore
const testCases = [ const testCases = [
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.COMPLETED, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.COMPLETED, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.COMPLETED, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.COMPLETED, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.COMPLETED, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.COMPLETED, res: true },
{ loc: AppPath.Verify, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: false }, { loc: AppPath.Verify, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: true },
{ loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.COMPLETED, res: false }, { loc: AppPath.Verify, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.COMPLETED, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: true }, { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: true },
{ loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.COMPLETED, res: true }, { loc: AppPath.VerifyEmail, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.COMPLETED, res: true },

View File

@ -6,9 +6,9 @@ import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState'; import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { isDefined } from 'twenty-shared/utils';
import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql'; import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { isDefined } from 'twenty-shared/utils';
export const useShowAuthModal = () => { export const useShowAuthModal = () => {
const { isMatchingLocation } = useIsMatchingLocation(); const { isMatchingLocation } = useIsMatchingLocation();
@ -21,8 +21,11 @@ export const useShowAuthModal = () => {
); );
return useMemo(() => { return useMemo(() => {
if (isMatchingLocation(AppPath.Verify)) { if (
return false; isMatchingLocation(AppPath.Verify) ||
isMatchingLocation(AppPath.VerifyEmail)
) {
return true;
} }
if ( if (

View File

@ -7,6 +7,7 @@ import {
useListenClickOutside, useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
@ -148,6 +149,7 @@ export type ModalProps = React.PropsWithChildren & {
className?: string; className?: string;
hotkeyScope?: ModalHotkeyScope; hotkeyScope?: ModalHotkeyScope;
onEnter?: () => void; onEnter?: () => void;
isOpenAnimated?: boolean;
modalVariant?: ModalVariants; modalVariant?: ModalVariants;
} & ( } & (
| { isClosable: true; onClose: () => void } | { isClosable: true; onClose: () => void }
@ -170,6 +172,7 @@ export const Modal = ({
isClosable = false, isClosable = false,
onClose, onClose,
modalVariant = 'primary', modalVariant = 'primary',
isOpenAnimated = true,
}: ModalProps) => { }: ModalProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@ -223,6 +226,8 @@ export const Modal = ({
e.stopPropagation(); e.stopPropagation();
}; };
const theme = useTheme();
return ( return (
<StyledBackDrop <StyledBackDrop
className="modal-backdrop" className="modal-backdrop"
@ -233,12 +238,13 @@ export const Modal = ({
ref={modalRef} ref={modalRef}
size={size} size={size}
padding={padding} padding={padding}
initial="hidden" initial={isOpenAnimated ? 'hidden' : 'visible'}
animate="visible" animate="visible"
exit="exit" exit="exit"
layout layout
modalVariant={modalVariant} modalVariant={modalVariant}
variants={modalAnimation} variants={modalAnimation}
transition={{ duration: theme.animation.duration.normal }}
className={className} className={className}
isMobile={isMobile} isMobile={isMobile}
> >

View File

@ -17,7 +17,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { Global, css, useTheme } from '@emotion/react'; import { Global, css, useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import { Outlet } from 'react-router-dom'; import { Outlet, useSearchParams } from 'react-router-dom';
import { useScreenSize } from 'twenty-ui/utilities'; import { useScreenSize } from 'twenty-ui/utilities';
const StyledLayout = styled.div` const StyledLayout = styled.div`
@ -63,6 +63,8 @@ export const DefaultLayout = () => {
const windowsWidth = useScreenSize().width; const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal(); const showAuthModal = useShowAuthModal();
const useShowFullScreen = useShowFullscreen(); const useShowFullScreen = useShowFullscreen();
const [searchParams] = useSearchParams();
const animateModal = searchParams.get('animateModal') !== 'false';
return ( return (
<> <>
@ -86,7 +88,9 @@ export const DefaultLayout = () => {
2 2
: 0, : 0,
}} }}
transition={{ duration: theme.animation.duration.normal }} transition={{
duration: theme.animation.duration.normal,
}}
> >
{!showAuthModal && ( {!showAuthModal && (
<> <>
@ -104,7 +108,7 @@ export const DefaultLayout = () => {
<SignInBackgroundMockPage /> <SignInBackgroundMockPage />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<LayoutGroup> <LayoutGroup>
<AuthModal> <AuthModal isOpenAnimated={animateModal}>
<Outlet /> <Outlet />
</AuthModal> </AuthModal>
</LayoutGroup> </LayoutGroup>

View File

@ -1,30 +1,27 @@
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useLingui } from '@lingui/react/macro';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsPath } from '@/types/SettingsPath';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MultiWorkspaceDropdownId';
import { useAuth } from '@/auth/hooks/useAuth'; import { useAuth } from '@/auth/hooks/useAuth';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces, workspacesState } from '@/auth/states/workspaces';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql'; import { SettingsPath } from '@/types/SettingsPath';
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 { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope'; import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MultiWorkspaceDropdownId';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme'; import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { useLingui } from '@lingui/react/macro';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { import {
Avatar, Avatar,
IconDotsVertical, IconDotsVertical,
@ -39,6 +36,9 @@ import {
MenuItemSelectAvatar, MenuItemSelectAvatar,
UndecoratedLink, UndecoratedLink,
} from 'twenty-ui/navigation'; } from 'twenty-ui/navigation';
import { useSignUpInNewWorkspaceMutation } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledDescription = styled.div` const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.light}; color: ${({ theme }) => theme.font.color.light};
@ -79,6 +79,7 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
AppPath.Verify, AppPath.Verify,
{ {
loginToken: data.signUpInNewWorkspace.loginToken.token, loginToken: data.signUpInNewWorkspace.loginToken.token,
animateModal: false,
}, },
'_blank', '_blank',
); );

View File

@ -15,6 +15,8 @@ export const UserProvider = ({ children }: React.PropsWithChildren) => {
const dateTimeFormat = useRecoilValue(dateTimeFormatState); const dateTimeFormat = useRecoilValue(dateTimeFormatState);
return !isCurrentUserLoaded && return !isCurrentUserLoaded &&
!isMatchingLocation(AppPath.Verify) &&
!isMatchingLocation(AppPath.VerifyEmail) &&
!isMatchingLocation(AppPath.CreateWorkspace) ? ( !isMatchingLocation(AppPath.CreateWorkspace) ? (
<UserOrMetadataLoader /> <UserOrMetadataLoader />
) : ( ) : (

View File

@ -3,8 +3,8 @@ import { useRecoilState, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState';
import { workspacesState } from '@/auth/states/workspaces'; import { workspacesState } from '@/auth/states/workspaces';
@ -16,15 +16,18 @@ import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
import { detectTimeZone } from '@/localization/utils/detectTimeZone'; import { detectTimeZone } from '@/localization/utils/detectTimeZone';
import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat'; import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat';
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { AppPath } from '@/types/AppPath';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { useGetCurrentUserQuery } from '~/generated/graphql';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { useGetCurrentUserQuery } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
export const UserProviderEffect = () => { export const UserProviderEffect = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { isMatchingLocation } = useIsMatchingLocation();
const [isCurrentUserLoaded, setIsCurrentUserLoaded] = useRecoilState( const [isCurrentUserLoaded, setIsCurrentUserLoaded] = useRecoilState(
isCurrentUserLoadedState, isCurrentUserLoadedState,
@ -44,7 +47,10 @@ export const UserProviderEffect = () => {
); );
const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({ const { loading: queryLoading, data: queryData } = useGetCurrentUserQuery({
skip: isCurrentUserLoaded, skip:
isCurrentUserLoaded ||
isMatchingLocation(AppPath.Verify) ||
isMatchingLocation(AppPath.VerifyEmail),
}); });
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,100 @@
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { useRecoilValue } from 'recoil';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import { useMemo } from 'react';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { motion } from 'framer-motion';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { PublicWorkspaceDataOutput } from '~/generated/graphql';
const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
width: 100%;
margin-top: ${({ theme }) => theme.spacing(10)};
`;
const StandardContent = ({
workspacePublicData,
signInUpForm,
title,
}: {
workspacePublicData: PublicWorkspaceDataOutput | null;
signInUpForm: JSX.Element | null;
title: string;
}) => {
return (
<>
<Logo secondaryLogo={workspacePublicData?.logo} />
<Title animate={false}>{title}</Title>
{signInUpForm}
</>
);
};
export const SignInUpLoading = () => {
const { t } = useLingui();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { workspaceInviteHash, workspace: workspaceFromInviteHash } =
useWorkspaceFromInviteHash();
const title = useMemo(() => {
if (isDefined(workspaceInviteHash)) {
return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`;
}
const workspaceName = !isDefined(workspacePublicData?.displayName)
? DEFAULT_WORKSPACE_NAME
: !isNonEmptyString(workspacePublicData?.displayName)
? t`Your Workspace`
: workspacePublicData?.displayName;
return t`Welcome to ${workspaceName}`;
}, [
workspaceFromInviteHash?.displayName,
workspaceInviteHash,
workspacePublicData?.displayName,
t,
]);
return (
<StandardContent
workspacePublicData={workspacePublicData}
signInUpForm={
<>
<p style={{ color: 'red', backgroundColor: 'blue' }}>
SignInUpLoading
</p>
<StyledContentContainer>
<StyledForm>
<MainButton
disabled={true}
title={t`Continue`}
type="submit"
variant={'primary'}
Icon={() => <Loader />}
fullWidth
/>
</StyledForm>
</StyledContentContainer>
</>
}
title={title}
/>
);
};

View File

@ -11,16 +11,16 @@ import { billingState } from '@/client-config/states/billingState';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { CardPicker, MainButton } from 'twenty-ui/input';
import { ActionLink, CAL_LINK } from 'twenty-ui/navigation';
import { import {
BillingPlanKey, BillingPlanKey,
BillingPriceLicensedDto, BillingPriceLicensedDto,
SubscriptionInterval, SubscriptionInterval,
useBillingBaseProductPricesQuery, useBillingBaseProductPricesQuery,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { ActionLink, CAL_LINK } from 'twenty-ui/navigation';
import { CardPicker, MainButton } from 'twenty-ui/input';
import { Loader } from 'twenty-ui/feedback';
const StyledSubscriptionContainer = styled.div<{ const StyledSubscriptionContainer = styled.div<{
withLongerMarginBottom: boolean; withLongerMarginBottom: boolean;
@ -80,6 +80,10 @@ const StyledLinkGroup = styled.div`
} }
`; `;
const StyledChooseYourPlanPlaceholder = styled.div`
height: 566px;
`;
export const ChooseYourPlan = () => { export const ChooseYourPlan = () => {
const billing = useRecoilValue(billingState); const billing = useRecoilValue(billingState);
const { t } = useLingui(); const { t } = useLingui();
@ -182,82 +186,87 @@ export const ChooseYourPlan = () => {
)?.baseProduct.name; )?.baseProduct.name;
return ( return (
isDefined(baseProductPrice) && <>
isDefined(billing) && ( {isDefined(baseProductPrice) && isDefined(billing) ? (
<> <>
<Title noMarginTop> <Title noMarginTop>
{hasWithoutCreditCardTrialPeriod {hasWithoutCreditCardTrialPeriod
? t`Choose your Trial` ? t`Choose your Trial`
: t`Get your subscription`} : t`Get your subscription`}
</Title> </Title>
{hasWithoutCreditCardTrialPeriod ? ( {hasWithoutCreditCardTrialPeriod ? (
<SubTitle>
<Trans>Cancel anytime</Trans>
</SubTitle>
) : (
withCreditCardTrialPeriod && (
<SubTitle> <SubTitle>
{t`Enjoy a ${withCreditCardTrialPeriodDuration}-days free trial`} <Trans>Cancel anytime</Trans>
</SubTitle> </SubTitle>
) ) : (
)} withCreditCardTrialPeriod && (
<StyledSubscriptionContainer <SubTitle>
withLongerMarginBottom={!hasWithoutCreditCardTrialPeriod} {t`Enjoy a ${withCreditCardTrialPeriodDuration}-days free trial`}
> </SubTitle>
<StyledSubscriptionPriceContainer> )
<SubscriptionPrice )}
type={baseProductPrice.recurringInterval} <StyledSubscriptionContainer
price={baseProductPrice.unitAmount / 100} withLongerMarginBottom={!hasWithoutCreditCardTrialPeriod}
/> >
</StyledSubscriptionPriceContainer> <StyledSubscriptionPriceContainer>
<StyledBenefitsContainer> <SubscriptionPrice
{benefits.map((benefit) => ( type={baseProductPrice.recurringInterval}
<SubscriptionBenefit key={benefit}>{benefit}</SubscriptionBenefit> price={baseProductPrice.unitAmount / 100}
))} />
</StyledBenefitsContainer> </StyledSubscriptionPriceContainer>
</StyledSubscriptionContainer> <StyledBenefitsContainer>
{hasWithoutCreditCardTrialPeriod && ( {benefits.map((benefit) => (
<StyledChooseTrialContainer> <SubscriptionBenefit key={benefit}>
{billing.trialPeriods.map((trialPeriod) => ( {benefit}
<CardPicker </SubscriptionBenefit>
checked={ ))}
billingCheckoutSession.requirePaymentMethod === </StyledBenefitsContainer>
trialPeriod.isCreditCardRequired </StyledSubscriptionContainer>
} {hasWithoutCreditCardTrialPeriod && (
handleChange={handleTrialPeriodChange( <StyledChooseTrialContainer>
trialPeriod.isCreditCardRequired, {billing.trialPeriods.map((trialPeriod) => (
)} <CardPicker
key={trialPeriod.duration} checked={
> billingCheckoutSession.requirePaymentMethod ===
<TrialCard trialPeriod.isCreditCardRequired
duration={trialPeriod.duration} }
withCreditCard={trialPeriod.isCreditCardRequired} handleChange={handleTrialPeriodChange(
/> trialPeriod.isCreditCardRequired,
</CardPicker> )}
))} key={trialPeriod.duration}
</StyledChooseTrialContainer> >
)} <TrialCard
<MainButton duration={trialPeriod.duration}
title={t`Continue`} withCreditCard={trialPeriod.isCreditCardRequired}
onClick={handleCheckoutSession} />
width={200} </CardPicker>
Icon={() => isSubmitting && <Loader />} ))}
disabled={isSubmitting} </StyledChooseTrialContainer>
/> )}
<StyledLinkGroup> <MainButton
<ActionLink onClick={signOut}> title={t`Continue`}
<Trans>Log out</Trans> onClick={handleCheckoutSession}
</ActionLink> width={200}
<span /> Icon={() => isSubmitting && <Loader />}
<ActionLink onClick={handleSwitchPlan(alternatePlan)}> disabled={isSubmitting}
<Trans>Switch to {alternatePlanName}</Trans> />
</ActionLink> <StyledLinkGroup>
<span /> <ActionLink onClick={signOut}>
<ActionLink href={CAL_LINK} target="_blank" rel="noreferrer"> <Trans>Log out</Trans>
<Trans>Book a Call</Trans> </ActionLink>
</ActionLink> <span />
</StyledLinkGroup> <ActionLink onClick={handleSwitchPlan(alternatePlan)}>
</> <Trans>Switch to {alternatePlanName}</Trans>
) </ActionLink>
<span />
<ActionLink href={CAL_LINK} target="_blank" rel="noreferrer">
<Trans>Book a Call</Trans>
</ActionLink>
</StyledLinkGroup>
</>
) : (
<StyledChooseYourPlanPlaceholder />
)}
</>
); );
}; };

View File

@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type'; import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
@ -44,7 +44,7 @@ export class DomainManagerService {
private appendSearchParams( private appendSearchParams(
url: URL, url: URL,
searchParams: Record<string, string | number>, searchParams: Record<string, string | number | boolean>,
) { ) {
Object.entries(searchParams).forEach(([key, value]) => { Object.entries(searchParams).forEach(([key, value]) => {
url.searchParams.set(key, value.toString()); url.searchParams.set(key, value.toString());
@ -78,7 +78,7 @@ export class DomainManagerService {
}: { }: {
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType; workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
pathname?: string; pathname?: string;
searchParams?: Record<string, string | number>; searchParams?: Record<string, string | number | boolean>;
}) { }) {
const workspaceUrls = this.getWorkspaceUrls(workspace); const workspaceUrls = this.getWorkspaceUrls(workspace);

View File

@ -2,11 +2,11 @@ import { DynamicModule, Global } from '@nestjs/common';
import { EmailModuleAsyncOptions } from 'src/engine/core-modules/email/interfaces/email.interface'; import { EmailModuleAsyncOptions } from 'src/engine/core-modules/email/interfaces/email.interface';
import { EMAIL_DRIVER } from 'src/engine/core-modules/email/email.constants';
import { LoggerDriver } from 'src/engine/core-modules/email/drivers/logger.driver'; import { LoggerDriver } from 'src/engine/core-modules/email/drivers/logger.driver';
import { SmtpDriver } from 'src/engine/core-modules/email/drivers/smtp.driver'; import { SmtpDriver } from 'src/engine/core-modules/email/drivers/smtp.driver';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service'; import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service';
import { EMAIL_DRIVER } from 'src/engine/core-modules/email/email.constants';
import { EmailService } from 'src/engine/core-modules/email/email.service';
@Global() @Global()
export class EmailModule { export class EmailModule {