From 02120aac42a65ce2db85ab5b904d7c2109dd1b06 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Wed, 24 Jun 2026 19:13:20 +0200
Subject: [PATCH] Add v2 create-workspace onboarding screen (#22075)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
https://github.com/user-attachments/assets/30d69db2-ef50-48b5-8233-d9a36511b5e8
Builds the second step of the new onboarding flow on top of #22027: the
v2 "Create your workspace" screen, shown inside `/welcome-v2` at the
`WorkspaceCreation` step.
What changed:
- New `SignInUpV2Header` (back chevron + Twenty logo) and
`SignInUpWorkspaceCreationFormV2` (left-aligned title/subtitle, logo
upload, Name + Subdomain fields, "Create workspace"), wired into
`SignInUpV2` for the workspace-creation step.
- When a subdomain is taken, a box now lists 3 server-verified-available
alternatives. Backend `SubdomainAvailabilityDTO` returns
`suggestedSubdomains` via a new `findAvailableSubdomains` helper.
- The shared `useWorkspaceSubdomainField` hook is extended additively
(new `suggestions` + `applySuggestionValue`) so the v1 `/welcome` screen
is untouched.
Reviewer notes:
- `generated-metadata/graphql.ts` was hand-patched (metadata codegen
needs a running server).
- Storybook: `Pages/Auth/SignInUpV2 → WorkspaceCreation`.
---
.../src/metadata/generated/schema.graphql | 1 +
.../src/metadata/generated/schema.ts | 2 +
.../src/metadata/generated/types.ts | 3 +
.../src/generated-metadata/graphql.ts | 5 +-
.../checkWorkspaceSubdomainAvailability.ts | 1 +
.../components/internal/SignInUpV2Header.tsx | 70 +++++
.../SignInUpWorkspaceCreationFormV2.tsx | 292 ++++++++++++++++++
.../SignInUpWorkspaceCreationFormV2.test.tsx | 151 +++++++++
.../useWorkspaceSubdomainField.test.tsx | 38 ++-
.../hooks/useWorkspaceSubdomainField.ts | 38 ++-
.../src/pages/auth/SignInUpV2.tsx | 12 +-
.../dtos/subdomain-availability.dto.ts | 3 +
.../subdomain-manager.service.spec.ts | 116 +++++++
.../services/subdomain-manager.service.ts | 91 ++++--
14 files changed, 773 insertions(+), 50 deletions(-)
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpV2Header.tsx
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx
create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx
create mode 100644 packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index 2cde22f2b3..ab7131a2b9 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -2355,6 +2355,7 @@ type SubdomainAvailabilityDTO {
isValid: Boolean!
available: Boolean!
suggestedSubdomain: String!
+ suggestedSubdomains: [String!]!
}
type WorkspaceCreationDefaultsDTO {
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 9885eb70ab..0ecc185e6a 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -2012,6 +2012,7 @@ export interface SubdomainAvailabilityDTO {
isValid: Scalars['Boolean']
available: Scalars['Boolean']
suggestedSubdomain: Scalars['String']
+ suggestedSubdomains: Scalars['String'][]
__typename: 'SubdomainAvailabilityDTO'
}
@@ -5117,6 +5118,7 @@ export interface SubdomainAvailabilityDTOGenqlSelection{
isValid?: boolean | number
available?: boolean | number
suggestedSubdomain?: boolean | number
+ suggestedSubdomains?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index 28db6c1c2f..e3cde05778 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -4610,6 +4610,9 @@ export default {
"suggestedSubdomain": [
1
],
+ "suggestedSubdomains": [
+ 1
+ ],
"__typename": [
1
]
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index f4b80eae8d..56d352c383 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -5091,6 +5091,7 @@ export type SubdomainAvailabilityDto = {
available: Scalars['Boolean']['output'];
isValid: Scalars['Boolean']['output'];
suggestedSubdomain: Scalars['String']['output'];
+ suggestedSubdomains: Array;
};
export type Subscription = {
@@ -6819,7 +6820,7 @@ export type CheckWorkspaceSubdomainAvailabilityQueryVariables = Exact<{
}>;
-export type CheckWorkspaceSubdomainAvailabilityQuery = { __typename?: 'Query', checkWorkspaceSubdomainAvailability: { __typename?: 'SubdomainAvailabilityDTO', isValid: boolean, available: boolean, suggestedSubdomain: string } };
+export type CheckWorkspaceSubdomainAvailabilityQuery = { __typename?: 'Query', checkWorkspaceSubdomainAvailability: { __typename?: 'SubdomainAvailabilityDTO', isValid: boolean, available: boolean, suggestedSubdomain: string, suggestedSubdomains: Array } };
export type FindApplicationRegistrationByClientIdQueryVariables = Exact<{
clientId: Scalars['String']['input'];
@@ -8733,7 +8734,7 @@ export const UploadNewWorkspaceLogoDocument = {"kind":"Document","definitions":[
export const VerifyEmailAndGetLoginTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailAndGetLoginToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"emailVerificationToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailAndGetLoginToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"emailVerificationToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"emailVerificationToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"loginToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}}]} as unknown as DocumentNode;
export const VerifyEmailAndGetWorkspaceAgnosticTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailAndGetWorkspaceAgnosticToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"emailVerificationToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailAndGetWorkspaceAgnosticToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"emailVerificationToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"emailVerificationToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspacesFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspaceFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"}},{"kind":"Field","name":{"kind":"Name","value":"inviteHash"}},{"kind":"Field","name":{"kind":"Name","value":"personalInviteToken"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AvailableWorkspacesFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AvailableWorkspaces"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignIn"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesForSignUp"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AvailableWorkspaceFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode;
export const CheckUserExistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CheckUserExists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkUserExists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"exists"}},{"kind":"Field","name":{"kind":"Name","value":"availableWorkspacesCount"}},{"kind":"Field","name":{"kind":"Name","value":"isEmailVerified"}}]}}]}}]} as unknown as DocumentNode;
-export const CheckWorkspaceSubdomainAvailabilityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CheckWorkspaceSubdomainAvailability"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"subdomain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkWorkspaceSubdomainAvailability"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"subdomain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"subdomain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"available"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedSubdomain"}}]}}]}}]} as unknown as DocumentNode;
+export const CheckWorkspaceSubdomainAvailabilityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CheckWorkspaceSubdomainAvailability"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"subdomain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkWorkspaceSubdomainAvailability"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"subdomain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"subdomain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"available"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedSubdomain"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedSubdomains"}}]}}]}}]} as unknown as DocumentNode;
export const FindApplicationRegistrationByClientIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindApplicationRegistrationByClientId"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findApplicationRegistrationByClientId"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"clientId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"websiteUrl"}}]}}]}}]} as unknown as DocumentNode;
export const GetPublicWorkspaceDataByDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPublicWorkspaceDataByDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPublicWorkspaceDataByDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}}]}},{"kind":"Field","name":{"kind":"Name","value":"google"}},{"kind":"Field","name":{"kind":"Name","value":"magicLink"}},{"kind":"Field","name":{"kind":"Name","value":"password"}},{"kind":"Field","name":{"kind":"Name","value":"microsoft"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authBypassProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"google"}},{"kind":"Field","name":{"kind":"Name","value":"password"}},{"kind":"Field","name":{"kind":"Name","value":"microsoft"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}}]} as unknown as DocumentNode;
export const GetWorkspaceCreationDefaultsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceCreationDefaults"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getWorkspaceCreationDefaults"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode;
diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkWorkspaceSubdomainAvailability.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkWorkspaceSubdomainAvailability.ts
index 8d0f17f5ef..cba8b23a24 100644
--- a/packages/twenty-front/src/modules/auth/graphql/queries/checkWorkspaceSubdomainAvailability.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkWorkspaceSubdomainAvailability.ts
@@ -6,6 +6,7 @@ export const CHECK_WORKSPACE_SUBDOMAIN_AVAILABILITY = gql`
isValid
available
suggestedSubdomain
+ suggestedSubdomains
}
}
`;
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpV2Header.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpV2Header.tsx
new file mode 100644
index 0000000000..a3cddfce9e
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpV2Header.tsx
@@ -0,0 +1,70 @@
+import { styled } from '@linaria/react';
+import { useLingui } from '@lingui/react/macro';
+import { IconChevronLeft } from 'twenty-ui/icon';
+import { LightIconButton } from 'twenty-ui/input';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+const HEADER_CENTER_WIDTH = 340;
+
+const StyledHeader = styled.div`
+ align-items: center;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ padding: ${themeCssVariables.spacing[8]};
+ width: 100%;
+`;
+
+const StyledSide = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 1 1 0;
+ min-width: 0;
+`;
+
+const StyledLeftSide = styled(StyledSide)`
+ justify-content: flex-end;
+ padding-right: ${themeCssVariables.spacing[1]};
+`;
+
+const StyledCenter = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 0 1 ${HEADER_CENTER_WIDTH}px;
+ justify-content: flex-start;
+ min-width: 0;
+`;
+
+const StyledLogo = styled.div`
+ background-image: url('/images/integrations/twenty-logo.svg');
+ background-size: cover;
+ height: ${themeCssVariables.spacing[6]};
+ opacity: 0.4;
+ width: ${themeCssVariables.spacing[6]};
+`;
+
+type SignInUpV2HeaderProps = {
+ onBack: () => void;
+};
+
+export const SignInUpV2Header = ({ onBack }: SignInUpV2HeaderProps) => {
+ const { t } = useLingui();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
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
new file mode 100644
index 0000000000..b6ac06e439
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx
@@ -0,0 +1,292 @@
+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 { 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 { styled } from '@linaria/react';
+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';
+
+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 [isSubmitting, setIsSubmitting] = useState(false);
+ const [logo, setLogo] = useState(undefined);
+ const [logoPreviewUrl, setLogoPreviewUrl] = useState(
+ undefined,
+ );
+ const hiddenFileInputRef = useRef(null);
+
+ const {
+ workspaceName,
+ subdomain,
+ status,
+ errorMessage,
+ suggestions,
+ isAvailable,
+ handleWorkspaceNameChange,
+ handleSubdomainChange,
+ applySuggestionValue,
+ } = useWorkspaceSubdomainField({
+ isSubdomainEnabled: isMultiWorkspaceEnabled,
+ });
+
+ const isContinueDisabled =
+ workspaceName.trim() === '' ||
+ isSubmitting ||
+ (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;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await createWorkspace({
+ displayName: workspaceName.trim(),
+ ...(isMultiWorkspaceEnabled ? { subdomain } : {}),
+ logo,
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ 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 (
+
+
+ {t`Create your workspace`}
+
+ {t`Move work forward across teams and agents`}
+
+
+
+
+
+ {
+ const file = event.target.files?.[0];
+ if (isDefined(file)) {
+ handleLogoUpload(file);
+ }
+ event.target.value = '';
+ }}
+ />
+
+
+
+
+
+
+
+ {isMultiWorkspaceEnabled && (
+
+
+ {status === 'unavailable' && (
+
+
+ {t`Subdomain already in use, here are some alternatives:`}
+
+ {suggestions.map((alternative) => (
+ applySuggestionValue(alternative)}
+ >
+
+ {alternative}
+
+ ))}
+
+ )}
+
+ )}
+
+ (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
new file mode 100644
index 0000000000..49cf705279
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx
@@ -0,0 +1,151 @@
+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 { 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(
+
+
+
+
+
+
+ ,
+ );
+
+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(undefined);
+
+ 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('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(undefined);
+
+ 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',
+ );
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useWorkspaceSubdomainField.test.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useWorkspaceSubdomainField.test.tsx
index 2e43bdd47c..b371525d05 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useWorkspaceSubdomainField.test.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useWorkspaceSubdomainField.test.tsx
@@ -17,7 +17,12 @@ dynamicActivate(SOURCE_LOCALE);
const availabilityMock = (
subdomain: string,
- result: { isValid: boolean; available: boolean; suggestedSubdomain: string },
+ result: {
+ isValid: boolean;
+ available: boolean;
+ suggestedSubdomain: string;
+ suggestedSubdomains: string[];
+ },
) => ({
request: {
query: CheckWorkspaceSubdomainAvailabilityDocument,
@@ -77,6 +82,7 @@ describe('useWorkspaceSubdomainField', () => {
isValid: true,
available: true,
suggestedSubdomain: 'apple',
+ suggestedSubdomains: ['apple'],
}),
]),
});
@@ -110,13 +116,14 @@ describe('useWorkspaceSubdomainField', () => {
expect(result.current.status).toBe('idle');
});
- it('reports an unavailable manual address and offers a suggestion', async () => {
+ it('reports an unavailable manual address and offers available alternatives', async () => {
const { result } = renderHook(() => useWorkspaceSubdomainField(), {
wrapper: createWrapper([
availabilityMock('taken', {
isValid: true,
available: false,
suggestedSubdomain: 'taken-2',
+ suggestedSubdomains: ['taken-2', 'taken-3', 'taken-4'],
}),
]),
});
@@ -128,22 +135,29 @@ describe('useWorkspaceSubdomainField', () => {
await waitFor(() => expect(result.current.status).toBe('unavailable'), {
timeout: 3000,
});
+ expect(result.current.suggestions).toEqual([
+ 'taken-2',
+ 'taken-3',
+ 'taken-4',
+ ]);
expect(result.current.suggestion).toBe('taken-2');
expect(result.current.isAvailable).toBe(false);
});
- it('re-validates an applied suggestion before enabling submission', async () => {
+ it('re-validates an applied alternative before enabling submission', async () => {
const { result } = renderHook(() => useWorkspaceSubdomainField(), {
wrapper: createWrapper([
availabilityMock('taken', {
isValid: true,
available: false,
suggestedSubdomain: 'taken-2',
+ suggestedSubdomains: ['taken-2', 'taken-3', 'taken-4'],
}),
- availabilityMock('taken-2', {
+ availabilityMock('taken-3', {
isValid: true,
available: true,
- suggestedSubdomain: 'taken-2',
+ suggestedSubdomain: 'taken-3',
+ suggestedSubdomains: ['taken-3'],
}),
]),
});
@@ -152,20 +166,22 @@ describe('useWorkspaceSubdomainField', () => {
result.current.handleSubdomainChange('taken');
});
- await waitFor(() => expect(result.current.suggestion).toBe('taken-2'), {
- timeout: 3000,
- });
+ await waitFor(
+ () => expect(result.current.suggestions).toContain('taken-3'),
+ { timeout: 3000 },
+ );
act(() => {
- result.current.applySuggestion();
+ result.current.applySuggestionValue('taken-3');
});
- // Continue stays disabled until the suggestion is re-confirmed available.
+ // Continue stays disabled until the alternative is re-confirmed available.
expect(result.current.isAvailable).toBe(false);
await waitFor(() => expect(result.current.status).toBe('available'), {
timeout: 3000,
});
- expect(result.current.subdomain).toBe('taken-2');
+ expect(result.current.subdomain).toBe('taken-3');
+ expect(result.current.suggestions).toEqual([]);
});
});
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts
index e15bf2f9e7..3b388a8c3a 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts
@@ -46,7 +46,7 @@ export const useWorkspaceSubdomainField = ({
seededSubdomain !== '' ? 'available' : 'idle',
);
const [errorMessage, setErrorMessage] = useState();
- const [suggestion, setSuggestion] = useState();
+ const [suggestions, setSuggestions] = useState([]);
const [checkAvailabilityQuery] = useLazyQuery(
CheckWorkspaceSubdomainAvailabilityDocument,
@@ -73,27 +73,27 @@ export const useWorkspaceSubdomainField = ({
setSubdomain(result.suggestedSubdomain);
setStatus('available');
setErrorMessage(undefined);
- setSuggestion(undefined);
+ setSuggestions([]);
return;
}
if (!result.isValid) {
setStatus('invalid');
- setSuggestion(undefined);
+ setSuggestions([]);
return;
}
if (result.available) {
setStatus('available');
- setSuggestion(undefined);
+ setSuggestions([]);
return;
}
setStatus('unavailable');
- setSuggestion(result.suggestedSubdomain);
+ setSuggestions(result.suggestedSubdomains);
} catch {
setStatus('error');
- setSuggestion(undefined);
+ setSuggestions([]);
}
};
@@ -109,7 +109,7 @@ export const useWorkspaceSubdomainField = ({
debouncedAvailabilityCheck.cancel();
setSubdomain('');
setStatus('idle');
- setSuggestion(undefined);
+ setSuggestions([]);
return;
}
@@ -134,7 +134,7 @@ export const useWorkspaceSubdomainField = ({
setIsManuallyEdited(false);
setSubdomain('');
setErrorMessage(undefined);
- setSuggestion(undefined);
+ setSuggestions([]);
autofillFromWorkspaceName(workspaceName);
return;
}
@@ -148,7 +148,7 @@ export const useWorkspaceSubdomainField = ({
debouncedAvailabilityCheck.cancel();
setStatus('invalid');
setErrorMessage(validation.error.issues[0].message);
- setSuggestion(undefined);
+ setSuggestions([]);
return;
}
@@ -157,17 +157,23 @@ export const useWorkspaceSubdomainField = ({
debouncedAvailabilityCheck(normalized, { adoptSuggestion: false });
};
+ const applySuggestionValue = (value: string) => {
+ setIsManuallyEdited(true);
+ setSubdomain(value);
+ setStatus('checking');
+ setErrorMessage(undefined);
+ setSuggestions([]);
+ debouncedAvailabilityCheck(value, { adoptSuggestion: false });
+ };
+
+ const suggestion: string | undefined = suggestions[0];
+
const applySuggestion = () => {
if (!isDefined(suggestion)) {
return;
}
- setIsManuallyEdited(true);
- setSubdomain(suggestion);
- setStatus('checking');
- setErrorMessage(undefined);
- setSuggestion(undefined);
- debouncedAvailabilityCheck(suggestion, { adoptSuggestion: false });
+ applySuggestionValue(suggestion);
};
return {
@@ -176,9 +182,11 @@ export const useWorkspaceSubdomainField = ({
status,
errorMessage,
suggestion,
+ suggestions,
isAvailable: status === 'available',
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
+ applySuggestionValue,
};
};
diff --git a/packages/twenty-front/src/pages/auth/SignInUpV2.tsx b/packages/twenty-front/src/pages/auth/SignInUpV2.tsx
index ed72983a84..0cf6812a60 100644
--- a/packages/twenty-front/src/pages/auth/SignInUpV2.tsx
+++ b/packages/twenty-front/src/pages/auth/SignInUpV2.tsx
@@ -15,7 +15,8 @@ import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGl
import { SignInUpV2StandardContent } from '@/auth/sign-in-up/components/SignInUpV2StandardContent';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection';
-import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
+import { SignInUpV2Header } from '@/auth/sign-in-up/components/internal/SignInUpV2Header';
+import { SignInUpWorkspaceCreationFormV2 } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain';
@@ -133,7 +134,7 @@ export const SignInUpV2 = () => {
// single-workspace self-host, so it must render regardless of domain or
// workspace scope.
if (signInUpStep === SignInUpStep.WorkspaceCreation) {
- return ;
+ return ;
}
if (isDefaultDomain && isMultiWorkspaceEnabled) {
@@ -191,6 +192,13 @@ export const SignInUpV2 = () => {
+ ) : signInUpStep === SignInUpStep.WorkspaceCreation ? (
+ <>
+
+
+ {signInUpForm}
+
+ >
) : (
String)
suggestedSubdomain: string;
+
+ @Field(() => [String])
+ suggestedSubdomains: string[];
}
diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts
new file mode 100644
index 0000000000..c1fedd8536
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts
@@ -0,0 +1,116 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+
+import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+
+describe('SubdomainManagerService', () => {
+ let service: SubdomainManagerService;
+ const takenSubdomains = new Set();
+ let areAllSubdomainsTaken = false;
+
+ beforeEach(async () => {
+ takenSubdomains.clear();
+ areAllSubdomainsTaken = false;
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ SubdomainManagerService,
+ {
+ provide: getRepositoryToken(WorkspaceEntity),
+ useValue: {
+ findOne: jest.fn((options: { where: { subdomain: string } }) =>
+ Promise.resolve(
+ areAllSubdomainsTaken ||
+ takenSubdomains.has(options.where.subdomain)
+ ? ({} as WorkspaceEntity)
+ : null,
+ ),
+ ),
+ find: jest.fn(
+ (options: { where: { subdomain: { value: string[] } } }) =>
+ Promise.resolve(
+ options.where.subdomain.value
+ .filter(
+ (candidate) =>
+ areAllSubdomainsTaken || takenSubdomains.has(candidate),
+ )
+ .map((subdomain) => ({ subdomain }) as WorkspaceEntity),
+ ),
+ ),
+ },
+ },
+ {
+ provide: TwentyConfigService,
+ useValue: {
+ get: jest.fn().mockReturnValue('app'),
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(SubdomainManagerService);
+ });
+
+ describe('getSubdomainAvailability', () => {
+ it('returns the input as the only suggestion when it is free', async () => {
+ const result = await service.getSubdomainAvailability('stripe');
+
+ expect(result.isValid).toBe(true);
+ expect(result.available).toBe(true);
+ expect(result.suggestedSubdomain).toBe('stripe');
+ expect(result.suggestedSubdomains).toEqual(['stripe']);
+ });
+
+ it('returns three distinct available alternatives when taken', async () => {
+ takenSubdomains.add('stripe');
+
+ const result = await service.getSubdomainAvailability('stripe');
+
+ expect(result.available).toBe(false);
+ expect(result.suggestedSubdomains).toHaveLength(3);
+ expect(new Set(result.suggestedSubdomains).size).toBe(3);
+ expect(result.suggestedSubdomains).not.toContain('stripe');
+ expect(result.suggestedSubdomain).toBe(result.suggestedSubdomains[0]);
+ result.suggestedSubdomains.forEach((candidate) =>
+ expect(takenSubdomains.has(candidate)).toBe(false),
+ );
+ });
+
+ it('skips taken numbered suffixes when collecting alternatives', async () => {
+ takenSubdomains.add('stripe');
+ takenSubdomains.add('stripe-2');
+
+ const result = await service.getSubdomainAvailability('stripe');
+
+ expect(result.suggestedSubdomains).toEqual([
+ 'stripe-3',
+ 'stripe-4',
+ 'stripe-5',
+ ]);
+ });
+ });
+
+ describe('findAvailableSubdomains', () => {
+ it('returns the requested number of distinct available subdomains', async () => {
+ takenSubdomains.add('acme');
+
+ const subdomains = await service.findAvailableSubdomains('acme', 3);
+
+ expect(subdomains).toHaveLength(3);
+ expect(new Set(subdomains).size).toBe(3);
+ subdomains.forEach((candidate) =>
+ expect(takenSubdomains.has(candidate)).toBe(false),
+ );
+ });
+
+ it('does not pad with unverified subdomains when availability is scarce', async () => {
+ areAllSubdomainsTaken = true;
+
+ const subdomains = await service.findAvailableSubdomains('acme', 3);
+
+ expect(subdomains).toHaveLength(1);
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts
index ccbc3dfd88..c459c89f8f 100644
--- a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts
@@ -6,7 +6,7 @@ import {
getSubdomainSlugFromDisplayName,
isDefined,
} from 'twenty-shared/utils';
-import { Repository } from 'typeorm';
+import { In, Repository } from 'typeorm';
import { type SubdomainAvailabilityDTO } from 'src/engine/core-modules/domain/subdomain-manager/dtos/subdomain-availability.dto';
import { type WorkspaceCreationDefaultsDTO } from 'src/engine/core-modules/domain/subdomain-manager/dtos/workspace-creation-defaults.dto';
@@ -23,6 +23,7 @@ import {
const SUBDOMAIN_MAX_LENGTH = 30;
const MAX_NUMBERED_SUFFIX_ATTEMPTS = 50;
const MAX_RANDOM_FALLBACK_ATTEMPTS = 10;
+const SUBDOMAIN_SUGGESTIONS_COUNT = 3;
@Injectable()
export class SubdomainManagerService {
@@ -62,6 +63,15 @@ export class SubdomainManagerService {
}
async findAvailableSubdomain(desired: string): Promise {
+ const [availableSubdomain] = await this.findAvailableSubdomains(desired, 1);
+
+ return availableSubdomain;
+ }
+
+ async findAvailableSubdomains(
+ desired: string,
+ count: number,
+ ): Promise {
const derivedBase = isSubdomainValid(desired)
? desired
: getSubdomainSlugFromDisplayName(desired);
@@ -71,27 +81,59 @@ export class SubdomainManagerService {
? derivedBase
: generateRandomSubdomain();
- if (await this.isSubdomainFreeToUse(base)) {
- return base;
+ const candidates = this.buildSubdomainCandidates(base);
+
+ const availableSubdomains =
+ await this.filterFreeToUseSubdomains(candidates);
+
+ if (availableSubdomains.length === 0) {
+ return [generateRandomSubdomain()];
}
- for (let suffix = 2; suffix <= MAX_NUMBERED_SUFFIX_ATTEMPTS; suffix++) {
- const candidate = this.appendNumberedSuffix(base, suffix);
+ return availableSubdomains.slice(0, count);
+ }
- if (await this.isSubdomainFreeToUse(candidate)) {
- return candidate;
- }
+ private buildSubdomainCandidates(base: string): string[] {
+ const numberedCandidates = Array.from(
+ { length: MAX_NUMBERED_SUFFIX_ATTEMPTS - 1 },
+ (_, index) => this.appendNumberedSuffix(base, index + 2),
+ );
+
+ const randomCandidates = Array.from(
+ { length: MAX_RANDOM_FALLBACK_ATTEMPTS },
+ () => generateRandomSubdomain(),
+ );
+
+ return [...new Set([base, ...numberedCandidates, ...randomCandidates])];
+ }
+
+ private async filterFreeToUseSubdomains(
+ candidates: string[],
+ ): Promise {
+ const defaultSubdomain = this.twentyConfigService.get('DEFAULT_SUBDOMAIN');
+
+ const validCandidates = candidates.filter(
+ (candidate) =>
+ isSubdomainValid(candidate) && candidate !== defaultSubdomain,
+ );
+
+ if (validCandidates.length === 0) {
+ return [];
}
- for (let attempt = 0; attempt < MAX_RANDOM_FALLBACK_ATTEMPTS; attempt++) {
- const candidate = generateRandomSubdomain();
+ const existingWorkspaces = await this.workspaceRepository.find({
+ where: { subdomain: In(validCandidates) },
+ withDeleted: true,
+ select: { subdomain: true },
+ });
- if (await this.isSubdomainFreeToUse(candidate)) {
- return candidate;
- }
- }
+ const takenSubdomains = new Set(
+ existingWorkspaces.map((workspace) => workspace.subdomain),
+ );
- return generateRandomSubdomain();
+ return validCandidates.filter(
+ (candidate) => !takenSubdomains.has(candidate),
+ );
}
async getSubdomainAvailability(
@@ -100,12 +142,21 @@ export class SubdomainManagerService {
const isValid = isSubdomainValid(subdomain);
const available = isValid && (await this.isSubdomainFreeToUse(subdomain));
- // Autofill adopts this directly, so never echo an invalid input back.
- const suggestedSubdomain = available
- ? subdomain
- : await this.findAvailableSubdomain(subdomain);
+ // Autofill adopts the first suggestion directly, so never echo an invalid
+ // input back.
+ const suggestedSubdomains = available
+ ? [subdomain]
+ : await this.findAvailableSubdomains(
+ subdomain,
+ SUBDOMAIN_SUGGESTIONS_COUNT,
+ );
- return { isValid, available, suggestedSubdomain };
+ return {
+ isValid,
+ available,
+ suggestedSubdomain: suggestedSubdomains[0],
+ suggestedSubdomains,
+ };
}
async isSubdomainAvailable(subdomain: string) {