From cb49a7a053f0e244bc284a1a5f95e90aaeeaec8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Fri, 26 Jun 2026 10:46:34 +0200
Subject: [PATCH] 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
---
...sePageChangeEffectNavigateLocation.test.ts | 38 ++++-
.../usePageChangeEffectNavigateLocation.ts | 8 +-
.../effect-components/PageChangeEffect.tsx | 3 +-
.../modules/app/hooks/useCreateAppRouter.tsx | 14 ++
.../app/hooks/useInitializeQueryParamState.ts | 6 +
.../modules/auth/constants/OnboardingPaths.ts | 1 +
.../SignInUpWorkspaceActivationV2.tsx | 98 +++++++++++++
.../SignInUpWorkspaceCreationLoader.tsx | 28 ++++
.../SignInUpWorkspaceActivationV2.stories.tsx | 38 +++++
.../SignInUpWorkspaceActivationV2Effect.tsx | 31 ++++
.../SignInUpWorkspaceCreationForm.tsx | 5 +
.../SignInUpWorkspaceCreationFormV2.tsx | 33 +++--
.../SignInUpWorkspaceCreationFormV2.test.tsx | 45 +++++-
.../constants/WorkspaceActivationMessages.ts | 8 ++
.../hooks/useSignUpInNewWorkspace.ts | 11 +-
.../auth/states/isCreatingWorkspaceState.ts | 6 +
.../auth/states/isOnboardingV2State.ts | 6 +
...useBuildSearchParamsFromUrlSyncedStates.ts | 3 +
.../components/MinimalMetadataGater.tsx | 1 +
.../src/pages/auth/SignInUpV2.tsx | 6 +-
.../onboarding/WorkspaceActivationV2.tsx | 135 ++++++++++++++++++
.../src/testing/constants/UntestedAppPaths.ts | 6 +-
.../twenty-front/src/utils/title-utils.ts | 1 +
packages/twenty-shared/src/types/AppPath.ts | 1 +
24 files changed, 508 insertions(+), 24 deletions(-)
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts
create mode 100644 packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts
create mode 100644 packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts
create mode 100644 packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx
diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
index 9386ccec36..20fcbcfa50 100644
--- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
+++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
@@ -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();
+ });
+});
diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
index 5ab89f153c..fa569582d7 100644
--- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
+++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
@@ -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 (
diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
index 61c1ebe972..c6a2a7c1c3 100644
--- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
@@ -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,
diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
index 9c6af895f1..fceea53e90 100644
--- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
+++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
@@ -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 = (
}
/>
+
+
+
+ }
+ />
{
store.set(returnToPathState.atom, value);
}
},
+ onboardingV2: (value: string) => {
+ if (value === 'true') {
+ store.set(isOnboardingV2State.atom, true);
+ }
+ },
};
const queryParams = new URLSearchParams(window.location.search);
diff --git a/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts b/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts
index ad4cbf9d18..019d94699f 100644
--- a/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts
+++ b/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts
@@ -2,6 +2,7 @@ import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_PATHS = [
AppPath.WorkspaceActivation,
+ AppPath.WorkspaceActivationV2,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InviteTeam,
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx
new file mode 100644
index 0000000000..7901de5d2f
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx
@@ -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 (
+ <>
+
+
+ {messages.map((message, index) => {
+ const stepOffset = index - messageIndex;
+ const isVisible = stepOffset >= 0 && stepOffset < VISIBLE_STEP_COUNT;
+
+ return (
+
+ {message}
+
+ );
+ })}
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx
new file mode 100644
index 0000000000..9fa4928120
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx
@@ -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 (
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx
new file mode 100644
index 0000000000..e3801b9854
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx
@@ -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 (
+
+
+
+
+ );
+};
+
+const meta: Meta = {
+ 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\n \n\n```\n',
+ },
+ },
+ render: RenderWithModalContent,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx
new file mode 100644
index 0000000000..aba5a9f0de
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx
@@ -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>;
+};
+
+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 <>>;
+};
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx
index 1133622fa4..518fdd919b 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx
@@ -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(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState(
@@ -108,6 +112,7 @@ export const SignInUpWorkspaceCreationForm = () => {
}
setIsSubmitting(true);
+ setIsOnboardingV2(false);
try {
await createWorkspace({
displayName: workspaceName.trim(),
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx
index b6ac06e439..3c59b0acb6 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx
@@ -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(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState(
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 ? : null)}
fullWidth
/>
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx
index 49cf705279..8f4a4111dc 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx
@@ -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((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();
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts b/packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts
new file mode 100644
index 0000000000..c6431bd588
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts
@@ -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...`,
+];
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts
index 2269b6fae9..6fedcafcde 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts
@@ -38,7 +38,7 @@ export const useSignUpInNewWorkspace = () => {
displayName?: string;
subdomain?: string;
logo?: File;
- } = {}) => {
+ } = {}): Promise => {
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;
}
};
diff --git a/packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts
new file mode 100644
index 0000000000..e7d45884cd
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const isCreatingWorkspaceState = createAtomState({
+ key: 'isCreatingWorkspaceState',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts b/packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts
new file mode 100644
index 0000000000..434a4202c7
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const isOnboardingV2State = createAtomState({
+ key: 'isOnboardingV2State',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts
index 44a83f4da6..9564aed9a6 100644
--- a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts
+++ b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts
@@ -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;
diff --git a/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx b/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx
index 4aa2dc8ecb..bea8f30c99 100644
--- a/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx
+++ b/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx
@@ -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);
diff --git a/packages/twenty-front/src/pages/auth/SignInUpV2.tsx b/packages/twenty-front/src/pages/auth/SignInUpV2.tsx
index 0cf6812a60..8333c6fe1d 100644
--- a/packages/twenty-front/src/pages/auth/SignInUpV2.tsx
+++ b/packages/twenty-front/src/pages/auth/SignInUpV2.tsx
@@ -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 = () => {
) : signInUpStep === SignInUpStep.WorkspaceCreation ? (
<>
-
+ {!isCreatingWorkspace ? (
+
+ ) : null}
{signInUpForm}
diff --git a/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx
new file mode 100644
index 0000000000..d4c7207b90
--- /dev/null
+++ b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Workspace creation failed
+
+
+
+ Something went wrong while creating your workspace. Please try again.
+
+
+
+ {
+ void activate();
+ }}
+ disabled={isActivating}
+ fullWidth
+ />
+
+
+ );
+};
diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts
index e9392c4f9b..b3aa851250 100644
--- a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts
+++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts
@@ -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,
+];
diff --git a/packages/twenty-front/src/utils/title-utils.ts b/packages/twenty-front/src/utils/title-utils.ts
index 7b5d6fd9a5..864741c0ab 100644
--- a/packages/twenty-front/src/utils/title-utils.ts
+++ b/packages/twenty-front/src/utils/title-utils.ts
@@ -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`;
diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts
index e9fbec6e66..9da7e99096 100644
--- a/packages/twenty-shared/src/types/AppPath.ts
+++ b/packages/twenty-shared/src/types/AppPath.ts
@@ -9,6 +9,7 @@ export enum AppPath {
// Onboarding
WorkspaceActivation = '/workspace-activation',
+ WorkspaceActivationV2 = '/workspace-activation-v2',
CreateProfile = '/create/profile',
SyncEmails = '/sync/emails',
InviteTeam = '/invite-team',