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:
@@ -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]);
|
||||
|
||||
|
||||
+11
-11
@@ -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>
|
||||
+8
-8
@@ -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(
|
||||
+72
@@ -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 {
|
||||
|
||||
+69
-48
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+23
-6
@@ -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 />
|
||||
)}
|
||||
-77
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-28
@@ -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>
|
||||
);
|
||||
};
|
||||
-38
@@ -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 = {};
|
||||
+7
-6
@@ -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]};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
+1
-8
@@ -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);
|
||||
|
||||
-31
@@ -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 <></>;
|
||||
};
|
||||
+246
-109
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-297
@@ -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>
|
||||
);
|
||||
};
|
||||
+68
-94
@@ -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({
|
||||
|
||||
-192
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-1
@@ -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)),
|
||||
);
|
||||
Reference in New Issue
Block a user