Remove v1 onboarding and rely only on v2 (#22398)

https://github.com/user-attachments/assets/a6bfaac3-6c79-4fd5-999a-e6a70cff8ac8


Removes the old (v1) signup and onboarding flow now that v2 is the only
path, and drops the `isOnboardingV2` flag entirely. The surviving
(formerly-v2) pages reclaim the canonical `AppPath` members and clean
URLs (`/welcome`, `/verify`, `/workspace-activation`, `/create/profile`,
`/sync/emails`, `/install-apps`, `/invite-team`, `/plan-required`).

- Deletes the v1 pages, the v1 workspace-creation form, the
`isOnboardingV2State` flag + `onboardingV2` URL-param plumbing, and
`InstallAppsAutoSkipEffect`.
- Collapses the router and page-change navigation matrix to a single set
of paths, and renames the v2 components/stories to drop the `V2` suffix.

Follow-up fixes so the single flow behaves correctly on every
deployment:

- Restore the captcha-token, query-param and pageview effects on the
default (root) domain, and serve `/authorize` there so OAuth login keeps
working.
- Gate the invite-team → `/plan-required` interception on billing so
billing-disabled instances aren't trapped on the upgrade page.
- On a cold boot to an auth/onboarding path, show the onboarding loader
instead of the CRM skeleton, and add `/verify-email` and
`/plan-required/payment-success` to that loader path list.
- Add a retry to PaymentSuccess after the confirmation timeout, fix the
InstallApps icon crossfade, restyle the book-call pages for the
full-page layout, and delete code orphaned by the v1 removal.
- Extract the pageview/captcha/query-param logic out of
`PageChangeEffect` into standalone Effect components shared by the root
and workspace app trees.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22398?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Raphaël Bosi
2026-07-03 18:12:34 +02:00
committed by GitHub
parent 9f4efa57ff
commit 566c3b6629
158 changed files with 3579 additions and 5374 deletions
@@ -1,45 +0,0 @@
import { AuthModalMountEffect } from '@/auth/components/AuthModalMountEffect';
import { AUTH_MODAL_ID } from '@/auth/constants/AuthModalId';
import { getAuthModalConfig } from '@/auth/utils/getAuthModalConfig';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { styled } from '@linaria/react';
import React from 'react';
import { useLocation } from 'react-router-dom';
const StyledContent = styled.div`
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 320px;
`;
type AuthModalProps = {
children: React.ReactNode;
};
export const AuthModal = ({ children }: AuthModalProps) => {
const location = useLocation();
const config = getAuthModalConfig(location);
return (
<>
<AuthModalMountEffect />
<ModalStatefulWrapper
modalInstanceId={AUTH_MODAL_ID}
padding="none"
size={config.size}
overlay={config.overlay}
>
{config.showScrollWrapper ? (
<ScrollWrapper componentInstanceId="scroll-wrapper-modal-content">
<StyledContent>{children}</StyledContent>
</ScrollWrapper>
) : (
<>{children}</>
)}
</ModalStatefulWrapper>
</>
);
};
@@ -1,19 +0,0 @@
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useEffect } from 'react';
import { AUTH_MODAL_ID } from '@/auth/constants/AuthModalId';
// TODO: Remove this component when we refactor the auth modal to open it directly in the PageChangeEffect
export const AuthModalMountEffect = () => {
const { openModal, closeModal } = useModal();
useEffect(() => {
openModal(AUTH_MODAL_ID);
return () => {
closeModal(AUTH_MODAL_ID);
};
}, [openModal, closeModal]);
return null;
};
@@ -0,0 +1,33 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { OnboardingVerifyLayout } from '@/onboarding/components/OnboardingVerifyLayout';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ModalContent } from 'twenty-ui/surfaces';
export const VerifyEmail = () => {
const { t } = useLingui();
const [searchParams] = useSearchParams();
const [isError, setIsError] = useState(false);
const email = searchParams.get('email');
if (isError) {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
</ModalContent>
);
}
return (
<>
<VerifyEmailEffect onError={() => setIsError(true)} />
<OnboardingVerifyLayout>
<SubTitle>{t`Verifying your email`}</SubTitle>
</OnboardingVerifyLayout>
</>
);
};
@@ -1,26 +1,27 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { AppPath } from 'twenty-shared/types';
import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
import { useAuth } from '@/auth/hooks/useAuth';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { ModalContent } from 'twenty-ui/surfaces';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useLingui } from '@lingui/react/macro';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const VerifyEmailEffect = () => {
type VerifyEmailEffectProps = {
onError: () => void;
};
export const VerifyEmailEffect = ({ onError }: VerifyEmailEffectProps) => {
const {
verifyEmailAndGetLoginToken,
verifyEmailAndGetWorkspaceAgnosticToken,
@@ -29,7 +30,6 @@ export const VerifyEmailEffect = () => {
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [searchParams] = useSearchParams();
const [isError, setIsError] = useState(false);
const setVerifyEmailRedirectPath = useSetAtomState(
verifyEmailRedirectPathState,
@@ -109,7 +109,7 @@ export const VerifyEmailEffect = () => {
navigate(AppPath.SignInUp);
}
setIsError(true);
onError();
}
};
@@ -123,13 +123,5 @@ export const VerifyEmailEffect = () => {
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [clientConfigApiStatus.isLoadedOnce]);
if (isError) {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
</ModalContent>
);
}
return <></>;
};
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
@@ -21,17 +21,21 @@ export const VerifyLoginTokenEffect = () => {
clientConfigApiStatusState,
);
// oxlint-disable-next-line twenty/no-state-useref
const hasVerifiedRef = useRef(false);
useEffect(() => {
if (!clientConfigLoaded) {
if (!clientConfigLoaded || hasVerifiedRef.current) {
return;
}
hasVerifiedRef.current = true;
if (isDefined(loginToken)) {
verifyLoginToken(loginToken);
} else if (!hasAccessTokenPair) {
navigate(AppPath.SignInUp);
}
// Verify only needs to run once at mount
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [clientConfigLoaded]);
@@ -1,14 +1,14 @@
import { type VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import { type VerifyEmail } from '@/auth/components/VerifyEmail';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
// Mock component that just renders the error state of VerifyEmailEffect directly
// (since normal VerifyEmailEffect has async logic that's hard to test in Storybook)
// Mock component that just renders the error state of VerifyEmail directly
// (since normal VerifyEmail has async logic that's hard to test in Storybook)
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { ModalContent } from 'twenty-ui/surfaces';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => {
const VerifyEmailErrorState = ({ email = 'user@example.com' }) => {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
@@ -16,9 +16,9 @@ const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => {
);
};
const meta: Meta<typeof VerifyEmailEffectErrorState> = {
title: 'Modules/Auth/VerifyEmailEffect',
component: VerifyEmailEffectErrorState,
const meta: Meta<typeof VerifyEmailErrorState> = {
title: 'Modules/Auth/VerifyEmail',
component: VerifyEmailErrorState,
decorators: [
(Story) => (
<div style={{ padding: '24px' }}>
@@ -29,13 +29,13 @@ const meta: Meta<typeof VerifyEmailEffectErrorState> = {
],
parameters: {
codeSection: {
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with ModalContent to maintain consistent styling.',
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmail, always wrap it with ModalContent to maintain consistent styling.',
},
},
};
export default meta;
type Story = StoryObj<typeof VerifyEmailEffect>;
type Story = StoryObj<typeof VerifyEmail>;
export const ErrorState: Story = {
args: {
@@ -43,7 +43,7 @@ export const ErrorState: Story = {
},
};
export const IntegratedExample: StoryObj<typeof VerifyEmailEffect> = {
export const IntegratedExample: StoryObj<typeof VerifyEmail> = {
render: () => (
<MemoryRouter
initialEntries={[
@@ -53,7 +53,7 @@ export const IntegratedExample: StoryObj<typeof VerifyEmailEffect> = {
<Routes>
<Route
path="/verify-email"
element={<VerifyEmailEffectErrorState email="user@example.com" />}
element={<VerifyEmailErrorState email="user@example.com" />}
/>
</Routes>
</MemoryRouter>
@@ -7,7 +7,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import { VerifyEmail } from '@/auth/components/VerifyEmail';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import {
jotaiStore,
@@ -60,7 +60,7 @@ jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
}),
}));
// Rendered by VerifyEmailEffect in the error state; isolate it from Apollo.
// Rendered by VerifyEmail in the error state; isolate it from Apollo.
jest.mock(
'@/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken',
() => ({
@@ -76,20 +76,20 @@ dynamicActivate(SOURCE_LOCALE);
const VERIFY_EMAIL_URL =
'/verify-email?email=user%40example.com&emailVerificationToken=valid-token';
const renderEffect = (initialEntry: string) =>
const renderVerifyEmail = (initialEntry: string) =>
render(
<JotaiProvider store={jotaiStore}>
<ThemeProvider colorScheme="light">
<I18nProvider i18n={i18n}>
<MemoryRouter initialEntries={[initialEntry]}>
<VerifyEmailEffect />
<VerifyEmail />
</MemoryRouter>
</I18nProvider>
</ThemeProvider>
</JotaiProvider>,
);
describe('VerifyEmailEffect', () => {
describe('VerifyEmail', () => {
beforeEach(() => {
jest.clearAllMocks();
resetJotaiStore();
@@ -106,7 +106,7 @@ describe('VerifyEmailEffect', () => {
it('navigates to the SignInUp page after a successful workspace-agnostic verification on the central domain', async () => {
verifyEmailAndGetWorkspaceAgnosticTokenMock.mockResolvedValue(undefined);
renderEffect(VERIFY_EMAIL_URL);
renderVerifyEmail(VERIFY_EMAIL_URL);
await waitFor(() => {
expect(verifyEmailAndGetWorkspaceAgnosticTokenMock).toHaveBeenCalledWith(
@@ -128,7 +128,7 @@ describe('VerifyEmailEffect', () => {
new Error('verification failed'),
);
renderEffect(VERIFY_EMAIL_URL);
renderVerifyEmail(VERIFY_EMAIL_URL);
await waitFor(() => {
expect(enqueueErrorSnackBarMock).toHaveBeenCalled();
@@ -143,7 +143,7 @@ describe('VerifyEmailEffect', () => {
workspaceUrls: { subdomainUrl: 'https://foo.twenty.com/' },
});
renderEffect(VERIFY_EMAIL_URL);
renderVerifyEmail(VERIFY_EMAIL_URL);
await waitFor(() => {
expect(verifyEmailAndGetLoginTokenMock).toHaveBeenCalledWith(
@@ -0,0 +1,72 @@
import { act, render, waitFor } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { StrictMode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
const verifyLoginTokenMock = jest.fn();
const navigateMock = jest.fn();
jest.mock('@/auth/hooks/useVerifyLogin', () => ({
useVerifyLogin: () => ({ verifyLoginToken: verifyLoginTokenMock }),
}));
jest.mock('~/hooks/useNavigateApp', () => ({
useNavigateApp: () => navigateMock,
}));
jest.mock('@/auth/hooks/useHasAccessTokenPair', () => ({
useHasAccessTokenPair: () => false,
}));
const setClientConfigSaved = (isSaved: boolean) => {
jotaiStore.set(clientConfigApiStatusState.atom, {
isLoadedOnce: true,
isLoading: false,
isErrored: false,
isSaved,
});
};
const renderEffect = (initialEntry: string) =>
render(
<JotaiProvider store={jotaiStore}>
<MemoryRouter initialEntries={[initialEntry]}>
<StrictMode>
<VerifyLoginTokenEffect />
</StrictMode>
</MemoryRouter>
</JotaiProvider>,
);
describe('VerifyLoginTokenEffect', () => {
beforeEach(() => {
jest.clearAllMocks();
resetJotaiStore();
setClientConfigSaved(true);
});
it('verifies the login token at most once even when the gating config re-triggers the effect', async () => {
renderEffect('/verify?loginToken=login-token');
await waitFor(() => {
expect(verifyLoginTokenMock).toHaveBeenCalledWith('login-token');
});
expect(verifyLoginTokenMock).toHaveBeenCalledTimes(1);
await act(async () => {
setClientConfigSaved(false);
});
await act(async () => {
setClientConfigSaved(true);
});
expect(verifyLoginTokenMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,9 @@
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { AppPath } from 'twenty-shared/types';
export const AUTH_AND_ONBOARDING_PATHS = [
...ONGOING_USER_CREATION_PATHS,
...ONBOARDING_PATHS,
AppPath.ResetPassword,
];
@@ -1,24 +0,0 @@
import { type ModalOverlay, type ModalSize } from 'twenty-ui/surfaces';
import { AppPath } from 'twenty-shared/types';
type AuthModalConfigType = {
size: ModalSize;
overlay: ModalOverlay;
showScrollWrapper: boolean;
};
export const AUTH_MODAL_CONFIG: {
default: AuthModalConfigType;
[key: string]: AuthModalConfigType;
} = {
default: {
size: 'medium',
overlay: 'dark',
showScrollWrapper: true,
},
[AppPath.BookCall]: {
size: 'extraLarge',
overlay: 'transparent',
showScrollWrapper: false,
},
};
@@ -1 +0,0 @@
export const AUTH_MODAL_ID = 'auth-modal';
@@ -2,16 +2,11 @@ import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_PATHS = [
AppPath.WorkspaceActivation,
AppPath.WorkspaceActivationV2,
AppPath.CreateProfile,
AppPath.CreateProfileV2,
AppPath.SyncEmails,
AppPath.SyncEmailsV2,
AppPath.InstallAppsV2,
AppPath.InstallApps,
AppPath.InviteTeam,
AppPath.InviteTeamV2,
AppPath.PlanRequired,
AppPath.PlanRequiredV2,
AppPath.PlanRequiredSuccess,
AppPath.BookCallDecision,
AppPath.BookCall,
@@ -0,0 +1,15 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_TRANSITION_PATHS = [
AppPath.SignInUp,
AppPath.Invite,
AppPath.Verify,
AppPath.VerifyEmail,
AppPath.WorkspaceActivation,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InstallApps,
AppPath.InviteTeam,
AppPath.PlanRequired,
AppPath.PlanRequiredSuccess,
];
@@ -1,12 +0,0 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_V2_PATHS = [
AppPath.SignInUpV2,
AppPath.VerifyV2,
AppPath.WorkspaceActivationV2,
AppPath.CreateProfileV2,
AppPath.SyncEmailsV2,
AppPath.InstallAppsV2,
AppPath.InviteTeamV2,
AppPath.PlanRequiredV2,
];
@@ -3,8 +3,6 @@ import { AppPath } from 'twenty-shared/types';
export const ONGOING_USER_CREATION_PATHS = [
AppPath.Invite,
AppPath.SignInUp,
AppPath.SignInUpV2,
AppPath.VerifyEmail,
AppPath.Verify,
AppPath.VerifyV2,
];
@@ -0,0 +1,11 @@
import { AUTH_AND_ONBOARDING_PATHS } from '@/auth/constants/AuthAndOnboardingPaths';
import { useLocation } from 'react-router-dom';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useIsOnAuthOrOnboardingPage = () => {
const location = useLocation();
return AUTH_AND_ONBOARDING_PATHS.some((appPath) =>
isMatchingLocation(location, appPath),
);
};
@@ -3,13 +3,15 @@ import { Trans } from '@lingui/react/macro';
import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCopyContainer = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
max-width: 280px;
line-height: 1.4;
max-width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
text-align: center;
& > a {
@@ -8,6 +8,8 @@ import { FormProvider } from 'react-hook-form';
import { ClickToActionLink, UndecoratedLink } from 'twenty-ui/navigation';
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { OnboardingStepAnimatedItem } from '@/onboarding/components/OnboardingStepAnimatedItem';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
@@ -36,6 +38,11 @@ import {
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledContentContainer = styled(StyledOnboardingContentContainer)`
max-width: 100%;
width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
`;
const StyledWorkspaceContainer = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.light};
@@ -156,70 +163,82 @@ export const SignInUpGlobalScopeForm = () => {
);
};
const availableWorkspacesList = [
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
];
return (
<>
{signInUpStep === SignInUpStep.WorkspaceSelection && (
<StyledOnboardingContentContainer>
<StyledContentContainer>
<StyledWorkspaceContainer>
{[
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
].map((availableWorkspace) => (
<UndecoratedLink
{availableWorkspacesList.map((availableWorkspace, index) => (
<OnboardingStepAnimatedItem
key={availableWorkspace.id}
to={getAvailableWorkspaceUrl(availableWorkspace)}
index={index}
>
<StyledWorkspaceItem>
<UndecoratedLink
to={getAvailableWorkspaceUrl(availableWorkspace)}
>
<StyledWorkspaceItem>
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
size="lg"
/>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>
{availableWorkspace.displayName ||
availableWorkspace.id}
</StyledWorkspaceName>
<StyledWorkspaceUrl>
{
new URL(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
).hostname
}
</StyledWorkspaceUrl>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</UndecoratedLink>
</OnboardingStepAnimatedItem>
))}
{!isDDLLocked && (
<OnboardingStepAnimatedItem
index={availableWorkspacesList.length}
>
<StyledWorkspaceItem
onClick={() =>
setSignInUpStep(SignInUpStep.WorkspaceCreation)
}
>
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
size="lg"
/>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>
{availableWorkspace.displayName ||
availableWorkspace.id}
</StyledWorkspaceName>
<StyledWorkspaceUrl>
{
new URL(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
).hostname
}
</StyledWorkspaceUrl>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</UndecoratedLink>
))}
{!isDDLLocked && (
<StyledWorkspaceItem
onClick={() => setSignInUpStep(SignInUpStep.WorkspaceCreation)}
>
<StyledWorkspaceContent>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</OnboardingStepAnimatedItem>
)}
</StyledWorkspaceContainer>
</StyledOnboardingContentContainer>
</StyledContentContainer>
)}
{signInUpStep !== SignInUpStep.WorkspaceSelection && (
<StyledOnboardingContentContainer>
<StyledContentContainer>
{authProviders.google && (
<SignInUpWithGoogle
action="list-available-workspaces"
@@ -233,7 +252,9 @@ export const SignInUpGlobalScopeForm = () => {
/>
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
<HorizontalSeparator
color={themeCssVariables.background.transparent.light}
/>
)}
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
@@ -248,7 +269,7 @@ export const SignInUpGlobalScopeForm = () => {
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledOnboardingContentContainer>
</StyledContentContainer>
)}
</>
);
@@ -3,13 +3,28 @@ import { Title } from '@/auth/components/Title';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { WorkspaceSelectionFooter } from '@/auth/sign-in-up/components/WorkspaceSelectionFooter';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { styled } from '@linaria/react';
import { type JSX } from 'react';
import { AppPath } from 'twenty-shared/types';
import { AnimatedEaseIn } from 'twenty-ui/layout';
import { ModalContent } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type PublicWorkspaceData } from '~/generated-metadata/graphql';
type SignInUpV2StandardContentProps = {
const StyledTitleContainer = styled.div`
line-height: 1.2;
margin-top: ${themeCssVariables.spacing[10]};
`;
const StyledFormContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
margin-bottom: ${themeCssVariables.spacing[6]};
margin-top: ${themeCssVariables.spacing[6]};
`;
type SignInUpStandardContentProps = {
workspacePublicData: PublicWorkspaceData | null;
signInUpForm: JSX.Element | null;
signInUpStep: SignInUpStep;
@@ -17,13 +32,13 @@ type SignInUpV2StandardContentProps = {
onClickOnLogo: () => void;
};
export const SignInUpV2StandardContent = ({
export const SignInUpStandardContent = ({
workspacePublicData,
signInUpForm,
signInUpStep,
title,
onClickOnLogo,
}: SignInUpV2StandardContentProps) => {
}: SignInUpStandardContentProps) => {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<AnimatedEaseIn>
@@ -31,11 +46,13 @@ export const SignInUpV2StandardContent = ({
secondaryLogo={workspacePublicData?.logo}
placeholder={workspacePublicData?.displayName}
onClick={onClickOnLogo}
to={AppPath.SignInUpV2}
to={AppPath.SignInUp}
/>
</AnimatedEaseIn>
<Title animate>{title}</Title>
{signInUpForm}
<StyledTitleContainer>
<Title animate>{title}</Title>
</StyledTitleContainer>
<StyledFormContainer>{signInUpForm}</StyledFormContainer>
{signInUpStep === SignInUpStep.WorkspaceSelection && (
<WorkspaceSelectionFooter />
)}
@@ -1,77 +0,0 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { motion, useReducedMotion } from 'framer-motion';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme-constants';
const STEP_OPACITIES = [1, 0.4, 0.12];
const VISIBLE_STEP_COUNT = STEP_OPACITIES.length;
const STEP_HEIGHT_IN_PX = 28;
const STEPS_CONTAINER_HEIGHT_IN_PX = STEP_HEIGHT_IN_PX * VISIBLE_STEP_COUNT;
const StyledStepsContainer = styled.div`
height: ${STEPS_CONTAINER_HEIGHT_IN_PX}px;
position: relative;
width: 100%;
`;
const StyledStepBase = styled.div`
left: 0;
position: absolute;
right: 0;
top: 0;
`;
const StyledStep = motion.create(StyledStepBase);
type SignInUpWorkspaceActivationV2Props = {
messageIndex: number;
};
export const SignInUpWorkspaceActivationV2 = ({
messageIndex,
}: SignInUpWorkspaceActivationV2Props) => {
const { i18n } = useLingui();
const { theme } = useContext(ThemeContext);
const shouldReduceMotion = useReducedMotion();
const messages = WORKSPACE_ACTIVATION_MESSAGES.map((message) =>
i18n._(message),
);
return (
<>
<OnboardingPulsingLogo />
<StyledStepsContainer>
{messages.map((message, index) => {
const stepOffset = index - messageIndex;
const isVisible = stepOffset >= 0 && stepOffset < VISIBLE_STEP_COUNT;
return (
<StyledStep
key={message}
initial={false}
animate={{
opacity: isVisible ? STEP_OPACITIES[stepOffset] : 0,
y: stepOffset * STEP_HEIGHT_IN_PX,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}
}
>
<SubTitle>{message}</SubTitle>
</StyledStep>
);
})}
</StyledStepsContainer>
</>
);
};
@@ -1,28 +0,0 @@
import { SignInUpWorkspaceActivationV2 } from '@/auth/sign-in-up/components/SignInUpWorkspaceActivationV2';
import { SignInUpWorkspaceActivationV2Effect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect';
import { styled } from '@linaria/react';
import { useState } from 'react';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
min-height: 100%;
width: 100%;
`;
export const SignInUpWorkspaceCreationLoader = () => {
const [messageIndex, setMessageIndex] = useState(0);
return (
<StyledContainer>
<SignInUpWorkspaceActivationV2Effect
messageIndex={messageIndex}
setMessageIndex={setMessageIndex}
/>
<SignInUpWorkspaceActivationV2 messageIndex={messageIndex} />
</StyledContainer>
);
};
@@ -1,38 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { SignInUpWorkspaceActivationV2 } from '@/auth/sign-in-up/components/SignInUpWorkspaceActivationV2';
import { SignInUpWorkspaceActivationV2Effect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect';
import { useState } from 'react';
import { ModalContent } from 'twenty-ui/surfaces';
import { ComponentDecorator } from 'twenty-ui/testing';
const RenderWithModalContent = () => {
const [messageIndex, setMessageIndex] = useState(0);
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<SignInUpWorkspaceActivationV2Effect
messageIndex={messageIndex}
setMessageIndex={setMessageIndex}
/>
<SignInUpWorkspaceActivationV2 messageIndex={messageIndex} />
</ModalContent>
);
};
const meta: Meta<typeof SignInUpWorkspaceActivationV2> = {
title: 'Modules/Auth/SignInUpWorkspaceActivationV2',
component: SignInUpWorkspaceActivationV2,
decorators: [ComponentDecorator],
parameters: {
codeSection: {
docs: 'This component should always be wrapped with ModalContent in the app.\n\nCorrect usage:\n```tsx\n<ModalContent isVerticallyCentered isHorizontallyCentered>\n <SignInUpWorkspaceActivationV2 />\n</ModalContent>\n```\n',
},
},
render: RenderWithModalContent,
};
export default meta;
type Story = StoryObj<typeof SignInUpWorkspaceActivationV2>;
export const Default: Story = {};
@@ -5,15 +5,16 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPillContainer = styled.span`
position: absolute;
right: calc(-1 * ${themeCssVariables.spacing[5]});
top: calc(-1 * ${themeCssVariables.spacing[2]});
right: -14px;
top: -10px;
> span {
background: ${themeCssVariables.color.blue3};
border: 1px solid ${themeCssVariables.color.blue5};
border-radius: ${themeCssVariables.border.radius.pill};
color: ${themeCssVariables.color.blue};
background: ${themeCssVariables.accent.accent3};
border: 1px solid ${themeCssVariables.accent.accent5};
border-radius: ${themeCssVariables.border.radius.md};
color: ${themeCssVariables.accent.accent9};
font-weight: ${themeCssVariables.font.weight.semiBold};
height: ${themeCssVariables.spacing[5]};
}
`;
@@ -17,13 +17,11 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { OTPInput, type SlotProps } from 'input-otp';
import { useState } from 'react';
import { Controller } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -183,7 +181,6 @@ export const SignInUpTOTPVerification = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const navigate = useNavigateApp();
const location = useLocation();
const { readCaptchaToken } = useReadCaptchaToken();
const { isCaptchaReady } = useCaptcha();
const loginToken = useAtomStateValue(loginTokenState);
@@ -206,11 +203,7 @@ export const SignInUpTOTPVerification = () => {
const captchaToken = readCaptchaToken();
if (!loginToken) {
return navigate(
isMatchingLocation(location, AppPath.SignInUpV2)
? AppPath.SignInUpV2
: AppPath.SignInUp,
);
return navigate(AppPath.SignInUp);
}
await getAuthTokensFromOTP(values.otp, loginToken, captchaToken);
@@ -1,31 +0,0 @@
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { type Dispatch, type SetStateAction, useEffect } from 'react';
const MESSAGE_INTERVAL_IN_MS = 1000;
type SignInUpWorkspaceActivationV2EffectProps = {
messageIndex: number;
setMessageIndex: Dispatch<SetStateAction<number>>;
};
export const SignInUpWorkspaceActivationV2Effect = ({
messageIndex,
setMessageIndex,
}: SignInUpWorkspaceActivationV2EffectProps) => {
useEffect(() => {
const isLastMessage =
messageIndex >= WORKSPACE_ACTIVATION_MESSAGES.length - 1;
if (isLastMessage) {
return;
}
const timeout = setTimeout(() => {
setMessageIndex((previousIndex) => previousIndex + 1);
}, MESSAGE_INTERVAL_IN_MS);
return () => clearTimeout(timeout);
}, [messageIndex, setMessageIndex]);
return <></>;
};
@@ -1,51 +1,137 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { OnboardingAnimatedReveal } from '@/onboarding/components/OnboardingAnimatedReveal';
import { OnboardingStepAnimatedItem } from '@/onboarding/components/OnboardingStepAnimatedItem';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { Avatar } from 'twenty-ui/data-display';
import { IconTrash, IconUpload } from 'twenty-ui/icon';
import { Button, LightIconButton, MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSection = styled.div`
margin-top: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[6]};
width: 100%;
`;
const StyledAvailableHint = styled.div`
color: ${themeCssVariables.color.green};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.regular};
margin-top: ${themeCssVariables.spacing[0.5]};
`;
const StyledUnavailableHint = styled.div`
color: ${themeCssVariables.color.red};
const StyledContentContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[14]};
max-width: 100%;
width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
`;
const StyledHeading = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
line-height: 1.2;
`;
const StyledSubtitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.4;
`;
const StyledFormSection = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[8]};
padding-bottom: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledLogoRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledLogoAvatar = styled(Avatar)`
height: ${themeCssVariables.spacing[8]};
width: ${themeCssVariables.spacing[8]};
`;
const StyledLogoButtons = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledHiddenFileInput = styled.input`
display: none;
`;
const StyledSubdomainSection = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledAlternativesBox = styled.div`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledAlternativesLabel = styled.span`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
padding-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledAlternativeRows = styled.div`
display: flex;
flex-direction: column;
font-size: ${themeCssVariables.font.size.xs};
gap: ${themeCssVariables.spacing[1]};
margin-top: ${themeCssVariables.spacing[0.5]};
`;
const StyledAlternativeRow = styled.button`
align-items: center;
background: transparent;
border: none;
color: ${themeCssVariables.color.green};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[1]};
padding: 2px 0;
text-align: left;
`;
const StyledAvailabilityDotBox = styled.div`
display: flex;
padding: ${themeCssVariables.spacing[1]};
`;
const StyledAvailabilityDot = styled.div`
background-color: ${themeCssVariables.color.green};
border-radius: 50%;
box-shadow: 0 0 0 3px ${themeCssVariables.color.green5};
flex-shrink: 0;
height: 6px;
width: 6px;
`;
export const SignInUpWorkspaceCreationForm = () => {
@@ -56,33 +142,37 @@ export const SignInUpWorkspaceCreationForm = () => {
isMultiWorkspaceEnabledState,
);
const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State);
const [isSubmitting, setIsSubmitting] = useState(false);
const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState);
const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
undefined,
);
const hiddenFileInputRef = useRef<HTMLInputElement>(null);
const {
workspaceName,
subdomain,
status,
errorMessage,
suggestion,
suggestions,
isAvailable,
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
applySuggestionValue,
} = useWorkspaceSubdomainField({
isSubdomainEnabled: isMultiWorkspaceEnabled,
});
const isContinueDisabled =
workspaceName.trim() === '' ||
isSubmitting ||
isCreatingWorkspace ||
(isMultiWorkspaceEnabled && !isAvailable);
const openFilePicker = () => {
hiddenFileInputRef.current?.click();
};
const handleLogoUpload = (file: File) => {
if (!isDefined(file)) {
return;
@@ -111,16 +201,16 @@ export const SignInUpWorkspaceCreationForm = () => {
return;
}
setIsSubmitting(true);
setIsOnboardingV2(false);
try {
await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
} finally {
setIsSubmitting(false);
setIsCreatingWorkspace(true);
const isWorkspaceCreated = await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
if (!isWorkspaceCreated) {
setIsCreatingWorkspace(false);
}
};
@@ -144,75 +234,122 @@ export const SignInUpWorkspaceCreationForm = () => {
: undefined;
return (
<StyledOnboardingContentContainer>
<SubTitle>
{isMultiWorkspaceEnabled
? t`Pick a name and a web address for your new workspace.`
: t`Pick a name and a logo for your new workspace.`}
</SubTitle>
<StyledSection>
<InputLabel>{t`Workspace logo`}</InputLabel>
<ImageInput
picture={logoPreviewUrl}
onUpload={handleLogoUpload}
onRemove={handleLogoRemove}
/>
</StyledSection>
<StyledSection>
<TextInput
autoFocus
label={t`Workspace name`}
value={workspaceName}
placeholder={t`Apple`}
onChange={handleWorkspaceNameChange}
onKeyDown={handleKeyDown}
fullWidth
/>
</StyledSection>
{isMultiWorkspaceEnabled && (
<StyledSection>
<StyledContentContainer>
<StyledHeading>
<OnboardingStepAnimatedItem index={0}>
<StyledTitle>{t`Create your workspace`}</StyledTitle>
</OnboardingStepAnimatedItem>
<OnboardingStepAnimatedItem index={1}>
<StyledSubtitle>
{t`Move work forward across teams and agents`}
</StyledSubtitle>
</OnboardingStepAnimatedItem>
</StyledHeading>
<StyledFormSection>
<OnboardingStepAnimatedItem index={2}>
<StyledLogoRow>
<StyledLogoAvatar
avatarUrl={logoPreviewUrl}
placeholder={
isNonEmptyString(workspaceName) ? workspaceName : '?'
}
placeholderColorSeed={workspaceName}
type="squared"
size="xl"
onClick={openFilePicker}
/>
<StyledHiddenFileInput
type="file"
ref={hiddenFileInputRef}
accept="image/jpeg, image/png, image/gif"
onChange={(event) => {
const file = event.target.files?.[0];
if (isDefined(file)) {
handleLogoUpload(file);
}
event.target.value = '';
}}
/>
<StyledLogoButtons>
<Button
Icon={IconUpload}
title={t`Upload logo`}
variant="secondary"
onClick={openFilePicker}
/>
<LightIconButton
Icon={IconTrash}
accent="tertiary"
size="medium"
onClick={handleLogoRemove}
disabled={!isDefined(logoPreviewUrl)}
aria-label={t`Remove logo`}
/>
</StyledLogoButtons>
</StyledLogoRow>
</OnboardingStepAnimatedItem>
<OnboardingStepAnimatedItem index={3}>
<TextInput
label={t`Workspace address`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
autoFocus
label={t`Name`}
value={workspaceName}
placeholder={t`Apple`}
onChange={handleWorkspaceNameChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
{status === 'checking' && <InputHint>{t`Checking…`}</InputHint>}
{status === 'available' && (
<StyledAvailableHint>
{t`This address is available`}
</StyledAvailableHint>
)}
{status === 'unavailable' && (
<StyledUnavailableHint>
{subdomainError}
{isDefined(suggestion) && (
<ClickToActionLink onClick={applySuggestion}>
{t`Use ${suggestion} instead`}
</ClickToActionLink>
)}
</StyledUnavailableHint>
)}
</StyledSection>
)}
<StyledButtonContainer>
</OnboardingStepAnimatedItem>
{isMultiWorkspaceEnabled && (
<OnboardingStepAnimatedItem index={4}>
<StyledSubdomainSection>
<TextInput
label={t`Subdomain`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
<OnboardingAnimatedReveal isVisible={status === 'unavailable'}>
<StyledAlternativesBox>
<StyledAlternativesLabel>
{t`Subdomain already in use, here are some alternatives:`}
</StyledAlternativesLabel>
<StyledAlternativeRows>
{suggestions.map((alternative) => (
<StyledAlternativeRow
key={alternative}
type="button"
onClick={() => applySuggestionValue(alternative)}
>
<StyledAvailabilityDotBox>
<StyledAvailabilityDot />
</StyledAvailabilityDotBox>
{alternative}
</StyledAlternativeRow>
))}
</StyledAlternativeRows>
</StyledAlternativesBox>
</OnboardingAnimatedReveal>
</StyledSubdomainSection>
</OnboardingStepAnimatedItem>
)}
</StyledFormSection>
<OnboardingStepAnimatedItem index={isMultiWorkspaceEnabled ? 5 : 4}>
<MainButton
title={t`Continue`}
title={t`Create workspace`}
onClick={handleSubmit}
disabled={isContinueDisabled}
Icon={() => (isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledButtonContainer>
</StyledOnboardingContentContainer>
</OnboardingStepAnimatedItem>
</StyledContentContainer>
);
};
@@ -1,297 +0,0 @@
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { IconTrash, IconUpload } from 'twenty-ui/icon';
import { Button, LightIconButton, MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHeading = styled.div`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const StyledTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledSubtitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
margin-top: ${themeCssVariables.spacing[1]};
`;
const StyledSection = styled.div`
margin-top: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledLogoRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledHiddenFileInput = styled.input`
display: none;
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[6]};
width: 100%;
`;
const StyledAlternativesBox = styled.div`
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
margin-top: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledAlternativesLabel = styled.span`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.xs};
`;
const StyledAlternativeRow = styled.button`
align-items: center;
background: transparent;
border: none;
color: ${themeCssVariables.color.green};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
padding: 0;
text-align: left;
`;
const StyledAvailabilityDot = styled.div`
background-color: ${themeCssVariables.color.green};
border-radius: 50%;
flex-shrink: 0;
height: 6px;
width: 6px;
`;
export const SignInUpWorkspaceCreationFormV2 = () => {
const { t } = useLingui();
const { createWorkspace } = useSignUpInNewWorkspace();
const { frontDomain } = useAtomStateValue(domainConfigurationState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState);
const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState);
const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
undefined,
);
const hiddenFileInputRef = useRef<HTMLInputElement>(null);
const {
workspaceName,
subdomain,
status,
errorMessage,
suggestions,
isAvailable,
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestionValue,
} = useWorkspaceSubdomainField({
isSubdomainEnabled: isMultiWorkspaceEnabled,
});
const isContinueDisabled =
workspaceName.trim() === '' ||
isCreatingWorkspace ||
(isMultiWorkspaceEnabled && !isAvailable);
const openFilePicker = () => {
hiddenFileInputRef.current?.click();
};
const handleLogoUpload = (file: File) => {
if (!isDefined(file)) {
return;
}
setLogo(file);
setLogoPreviewUrl(URL.createObjectURL(file));
};
const handleLogoRemove = () => {
setLogo(undefined);
setLogoPreviewUrl(undefined);
};
useEffect(() => {
if (!isDefined(logoPreviewUrl)) {
return;
}
return () => {
URL.revokeObjectURL(logoPreviewUrl);
};
}, [logoPreviewUrl]);
const handleSubmit = async () => {
if (isContinueDisabled) {
return;
}
setIsCreatingWorkspace(true);
setIsOnboardingV2(true);
const isWorkspaceCreated = await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
if (!isWorkspaceCreated) {
setIsCreatingWorkspace(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) {
return;
}
if (event.key === Key.Enter) {
event.preventDefault();
handleSubmit();
}
};
const subdomainError =
status === 'invalid'
? errorMessage
: status === 'unavailable'
? t`This address is already taken`
: status === 'error'
? t`Couldn't check availability. Please try again.`
: undefined;
return (
<StyledOnboardingContentContainer>
<StyledHeading>
<StyledTitle>{t`Create your workspace`}</StyledTitle>
<StyledSubtitle>
{t`Move work forward across teams and agents`}
</StyledSubtitle>
</StyledHeading>
<StyledSection>
<StyledLogoRow>
<Avatar
avatarUrl={logoPreviewUrl}
placeholder={isNonEmptyString(workspaceName) ? workspaceName : '?'}
placeholderColorSeed={workspaceName}
type="squared"
size="xl"
onClick={openFilePicker}
/>
<StyledHiddenFileInput
type="file"
ref={hiddenFileInputRef}
accept="image/jpeg, image/png, image/gif"
onChange={(event) => {
const file = event.target.files?.[0];
if (isDefined(file)) {
handleLogoUpload(file);
}
event.target.value = '';
}}
/>
<Button
Icon={IconUpload}
title={t`Upload logo`}
variant="secondary"
onClick={openFilePicker}
/>
<LightIconButton
Icon={IconTrash}
accent="tertiary"
onClick={handleLogoRemove}
disabled={!isDefined(logoPreviewUrl)}
aria-label={t`Remove logo`}
/>
</StyledLogoRow>
</StyledSection>
<StyledSection>
<TextInput
autoFocus
label={t`Name`}
value={workspaceName}
placeholder={t`Apple`}
onChange={handleWorkspaceNameChange}
onKeyDown={handleKeyDown}
fullWidth
/>
</StyledSection>
{isMultiWorkspaceEnabled && (
<StyledSection>
<TextInput
label={t`Subdomain`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
{status === 'unavailable' && (
<StyledAlternativesBox>
<StyledAlternativesLabel>
{t`Subdomain already in use, here are some alternatives:`}
</StyledAlternativesLabel>
{suggestions.map((alternative) => (
<StyledAlternativeRow
key={alternative}
type="button"
onClick={() => applySuggestionValue(alternative)}
>
<StyledAvailabilityDot />
{alternative}
</StyledAlternativeRow>
))}
</StyledAlternativesBox>
)}
</StyledSection>
)}
<StyledButtonContainer>
<MainButton
title={t`Create workspace`}
onClick={handleSubmit}
disabled={isContinueDisabled}
fullWidth
/>
</StyledButtonContainer>
</StyledOnboardingContentContainer>
);
};
@@ -6,6 +6,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
@@ -14,7 +15,7 @@ import {
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
const createWorkspaceMock = jest.fn();
const applySuggestionMock = jest.fn();
const applySuggestionValueMock = jest.fn();
const handleSubdomainChangeMock = jest.fn();
const handleWorkspaceNameChangeMock = jest.fn();
const useWorkspaceSubdomainFieldMock = jest.fn();
@@ -56,11 +57,11 @@ describe('SignInUpWorkspaceCreationForm', () => {
subdomain: 'apple',
status: 'available',
errorMessage: undefined,
suggestion: undefined,
suggestions: [],
isAvailable: true,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
applySuggestionValue: applySuggestionValueMock,
});
});
@@ -69,35 +70,18 @@ describe('SignInUpWorkspaceCreationForm', () => {
setMultiWorkspaceEnabled(true);
});
it('keeps Continue disabled until a workspace name is entered', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: '',
subdomain: '',
status: 'idle',
errorMessage: undefined,
suggestion: undefined,
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
it('creates the workspace with the chosen name and subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
const createButton = screen.getByRole('button', {
name: 'Create workspace',
});
renderForm();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
it('creates the workspace with the chosen name and address in the same tab', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
expect(createButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
fireEvent.click(createButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
@@ -107,61 +91,73 @@ describe('SignInUpWorkspaceCreationForm', () => {
});
});
it('passes the picked logo file when creating the workspace', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
it('keeps the loader on through a successful creation, until the redirect', async () => {
let resolveCreateWorkspace: () => void = () => {};
createWorkspaceMock.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCreateWorkspace = () => resolve(true);
}),
);
const { container } = renderForm();
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const logoFile = new File(['logo'], 'logo.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [logoFile] } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: logoFile,
});
});
it('routes name edits back through the field hook', () => {
renderForm();
fireEvent.change(screen.getByLabelText('Workspace name'), {
target: { value: 'Acme' },
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(handleWorkspaceNameChangeMock).toHaveBeenCalledWith('Acme');
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
expect(createWorkspaceMock).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreateWorkspace();
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
});
it('offers a one-click suggestion when the address is taken', () => {
it('returns to the form when workspace creation fails', async () => {
createWorkspaceMock.mockResolvedValue(false);
renderForm();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(false);
});
it('lists available alternatives and applies the picked one when the subdomain is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
workspaceName: 'Stripe',
subdomain: 'stripe',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
suggestions: ['stripe-2', 'mystripe', 'stripeeinc'],
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
applySuggestionValue: applySuggestionValueMock,
});
renderForm();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
expect(
screen.getByText(
'Subdomain already in use, here are some alternatives:',
),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Create workspace' }),
).toBeDisabled();
fireEvent.click(screen.getByText('Use apple-2 instead'));
fireEvent.click(screen.getByRole('button', { name: 'mystripe' }));
expect(applySuggestionMock).toHaveBeenCalledTimes(1);
expect(applySuggestionValueMock).toHaveBeenCalledWith('mystripe');
});
});
@@ -170,40 +166,18 @@ describe('SignInUpWorkspaceCreationForm', () => {
setMultiWorkspaceEnabled(false);
});
it('hides the workspace address field', () => {
renderForm();
expect(screen.getByLabelText('Workspace name')).toBeInTheDocument();
expect(
screen.queryByLabelText('Workspace address'),
).not.toBeInTheDocument();
});
it('enables Continue based on the name only and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
// An unavailable subdomain status must not block submission when the
// address field is hidden.
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
it('hides the subdomain field and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.queryByLabelText('Subdomain')).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(continueButton);
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
@@ -1,192 +0,0 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { SignInUpWorkspaceCreationFormV2 } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
const createWorkspaceMock = jest.fn();
const applySuggestionValueMock = jest.fn();
const handleSubdomainChangeMock = jest.fn();
const handleWorkspaceNameChangeMock = jest.fn();
const useWorkspaceSubdomainFieldMock = jest.fn();
jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({
useSignUpInNewWorkspace: () => ({ createWorkspace: createWorkspaceMock }),
}));
jest.mock('@/auth/sign-in-up/hooks/useWorkspaceSubdomainField', () => ({
useWorkspaceSubdomainField: () => useWorkspaceSubdomainFieldMock(),
}));
global.URL.createObjectURL = jest.fn(() => 'blob:logo-preview');
global.URL.revokeObjectURL = jest.fn();
dynamicActivate(SOURCE_LOCALE);
const setMultiWorkspaceEnabled = (isEnabled: boolean) => {
jotaiStore.set(isMultiWorkspaceEnabledState.atom, isEnabled);
};
const renderForm = () =>
render(
<JotaiProvider store={jotaiStore}>
<ThemeProvider colorScheme="light">
<I18nProvider i18n={i18n}>
<SignInUpWorkspaceCreationFormV2 />
</I18nProvider>
</ThemeProvider>
</JotaiProvider>,
);
describe('SignInUpWorkspaceCreationFormV2', () => {
beforeEach(() => {
jest.clearAllMocks();
resetJotaiStore();
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'available',
errorMessage: undefined,
suggestions: [],
isAvailable: true,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestionValue: applySuggestionValueMock,
});
});
describe('multi-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(true);
});
it('creates the workspace with the chosen name and subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
const createButton = screen.getByRole('button', {
name: 'Create workspace',
});
expect(createButton).toBeEnabled();
await act(async () => {
fireEvent.click(createButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: undefined,
});
});
it('keeps the loader on through a successful creation, until the redirect', async () => {
let resolveCreateWorkspace: () => void = () => {};
createWorkspaceMock.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCreateWorkspace = () => resolve(true);
}),
);
renderForm();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
expect(createWorkspaceMock).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreateWorkspace();
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
});
it('returns to the form when workspace creation fails', async () => {
createWorkspaceMock.mockResolvedValue(false);
renderForm();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(false);
});
it('lists available alternatives and applies the picked one when the subdomain is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Stripe',
subdomain: 'stripe',
status: 'unavailable',
errorMessage: undefined,
suggestions: ['stripe-2', 'mystripe', 'stripeeinc'],
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestionValue: applySuggestionValueMock,
});
renderForm();
expect(
screen.getByText(
'Subdomain already in use, here are some alternatives:',
),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Create workspace' }),
).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'mystripe' }));
expect(applySuggestionValueMock).toHaveBeenCalledWith('mystripe');
});
});
describe('single-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(false);
});
it('hides the subdomain field and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.queryByLabelText('Subdomain')).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
logo: undefined,
});
expect(createWorkspaceMock.mock.calls[0][0]).not.toHaveProperty(
'subdomain',
);
});
});
});
@@ -140,7 +140,6 @@ describe('useWorkspaceSubdomainField', () => {
'taken-3',
'taken-4',
]);
expect(result.current.suggestion).toBe('taken-2');
expect(result.current.isAvailable).toBe(false);
});
@@ -1,5 +1,4 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -14,7 +13,6 @@ import {
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { useLingui } from '@lingui/react/macro';
import { useStore } from 'jotai';
export const useSignUpInNewWorkspace = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
@@ -22,7 +20,6 @@ export const useSignUpInNewWorkspace = () => {
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const store = useStore();
const { enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
@@ -76,11 +73,9 @@ export const useSignUpInNewWorkspace = () => {
return true;
}
const isOnboardingV2 = store.get(isOnboardingV2State.atom);
await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
isOnboardingV2 ? AppPath.VerifyV2 : AppPath.Verify,
AppPath.Verify,
{ loginToken },
'_self',
);
@@ -166,27 +166,15 @@ export const useWorkspaceSubdomainField = ({
debouncedAvailabilityCheck(value, { adoptSuggestion: false });
};
const suggestion: string | undefined = suggestions[0];
const applySuggestion = () => {
if (!isDefined(suggestion)) {
return;
}
applySuggestionValue(suggestion);
};
return {
workspaceName,
subdomain,
status,
errorMessage,
suggestion,
suggestions,
isAvailable: status === 'available',
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
applySuggestionValue,
};
};
@@ -1,49 +0,0 @@
import { createStore } from 'jotai';
import { type isOnboardingV2State as IsOnboardingV2State } from '@/auth/states/isOnboardingV2State';
const loadAtom = (): typeof IsOnboardingV2State => {
let state: typeof IsOnboardingV2State | undefined;
jest.isolateModules(() => {
state = require('@/auth/states/isOnboardingV2State').isOnboardingV2State;
});
if (state === undefined) {
throw new Error('Failed to load isOnboardingV2State');
}
return state;
};
describe('isOnboardingV2State', () => {
afterEach(() => {
sessionStorage.clear();
jest.resetModules();
});
it('hydrates from sessionStorage on load so the flag survives the email-connect OAuth round-trip', () => {
sessionStorage.setItem('isOnboardingV2State', JSON.stringify(true));
const isOnboardingV2State = loadAtom();
expect(createStore().get(isOnboardingV2State.atom)).toBe(true);
});
it('writes through to sessionStorage when set', () => {
const isOnboardingV2State = loadAtom();
const store = createStore();
store.set(isOnboardingV2State.atom, true);
expect(sessionStorage.getItem('isOnboardingV2State')).toBe(
JSON.stringify(true),
);
});
it('defaults to false when nothing is persisted', () => {
const isOnboardingV2State = loadAtom();
expect(createStore().get(isOnboardingV2State.atom)).toBe(false);
});
});
@@ -1,7 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isOnboardingV2State = createAtomState<boolean>({
key: 'isOnboardingV2State',
defaultValue: false,
useSessionStorage: true,
});
@@ -1,18 +0,0 @@
import { AUTH_MODAL_CONFIG } from '@/auth/constants/AuthModalConfig';
import { type Location } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const getAuthModalConfig = (location: Location) => {
for (const path of Object.values(AppPath)) {
if (
isMatchingLocation(location, path) &&
isDefined(AUTH_MODAL_CONFIG[path])
) {
return AUTH_MODAL_CONFIG[path];
}
}
return AUTH_MODAL_CONFIG.default;
};
@@ -0,0 +1,9 @@
import { matchPath } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { ONBOARDING_TRANSITION_PATHS } from '@/auth/constants/OnboardingTransitionPaths';
export const isOnOnboardingTransitionPath = (pathname: string) =>
ONBOARDING_TRANSITION_PATHS.some((onboardingPath) =>
isDefined(matchPath(onboardingPath, pathname)),
);