Add v2 onboarding loading screen while creating workspace (#22152)

https://github.com/user-attachments/assets/cc7b1d10-7495-4f21-9311-4c22c0f14771

Adds the full-screen loading screen shown while a new workspace is being
created in the v2 sign-up flow (`SignInUpV2`), building on the v2
"Create your workspace" step.

How it works:
- Submitting the v2 create-workspace form marks the flow as v2
(`isOnboardingV2State`) and creates the workspace. The flag is carried
across the cross-subdomain redirect with an `onboardingV2=true` URL
param, so v2 users land on a new `/workspace-activation-v2` route
instead of v1's `/workspace-activation`.
- `WorkspaceActivationV2` runs the real `activateWorkspace` mutation on
mount and renders the loader: a pulsing Twenty logomark above a stack of
status messages that shift up one at a time, cycling once per second.
There is no faked/minimum duration; it advances to the next onboarding
step as soon as the workspace is activated.
- On activation failure it shows a "Workspace creation failed" screen
with a Retry button.

v1 onboarding is unchanged. Storybook:
`Modules/Auth/SignInUpWorkspaceActivationV2`.

Note: The flashes will be fixed in later PRs

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22152?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. -->
This commit is contained in:
Raphaël Bosi
2026-06-26 10:46:34 +02:00
committed by GitHub
parent ce4d0f3447
commit cb49a7a053
24 changed files with 508 additions and 24 deletions
@@ -85,6 +85,7 @@ const setupMockState = (
calendarBookingPageId?: string | null,
returnToPath?: string,
currentWorkspace: object | null = { id: 'mock-workspace-id' },
isOnboardingV2 = false,
) => {
jest
.mocked(useAtomStateValue)
@@ -92,7 +93,8 @@ const setupMockState = (
.mockReturnValueOnce(calendarBookingPageId ?? 'mock-calendar-id')
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
.mockReturnValueOnce(verifyEmailRedirectPath)
.mockReturnValueOnce(returnToPath ?? '');
.mockReturnValueOnce(returnToPath ?? '')
.mockReturnValueOnce(isOnboardingV2);
};
// prettier-ignore
@@ -454,3 +456,37 @@ describe('usePageChangeEffectNavigateLocation — authenticated with no current
},
);
});
describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
const setupWorkspaceActivationV2Case = (loc: AppPath) => {
setupMockIsMatchingLocation(loc);
setupMockOnboardingStatus(OnboardingStatus.WORKSPACE_ACTIVATION);
setupMockIsWorkspaceActivationStatusEqualsTo(false);
setupMockHasAccessTokenPair(true);
setupMockIsOnAWorkspace(true);
setupMockUseQuery();
setupMockUseParams();
setupMockState(
undefined,
undefined,
undefined,
undefined,
{ id: 'mock-workspace-id' },
true,
);
};
it('routes to WorkspaceActivationV2 when onboardingV2 is active and status is WORKSPACE_ACTIVATION', () => {
setupWorkspaceActivationV2Case(AppPath.SignInUpV2);
expect(usePageChangeEffectNavigateLocation()).toEqual(
AppPath.WorkspaceActivationV2,
);
});
it('does not redirect away from the WorkspaceActivationV2 page during activation', () => {
setupWorkspaceActivationV2Case(AppPath.WorkspaceActivationV2);
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
});
@@ -3,6 +3,7 @@ import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
@@ -76,6 +77,8 @@ export const usePageChangeEffectNavigateLocation = () => {
? returnToPath
: readReturnToPathFromUrlSearchParams();
const isOnboardingV2 = useAtomStateValue(isOnboardingV2State);
if (
(!hasAccessTokenPair || !isOnAWorkspace || !isDefined(currentWorkspace)) &&
!someMatchingLocationOf([
@@ -118,11 +121,14 @@ export const usePageChangeEffectNavigateLocation = () => {
onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION &&
!someMatchingLocationOf([
AppPath.WorkspaceActivation,
AppPath.WorkspaceActivationV2,
AppPath.BookCallDecision,
AppPath.BookCall,
])
) {
return AppPath.WorkspaceActivation;
return isOnboardingV2
? AppPath.WorkspaceActivationV2
: AppPath.WorkspaceActivation;
}
if (
@@ -302,7 +302,8 @@ export const PageChangeEffect = () => {
});
break;
}
case isMatchingLocation(location, AppPath.WorkspaceActivation): {
case isMatchingLocation(location, AppPath.WorkspaceActivation):
case isMatchingLocation(location, AppPath.WorkspaceActivationV2): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.WorkspaceActivation,
@@ -60,6 +60,12 @@ const WorkspaceActivation = lazy(() =>
})),
);
const WorkspaceActivationV2 = lazy(() =>
import('~/pages/onboarding/WorkspaceActivationV2').then((module) => ({
default: module.WorkspaceActivationV2,
})),
);
const CreateProfile = lazy(() =>
import('~/pages/onboarding/CreateProfile').then((module) => ({
default: module.CreateProfile,
@@ -271,6 +277,14 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route
path={AppPath.WorkspaceActivationV2}
element={
<LazyRoute fallback={null}>
<WorkspaceActivationV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.Authorize}
element={
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
@@ -50,6 +51,11 @@ export const useInitializeQueryParamState = () => {
store.set(returnToPathState.atom, value);
}
},
onboardingV2: (value: string) => {
if (value === 'true') {
store.set(isOnboardingV2State.atom, true);
}
},
};
const queryParams = new URLSearchParams(window.location.search);
@@ -2,6 +2,7 @@ import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_PATHS = [
AppPath.WorkspaceActivation,
AppPath.WorkspaceActivationV2,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InviteTeam,
@@ -0,0 +1,98 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { motion, useReducedMotion } from 'framer-motion';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } 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 StyledLogo = styled.img`
animation: signInUpWorkspaceActivationLogoPulse 0.8s ease-in-out infinite
alternate;
height: ${themeCssVariables.spacing[12]};
margin-bottom: ${themeCssVariables.spacing[8]};
width: ${themeCssVariables.spacing[12]};
@keyframes signInUpWorkspaceActivationLogoPulse {
from {
opacity: 1;
}
to {
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
}
`;
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 (
<>
<StyledLogo src="/images/integrations/twenty-logo.svg" alt="" />
<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>
</>
);
};
@@ -0,0 +1,28 @@
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>
);
};
@@ -0,0 +1,38 @@
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 = {};
@@ -0,0 +1,31 @@
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 <></>;
};
@@ -2,6 +2,7 @@ import { SubTitle } from '@/auth/components/SubTitle';
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 { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { ImageInput } from '@/ui/input/components/ImageInput';
@@ -9,6 +10,7 @@ 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 { isNonEmptyString } from '@sniptt/guards';
@@ -54,6 +56,8 @@ export const SignInUpWorkspaceCreationForm = () => {
isMultiWorkspaceEnabledState,
);
const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State);
const [isSubmitting, setIsSubmitting] = useState(false);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
@@ -108,6 +112,7 @@ export const SignInUpWorkspaceCreationForm = () => {
}
setIsSubmitting(true);
setIsOnboardingV2(false);
try {
await createWorkspace({
displayName: workspaceName.trim(),
@@ -1,18 +1,20 @@
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 { useLingui } from '@lingui/react/macro';
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 { Loader } from 'twenty-ui/feedback';
import { IconTrash, IconUpload } from 'twenty-ui/icon';
import { Button, LightIconButton, MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -97,7 +99,9 @@ export const SignInUpWorkspaceCreationFormV2 = () => {
isMultiWorkspaceEnabledState,
);
const [isSubmitting, setIsSubmitting] = useState(false);
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,
@@ -120,7 +124,7 @@ export const SignInUpWorkspaceCreationFormV2 = () => {
const isContinueDisabled =
workspaceName.trim() === '' ||
isSubmitting ||
isCreatingWorkspace ||
(isMultiWorkspaceEnabled && !isAvailable);
const openFilePicker = () => {
@@ -155,15 +159,17 @@ export const SignInUpWorkspaceCreationFormV2 = () => {
return;
}
setIsSubmitting(true);
try {
await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
} finally {
setIsSubmitting(false);
setIsCreatingWorkspace(true);
setIsOnboardingV2(true);
const isWorkspaceCreated = await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
if (!isWorkspaceCreated) {
setIsCreatingWorkspace(false);
}
};
@@ -283,7 +289,6 @@ export const SignInUpWorkspaceCreationFormV2 = () => {
title={t`Create workspace`}
onClick={handleSubmit}
disabled={isContinueDisabled}
Icon={() => (isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledButtonContainer>
@@ -6,6 +6,7 @@ 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,
@@ -70,7 +71,7 @@ describe('SignInUpWorkspaceCreationFormV2', () => {
});
it('creates the workspace with the chosen name and subdomain', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
createWorkspaceMock.mockResolvedValue(true);
renderForm();
@@ -90,6 +91,46 @@ describe('SignInUpWorkspaceCreationFormV2', () => {
});
});
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',
@@ -126,7 +167,7 @@ describe('SignInUpWorkspaceCreationFormV2', () => {
});
it('hides the subdomain field and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
createWorkspaceMock.mockResolvedValue(true);
renderForm();
@@ -0,0 +1,8 @@
import { msg } from '@lingui/core/macro';
export const WORKSPACE_ACTIVATION_MESSAGES = [
msg`Creating your workspace...`,
msg`Setting up your database...`,
msg`Creating your data model...`,
msg`Prefilling your workspace data...`,
];
@@ -38,7 +38,7 @@ export const useSignUpInNewWorkspace = () => {
displayName?: string;
subdomain?: string;
logo?: File;
} = {}) => {
} = {}): Promise<boolean> => {
try {
const { data } = await signUpInNewWorkspaceMutation({
variables: { input: { displayName, subdomain } },
@@ -69,15 +69,18 @@ export const useSignUpInNewWorkspace = () => {
const loginToken = data.signUpInNewWorkspace.loginToken.token;
if (!isMultiWorkspaceEnabled) {
return await getAuthTokensFromLoginToken(loginToken);
await getAuthTokensFromLoginToken(loginToken);
return true;
}
return await redirectToWorkspaceDomain(
await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{ loginToken },
'_self',
);
return true;
} catch (error) {
enqueueErrorSnackBar(
CombinedGraphQLErrors.is(error)
@@ -89,6 +92,8 @@ export const useSignUpInNewWorkspace = () => {
: t`Workspace creation failed`,
},
);
return false;
}
};
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isCreatingWorkspaceState = createAtomState<boolean>({
key: 'isCreatingWorkspaceState',
defaultValue: false,
});
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isOnboardingV2State = createAtomState<boolean>({
key: 'isOnboardingV2State',
defaultValue: false,
});
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue';
import { isNonEmptyString } from '@sniptt/guards';
@@ -11,6 +12,7 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => {
const buildSearchParamsFromUrlSyncedStates = useCallback(async () => {
const billingCheckoutSession = store.get(billingCheckoutSessionState.atom);
const returnToPath = store.get(returnToPathState.atom);
const isOnboardingV2 = store.get(isOnboardingV2State.atom);
const output = {
...(billingCheckoutSession !== BILLING_CHECKOUT_SESSION_DEFAULT_VALUE
@@ -19,6 +21,7 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => {
}
: {}),
...(isNonEmptyString(returnToPath) ? { returnToPath } : {}),
...(isOnboardingV2 ? { onboardingV2: 'true' } : {}),
};
return output;
@@ -23,6 +23,7 @@ export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => {
isMatchingLocation(location, AppPath.Invite) ||
isMatchingLocation(location, AppPath.ResetPassword) ||
isMatchingLocation(location, AppPath.WorkspaceActivation) ||
isMatchingLocation(location, AppPath.WorkspaceActivationV2) ||
isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) ||
isMatchingLocation(location, AppPath.Authorize);
@@ -1,5 +1,6 @@
import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import {
SignInUpStep,
signInUpStepState,
@@ -57,6 +58,7 @@ export const SignInUpV2 = () => {
const { t } = useLingui();
const setSignInUpStep = useSetAtomState(signInUpStepState);
const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState);
const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState);
const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form);
@@ -194,7 +196,9 @@ export const SignInUpV2 = () => {
</ModalContent>
) : signInUpStep === SignInUpStep.WorkspaceCreation ? (
<>
<SignInUpV2Header onBack={onClickOnLogo} />
{!isCreatingWorkspace ? (
<SignInUpV2Header onBack={onClickOnLogo} />
) : null}
<ModalContent isVerticallyCentered isHorizontallyCentered>
{signInUpForm}
</ModalContent>
@@ -0,0 +1,135 @@
import { styled } from '@linaria/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { SignInUpWorkspaceCreationLoader } from '@/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
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 { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ActivateWorkspaceDocument } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
align-items: center;
background: ${themeCssVariables.background.primary};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
width: 200px;
`;
export const WorkspaceActivationV2 = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const { loadCurrentUser } = useLoadCurrentUser();
const [activateWorkspace, { loading: isActivating }] = useMutation(
ActivateWorkspaceDocument,
);
const [hasFailed, setHasFailed] = useState(false);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState);
const activate = useCallback(async () => {
setHasFailed(false);
try {
const result = await activateWorkspace({
variables: {
input: {},
},
});
if (isDefined(result.error)) {
throw result.error;
}
await loadCurrentUser();
setIsCreatingWorkspace(false);
setNextOnboardingStatus();
} catch (error) {
setHasFailed(true);
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
}, [
activateWorkspace,
enqueueErrorSnackBar,
loadCurrentUser,
setIsCreatingWorkspace,
setNextOnboardingStatus,
]);
// Guard the one-shot trigger with a ref, not state: a ref mutation is
// synchronous and survives StrictMode's double-invocation of effects, whereas
// a state flag stays false in the second (same-closure) invocation and fires
// a second concurrent activation that trips the server's lock.
// oxlint-disable-next-line twenty/no-state-useref
const hasTriggeredRef = useRef(false);
useEffect(() => {
if (hasTriggeredRef.current || !isDefined(currentWorkspace)) {
return;
}
hasTriggeredRef.current = true;
void activate();
}, [activate, currentWorkspace]);
if (!hasFailed) {
return (
<StyledContainer>
<SignInUpWorkspaceCreationLoader />
</StyledContainer>
);
}
return (
<StyledContainer>
<Logo
primaryLogo={
isNonEmptyString(currentWorkspace?.logo)
? currentWorkspace?.logo
: undefined
}
/>
<Title>
<Trans>Workspace creation failed</Trans>
</Title>
<SubTitle>
<Trans>
Something went wrong while creating your workspace. Please try again.
</Trans>
</SubTitle>
<StyledButtonContainer>
<MainButton
title={t`Retry`}
onClick={() => {
void activate();
}}
disabled={isActivating}
fullWidth
/>
</StyledButtonContainer>
</StyledContainer>
);
};
@@ -1,3 +1,7 @@
import { AppPath } from 'twenty-shared/types';
export const UNTESTED_APP_PATHS = [AppPath.Settings, AppPath.Developers];
export const UNTESTED_APP_PATHS = [
AppPath.Settings,
AppPath.Developers,
AppPath.WorkspaceActivationV2,
];
@@ -34,6 +34,7 @@ export const getPageTitleFromPath = (pathname: string): string => {
case AppPath.Invite:
return t`Invite`;
case AppPath.WorkspaceActivation:
case AppPath.WorkspaceActivationV2:
return t`Create Workspace`;
case AppPath.CreateProfile:
return t`Create Profile`;
@@ -9,6 +9,7 @@ export enum AppPath {
// Onboarding
WorkspaceActivation = '/workspace-activation',
WorkspaceActivationV2 = '/workspace-activation-v2',
CreateProfile = '/create/profile',
SyncEmails = '/sync/emails',
InviteTeam = '/invite-team',