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:
@@ -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>;
|
||||
|
||||
+50
-30
@@ -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 },
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
+31
-36
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
-1
@@ -23,7 +23,6 @@ export const SignInUpGlobalScopeFormEffect = () => {
|
||||
await navigateAfterMultiWorkspaceSignInUp(
|
||||
user.availableWorkspaces,
|
||||
user.email,
|
||||
{ newTab: false },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+88
-37
@@ -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`}
|
||||
|
||||
+141
-51
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+16
@@ -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(
|
||||
|
||||
+4
-2
@@ -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
-1
@@ -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());
|
||||
}
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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) ||
|
||||
|
||||
+10
-28
@@ -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
-6
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+10
-2
@@ -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(
|
||||
{
|
||||
|
||||
+37
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+29
-1
@@ -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', () => {
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+78
-43
@@ -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,
|
||||
});
|
||||
|
||||
+174
-2
@@ -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,
|
||||
|
||||
+5
-3
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user