feat(auth): collect the workspace logo on the sign-up creation step (#21723)

## What & why

A single, consistent **workspace-creation step** for both
multi-workspace and single-workspace self-host — collecting **name +
logo** (and the **subdomain** in multi-workspace) — which **removes the
duplicate name/logo prompt** that previously reappeared on the workspace
subdomain (reported after #21641).

## Changes

**One creation form for both modes**
- With 0 workspaces, both multi-workspace and single-workspace route to
the shared `SignInUpWorkspaceCreationForm`; `SignInUp` renders it for
the `WorkspaceCreation` step regardless of domain/scope.
- The subdomain field shows only in multi-workspace; single-workspace
keeps its fixed address.

**Logo on the creation step**
- New scoped `uploadNewWorkspaceLogo(workspaceId, file)` mutation: the
creator sets a logo on their just-created `PENDING_CREATION` workspace
via the workspace-agnostic token (membership enforced — only the creator
is a member at that point), reusing `uploadWorkspacePicture`. Upload
size is capped via `settings.storage.maxFileSize` (also applied to the
existing logo / profile-picture uploads).
- The picked file is held locally (object-URL preview, revoked on
unmount) and uploaded right after creation (non-fatal on failure).

**Onboarding step → pure activation loader**
- The old "Create your workspace" form (name + logo) is removed. The
onboarding step now activates the pending workspace on mount and shows
the loader, with a **Retry** action on failure.

## Testing
- typecheck (front + server) ; oxlint + oxfmt clean on changed files 
- Unit tests: `auth.resolver.spec`, `useWorkspaceSubdomainField`,
`SignInUpWorkspaceCreationForm` (multi + single-workspace), `useAuth` 
- Metadata GraphQL + `twenty-client-sdk` schema regenerated.

Follow-up to #21641.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Xw37hR5seiCyWnppG9z4op

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-18 17:56:14 +02:00
committed by GitHub
parent 880e7144ec
commit 6a1b28bc12
55 changed files with 1136 additions and 569 deletions
@@ -3395,6 +3395,7 @@ type Mutation {
signUp(email: String!, password: String!, captchaToken: String, locale: String, verifyEmailRedirectPath: String): AvailableWorkspacesAndAccessTokens!
signUpInWorkspace(email: String!, password: String!, workspaceId: UUID, workspaceInviteHash: String, workspacePersonalInviteToken: String, captchaToken: String, locale: String, verifyEmailRedirectPath: String): SignUp!
signUpInNewWorkspace(input: SignUpInNewWorkspaceInput): SignUp!
uploadNewWorkspaceLogo(workspaceId: String!, file: Upload!): FileWithSignedUrl!
generateTransientToken: TransientToken!
getAuthTokensFromLoginToken(loginToken: String!, origin: String!): AuthTokens!
authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp!
@@ -4475,6 +4476,9 @@ input UpdateWorkspaceMemberSettingsInput {
}
input ActivateWorkspaceInput {
"""
Deprecated: the workspace name is set at creation (signUpInNewWorkspace) and this field is ignored during activation. Kept for backward compatibility.
"""
displayName: String
}
@@ -2916,6 +2916,7 @@ export interface Mutation {
signUp: AvailableWorkspacesAndAccessTokens
signUpInWorkspace: SignUp
signUpInNewWorkspace: SignUp
uploadNewWorkspaceLogo: FileWithSignedUrl
generateTransientToken: TransientToken
getAuthTokensFromLoginToken: AuthTokens
authorizeApp: AuthorizeApp
@@ -6094,6 +6095,7 @@ export interface MutationGenqlSelection{
signUp?: (AvailableWorkspacesAndAccessTokensGenqlSelection & { __args: {email: Scalars['String'], password: Scalars['String'], captchaToken?: (Scalars['String'] | null), locale?: (Scalars['String'] | null), verifyEmailRedirectPath?: (Scalars['String'] | null)} })
signUpInWorkspace?: (SignUpGenqlSelection & { __args: {email: Scalars['String'], password: Scalars['String'], workspaceId?: (Scalars['UUID'] | null), workspaceInviteHash?: (Scalars['String'] | null), workspacePersonalInviteToken?: (Scalars['String'] | null), captchaToken?: (Scalars['String'] | null), locale?: (Scalars['String'] | null), verifyEmailRedirectPath?: (Scalars['String'] | null)} })
signUpInNewWorkspace?: (SignUpGenqlSelection & { __args?: {input?: (SignUpInNewWorkspaceInput | null)} })
uploadNewWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {workspaceId: Scalars['String'], file: Scalars['Upload']} })
generateTransientToken?: TransientTokenGenqlSelection
getAuthTokensFromLoginToken?: (AuthTokensGenqlSelection & { __args: {loginToken: Scalars['String'], origin: Scalars['String']} })
authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} })
@@ -6507,7 +6509,9 @@ export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars[
export interface UpdateWorkspaceMemberSettingsInput {workspaceMemberId: Scalars['UUID'],update: Scalars['JSON']}
export interface ActivateWorkspaceInput {displayName?: (Scalars['String'] | null)}
export interface ActivateWorkspaceInput {
/** Deprecated: the workspace name is set at creation (signUpInNewWorkspace) and this field is ignored during activation. Kept for backward compatibility. */
displayName?: (Scalars['String'] | null)}
export interface UpdateWorkspaceInput {subdomain?: (Scalars['String'] | null),customDomain?: (Scalars['String'] | null),displayName?: (Scalars['String'] | null),logo?: (Scalars['String'] | null),inviteHash?: (Scalars['String'] | null),isPublicInviteLinkEnabled?: (Scalars['Boolean'] | null),allowImpersonation?: (Scalars['Boolean'] | null),isGoogleAuthEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthEnabled?: (Scalars['Boolean'] | null),isPasswordAuthEnabled?: (Scalars['Boolean'] | null),isGoogleAuthBypassEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthBypassEnabled?: (Scalars['Boolean'] | null),isPasswordAuthBypassEnabled?: (Scalars['Boolean'] | null),defaultRoleId?: (Scalars['UUID'] | null),isTwoFactorAuthenticationEnforced?: (Scalars['Boolean'] | null),trashRetentionDays?: (Scalars['Float'] | null),eventLogRetentionDays?: (Scalars['Float'] | null),fastModel?: (Scalars['String'] | null),smartModel?: (Scalars['String'] | null),aiAdditionalInstructions?: (Scalars['String'] | null),editableProfileFields?: (Scalars['String'][] | null),enabledAiModelIds?: (Scalars['String'][] | null),useRecommendedModels?: (Scalars['Boolean'] | null),isInternalMessagesImportEnabled?: (Scalars['Boolean'] | null)}
@@ -8454,6 +8454,19 @@ export default {
]
}
],
"uploadNewWorkspaceLogo": [
131,
{
"workspaceId": [
1,
"String!"
],
"file": [
357,
"Upload!"
]
}
],
"generateTransientToken": [
250
],
@@ -22,6 +22,7 @@ export type Scalars = {
};
export type ActivateWorkspaceInput = {
/** Deprecated: the workspace name is set at creation (signUpInNewWorkspace) and this field is ignored during activation. Kept for backward compatibility. */
displayName?: InputMaybe<Scalars['String']['input']>;
};
@@ -2646,6 +2647,7 @@ export type Mutation = {
uploadEmailAttachmentFile: FileWithSignedUrl;
uploadFilesFieldFile: FileWithSignedUrl;
uploadFilesFieldFileByUniversalIdentifier: FileWithSignedUrl;
uploadNewWorkspaceLogo: FileWithSignedUrl;
uploadWorkflowFile: FileWithSignedUrl;
uploadWorkspaceLogo: FileWithSignedUrl;
uploadWorkspaceMemberProfilePicture: FileWithSignedUrl;
@@ -3677,6 +3679,12 @@ export type MutationUploadFilesFieldFileByUniversalIdentifierArgs = {
};
export type MutationUploadNewWorkspaceLogoArgs = {
file: Scalars['Upload']['input'];
workspaceId: Scalars['String']['input'];
};
export type MutationUploadWorkflowFileArgs = {
file: Scalars['Upload']['input'];
};
@@ -6758,6 +6766,14 @@ export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
export type UpdatePasswordViaResetTokenMutation = { __typename?: 'Mutation', updatePasswordViaResetToken: { __typename?: 'InvalidatePassword', success: boolean } };
export type UploadNewWorkspaceLogoMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
file: Scalars['Upload']['input'];
}>;
export type UploadNewWorkspaceLogoMutation = { __typename?: 'Mutation', uploadNewWorkspaceLogo: { __typename?: 'FileWithSignedUrl', url: string } };
export type VerifyEmailAndGetLoginTokenMutationVariables = Exact<{
emailVerificationToken: Scalars['String']['input'];
email: Scalars['String']['input'];
@@ -8699,6 +8715,7 @@ export const SignUpDocument = {"kind":"Document","definitions":[{"kind":"Operati
export const SignUpInNewWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignUpInNewWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SignUpInNewWorkspaceInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signUpInNewWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"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":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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<SignUpInNewWorkspaceMutation, SignUpInNewWorkspaceMutationVariables>;
export const SignUpInWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignUpInWorkspace"},"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":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceInviteHash"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspacePersonalInviteToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}},"defaultValue":{"kind":"NullValue"}},{"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":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"signUpInWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceInviteHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceInviteHash"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspacePersonalInviteToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspacePersonalInviteToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}],"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":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"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"}}]}}]} as unknown as DocumentNode<SignUpInWorkspaceMutation, SignUpInWorkspaceMutationVariables>;
export const UpdatePasswordViaResetTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdatePasswordViaResetToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatePasswordViaResetToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"passwordResetToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"newPassword"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode<UpdatePasswordViaResetTokenMutation, UpdatePasswordViaResetTokenMutationVariables>;
export const UploadNewWorkspaceLogoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadNewWorkspaceLogo"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadNewWorkspaceLogo"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadNewWorkspaceLogoMutation, UploadNewWorkspaceLogoMutationVariables>;
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<VerifyEmailAndGetLoginTokenMutation, VerifyEmailAndGetLoginTokenMutationVariables>;
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<VerifyEmailAndGetWorkspaceAgnosticTokenMutation, VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables>;
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<CheckUserExistsQuery, CheckUserExistsQueryVariables>;
@@ -84,9 +84,11 @@ const setupMockState = (
verifyEmailRedirectPath?: string,
calendarBookingPageId?: string | null,
returnToPath?: string,
currentWorkspace: object | null = { id: 'mock-workspace-id' },
) => {
jest
.mocked(useAtomStateValue)
.mockReturnValueOnce(currentWorkspace)
.mockReturnValueOnce(calendarBookingPageId ?? 'mock-calendar-id')
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
.mockReturnValueOnce(verifyEmailRedirectPath)
@@ -111,7 +113,7 @@ const testCases: {
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.Verify, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -121,7 +123,7 @@ const testCases: {
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.SignInUp, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -131,7 +133,7 @@ const testCases: {
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.Invite, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/create/workspace' },
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' },
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' },
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
@@ -141,7 +143,7 @@ const testCases: {
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/create/workspace' },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
@@ -153,27 +155,27 @@ const testCases: {
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, verifyEmailRedirectPath: '/nextPath?key=value', res: undefined },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
{ loc: AppPath.CreateWorkspace, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: undefined },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -183,7 +185,7 @@ const testCases: {
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined },
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -193,7 +195,7 @@ const testCases: {
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined },
@@ -223,7 +225,7 @@ const testCases: {
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -233,7 +235,7 @@ const testCases: {
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -243,7 +245,7 @@ const testCases: {
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.Index, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -253,7 +255,7 @@ const testCases: {
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.TasksPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -263,7 +265,7 @@ const testCases: {
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -273,7 +275,7 @@ const testCases: {
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -285,7 +287,7 @@ const testCases: {
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -295,7 +297,7 @@ const testCases: {
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -309,7 +311,7 @@ const testCases: {
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -319,7 +321,7 @@ const testCases: {
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -329,7 +331,7 @@ const testCases: {
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.Authorize, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -339,7 +341,7 @@ const testCases: {
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -349,7 +351,7 @@ const testCases: {
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
{ loc: AppPath.NotFound, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
@@ -424,3 +426,21 @@ describe('usePageChangeEffectNavigateLocation', () => {
});
});
});
describe('usePageChangeEffectNavigateLocation — authenticated with no current workspace', () => {
it.each([AppPath.Index, AppPath.TasksPage, AppPath.SettingsCatchAll])(
'redirects to SignInUp from %s when authenticated with no current workspace',
(loc) => {
setupMockIsMatchingLocation(loc);
setupMockOnboardingStatus(undefined);
setupMockIsWorkspaceActivationStatusEqualsTo(false);
setupMockHasAccessTokenPair(true);
setupMockIsOnAWorkspace(true);
setupMockUseQuery();
setupMockUseParams();
setupMockState(undefined, undefined, undefined, undefined, null);
expect(usePageChangeEffectNavigateLocation()).toEqual(AppPath.SignInUp);
},
);
});
@@ -2,6 +2,7 @@ import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPa
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 { returnToPathState } from '@/auth/states/returnToPathState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
@@ -32,6 +33,7 @@ const readReturnToPathFromUrlSearchParams = (): string | null => {
export const usePageChangeEffectNavigateLocation = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const onboardingStatus = useOnboardingStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
@@ -75,7 +77,7 @@ export const usePageChangeEffectNavigateLocation = () => {
: readReturnToPathFromUrlSearchParams();
if (
(!hasAccessTokenPair || (hasAccessTokenPair && !isOnAWorkspace)) &&
(!hasAccessTokenPair || !isOnAWorkspace || !isDefined(currentWorkspace)) &&
!someMatchingLocationOf([
...ONGOING_USER_CREATION_PATHS,
AppPath.ResetPassword,
@@ -115,12 +117,12 @@ export const usePageChangeEffectNavigateLocation = () => {
if (
onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION &&
!someMatchingLocationOf([
AppPath.CreateWorkspace,
AppPath.WorkspaceActivation,
AppPath.BookCallDecision,
AppPath.BookCall,
])
) {
return AppPath.CreateWorkspace;
return AppPath.WorkspaceActivation;
}
if (
@@ -301,13 +301,13 @@ export const PageChangeEffect = () => {
});
break;
}
case isMatchingLocation(location, AppPath.CreateWorkspace): {
case isMatchingLocation(location, AppPath.WorkspaceActivation): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.CreateWorkspace,
focusId: PageFocusId.WorkspaceActivation,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.CreateWorkspace,
componentInstanceId: PageFocusId.WorkspaceActivation,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
@@ -48,9 +48,9 @@ const Authorize = lazy(() =>
})),
);
const CreateWorkspace = lazy(() =>
import('~/pages/onboarding/CreateWorkspace').then((module) => ({
default: module.CreateWorkspace,
const WorkspaceActivation = lazy(() =>
import('~/pages/onboarding/WorkspaceActivation').then((module) => ({
default: module.WorkspaceActivation,
})),
);
@@ -148,10 +148,10 @@ export const useCreateAppRouter = (
}
/>
<Route
path={AppPath.CreateWorkspace}
path={AppPath.WorkspaceActivation}
element={
<LazyRoute fallback={null}>
<CreateWorkspace />
<WorkspaceActivation />
</LazyRoute>
}
/>
@@ -78,7 +78,7 @@ export const Logo = ({
{isUsingDefaultLogo ? (
<UndecoratedLink
to={AppPath.SignInUp}
onClick={redirectToDefaultDomain}
onClick={() => redirectToDefaultDomain()}
>
<StyledPrimaryLogo
style={{ backgroundImage: `url(${primaryLogoUrl})` }}
@@ -1,7 +1,7 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_PATHS = [
AppPath.CreateWorkspace,
AppPath.WorkspaceActivation,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InviteTeam,
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPLOAD_NEW_WORKSPACE_LOGO = gql`
mutation UploadNewWorkspaceLogo($workspaceId: String!, $file: Upload!) {
uploadNewWorkspaceLogo(workspaceId: $workspaceId, file: $file) {
url
}
}
`;
@@ -32,7 +32,6 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
SignInUpStep,
@@ -77,8 +76,6 @@ export const useAuth = () => {
const { loadCurrentUser } = useLoadCurrentUser();
const apolloClient = useApolloClient();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetAtomState(signInUpStepState);
const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
@@ -131,16 +128,18 @@ export const useAuth = () => {
async (
availableWorkspaces: Parameters<typeof countAvailableWorkspaces>[0],
email: string,
{ newTab = true }: { newTab?: boolean } = {},
) => {
const availableWorkspacesCount =
countAvailableWorkspaces(availableWorkspaces);
if (availableWorkspacesCount === 0) {
if (!isMultiWorkspaceEnabled) {
return await createWorkspace({ newTab });
}
// The in-app "Create Workspace" entry point redirects here with this
// signal so an existing user with workspaces lands on the creation form
// instead of the workspace selection step.
const wantsToCreateNewWorkspace =
new URLSearchParams(window.location.search).get('action') ===
'create-new-workspace';
if (availableWorkspacesCount === 0 || wantsToCreateNewWorkspace) {
await apolloClient.query({
query: GetWorkspaceCreationDefaultsDocument,
});
@@ -166,13 +165,7 @@ export const useAuth = () => {
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
[
apolloClient,
createWorkspace,
isMultiWorkspaceEnabled,
redirectToWorkspaceDomain,
setSignInUpStep,
],
[apolloClient, redirectToWorkspaceDomain, setSignInUpStep],
);
const handleGetLoginTokenFromCredentials = useCallback(
@@ -264,7 +257,6 @@ export const useAuth = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
},
[
@@ -363,7 +355,6 @@ export const useAuth = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
},
onError: (error) => {
@@ -418,7 +409,6 @@ export const useAuth = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
},
[
@@ -11,7 +11,6 @@ import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboar
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import {
@@ -218,42 +217,38 @@ export const SignInUpGlobalScopeForm = () => {
</StyledWorkspaceContainer>
</StyledOnboardingContentContainer>
)}
{signInUpStep === SignInUpStep.WorkspaceCreation && (
<SignInUpWorkspaceCreationForm />
{signInUpStep !== SignInUpStep.WorkspaceSelection && (
<StyledOnboardingContentContainer>
{authProviders.google && (
<SignInUpWithGoogle
action="list-available-workspaces"
isGlobalScope
/>
)}
{authProviders.microsoft && (
<SignInUpWithMicrosoft
action="list-available-workspaces"
isGlobalScope
/>
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<SignInUpWithCredentials isGlobalScope />
</FormProvider>
{signInUpStep === SignInUpStep.Password && (
<StyledForgotPasswordLinkContainer>
<ClickToActionLink
onClick={handleResetPassword(form.getValues('email'))}
>
<Trans>Forgot your password?</Trans>
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledOnboardingContentContainer>
)}
{signInUpStep !== SignInUpStep.WorkspaceSelection &&
signInUpStep !== SignInUpStep.WorkspaceCreation && (
<StyledOnboardingContentContainer>
{authProviders.google && (
<SignInUpWithGoogle
action="list-available-workspaces"
isGlobalScope
/>
)}
{authProviders.microsoft && (
<SignInUpWithMicrosoft
action="list-available-workspaces"
isGlobalScope
/>
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<SignInUpWithCredentials isGlobalScope />
</FormProvider>
{signInUpStep === SignInUpStep.Password && (
<StyledForgotPasswordLinkContainer>
<ClickToActionLink
onClick={handleResetPassword(form.getValues('email'))}
>
<Trans>Forgot your password?</Trans>
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledOnboardingContentContainer>
)}
</>
);
};
@@ -23,7 +23,6 @@ export const SignInUpGlobalScopeFormEffect = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
};
@@ -2,14 +2,17 @@ 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 { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
@@ -47,8 +50,15 @@ export const SignInUpWorkspaceCreationForm = () => {
const { t } = useLingui();
const { createWorkspace } = useSignUpInNewWorkspace();
const { frontDomain } = useAtomStateValue(domainConfigurationState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
undefined,
);
const {
workspaceName,
@@ -60,10 +70,37 @@ export const SignInUpWorkspaceCreationForm = () => {
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
} = useWorkspaceSubdomainField();
} = useWorkspaceSubdomainField({
isSubdomainEnabled: isMultiWorkspaceEnabled,
});
const isContinueDisabled =
workspaceName.trim() === '' || !isAvailable || isSubmitting;
workspaceName.trim() === '' ||
isSubmitting ||
(isMultiWorkspaceEnabled && !isAvailable);
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) {
@@ -74,8 +111,8 @@ export const SignInUpWorkspaceCreationForm = () => {
try {
await createWorkspace({
displayName: workspaceName.trim(),
subdomain,
newTab: false,
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
} finally {
setIsSubmitting(false);
@@ -104,8 +141,18 @@ export const SignInUpWorkspaceCreationForm = () => {
return (
<StyledOnboardingContentContainer>
<SubTitle>
{t`Pick a name and a web address for your new workspace.`}
{isMultiWorkspaceEnabled
? t`Pick a name and a web address for your new workspace.`
: t`Pick a name and a logo for your new workspace.`}
</SubTitle>
<StyledSection>
<InputLabel>{t`Workspace logo`}</InputLabel>
<ImageInput
picture={logoPreviewUrl}
onUpload={handleLogoUpload}
onRemove={handleLogoRemove}
/>
</StyledSection>
<StyledSection>
<TextInput
autoFocus
@@ -117,37 +164,41 @@ export const SignInUpWorkspaceCreationForm = () => {
fullWidth
/>
</StyledSection>
<StyledSection>
<TextInput
label={t`Workspace address`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={status === 'unavailable' || !isDefined(subdomainError)}
fullWidth
/>
{status === 'checking' && <InputHint>{t`Checking…`}</InputHint>}
{status === 'available' && (
<StyledAvailableHint>
{t`This address is available`}
</StyledAvailableHint>
)}
{status === 'unavailable' && (
<StyledUnavailableHint>
{subdomainError}
{isDefined(suggestion) && (
<ClickToActionLink onClick={applySuggestion}>
{t`Use ${suggestion} instead`}
</ClickToActionLink>
)}
</StyledUnavailableHint>
)}
</StyledSection>
{isMultiWorkspaceEnabled && (
<StyledSection>
<TextInput
label={t`Workspace address`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
{status === 'checking' && <InputHint>{t`Checking…`}</InputHint>}
{status === 'available' && (
<StyledAvailableHint>
{t`This address is available`}
</StyledAvailableHint>
)}
{status === 'unavailable' && (
<StyledUnavailableHint>
{subdomainError}
{isDefined(suggestion) && (
<ClickToActionLink onClick={applySuggestion}>
{t`Use ${suggestion} instead`}
</ClickToActionLink>
)}
</StyledUnavailableHint>
)}
</StyledSection>
)}
<StyledButtonContainer>
<MainButton
title={t`Continue`}
@@ -6,6 +6,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
resetJotaiStore,
@@ -26,8 +27,15 @@ jest.mock('@/auth/sign-in-up/hooks/useWorkspaceSubdomainField', () => ({
useWorkspaceSubdomainField: () => useWorkspaceSubdomainFieldMock(),
}));
global.URL.createObjectURL = jest.fn(() => 'blob:logo-preview');
global.URL.revokeObjectURL = jest.fn();
dynamicActivate(SOURCE_LOCALE);
const setMultiWorkspaceEnabled = (isEnabled: boolean) => {
jotaiStore.set(isMultiWorkspaceEnabledState.atom, isEnabled);
};
const renderForm = () =>
render(
<JotaiProvider store={jotaiStore}>
@@ -56,73 +64,155 @@ describe('SignInUpWorkspaceCreationForm', () => {
});
});
it('keeps Continue disabled until a workspace name is entered', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: '',
subdomain: '',
status: 'idle',
errorMessage: undefined,
suggestion: undefined,
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
describe('multi-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(true);
});
renderForm();
it('keeps Continue disabled until a workspace name is entered', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: '',
subdomain: '',
status: 'idle',
errorMessage: undefined,
suggestion: undefined,
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
renderForm();
it('creates the workspace with the chosen name and address in the same tab', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
newTab: false,
it('creates the workspace with the chosen name and address in the same tab', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: undefined,
});
});
it('passes the picked logo file when creating the workspace', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
const { container } = renderForm();
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const logoFile = new File(['logo'], 'logo.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [logoFile] } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: logoFile,
});
});
it('routes name edits back through the field hook', () => {
renderForm();
fireEvent.change(screen.getByLabelText('Workspace name'), {
target: { value: 'Acme' },
});
expect(handleWorkspaceNameChangeMock).toHaveBeenCalledWith('Acme');
});
it('offers a one-click suggestion when the address is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
renderForm();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
fireEvent.click(screen.getByText('Use apple-2 instead'));
expect(applySuggestionMock).toHaveBeenCalledTimes(1);
});
});
it('routes name edits back through the field hook', () => {
renderForm();
fireEvent.change(screen.getByLabelText('Workspace name'), {
target: { value: 'Acme' },
describe('single-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(false);
});
expect(handleWorkspaceNameChangeMock).toHaveBeenCalledWith('Acme');
});
it('hides the workspace address field', () => {
renderForm();
it('offers a one-click suggestion when the address is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
expect(screen.getByLabelText('Workspace name')).toBeInTheDocument();
expect(
screen.queryByLabelText('Workspace address'),
).not.toBeInTheDocument();
});
renderForm();
it('enables Continue based on the name only and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
// An unavailable subdomain status must not block submission when the
// address field is hidden.
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
fireEvent.click(screen.getByText('Use apple-2 instead'));
renderForm();
expect(applySuggestionMock).toHaveBeenCalledTimes(1);
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
logo: undefined,
});
expect(createWorkspaceMock.mock.calls[0][0]).not.toHaveProperty(
'subdomain',
);
});
});
});
@@ -94,6 +94,22 @@ describe('useWorkspaceSubdomainField', () => {
expect(result.current.isAvailable).toBe(true);
});
it('skips availability checks when the subdomain field is disabled', () => {
const { result } = renderHook(
() => useWorkspaceSubdomainField({ isSubdomainEnabled: false }),
{ wrapper: createWrapper([]) },
);
act(() => {
result.current.handleWorkspaceNameChange('Apple');
});
// The name is still tracked, but no subdomain is derived and no lookup runs.
expect(result.current.workspaceName).toBe('Apple');
expect(result.current.subdomain).toBe('');
expect(result.current.status).toBe('idle');
});
it('reports an unavailable manual address and offers a suggestion', async () => {
const { result } = renderHook(() => useWorkspaceSubdomainField(), {
wrapper: createWrapper([
@@ -13,6 +13,7 @@ import { AuthenticatedMethod } from '@/auth/types/AuthenticatedMethod.enum';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useBuildSearchParamsFromUrlSyncedStates } from '@/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -24,6 +25,7 @@ import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAuth } from '@/auth/hooks/useAuth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useSignInUp = (form: UseFormReturn<Form>) => {
@@ -33,6 +35,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const [signInUpStep, setSignInUpStep] = useAtomState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useAtomState(signInUpModeState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const { isCaptchaReady } = useCaptcha();
const setLastAuthenticatedMethod = useSetAtomState(
lastAuthenticatedMethodState,
@@ -156,7 +161,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignUp &&
!isOnAWorkspace
(!isOnAWorkspace || !isMultiWorkspaceEnabled)
) {
return await signUpWithCredentials(
data.email.toLowerCase().trim(),
@@ -198,6 +203,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
enqueueErrorSnackBar,
buildSearchParamsFromUrlSyncedStates,
isOnAWorkspace,
isMultiWorkspaceEnabled,
setLastAuthenticatedMethod,
t,
],
@@ -1,43 +1,82 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { AppPath } from 'twenty-shared/types';
import { useMutation } from '@apollo/client/react';
import { SignUpInNewWorkspaceDocument } from '~/generated-metadata/graphql';
import {
SignUpInNewWorkspaceDocument,
UploadNewWorkspaceLogoDocument,
} from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { useLingui } from '@lingui/react/macro';
export const useSignUpInNewWorkspace = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { getAuthTokensFromLoginToken } = useAuth();
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const { enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const [signUpInNewWorkspaceMutation] = useMutation(
SignUpInNewWorkspaceDocument,
);
const [uploadNewWorkspaceLogoMutation] = useMutation(
UploadNewWorkspaceLogoDocument,
);
const createWorkspace = async ({
displayName,
subdomain,
newTab = true,
logo,
}: {
displayName?: string;
subdomain?: string;
newTab?: boolean;
logo?: File;
} = {}) => {
try {
const { data } = await signUpInNewWorkspaceMutation({
variables: { input: { displayName, subdomain } },
});
assertIsDefinedOrThrow(data?.signUpInNewWorkspace);
const workspaceId = data.signUpInNewWorkspace.workspace.id;
if (isDefined(logo)) {
try {
await uploadNewWorkspaceLogoMutation({
variables: { workspaceId, file: logo },
});
} catch (logoUploadError) {
enqueueErrorSnackBar(
CombinedGraphQLErrors.is(logoUploadError)
? { apolloError: logoUploadError }
: {
message:
logoUploadError instanceof Error
? logoUploadError.message
: t`Workspace logo upload failed`,
},
);
}
}
const loginToken = data.signUpInNewWorkspace.loginToken.token;
if (!isMultiWorkspaceEnabled) {
return await getAuthTokensFromLoginToken(loginToken);
}
return await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
newTab ? '_blank' : '_self',
{ loginToken },
'_self',
);
} catch (error) {
enqueueErrorSnackBar(
@@ -21,7 +21,9 @@ export type SubdomainFieldStatus =
const AVAILABILITY_CHECK_DEBOUNCE_MS = 400;
export const useWorkspaceSubdomainField = () => {
export const useWorkspaceSubdomainField = ({
isSubdomainEnabled = true,
}: { isSubdomainEnabled?: boolean } = {}) => {
const apolloClient = useApolloClient();
const subdomainSchema = useMemo(() => getSubdomainValidationSchema(), []);
@@ -118,7 +120,7 @@ export const useWorkspaceSubdomainField = () => {
const handleWorkspaceNameChange = (name: string) => {
setWorkspaceName(name);
if (isManuallyEdited) {
if (!isSubdomainEnabled || isManuallyEdited) {
return;
}
@@ -24,7 +24,7 @@ describe('isValidReturnToPath', () => {
});
it('should return false for onboarding paths', () => {
expect(isValidReturnToPath('/create/workspace')).toBe(false);
expect(isValidReturnToPath('/workspace-activation')).toBe(false);
expect(isValidReturnToPath('/create/profile')).toBe(false);
});
@@ -12,7 +12,10 @@ export const useRedirectToDefaultDomain = () => {
const store = useStore();
const { redirect } = useRedirect();
const redirectToDefaultDomain = () => {
const redirectToDefaultDomain = (options?: {
pathname?: string;
searchParams?: Record<string, string>;
}) => {
const url = new URL(window.location.href);
if (url.hostname !== defaultDomain) {
setLastAuthenticateWorkspaceDomain(null);
@@ -25,6 +28,14 @@ export const useRedirectToDefaultDomain = () => {
url.searchParams.set('returnToPath', returnToPath);
}
if (isNonEmptyString(options?.pathname)) {
url.pathname = options.pathname;
}
Object.entries(options?.searchParams ?? {}).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
url.hostname = defaultDomain;
redirect(url.toString());
}
@@ -21,7 +21,7 @@ export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => {
isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(location, AppPath.Invite) ||
isMatchingLocation(location, AppPath.ResetPassword) ||
isMatchingLocation(location, AppPath.CreateWorkspace) ||
isMatchingLocation(location, AppPath.WorkspaceActivation) ||
isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) ||
isMatchingLocation(location, AppPath.Authorize);
@@ -1,6 +1,6 @@
export enum PageFocusId {
Settings = 'settings',
CreateWorkspace = 'create-workspace',
WorkspaceActivation = 'workspace-activation',
SignInUp = 'sign-in-up',
CreateProfile = 'create-profile',
InviteTeam = 'invite-team',
@@ -32,7 +32,7 @@ const testCases = [
{ loc: AppPath.SignInUp, res: true },
{ loc: AppPath.Invite, res: true },
{ loc: AppPath.ResetPassword, res: true },
{ loc: AppPath.CreateWorkspace, res: true },
{ loc: AppPath.WorkspaceActivation, res: true },
{ loc: AppPath.SyncEmails, res: true },
{ loc: AppPath.InviteTeam, res: true },
{ loc: AppPath.PlanRequired, res: true },
@@ -17,7 +17,7 @@ export const useShowAuthModal = () => {
isMatchingLocation(location, AppPath.VerifyEmail) ||
isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(location, AppPath.CreateWorkspace) ||
isMatchingLocation(location, AppPath.WorkspaceActivation) ||
isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) ||
isMatchingLocation(location, AppPath.BookCallDecision) ||
@@ -6,8 +6,8 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
import { supportChatState } from '@/client-config/states/supportChatState';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@@ -21,7 +21,6 @@ import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/s
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -44,11 +43,7 @@ import {
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui/navigation';
import { useMutation } from '@apollo/client/react';
import {
type AvailableWorkspace,
SignUpInNewWorkspaceDocument,
} from '~/generated-metadata/graphql';
import { type AvailableWorkspace } from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledDescription = styled.div`
@@ -64,19 +59,15 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
const availableWorkspacesCount =
countAvailableWorkspaces(availableWorkspaces);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const { closeDropdown } = useCloseDropdown();
const { signOut } = useAuth();
const { enqueueErrorSnackBar } = useSnackBar();
const { colorScheme, colorSchemeList } = useColorScheme();
const supportChat = useAtomStateValue(supportChatState);
const isSupportChatConfigured =
supportChat?.supportDriver === 'FRONT' &&
isNonEmptyString(supportChat.supportFrontChatId);
const [signUpInNewWorkspaceMutation] = useMutation(
SignUpInNewWorkspaceDocument,
);
const setMultiWorkspaceDropdown = useSetAtomState(
multiWorkspaceDropdownState,
);
@@ -94,23 +85,14 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
);
};
// The workspace name (and logo + subdomain) are collected by the shared
// creation form on the root domain, so we send the user there on the
// WorkspaceCreation step instead of creating a nameless workspace on the fly.
const createWorkspace = () => {
signUpInNewWorkspaceMutation({
onCompleted: async (data) => {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
'_blank',
);
},
onError: (error) => {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
},
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
redirectToDefaultDomain({
pathname: AppPath.SignInUp,
searchParams: { action: 'create-new-workspace' },
});
};
@@ -18,6 +18,7 @@ import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGl
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { WorkspaceSelectionFooter } from '@/auth/sign-in-up/components/WorkspaceSelectionFooter';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain';
@@ -163,6 +164,13 @@ export const SignInUp = () => {
);
}
// The workspace creation form is shared by both multi-workspace and
// single-workspace self-host, so it must render regardless of domain or
// workspace scope.
if (signInUpStep === SignInUpStep.WorkspaceCreation) {
return <SignInUpWorkspaceCreationForm />;
}
if (isDefaultDomain && isMultiWorkspaceEnabled) {
return (
<>
@@ -1,245 +0,0 @@
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useState } from 'react';
import { Controller, type SubmitHandler, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
import { z } from 'zod';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { ModalContent } from 'twenty-ui/surfaces';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/typography';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { ActivateWorkspaceDocument } from '~/generated-metadata/graphql';
const StyledContentContainer = styled.div`
width: 100%;
`;
const StyledSectionContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
width: 200px;
`;
const StyledLoaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[8]};
margin-top: ${themeCssVariables.spacing[8]};
width: 100%;
`;
enum PendingCreationLoaderStep {
None = 'none',
Step1 = 'step-1',
Step2 = 'step-2',
Step3 = 'step-3',
}
const StyledPendingCreationLoader = styled.div`
align-items: center;
display: flex;
justify-content: center;
width: 100%;
`;
export const CreateWorkspace = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const { loadCurrentUser } = useLoadCurrentUser();
const [activateWorkspace] = useMutation(ActivateWorkspaceDocument);
const [pendingCreationLoaderStep, setPendingCreationLoaderStep] = useState(
PendingCreationLoaderStep.None,
);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const validationSchema = z
.object({
name: z.string().min(1, { message: t`Name can not be empty` }),
})
.required();
type Form = z.infer<typeof validationSchema>;
// Form
const {
control,
handleSubmit,
formState: { isValid, isSubmitting },
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
name: currentWorkspace?.displayName ?? '',
},
resolver: zodResolver(validationSchema),
});
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
setTimeout(() => {
setPendingCreationLoaderStep(PendingCreationLoaderStep.Step1);
}, 500);
setTimeout(() => {
setPendingCreationLoaderStep(PendingCreationLoaderStep.Step2);
}, 2000);
setTimeout(() => {
setPendingCreationLoaderStep(PendingCreationLoaderStep.Step3);
}, 5000);
const result = await activateWorkspace({
variables: {
input: {
displayName: data.name,
},
},
});
if (isDefined(result.error)) {
throw result.error ?? new Error(t`Unknown error`);
}
await loadCurrentUser();
setNextOnboardingStatus();
} catch (error: any) {
setPendingCreationLoaderStep(PendingCreationLoaderStep.None);
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
},
[
activateWorkspace,
enqueueErrorSnackBar,
loadCurrentUser,
setNextOnboardingStatus,
t,
],
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) {
return;
}
if (event.key === Key.Enter) {
event.preventDefault();
handleSubmit(onSubmit)();
}
};
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
{pendingCreationLoaderStep !== PendingCreationLoaderStep.None && (
<>
<Logo
primaryLogo={
isNonEmptyString(currentWorkspace?.logo)
? currentWorkspace?.logo
: undefined
}
/>
<Title>
<Trans>Creating your workspace</Trans>
</Title>
<StyledPendingCreationLoader>
{pendingCreationLoaderStep === PendingCreationLoaderStep.Step1 && (
<SubTitle>
<Trans>Setting up your database...</Trans>
</SubTitle>
)}
{pendingCreationLoaderStep === PendingCreationLoaderStep.Step2 && (
<SubTitle>
<Trans>Creating your data model...</Trans>
</SubTitle>
)}
{pendingCreationLoaderStep === PendingCreationLoaderStep.Step3 && (
<SubTitle>
<Trans>Prefilling your workspace data...</Trans>
</SubTitle>
)}
</StyledPendingCreationLoader>
<StyledLoaderContainer>
<Loader color="gray" />
</StyledLoaderContainer>
</>
)}
{pendingCreationLoaderStep === PendingCreationLoaderStep.None && (
<>
<Title noMarginTop>
<Trans>Create your workspace</Trans>
</Title>
<SubTitle>
<Trans>
A shared environment where you will be able to manage your
customer relations with your team.
</Trans>
</SubTitle>
<StyledContentContainer>
<StyledSectionContainer>
<H2Title title={t`Workspace logo`} />
<WorkspaceLogoUploader />
</StyledSectionContainer>
<StyledSectionContainer>
<H2Title
title={t`Workspace name`}
description={t`The name of your organization`}
/>
<Controller
name="name"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
autoFocus
value={value}
placeholder={t`Apple`}
onBlur={onBlur}
onChange={onChange}
error={error?.message}
onKeyDown={handleKeyDown}
fullWidth
/>
)}
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title={t`Continue`}
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
Icon={() => isSubmitting && <Loader />}
fullWidth
/>
</StyledButtonContainer>
</>
)}
</ModalContent>
);
};
@@ -40,7 +40,7 @@ export const PaymentSuccess = () => {
try {
if (isDefined(subscriptionStatus)) {
navigate(AppPath.CreateWorkspace);
navigate(AppPath.WorkspaceActivation);
return;
}
@@ -51,7 +51,7 @@ export const PaymentSuccess = () => {
if (isDefined(currentUser) && isDefined(refreshedSubscriptionStatus)) {
setCurrentUser(currentUser);
navigate(AppPath.CreateWorkspace);
navigate(AppPath.WorkspaceActivation);
return;
}
@@ -0,0 +1,180 @@
import { styled } from '@linaria/react';
import { useCallback, useEffect, useState } from 'react';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ModalContent } from 'twenty-ui/surfaces';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { ActivateWorkspaceDocument } from '~/generated-metadata/graphql';
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
width: 200px;
`;
const StyledLoaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[8]};
margin-top: ${themeCssVariables.spacing[8]};
width: 100%;
`;
type ActivationStep = 'pending' | 'database' | 'data-model' | 'prefill';
const StyledActivationStep = styled.div`
align-items: center;
display: flex;
justify-content: center;
width: 100%;
`;
export const WorkspaceActivation = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const { loadCurrentUser } = useLoadCurrentUser();
const [activateWorkspace, { loading: isActivating }] = useMutation(
ActivateWorkspaceDocument,
);
const [activationStep, setActivationStep] =
useState<ActivationStep>('pending');
const [hasFailed, setHasFailed] = useState(false);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const activate = useCallback(async () => {
setHasFailed(false);
const databaseTimeout = setTimeout(() => {
setActivationStep('database');
}, 500);
const dataModelTimeout = setTimeout(() => {
setActivationStep('data-model');
}, 2000);
const prefillTimeout = setTimeout(() => {
setActivationStep('prefill');
}, 5000);
const clearStepTimeouts = () => {
clearTimeout(databaseTimeout);
clearTimeout(dataModelTimeout);
clearTimeout(prefillTimeout);
};
try {
const result = await activateWorkspace({
variables: {
input: {},
},
});
if (isDefined(result.error)) {
throw result.error;
}
await loadCurrentUser();
setNextOnboardingStatus();
} catch (error) {
setActivationStep('pending');
setHasFailed(true);
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
} finally {
clearStepTimeouts();
}
}, [
activateWorkspace,
enqueueErrorSnackBar,
loadCurrentUser,
setNextOnboardingStatus,
]);
const [hasTriggered, setHasTriggered] = useState(false);
useEffect(() => {
if (hasTriggered || !isDefined(currentWorkspace)) {
return;
}
setHasTriggered(true);
void activate();
}, [activate, hasTriggered, currentWorkspace]);
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<Logo
primaryLogo={
isNonEmptyString(currentWorkspace?.logo)
? currentWorkspace?.logo
: undefined
}
/>
<Title>
{hasFailed ? (
<Trans>Workspace creation failed</Trans>
) : (
<Trans>Creating your workspace</Trans>
)}
</Title>
{hasFailed ? (
<>
<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>
</>
) : (
<>
<StyledActivationStep>
{activationStep === 'database' && (
<SubTitle>
<Trans>Setting up your database...</Trans>
</SubTitle>
)}
{activationStep === 'data-model' && (
<SubTitle>
<Trans>Creating your data model...</Trans>
</SubTitle>
)}
{activationStep === 'prefill' && (
<SubTitle>
<Trans>Prefilling your workspace data...</Trans>
</SubTitle>
)}
</StyledActivationStep>
<StyledLoaderContainer>
<Loader color="gray" />
</StyledLoaderContainer>
</>
)}
</ModalContent>
);
};
@@ -6,7 +6,7 @@ import { within } from 'storybook/test';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { AppPath } from 'twenty-shared/types';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import { CreateWorkspace } from '~/pages/onboarding/CreateWorkspace';
import { WorkspaceActivation } from '~/pages/onboarding/WorkspaceActivation';
import {
PageDecorator,
type PageDecoratorArgs,
@@ -15,10 +15,10 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedOnboardingUserData } from '~/testing/mock-data/users';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Onboarding/CreateWorkspace',
component: CreateWorkspace,
title: 'Pages/Onboarding/WorkspaceActivation',
component: WorkspaceActivation,
decorators: [PageDecorator],
args: { routePath: AppPath.CreateWorkspace },
args: { routePath: AppPath.WorkspaceActivation },
parameters: {
msw: {
handlers: [
@@ -39,12 +39,12 @@ const meta: Meta<PageDecoratorArgs> = {
export default meta;
export type Story = StoryObj<typeof CreateWorkspace>;
export type Story = StoryObj<typeof WorkspaceActivation>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await canvas.findByText('Create your workspace', undefined, {
await canvas.findByText('Creating your workspace', undefined, {
timeout: 3000,
});
},
@@ -12,7 +12,9 @@ describe('title-utils', () => {
'Sign in or Create an account',
);
expect(getPageTitleFromPath('/invite/:workspaceInviteHash')).toBe('Invite');
expect(getPageTitleFromPath('/create/workspace')).toBe('Create Workspace');
expect(getPageTitleFromPath('/workspace-activation')).toBe(
'Create Workspace',
);
expect(getPageTitleFromPath('/create/profile')).toBe('Create Profile');
expect(getPageTitleFromPath('/settings/objects/opportunities')).toBe(
'Data model - Settings',
@@ -32,7 +32,7 @@ export const getPageTitleFromPath = (pathname: string): string => {
return t`Sign in or Create an account`;
case AppPath.Invite:
return t`Invite`;
case AppPath.CreateWorkspace:
case AppPath.WorkspaceActivation:
return t`Create Workspace`;
case AppPath.CreateProfile:
return t`Create Profile`;
+1 -1
View File
@@ -20,7 +20,7 @@ const jestConfig = {
// jsdom 29 pulls ESM-only transitive deps (parse5, entities, tough-cookie,
// @exodus/bytes via html-encoding-sniffer, @csstools/@asamuzakjp css engine).
// jest's CJS runtime can't load their `export` syntax, so let swc transform them.
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt|email-reply-parser|jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp)/)',
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt|email-reply-parser|jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp|graphql-upload|fs-capacitor)/)',
],
testRegex: '.*\\.spec\\.ts$',
transform: {
@@ -320,6 +320,15 @@ export class ApplicationService {
},
queryRunner?: QueryRunner,
) {
const existingApplication = await this.findByUniversalIdentifier({
universalIdentifier: TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId,
});
if (isDefined(existingApplication)) {
return existingApplication;
}
const defaultPackageFields = await getDefaultApplicationPackageFields();
const twentyStandardApplication = await this.create(
@@ -15,6 +15,7 @@ import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomai
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
@@ -77,6 +78,10 @@ describe('AuthResolver', () => {
provide: SubdomainManagerService,
useValue: {},
},
{
provide: FileCorePictureService,
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {},
@@ -2,6 +2,8 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Context, Mutation, Query } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import bytes from 'bytes';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import omit from 'lodash.omit';
import { PermissionFlagType } from 'twenty-shared/constants';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
@@ -9,7 +11,10 @@ import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { settings } from 'src/engine/constants/settings';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
@@ -57,6 +62,8 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util';
import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
@@ -84,6 +91,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthToken } from './dto/auth-token.dto';
@@ -136,6 +144,7 @@ export class AuthResolver {
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
private readonly subdomainManagerService: SubdomainManagerService,
private readonly fileCorePictureService: FileCorePictureService,
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
@@ -557,6 +566,34 @@ export class AuthResolver {
};
}
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async uploadNewWorkspaceLogo(
@AuthUser() currentUser: AuthContextUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDTO> {
const workspace =
await this.fileCorePictureService.getPendingWorkspaceForLogoUploadOrThrow(
{
userId: currentUser.id,
workspaceId,
},
);
const buffer = await streamToBuffer(
createReadStream(),
bytes(settings.storage.maxFileSize) ?? undefined,
);
return this.fileCorePictureService.uploadWorkspacePicture({
file: buffer,
filename,
workspace,
});
}
@Mutation(() => TransientTokenDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async generateTransientToken(
@@ -951,7 +951,6 @@ export class AuthService {
workspaceInviteHash,
workspaceId,
billingCheckoutSessionState,
action,
locale,
returnToPath,
}: MicrosoftRequest['user'] | GoogleRequest['user'],
@@ -964,11 +963,7 @@ export class AuthService {
// Route SSO sign-ins through the same create-or-select flow as credentials
// instead of landing straight on a workspace subdomain.
if (
!workspaceId &&
!workspaceInviteHash &&
action === 'list-available-workspaces'
) {
if (!workspaceId && !workspaceInviteHash) {
const user =
existingUser ??
(await this.signInUpService.signUpWithoutWorkspace(
@@ -1010,15 +1005,12 @@ export class AuthService {
return url.toString();
}
const currentWorkspace =
action === 'create-new-workspace'
? undefined
: await this.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider,
});
const currentWorkspace = await this.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider,
});
try {
const invitation =
@@ -516,6 +516,18 @@ export class SignInUpService {
await this.assertWorkspaceCreationAllowed(userData);
const displayName = options?.displayName?.trim();
if (!displayName) {
throw new AuthException(
'Workspace name is required',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Workspace name is required`,
},
);
}
const requestedSubdomain = options?.subdomain;
if (isDefined(requestedSubdomain)) {
@@ -544,7 +556,7 @@ export class SignInUpService {
isWorkEmailFound ? { userEmail: email } : {},
),
workspaceCustomApplicationId,
displayName: options?.displayName ?? '',
displayName,
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
});
@@ -8,13 +8,18 @@ import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-p
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity]),
TypeOrmModule.forFeature([
FileEntity,
WorkspaceEntity,
UserWorkspaceEntity,
]),
PermissionsModule,
FileStorageModule,
FileUrlModule,
@@ -1,12 +1,14 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import bytes from 'bytes';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { settings } from 'src/engine/constants/settings';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -41,7 +43,10 @@ export class FileCorePictureResolver {
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDTO> {
const buffer = await streamToBuffer(createReadStream());
const buffer = await streamToBuffer(
createReadStream(),
bytes(settings.storage.maxFileSize) ?? undefined,
);
return await this.fileCorePictureService.uploadWorkspacePicture({
file: buffer,
@@ -57,7 +62,10 @@ export class FileCorePictureResolver {
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDTO> {
const buffer = await streamToBuffer(createReadStream());
const buffer = await streamToBuffer(
createReadStream(),
bytes(settings.storage.maxFileSize) ?? undefined,
);
return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture(
{
@@ -8,6 +8,7 @@ import { FileTypeParser } from 'file-type';
import { detectPdf } from '@file-type/pdf';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Like, type QueryRunner, Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -15,6 +16,10 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -22,6 +27,7 @@ import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.s
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@@ -35,6 +41,8 @@ export class FileCorePictureService {
private readonly fileStorageService: FileStorageService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectWorkspaceScopedRepository(FileEntity)
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
private readonly fileUrlService: FileUrlService,
@@ -137,6 +145,35 @@ export class FileCorePictureService {
};
}
async getPendingWorkspaceForLogoUploadOrThrow({
userId,
workspaceId,
}: {
userId: string;
workspaceId: string;
}): Promise<WorkspaceEntity> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId, workspaceId },
});
if (
!isDefined(workspace) ||
!isDefined(userWorkspace) ||
workspace.activationStatus !== WorkspaceActivationStatus.PENDING_CREATION
) {
throw new AuthException(
'Cannot set a logo for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return workspace;
}
async uploadWorkspaceMemberProfilePicture({
file,
filename,
@@ -39,7 +39,9 @@ export class OnboardingService {
private isWorkspaceActivationPending(workspace: WorkspaceEntity) {
return (
workspace.activationStatus === WorkspaceActivationStatus.PENDING_CREATION
workspace.activationStatus ===
WorkspaceActivationStatus.PENDING_CREATION ||
workspace.activationStatus === WorkspaceActivationStatus.ONGOING_CREATION
);
}
@@ -166,6 +166,14 @@ export class UpgradeMigrationService {
? queryRunner.manager.getRepository(UpgradeMigrationEntity)
: this.upgradeMigrationRepository;
const existingInitialMigration = await repository.findOne({
where: { name, attempt: 1, workspaceId, isInitial: true },
});
if (isDefined(existingInitialMigration)) {
return;
}
await repository.save({
name,
status,
@@ -275,7 +275,10 @@ describe('UserWorkspaceService', () => {
];
const workspaceMemberRepository = {
insert: jest.fn(),
find: jest.fn().mockResolvedValue(workspaceMember),
find: jest
.fn()
.mockResolvedValueOnce([])
.mockResolvedValue(workspaceMember),
};
jest
@@ -304,6 +307,31 @@ describe('UserWorkspaceService', () => {
avatarUrl: 'userWorkspace-avatar-url',
});
});
it('should not create a workspace member when one already exists', async () => {
const workspaceId = 'workspace-id';
const user = {
id: 'user-id',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
locale: 'en',
} as unknown as AuthContextUser;
const workspaceMemberRepository = {
insert: jest.fn(),
find: jest
.fn()
.mockResolvedValue([{ id: 'existing-member-id', userId: 'user-id' }]),
};
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(workspaceMemberRepository as any);
await service.createWorkspaceMember(workspaceId, user);
expect(workspaceMemberRepository.insert).not.toHaveBeenCalled();
});
});
describe('addUserToWorkspaceIfUserNotInWorkspace', () => {
@@ -149,6 +149,14 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
{ shouldBypassPermissionChecks: true },
);
const existingWorkspaceMembers = await workspaceMemberRepository.find({
where: { userId: user.id },
});
if (existingWorkspaceMembers.length > 0) {
return;
}
const userWorkspace = await this.userWorkspaceRepository.findOneOrFail({
where: {
userId: user.id,
@@ -4,7 +4,13 @@ import { IsOptional, IsString } from 'class-validator';
@InputType()
export class ActivateWorkspaceInput {
@Field({ nullable: true })
// The workspace name is set at creation (signUpInNewWorkspace). This field is
// ignored during activation and kept only for backward compatibility.
@Field({
nullable: true,
description:
'Deprecated: the workspace name is set at creation (signUpInNewWorkspace) and this field is ignored during activation. Kept for backward compatibility.',
})
@IsString()
@IsOptional()
displayName?: string;
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
@@ -8,7 +8,7 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { DataSource, QueryRunner, Repository } from 'typeorm';
import { DataSource, LessThan, QueryRunner, Repository } from 'typeorm';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
@@ -42,7 +42,6 @@ import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/se
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
WorkspaceException,
@@ -76,6 +75,12 @@ import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
// A workspace stuck in ONGOING_CREATION for longer than this is treated as a
// crashed activation (the process died before the catch block could reset it to
// PENDING_CREATION) and may be retried. It is far longer than a real activation
// takes, so a genuinely in-progress activation is never reclaimed.
const WORKSPACE_ACTIVATION_STALE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
@Injectable()
// oxlint-disable-next-line twenty/inject-workspace-repository
export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
@@ -324,57 +329,90 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
return updatedWorkspace;
}
async activateWorkspace(
user: AuthContextUser,
workspace: WorkspaceEntity,
data: ActivateWorkspaceInput,
) {
if (!data.displayName || !data.displayName.length) {
throw new BadRequestException("'displayName' not provided");
async activateWorkspace(user: AuthContextUser, workspace: WorkspaceEntity) {
// Acquire the activation lock by atomically moving the workspace to
// ONGOING_CREATION. First try the normal case (PENDING_CREATION). If nothing
// matches, the workspace may be stuck in ONGOING_CREATION from a prior
// attempt that was killed before the catch block could reset it — reclaim it,
// but only once the lock is stale, so a genuinely concurrent activation is
// never interrupted. Postgres row locking serializes concurrent reclaims, and
// repository.update bumps updatedAt, so a reclaimed lock is immediately fresh.
let activationLockResult = await this.workspaceRepository.update(
{
id: workspace.id,
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
},
{ activationStatus: WorkspaceActivationStatus.ONGOING_CREATION },
);
if ((activationLockResult.affected ?? 0) === 0) {
activationLockResult = await this.workspaceRepository.update(
{
id: workspace.id,
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
updatedAt: LessThan(
new Date(Date.now() - WORKSPACE_ACTIVATION_STALE_LOCK_TIMEOUT_MS),
),
},
{ activationStatus: WorkspaceActivationStatus.ONGOING_CREATION },
);
}
if (
workspace.activationStatus === WorkspaceActivationStatus.ONGOING_CREATION
) {
if ((activationLockResult.affected ?? 0) === 0) {
// Activation is idempotent for the terminal state: if a prior attempt
// already completed (e.g. the client lost the response and retried),
// return the active workspace instead of failing. Otherwise another
// activation is genuinely in progress and must not be interrupted.
const existingWorkspace = await this.workspaceRepository.findOneBy({
id: workspace.id,
});
if (
existingWorkspace?.activationStatus === WorkspaceActivationStatus.ACTIVE
) {
return existingWorkspace;
}
throw new Error('Workspace is already being created');
}
if (
workspace.activationStatus !== WorkspaceActivationStatus.PENDING_CREATION
) {
throw new Error('Workspace is not pending creation');
}
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
});
await this.coreEntityCacheService.invalidate(
'workspaceEntity',
workspace.id,
);
await this.workspaceManagerService.init({
workspace,
userId: user.id,
});
try {
await this.workspaceManagerService.init({
workspace,
userId: user.id,
});
await this.featureFlagService.enableFeatureFlags(
DEFAULT_FEATURE_FLAGS,
workspace.id,
);
await this.featureFlagService.enableFeatureFlags(
DEFAULT_FEATURE_FLAGS,
workspace.id,
);
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
await this.prefillCreatedWorkspaceRecords({
workspaceId: workspace.id,
schemaName: getWorkspaceSchemaName(workspace.id),
});
await this.prefillCreatedWorkspaceRecords({
workspaceId: workspace.id,
schemaName: getWorkspaceSchemaName(workspace.id),
});
await this.activateAndInitializeUpgradeState({
workspaceId: workspace.id,
displayName: data.displayName,
});
await this.activateAndInitializeUpgradeState({
workspaceId: workspace.id,
});
} catch (error) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
});
await this.coreEntityCacheService.invalidate(
'workspaceEntity',
workspace.id,
);
throw error;
}
try {
await this.sdkClientGenerationService.enqueueSdkClientGenerationForWorkspace(
@@ -399,11 +437,9 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
private async activateAndInitializeUpgradeState({
displayName,
workspaceId,
}: {
workspaceId: string;
displayName: string;
}): Promise<void> {
const lastAttemptedInstanceCommand =
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
@@ -423,7 +459,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
try {
await queryRunner.manager.update(WorkspaceEntity, workspaceId, {
displayName,
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
@@ -115,11 +115,14 @@ export class WorkspaceResolver {
@Mutation(() => WorkspaceEntity)
@UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
async activateWorkspace(
@Args('data') data: ActivateWorkspaceInput,
// Deprecated: the workspace name is set at creation. This argument is kept
// for backward compatibility (removing it would be a breaking schema change)
// but is ignored.
@Args('data') _data: ActivateWorkspaceInput,
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return await this.workspaceService.activateWorkspace(user, workspace, data);
return await this.workspaceService.activateWorkspace(user, workspace);
}
@Mutation(() => WorkspaceEntity)
@@ -10,6 +10,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { MEMBER_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/member-role-label.constants';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
@@ -117,11 +118,17 @@ export class WorkspaceManagerService {
});
}
const memberRole = await this.roleService.createMemberRole({
workspaceId,
ownerFlatApplication: workspaceCustomFlatApplication,
const existingMemberRole = await this.roleRepository.findOne(workspaceId, {
where: { label: MEMBER_ROLE_LABEL },
});
const memberRole =
existingMemberRole ??
(await this.roleService.createMemberRole({
workspaceId,
ownerFlatApplication: workspaceCustomFlatApplication,
}));
await this.workspaceRepository.update(workspaceId, {
defaultRoleId: memberRole.id,
});
@@ -72,7 +72,6 @@ describe('Successful user and workspace creation', () => {
data: { activateWorkspace: activateWorkspaceData },
} = await activateWorkspace({
accessToken: newWorkspaceAccessToken,
displayName: '42 answer',
expectToFail: false,
});
@@ -131,6 +130,180 @@ describe('Successful user and workspace creation', () => {
);
});
it('should reclaim a stale ONGOING_CREATION workspace and activate it', async () => {
const uniqueEmail = `test-reclaim-${randomUUID()}@example.com`;
const { data } = await signUp({
input: {
email: uniqueEmail,
password: 'Test123!@#',
},
expectToFail: false,
});
createdUserAccessToken =
data.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const {
data: { signUpInNewWorkspace: signUpInNewWorkspaceData },
} = await signUpInNewWorkspace({
accessToken: createdUserAccessToken,
expectToFail: false,
});
const workspaceId = signUpInNewWorkspaceData.workspace.id;
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
origin: signUpInNewWorkspaceData.workspace.workspaceUrls.subdomainUrl,
loginToken: signUpInNewWorkspaceData.loginToken.token,
expectToFail: false,
});
const newWorkspaceAccessToken =
authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
// Simulate an activation attempt that was killed mid-flight: the workspace
// is stuck in ONGOING_CREATION with a stale updatedAt. A retry should
// reclaim the stale lock and finish activation.
await testDataSource.query(
`UPDATE core.workspace SET "activationStatus" = 'ONGOING_CREATION', "updatedAt" = now() - interval '10 minutes' WHERE id = $1`,
[workspaceId],
);
const {
data: { activateWorkspace: activateWorkspaceData },
} = await activateWorkspace({
accessToken: newWorkspaceAccessToken,
expectToFail: false,
});
expect(activateWorkspaceData.activationStatus).toBe(
WorkspaceActivationStatus.ACTIVE,
);
});
it('should not reclaim a workspace whose activation is genuinely in progress', async () => {
const uniqueEmail = `test-fresh-ongoing-${randomUUID()}@example.com`;
const { data } = await signUp({
input: {
email: uniqueEmail,
password: 'Test123!@#',
},
expectToFail: false,
});
createdUserAccessToken =
data.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const {
data: { signUpInNewWorkspace: signUpInNewWorkspaceData },
} = await signUpInNewWorkspace({
accessToken: createdUserAccessToken,
expectToFail: false,
});
const workspaceId = signUpInNewWorkspaceData.workspace.id;
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
origin: signUpInNewWorkspaceData.workspace.workspaceUrls.subdomainUrl,
loginToken: signUpInNewWorkspaceData.loginToken.token,
expectToFail: false,
});
const newWorkspaceAccessToken =
authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
// A fresh ONGOING_CREATION lock means another activation is genuinely in
// progress, so a concurrent attempt must be rejected rather than reclaim it.
await testDataSource.query(
`UPDATE core.workspace SET "activationStatus" = 'ONGOING_CREATION', "updatedAt" = now() WHERE id = $1`,
[workspaceId],
);
const { errors } = await activateWorkspace({
accessToken: newWorkspaceAccessToken,
expectToFail: true,
});
expect(errors).toBeDefined();
// This workspace was never actually activated (no tenant schema/metadata),
// so the standard deleteUser teardown cannot operate on it. Skip the
// afterEach cleanup; the integration test database is reset per run.
createdUserAccessToken = undefined;
});
it('should be idempotent when re-activating an already-active workspace', async () => {
const uniqueEmail = `test-idempotent-${randomUUID()}@example.com`;
const { data } = await signUp({
input: {
email: uniqueEmail,
password: 'Test123!@#',
},
expectToFail: false,
});
createdUserAccessToken =
data.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const {
data: { signUpInNewWorkspace: signUpInNewWorkspaceData },
} = await signUpInNewWorkspace({
accessToken: createdUserAccessToken,
expectToFail: false,
});
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
origin: signUpInNewWorkspaceData.workspace.workspaceUrls.subdomainUrl,
loginToken: signUpInNewWorkspaceData.loginToken.token,
expectToFail: false,
});
const newWorkspaceAccessToken =
authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
await activateWorkspace({
accessToken: newWorkspaceAccessToken,
expectToFail: false,
});
// A second activation (e.g. the client lost the first response and retried)
// must succeed idempotently instead of throwing "already being created".
const {
data: { activateWorkspace: secondActivation },
} = await activateWorkspace({
accessToken: newWorkspaceAccessToken,
expectToFail: false,
});
expect(secondActivation.activationStatus).toBe(
WorkspaceActivationStatus.ACTIVE,
);
});
it('should suspend and soft-delete workspace when last user is deleted', async () => {
const uniqueEmail = `test-delete-${randomUUID()}@example.com`;
@@ -173,7 +346,6 @@ describe('Successful user and workspace creation', () => {
await activateWorkspace({
accessToken: newWorkspaceAccessToken,
displayName: 'Test Workspace for Deletion',
expectToFail: false,
});
@@ -8,13 +8,11 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
type ActivateWorkspaceUtilArgs = {
accessToken: string;
displayName: string;
expectToFail?: boolean;
};
export const activateWorkspace = async ({
accessToken,
displayName,
expectToFail,
}: ActivateWorkspaceUtilArgs): CommonResponseBody<{
activateWorkspace: WorkspaceEntity;
@@ -36,9 +34,7 @@ export const activateWorkspace = async ({
{
query: mutation,
variables: {
input: {
displayName,
},
input: {},
},
},
accessToken,
@@ -8,18 +8,20 @@ import { type SignUpDTO } from 'src/engine/core-modules/auth/dto/sign-up.dto';
type SignUpOnNewWorkspaceUtilArgs = {
accessToken: string;
displayName?: string;
expectToFail?: boolean;
};
export const signUpInNewWorkspace = async ({
accessToken,
displayName = 'Test Workspace',
expectToFail,
}: SignUpOnNewWorkspaceUtilArgs): CommonResponseBody<{
signUpInNewWorkspace: SignUpDTO;
}> => {
const mutation = gql`
mutation SignUpInNewWorkspace {
signUpInNewWorkspace {
mutation SignUpInNewWorkspace($input: SignUpInNewWorkspaceInput) {
signUpInNewWorkspace(input: $input) {
loginToken {
token
expiresAt
@@ -38,7 +40,7 @@ export const signUpInNewWorkspace = async ({
const response = await makeMetadataAPIRequest(
{
query: mutation,
variables: {},
variables: { input: { displayName } },
},
accessToken,
);
+1 -1
View File
@@ -7,7 +7,7 @@ export enum AppPath {
ResetPassword = '/reset-password/:passwordResetToken',
// Onboarding
CreateWorkspace = '/create/workspace',
WorkspaceActivation = '/workspace-activation',
CreateProfile = '/create/profile',
SyncEmails = '/sync/emails',
InviteTeam = '/invite-team',