From 7943141d03535a8a1ee9171931d3bd0dcab238ff Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 3 Dec 2024 19:06:28 +0100 Subject: [PATCH] feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656) ## Summary Add support for multi-workspace feature and adjust configurations and states accordingly. - Introduced new state isMultiWorkspaceEnabledState. - Updated ClientConfigProviderEffect component to handle multi-workspace. - Modified GraphQL schema and queries to include multi-workspace related configurations. - Adjusted server environment variables and their respective documentation to support multi-workspace toggle. - Updated server-side logic to handle new multi-workspace configurations and conditions. --- .../drivers/env_variables.ts | 1 - .../src/generated-metadata/graphql.ts | 131 ++++-- .../twenty-front/src/generated/graphql.tsx | 406 +++++++++------- .../app/components/AppRouterProviders.tsx | 2 + .../modules/app/components/SettingsRoutes.tsx | 20 +- .../findAvailableSSOIdentityProviders.ts | 13 - .../auth/graphql/mutations/generateJWT.ts | 24 - .../auth/graphql/mutations/switchWorkspace.ts | 23 + .../auth/graphql/queries/checkUserExists.ts | 21 +- .../getPublicWorkspaceDataBySubdomain.ts | 25 + .../auth/hooks/__tests__/useAuth.test.tsx | 6 +- .../src/modules/auth/hooks/useAuth.ts | 72 ++- .../components/SignInUpEmailField.tsx | 60 +++ .../sign-in-up/components/SignInUpForm.tsx | 393 ---------------- .../components/SignInUpGlobalScopeForm.tsx | 164 +++++++ .../components/SignInUpPasswordField.tsx | 67 +++ .../SignInUpSSOIdentityProviderSelection.tsx | 41 ++ .../components/SignInUpWithCredentials.tsx | 142 ++++++ .../components/SignInUpWithGoogle.tsx | 32 ++ .../components/SignInUpWithMicrosoft.tsx | 27 ++ .../sign-in-up/components/SignInUpWithSSO.tsx | 40 ++ .../components/SignInUpWorkspaceScopeForm.tsx | 86 ++++ .../modules/auth/sign-in-up/hooks/useSSO.ts | 15 - .../auth/sign-in-up/hooks/useSignInUp.tsx | 102 ++-- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 49 +- .../availableIdentityProviderForAuthState.ts | 9 + .../auth/states/availableWorkspacesForSSO.ts | 11 - .../auth/states/currentWorkspaceState.ts | 4 + .../states/lastAuthenticateWorkspaceState.ts | 18 + .../modules/auth/states/signInUpModeState.ts | 7 + .../modules/auth/states/signInUpStepState.ts | 4 +- .../src/modules/auth/states/tokenPairState.ts | 12 +- .../auth/states/workspacePublicDataState.ts | 8 + .../src/modules/auth/states/workspaces.ts | 5 +- .../modules/auth/types/signInUpMode.type.ts | 4 + .../components/ClientConfigProviderEffect.tsx | 31 +- .../graphql/queries/getClientConfig.ts | 11 +- .../states/authProvidersState.ts | 6 +- .../states/isMultiWorkspaceEnabledState.ts | 6 + .../client-config/states/isSSOEnabledState.ts | 6 + .../states/isSignUpDisabledState.ts | 6 - ...ColumnDefinitionsFromFieldMetadata.test.ts | 4 + .../SettingsNavigationDrawerItems.tsx | 25 +- .../SettingsSSOIdentitiesProvidersForm.tsx | 18 +- ...SettingsSSOIdentitiesProvidersListCard.tsx | 29 +- ...sSSOIdentitiesProvidersListCardWrapper.tsx | 26 +- .../SettingsSecurityOptionsList.tsx | 144 +++++- .../security/types/AuthProviders.type.ts | 4 + .../security/types/SSOIdentityProvider.ts | 7 +- .../utils/parseSAMLMetadataFromXMLFile.ts | 7 +- .../utils/sSOIdentityProviderDefaultValues.ts | 14 +- .../components/WorkspaceLogoUploader.tsx | 4 +- .../src/modules/types/SettingsPath.ts | 1 + .../MultiWorkspaceDropdownButton.tsx | 53 ++- .../components/NavigationDrawerHeader.tsx | 8 +- .../hooks/useWorkspaceSwitching.ts | 80 +--- .../url-manager/hooks/useUrlManager.ts | 110 +++++ .../url-manager/states/url-manager.state.ts | 12 + .../graphql/fragments/userQueryFragment.ts | 5 + .../components/WorkspaceProviderEffect.tsx | 96 ++++ .../graphql/mutations/activateWorkspace.ts | 8 +- .../graphql/mutations/updateWorkspace.ts | 5 + .../twenty-front/src/pages/auth/Invite.tsx | 8 +- .../src/pages/auth/PasswordReset.tsx | 7 +- .../src/pages/auth/SSOWorkspaceSelection.tsx | 68 --- .../twenty-front/src/pages/auth/SignInUp.tsx | 84 ++-- .../src/pages/onboarding/CreateWorkspace.tsx | 20 +- .../src/pages/settings/SettingsWorkspace.tsx | 100 ++-- .../settings/security/SettingsSecurity.tsx | 41 +- .../settings/workspace/SettingsDomain.tsx | 154 +++++++ .../src/testing/decorators/PageDecorator.tsx | 2 + .../twenty-front/src/testing/graphqlMocks.ts | 23 + .../src/testing/mock-data/config.ts | 13 +- .../src/testing/mock-data/users.ts | 4 + .../twenty-front/src/utils/cookie-storage.ts | 4 +- .../twenty-front/src/utils/recoil-effects.ts | 40 +- packages/twenty-server/.env.example | 2 +- .../0-34/0-34-generate-subdomain.command.ts | 123 +++++ .../0-34/0-34-upgrade-version.command.ts | 38 ++ .../0-34/0-34-upgrade-version.module.ts | 25 + .../database/typeorm-seeds/core/workspaces.ts | 4 + .../1730137590546-addSubdomainToWorkspace.ts | 26 ++ ...6367-addAuthProvidersColumnsToWorkspace.ts | 31 ++ .../core-query-builder.factory.ts | 16 +- .../core-query-builder.module.ts | 3 +- .../metadata/rest-api-metadata.service.ts | 8 +- .../engine/core-modules/auth/auth.module.ts | 18 +- .../core-modules/auth/auth.resolver.spec.ts | 9 + .../engine/core-modules/auth/auth.resolver.ts | 124 ++--- .../google-apis-auth.controller.ts | 27 +- .../controllers/google-auth.controller.ts | 108 ++++- .../microsoft-apis-auth.controller.ts | 29 +- .../controllers/microsoft-auth.controller.ts | 77 +++- .../auth/controllers/sso-auth.controller.ts | 87 ++-- .../verify-auth.controller.spec.ts | 32 -- .../controllers/verify-auth.controller.ts | 26 -- .../auth/dto/available-workspaces.output.ts | 45 ++ .../auth/dto/generateJWT.output.ts | 43 -- ...jwt.input.ts => switch-workspace.input.ts} | 2 +- .../auth/dto/user-exists.entity.ts | 27 +- .../filters/auth-oauth-exception.filter.ts | 6 +- .../auth/guards/google-oauth.guard.ts | 7 + .../auth/guards/microsoft-oauth.guard.ts | 7 + .../auth/services/auth.service.spec.ts | 138 +++++- .../auth/services/auth.service.ts | 186 ++++++-- .../services/reset-password.service.spec.ts | 9 + .../auth/services/reset-password.service.ts | 9 +- .../auth/services/sign-in-up.service.spec.ts | 435 +++++++++++++++++- .../auth/services/sign-in-up.service.ts | 219 +++++---- .../services/switch-workspace.service.spec.ts | 59 ++- .../auth/services/switch-workspace.service.ts | 44 +- .../auth/strategies/google.auth.strategy.ts | 3 + .../strategies/microsoft.auth.strategy.ts | 3 + .../auth/strategies/saml.auth.strategy.ts | 52 ++- .../services/access-token.service.spec.ts | 4 +- .../token/services/access-token.service.ts | 24 +- .../services/login-token.service.spec.ts | 2 +- .../token/services/login-token.service.ts | 5 +- .../core-modules/billing/billing.module.ts | 2 + .../billing-portal.workspace-service.ts | 6 +- .../billing/stripe/stripe.module.ts | 2 + .../billing/stripe/stripe.service.ts | 10 +- .../client-config/client-config.entity.ts | 38 +- .../client-config/client-config.resolver.ts | 14 +- .../domain-manager/domain-manager.module.ts | 13 + .../service/domain-manager.service.spec.ts | 157 +++++++ .../service/domain-manager.service.ts | 264 +++++++++++ .../environment/environment-variables.ts | 28 +- .../core-modules/open-api/open-api.service.ts | 2 +- .../core-modules/sso/services/sso.service.ts | 39 -- .../engine/core-modules/sso/sso.resolver.ts | 9 - .../workspace-sso-identity-provider.entity.ts | 2 +- .../user-workspace/user-workspace.module.ts | 7 +- .../user-workspace/user-workspace.service.ts | 44 +- .../user/services/user.service.ts | 34 +- .../engine/core-modules/user/user.resolver.ts | 23 +- .../engine/core-modules/user/user.validate.ts | 34 ++ .../workspace-invitation.service.spec.ts | 27 ++ .../services/workspace-invitation.service.ts | 194 ++++++-- ...-app-token-to-workspace-invitation.spec.ts | 61 +++ ...-app-token-to-workspace-invitation.util.ts | 30 ++ .../workspace-invitation.exception.ts | 1 - .../workspace-invitation.module.ts | 10 +- .../dtos/activate-workspace-output.ts | 13 + .../dtos/public-workspace-data.output.ts | 61 +++ .../workspace/dtos/update-workspace-input.ts | 20 + .../services/workspace.service.spec.ts | 5 + .../workspace/services/workspace.service.ts | 15 +- .../utils/getAuthProvidersByWorkspace.spec.ts | 80 ++++ .../utils/getAuthProvidersByWorkspace.ts | 17 + .../workspace/workspace.entity.ts | 16 + .../workspace/workspace.exception.ts | 12 + .../workspace/workspace.module.ts | 8 +- .../workspace/workspace.resolver.ts | 53 ++- .../workspace/workspace.validate.ts | 64 +++ .../auth/origin-header.decorator.ts | 11 + .../src/engine/guards/jwt-auth.guard.ts | 3 +- ...l-hydrate-request-from-token.middleware.ts | 4 +- .../__test__/get-domain-name-by-email.spec.ts | 27 ++ .../src/utils/__test__/is-work-email.spec.ts | 24 + .../src/utils/get-domain-name-by-email.ts | 19 + .../twenty-server/src/utils/is-work-email.ts | 19 +- .../src/utils/workspace-url.util.ts | 33 ++ .../display/icon/components/TablerIcons.ts | 1 + .../components/MenuItemSelectAvatar.tsx | 2 +- .../self-hosting/self-hosting-var.mdx | 9 +- .../developers/self-hosting/upgrade-guide.mdx | 34 +- 167 files changed, 5180 insertions(+), 1901 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts delete mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx create mode 100644 packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts delete mode 100644 packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts create mode 100644 packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/signInUpModeState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts create mode 100644 packages/twenty-front/src/modules/auth/types/signInUpMode.type.ts create mode 100644 packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts create mode 100644 packages/twenty-front/src/modules/client-config/states/isSSOEnabledState.ts delete mode 100644 packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts create mode 100644 packages/twenty-front/src/modules/settings/security/types/AuthProviders.type.ts create mode 100644 packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts create mode 100644 packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts create mode 100644 packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx delete mode 100644 packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx create mode 100644 packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.module.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts rename packages/twenty-server/src/engine/core-modules/auth/dto/{generate-jwt.input.ts => switch-workspace.input.ts} (84%) create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/user.validate.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/activate-workspace-output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts create mode 100644 packages/twenty-server/src/engine/decorators/auth/origin-header.decorator.ts create mode 100644 packages/twenty-server/src/utils/__test__/get-domain-name-by-email.spec.ts create mode 100644 packages/twenty-server/src/utils/__test__/is-work-email.spec.ts create mode 100644 packages/twenty-server/src/utils/get-domain-name-by-email.ts create mode 100644 packages/twenty-server/src/utils/workspace-url.util.ts diff --git a/packages/twenty-e2e-testing/drivers/env_variables.ts b/packages/twenty-e2e-testing/drivers/env_variables.ts index 7fd2860cb..768b1872c 100644 --- a/packages/twenty-e2e-testing/drivers/env_variables.ts +++ b/packages/twenty-e2e-testing/drivers/env_variables.ts @@ -4,7 +4,6 @@ import path from 'path'; export const envVariables = (variables: string) => { let payload = ` PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/default - FRONT_BASE_URL=http://localhost:3001 ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 2cfcd0d01..54cd29a02 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -32,6 +32,12 @@ export type ActivateWorkspaceInput = { displayName?: InputMaybe; }; +export type ActivateWorkspaceOutput = { + __typename?: 'ActivateWorkspaceOutput'; + loginToken: AuthToken; + workspace: Workspace; +}; + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -81,7 +87,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']['output']; microsoft: Scalars['Boolean']['output']; password: Scalars['Boolean']['output']; - sso: Scalars['Boolean']['output']; + sso: Array; }; export type AuthToken = { @@ -106,6 +112,15 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']['output']; }; +export type AvailableWorkspaceOutput = { + __typename?: 'AvailableWorkspaceOutput'; + displayName?: Maybe; + id: Scalars['String']['output']; + logo?: Maybe; + sso: Array; + subdomain: Scalars['String']['output']; +}; + export type Billing = { __typename?: 'Billing'; billingFreeTrialDurationInDays?: Maybe; @@ -161,14 +176,16 @@ export type ClientConfig = { __typename?: 'ClientConfig'; analyticsEnabled: Scalars['Boolean']['output']; api: ApiConfig; - authProviders: AuthProviders; billing: Billing; captcha: Captcha; chromeExtensionId?: Maybe; debugMode: Scalars['Boolean']['output']; + defaultSubdomain?: Maybe; + frontDomain: Scalars['String']['output']; + isMultiWorkspaceEnabled: Scalars['Boolean']['output']; + isSSOEnabled: Scalars['Boolean']['output']; sentry: Sentry; signInPrefilled: Scalars['Boolean']['output']; - signUpDisabled: Scalars['Boolean']['output']; support: Support; }; @@ -332,7 +349,7 @@ export type EditSsoOutput = { issuer: Scalars['String']['output']; name: Scalars['String']['output']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; export type EmailPasswordResetLink = { @@ -424,17 +441,13 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } -export type FindAvailableSsoidpInput = { - email: Scalars['String']['input']; -}; - export type FindAvailableSsoidpOutput = { __typename?: 'FindAvailableSSOIDPOutput'; id: Scalars['String']['output']; issuer: Scalars['String']['output']; name: Scalars['String']['output']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; workspace: WorkspaceNameAndId; }; @@ -451,22 +464,6 @@ export type FullName = { lastName: Scalars['String']['output']; }; -export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; - -export type GenerateJwtOutputWithAuthTokens = { - __typename?: 'GenerateJWTOutputWithAuthTokens'; - authTokens: AuthTokens; - reason: Scalars['String']['output']; - success: Scalars['Boolean']['output']; -}; - -export type GenerateJwtOutputWithSsoauth = { - __typename?: 'GenerateJWTOutputWithSSOAUTH'; - availableSSOIDPs: Array; - reason: Scalars['String']['output']; - success: Scalars['Boolean']['output']; -}; - export type GetAuthorizationUrlInput = { identityProviderId: Scalars['String']['input']; }; @@ -485,7 +482,7 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']['input']; }; -export enum IdpType { +export enum IdentityProviderType { Oidc = 'OIDC', Saml = 'SAML' } @@ -553,7 +550,7 @@ export enum MessageChannelVisibility { export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']['output']; - activateWorkspace: Workspace; + activateWorkspace: ActivateWorkspaceOutput; addUserToWorkspace: User; addUserToWorkspaceByInviteToken: User; authorizeApp: AuthorizeApp; @@ -578,7 +575,7 @@ export type Mutation = { deleteOneServerlessFunction: ServerlessFunction; deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; - deleteWorkflowVersionStep: Scalars['Boolean']['output']; + deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; @@ -586,9 +583,7 @@ export type Mutation = { enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; - findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: GenerateJwt; generateTransientToken: TransientToken; getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; @@ -599,6 +594,7 @@ export type Mutation = { sendInvitations: SendInvitationsOutput; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; + switchWorkspace: PublicWorkspaceDataOutput; syncRemoteTable: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable; track: Analytics; @@ -609,7 +605,7 @@ export type Mutation = { updateOneRemoteServer: RemoteServer; updateOneServerlessFunction: ServerlessFunction; updatePasswordViaResetToken: InvalidatePassword; - updateWorkflowVersionStep: Scalars['Boolean']['output']; + updateWorkflowVersionStep: WorkflowAction; updateWorkspace: Workspace; updateWorkspaceFeatureFlag: Scalars['Boolean']['output']; uploadFile: Scalars['String']['output']; @@ -778,22 +774,12 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; -export type MutationFindAvailableSsoIdentityProvidersArgs = { - input: FindAvailableSsoidpInput; -}; - - export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']['input']; expiresAt: Scalars['String']['input']; }; -export type MutationGenerateJwtArgs = { - workspaceId: Scalars['String']['input']; -}; - - export type MutationGetAuthorizationUrlArgs = { input: GetAuthorizationUrlInput; }; @@ -838,6 +824,11 @@ export type MutationSignUpArgs = { }; +export type MutationSwitchWorkspaceArgs = { + workspaceId: Scalars['String']['input']; +}; + + export type MutationSyncRemoteTableArgs = { input: RemoteTableInput; }; @@ -1007,6 +998,15 @@ export type ProductPricesEntity = { totalNumberOfPrices: Scalars['Int']['output']; }; +export type PublicWorkspaceDataOutput = { + __typename?: 'PublicWorkspaceDataOutput'; + authProviders: AuthProviders; + displayName?: Maybe; + id: Scalars['String']['output']; + logo?: Maybe; + subdomain: Scalars['String']['output']; +}; + export type PublishServerlessFunctionInput = { /** The id of the function. */ id: Scalars['ID']['input']; @@ -1015,13 +1015,14 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: SessionEntity; - checkUserExists: UserExists; + checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; currentUser: User; currentWorkspace: Workspace; field: Field; fields: FieldConnection; + findAvailableWorkspacesByEmail: Array; findDistantTablesWithStatus: Array; findManyRemoteServersByType: Array; findManyServerlessFunctions: Array; @@ -1032,6 +1033,7 @@ export type Query = { getAvailablePackages: Scalars['JSON']['output']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; + getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; @@ -1075,6 +1077,11 @@ export type QueryFieldsArgs = { }; +export type QueryFindAvailableWorkspacesByEmailArgs = { + email: Scalars['String']['input']; +}; + + export type QueryFindDistantTablesWithStatusArgs = { input: FindManyRemoteTablesInput; }; @@ -1262,6 +1269,24 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']['input']; }; +export type SsoConnection = { + __typename?: 'SSOConnection'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdentityProviderType; +}; + +export type SsoIdentityProvider = { + __typename?: 'SSOIdentityProvider'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdentityProviderType; +}; + export enum SsoIdentityProviderStatus { Active = 'Active', Error = 'Error', @@ -1353,7 +1378,7 @@ export type SetupSsoOutput = { issuer: Scalars['String']['output']; name: Scalars['String']['output']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; /** Sort Directions */ @@ -1555,8 +1580,12 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isGoogleAuthEnabled?: InputMaybe; + isMicrosoftAuthEnabled?: InputMaybe; + isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; + subdomain?: InputMaybe; }; export type User = { @@ -1594,9 +1623,12 @@ export type UserEdge = { export type UserExists = { __typename?: 'UserExists'; + availableWorkspaces: Array; exists: Scalars['Boolean']['output']; }; +export type UserExistsOutput = UserExists | UserNotExists; + export type UserInfo = { __typename?: 'UserInfo'; email: Scalars['String']['output']; @@ -1626,6 +1658,11 @@ export type UserMappingOptionsUser = { user?: Maybe; }; +export type UserNotExists = { + __typename?: 'UserNotExists'; + exists: Scalars['Boolean']['output']; +}; + export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']['output']; @@ -1653,6 +1690,10 @@ export type Verify = { export type WorkflowAction = { __typename?: 'WorkflowAction'; id: Scalars['UUID']['output']; + name: Scalars['String']['output']; + settings: Scalars['JSON']['output']; + type: Scalars['String']['output']; + valid: Scalars['Boolean']['output']; }; export type WorkflowRun = { @@ -1677,9 +1718,13 @@ export type Workspace = { hasValidEntrepriseKey: Scalars['Boolean']['output']; id: Scalars['UUID']['output']; inviteHash?: Maybe; + isGoogleAuthEnabled: Scalars['Boolean']['output']; + isMicrosoftAuthEnabled: Scalars['Boolean']['output']; + isPasswordAuthEnabled: Scalars['Boolean']['output']; isPublicInviteLinkEnabled: Scalars['Boolean']['output']; logo?: Maybe; metadataVersion: Scalars['Float']['output']; + subdomain: Scalars['String']['output']; updatedAt: Scalars['DateTime']['output']; workspaceMembersCount?: Maybe; }; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index ee5df1c08..ef7b67fbc 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -25,6 +25,12 @@ export type ActivateWorkspaceInput = { displayName?: InputMaybe; }; +export type ActivateWorkspaceOutput = { + __typename?: 'ActivateWorkspaceOutput'; + loginToken: AuthToken; + workspace: Workspace; +}; + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -74,7 +80,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']; microsoft: Scalars['Boolean']; password: Scalars['Boolean']; - sso: Scalars['Boolean']; + sso: Array; }; export type AuthToken = { @@ -99,6 +105,15 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']; }; +export type AvailableWorkspaceOutput = { + __typename?: 'AvailableWorkspaceOutput'; + displayName?: Maybe; + id: Scalars['String']; + logo?: Maybe; + sso: Array; + subdomain: Scalars['String']; +}; + export type Billing = { __typename?: 'Billing'; billingFreeTrialDurationInDays?: Maybe; @@ -154,14 +169,16 @@ export type ClientConfig = { __typename?: 'ClientConfig'; analyticsEnabled: Scalars['Boolean']; api: ApiConfig; - authProviders: AuthProviders; billing: Billing; captcha: Captcha; chromeExtensionId?: Maybe; debugMode: Scalars['Boolean']; + defaultSubdomain?: Maybe; + frontDomain: Scalars['String']; + isMultiWorkspaceEnabled: Scalars['Boolean']; + isSSOEnabled: Scalars['Boolean']; sentry: Sentry; signInPrefilled: Scalars['Boolean']; - signUpDisabled: Scalars['Boolean']; support: Support; }; @@ -233,7 +250,7 @@ export type EditSsoOutput = { issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; export type EmailPasswordResetLink = { @@ -325,17 +342,13 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } -export type FindAvailableSsoidpInput = { - email: Scalars['String']; -}; - export type FindAvailableSsoidpOutput = { __typename?: 'FindAvailableSSOIDPOutput'; id: Scalars['String']; issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; workspace: WorkspaceNameAndId; }; @@ -345,22 +358,6 @@ export type FullName = { lastName: Scalars['String']; }; -export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; - -export type GenerateJwtOutputWithAuthTokens = { - __typename?: 'GenerateJWTOutputWithAuthTokens'; - authTokens: AuthTokens; - reason: Scalars['String']; - success: Scalars['Boolean']; -}; - -export type GenerateJwtOutputWithSsoauth = { - __typename?: 'GenerateJWTOutputWithSSOAUTH'; - availableSSOIDPs: Array; - reason: Scalars['String']; - success: Scalars['Boolean']; -}; - export type GetAuthorizationUrlInput = { identityProviderId: Scalars['String']; }; @@ -379,7 +376,7 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; -export enum IdpType { +export enum IdentityProviderType { Oidc = 'OIDC', Saml = 'SAML' } @@ -447,7 +444,7 @@ export enum MessageChannelVisibility { export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']; - activateWorkspace: Workspace; + activateWorkspace: ActivateWorkspaceOutput; addUserToWorkspace: User; addUserToWorkspaceByInviteToken: User; authorizeApp: AuthorizeApp; @@ -474,9 +471,7 @@ export type Mutation = { enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; - findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: GenerateJwt; generateTransientToken: TransientToken; getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; @@ -487,6 +482,7 @@ export type Mutation = { sendInvitations: SendInvitationsOutput; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; + switchWorkspace: PublicWorkspaceDataOutput; track: Analytics; updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; @@ -621,22 +617,12 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; -export type MutationFindAvailableSsoIdentityProvidersArgs = { - input: FindAvailableSsoidpInput; -}; - - export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']; expiresAt: Scalars['String']; }; -export type MutationGenerateJwtArgs = { - workspaceId: Scalars['String']; -}; - - export type MutationGetAuthorizationUrlArgs = { input: GetAuthorizationUrlInput; }; @@ -681,6 +667,11 @@ export type MutationSignUpArgs = { }; +export type MutationSwitchWorkspaceArgs = { + workspaceId: Scalars['String']; +}; + + export type MutationTrackArgs = { action: Scalars['String']; payload: Scalars['JSON']; @@ -825,6 +816,15 @@ export type ProductPricesEntity = { totalNumberOfPrices: Scalars['Int']; }; +export type PublicWorkspaceDataOutput = { + __typename?: 'PublicWorkspaceDataOutput'; + authProviders: AuthProviders; + displayName?: Maybe; + id: Scalars['String']; + logo?: Maybe; + subdomain: Scalars['String']; +}; + export type PublishServerlessFunctionInput = { /** The id of the function. */ id: Scalars['ID']; @@ -833,11 +833,12 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: SessionEntity; - checkUserExists: UserExists; + checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; currentUser: User; currentWorkspace: Workspace; + findAvailableWorkspacesByEmail: Array; findManyServerlessFunctions: Array; findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; @@ -845,6 +846,7 @@ export type Query = { getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; + getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; @@ -875,6 +877,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryFindAvailableWorkspacesByEmailArgs = { + email: Scalars['String']; +}; + + export type QueryFindOneServerlessFunctionArgs = { input: ServerlessFunctionIdInput; }; @@ -1001,6 +1008,24 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']; }; +export type SsoConnection = { + __typename?: 'SSOConnection'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdentityProviderType; +}; + +export type SsoIdentityProvider = { + __typename?: 'SSOIdentityProvider'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdentityProviderType; +}; + export enum SsoIdentityProviderStatus { Active = 'Active', Error = 'Error', @@ -1092,7 +1117,7 @@ export type SetupSsoOutput = { issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; /** Sort Directions */ @@ -1263,8 +1288,12 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isGoogleAuthEnabled?: InputMaybe; + isMicrosoftAuthEnabled?: InputMaybe; + isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; + subdomain?: InputMaybe; }; export type User = { @@ -1302,9 +1331,12 @@ export type UserEdge = { export type UserExists = { __typename?: 'UserExists'; + availableWorkspaces: Array; exists: Scalars['Boolean']; }; +export type UserExistsOutput = UserExists | UserNotExists; + export type UserInfo = { __typename?: 'UserInfo'; email: Scalars['String']; @@ -1324,6 +1356,11 @@ export type UserMappingOptionsUser = { user?: Maybe; }; +export type UserNotExists = { + __typename?: 'UserNotExists'; + exists: Scalars['Boolean']; +}; + export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']; @@ -1379,9 +1416,13 @@ export type Workspace = { hasValidEntrepriseKey: Scalars['Boolean']; id: Scalars['UUID']; inviteHash?: Maybe; + isGoogleAuthEnabled: Scalars['Boolean']; + isMicrosoftAuthEnabled: Scalars['Boolean']; + isPasswordAuthEnabled: Scalars['Boolean']; isPublicInviteLinkEnabled: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; + subdomain: Scalars['String']; updatedAt: Scalars['DateTime']; workspaceMembersCount?: Maybe; }; @@ -1784,13 +1825,6 @@ export type EmailPasswordResetLinkMutationVariables = Exact<{ export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } }; -export type FindAvailableSsoIdentityProvidersMutationVariables = Exact<{ - input: FindAvailableSsoidpInput; -}>; - - -export type FindAvailableSsoIdentityProvidersMutation = { __typename?: 'Mutation', findAvailableSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> }; - export type GenerateApiKeyTokenMutationVariables = Exact<{ apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -1799,13 +1833,6 @@ export type GenerateApiKeyTokenMutationVariables = Exact<{ export type GenerateApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyToken: { __typename?: 'ApiKeyToken', token: string } }; -export type GenerateJwtMutationVariables = Exact<{ - workspaceId: Scalars['String']; -}>; - - -export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'GenerateJWTOutputWithAuthTokens', success: boolean, reason: string, authTokens: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } } | { __typename?: 'GenerateJWTOutputWithSSOAUTH', success: boolean, reason: string, availableSSOIDPs: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> } }; - export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>; @@ -1823,7 +1850,7 @@ export type ImpersonateMutationVariables = Exact<{ }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1843,6 +1870,13 @@ export type SignUpMutationVariables = Exact<{ export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; +export type SwitchWorkspaceMutationVariables = Exact<{ + workspaceId: Scalars['String']; +}>; + + +export type SwitchWorkspaceMutation = { __typename?: 'Mutation', switchWorkspace: { __typename?: 'PublicWorkspaceDataOutput', id: string, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } }; + export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ token: Scalars['String']; newPassword: Scalars['String']; @@ -1856,7 +1890,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1864,7 +1898,12 @@ export type CheckUserExistsQueryVariables = Exact<{ }>; -export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } }; +export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } }; + +export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } }; export type ValidatePasswordResetTokenQueryVariables = Exact<{ token: Scalars['String']; @@ -1903,7 +1942,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isSSOEnabled: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -1931,14 +1970,14 @@ export type CreateOidcIdentityProviderMutationVariables = Exact<{ }>; -export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; export type CreateSamlIdentityProviderMutationVariables = Exact<{ input: SetupSamlSsoInput; }>; -export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; @@ -1952,14 +1991,14 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ }>; -export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; -export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdpType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; +export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1976,7 +2015,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -2074,7 +2113,7 @@ export type ActivateWorkspaceMutationVariables = Exact<{ }>; -export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'Workspace', id: any } }; +export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'ActivateWorkspaceOutput', workspace: { __typename?: 'Workspace', id: any, subdomain: string }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; @@ -2086,7 +2125,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{ }>; -export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, domainName?: string | null, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } }; +export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, domainName?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } }; export type UploadWorkspaceLogoMutationVariables = Exact<{ file: Scalars['Upload']; @@ -2248,6 +2287,10 @@ export const UserQueryFragmentFragmentDoc = gql` allowImpersonation activationStatus isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled + subdomain hasValidEntrepriseKey featureFlags { id @@ -2269,6 +2312,7 @@ export const UserQueryFragmentFragmentDoc = gql` logo displayName domainName + subdomain } } userVars @@ -2645,39 +2689,6 @@ export function useEmailPasswordResetLinkMutation(baseOptions?: Apollo.MutationH export type EmailPasswordResetLinkMutationHookResult = ReturnType; export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult; export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions; -export const FindAvailableSsoIdentityProvidersDocument = gql` - mutation FindAvailableSSOIdentityProviders($input: FindAvailableSSOIDPInput!) { - findAvailableSSOIdentityProviders(input: $input) { - ...AvailableSSOIdentityProvidersFragment - } -} - ${AvailableSsoIdentityProvidersFragmentFragmentDoc}`; -export type FindAvailableSsoIdentityProvidersMutationFn = Apollo.MutationFunction; - -/** - * __useFindAvailableSsoIdentityProvidersMutation__ - * - * To run a mutation, you first call `useFindAvailableSsoIdentityProvidersMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useFindAvailableSsoIdentityProvidersMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [findAvailableSsoIdentityProvidersMutation, { data, loading, error }] = useFindAvailableSsoIdentityProvidersMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useFindAvailableSsoIdentityProvidersMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(FindAvailableSsoIdentityProvidersDocument, options); - } -export type FindAvailableSsoIdentityProvidersMutationHookResult = ReturnType; -export type FindAvailableSsoIdentityProvidersMutationResult = Apollo.MutationResult; -export type FindAvailableSsoIdentityProvidersMutationOptions = Apollo.BaseMutationOptions; export const GenerateApiKeyTokenDocument = gql` mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) { generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) { @@ -2712,55 +2723,6 @@ export function useGenerateApiKeyTokenMutation(baseOptions?: Apollo.MutationHook export type GenerateApiKeyTokenMutationHookResult = ReturnType; export type GenerateApiKeyTokenMutationResult = Apollo.MutationResult; export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions; -export const GenerateJwtDocument = gql` - mutation GenerateJWT($workspaceId: String!) { - generateJWT(workspaceId: $workspaceId) { - ... on GenerateJWTOutputWithAuthTokens { - success - reason - authTokens { - tokens { - ...AuthTokensFragment - } - } - } - ... on GenerateJWTOutputWithSSOAUTH { - success - reason - availableSSOIDPs { - ...AvailableSSOIdentityProvidersFragment - } - } - } -} - ${AuthTokensFragmentFragmentDoc} -${AvailableSsoIdentityProvidersFragmentFragmentDoc}`; -export type GenerateJwtMutationFn = Apollo.MutationFunction; - -/** - * __useGenerateJwtMutation__ - * - * To run a mutation, you first call `useGenerateJwtMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useGenerateJwtMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [generateJwtMutation, { data, loading, error }] = useGenerateJwtMutation({ - * variables: { - * workspaceId: // value for 'workspaceId' - * }, - * }); - */ -export function useGenerateJwtMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(GenerateJwtDocument, options); - } -export type GenerateJwtMutationHookResult = ReturnType; -export type GenerateJwtMutationResult = Apollo.MutationResult; -export type GenerateJwtMutationOptions = Apollo.BaseMutationOptions; export const GenerateTransientTokenDocument = gql` mutation generateTransientToken { generateTransientToken { @@ -2949,6 +2911,53 @@ export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions; export type SignUpMutationResult = Apollo.MutationResult; export type SignUpMutationOptions = Apollo.BaseMutationOptions; +export const SwitchWorkspaceDocument = gql` + mutation SwitchWorkspace($workspaceId: String!) { + switchWorkspace(workspaceId: $workspaceId) { + id + subdomain + authProviders { + sso { + id + name + type + status + issuer + } + google + magicLink + password + microsoft + } + } +} + `; +export type SwitchWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __useSwitchWorkspaceMutation__ + * + * To run a mutation, you first call `useSwitchWorkspaceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSwitchWorkspaceMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [switchWorkspaceMutation, { data, loading, error }] = useSwitchWorkspaceMutation({ + * variables: { + * workspaceId: // value for 'workspaceId' + * }, + * }); + */ +export function useSwitchWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SwitchWorkspaceDocument, options); + } +export type SwitchWorkspaceMutationHookResult = ReturnType; +export type SwitchWorkspaceMutationResult = Apollo.MutationResult; +export type SwitchWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const UpdatePasswordViaResetTokenDocument = gql` mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) { updatePasswordViaResetToken( @@ -3028,7 +3037,26 @@ export type VerifyMutationOptions = Apollo.BaseMutationOptions; export type CheckUserExistsLazyQueryHookResult = ReturnType; export type CheckUserExistsQueryResult = Apollo.QueryResult; +export const GetPublicWorkspaceDataBySubdomainDocument = gql` + query GetPublicWorkspaceDataBySubdomain { + getPublicWorkspaceDataBySubdomain { + id + logo + displayName + subdomain + authProviders { + sso { + id + name + type + status + issuer + } + google + magicLink + password + microsoft + } + } +} + `; + +/** + * __useGetPublicWorkspaceDataBySubdomainQuery__ + * + * To run a query within a React component, call `useGetPublicWorkspaceDataBySubdomainQuery` and pass it any options that fit your needs. + * When your component renders, `useGetPublicWorkspaceDataBySubdomainQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetPublicWorkspaceDataBySubdomainQuery({ + * variables: { + * }, + * }); + */ +export function useGetPublicWorkspaceDataBySubdomainQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetPublicWorkspaceDataBySubdomainDocument, options); + } +export function useGetPublicWorkspaceDataBySubdomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetPublicWorkspaceDataBySubdomainDocument, options); + } +export type GetPublicWorkspaceDataBySubdomainQueryHookResult = ReturnType; +export type GetPublicWorkspaceDataBySubdomainLazyQueryHookResult = ReturnType; +export type GetPublicWorkspaceDataBySubdomainQueryResult = Apollo.QueryResult; export const ValidatePasswordResetTokenDocument = gql` query ValidatePasswordResetToken($token: String!) { validatePasswordResetToken(passwordResetToken: $token) { @@ -3244,19 +3322,16 @@ export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOption export const GetClientConfigDocument = gql` query GetClientConfig { clientConfig { - authProviders { - google - password - microsoft - sso - } billing { isBillingEnabled billingUrl billingFreeTrialDurationInDays } signInPrefilled - signUpDisabled + isMultiWorkspaceEnabled + isSSOEnabled + defaultSubdomain + frontDomain debugMode analyticsEnabled support { @@ -4163,10 +4238,16 @@ export type AddUserToWorkspaceByInviteTokenMutationOptions = Apollo.BaseMutation export const ActivateWorkspaceDocument = gql` mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { activateWorkspace(data: $input) { - id + workspace { + id + subdomain + } + loginToken { + ...AuthTokenFragment + } } } - `; + ${AuthTokenFragmentFragmentDoc}`; export type ActivateWorkspaceMutationFn = Apollo.MutationFunction; /** @@ -4230,9 +4311,14 @@ export const UpdateWorkspaceDocument = gql` updateWorkspace(data: $input) { id domainName + subdomain displayName logo allowImpersonation + isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled } } `; diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index 1fe08501a..9cd28314c 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -18,6 +18,7 @@ import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider'; import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; import { UserProvider } from '@/users/components/UserProvider'; import { UserProviderEffect } from '@/users/components/UserProviderEffect'; +import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect'; import { StrictMode } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import { getPageTitleFromPath } from '~/utils/title-utils'; @@ -34,6 +35,7 @@ export const AppRouterProviders = () => { + diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index f8286c398..23ee77505 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -105,6 +105,12 @@ const SettingsWorkspace = lazy(() => })), ); +const SettingsDomain = lazy(() => + import('~/pages/settings/workspace/SettingsDomain').then((module) => ({ + default: module.SettingsDomain, + })), +); + const SettingsWorkspaceMembers = lazy(() => import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({ default: module.SettingsWorkspaceMembers, @@ -288,6 +294,8 @@ export const SettingsRoutes = ({ {isBillingEnabled && ( } /> )} + } /> + } /> } @@ -382,14 +390,12 @@ export const SettingsRoutes = ({ element={} /> } /> + } /> {isSSOEnabled && ( - <> - } /> - } - /> - + } + /> )} {isAdminPageEnabled && ( <> diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts deleted file mode 100644 index 888cda398..000000000 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* @license Enterprise */ - -import { gql } from '@apollo/client'; - -export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql` - mutation FindAvailableSSOIdentityProviders( - $input: FindAvailableSSOIDPInput! - ) { - findAvailableSSOIdentityProviders(input: $input) { - ...AvailableSSOIdentityProvidersFragment - } - } -`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts deleted file mode 100644 index 620f70c69..000000000 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { gql } from '@apollo/client'; - -export const GENERATE_JWT = gql` - mutation GenerateJWT($workspaceId: String!) { - generateJWT(workspaceId: $workspaceId) { - ... on GenerateJWTOutputWithAuthTokens { - success - reason - authTokens { - tokens { - ...AuthTokensFragment - } - } - } - ... on GenerateJWTOutputWithSSOAUTH { - success - reason - availableSSOIDPs { - ...AvailableSSOIdentityProvidersFragment - } - } - } - } -`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts new file mode 100644 index 000000000..e4b604ded --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts @@ -0,0 +1,23 @@ +import { gql } from '@apollo/client'; + +export const SWITCH_WORKSPACE = gql` + mutation SwitchWorkspace($workspaceId: String!) { + switchWorkspace(workspaceId: $workspaceId) { + id + subdomain + authProviders { + sso { + id + name + type + status + issuer + } + google + magicLink + password + microsoft + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts index ddf5505bb..0a3c9b0ac 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts @@ -3,7 +3,26 @@ import { gql } from '@apollo/client'; export const CHECK_USER_EXISTS = gql` query CheckUserExists($email: String!, $captchaToken: String) { checkUserExists(email: $email, captchaToken: $captchaToken) { - exists + __typename + ... on UserExists { + exists + availableWorkspaces { + id + displayName + subdomain + logo + sso { + type + id + issuer + name + status + } + } + } + ... on UserNotExists { + exists + } } } `; diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts new file mode 100644 index 000000000..b91ccf5fd --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts @@ -0,0 +1,25 @@ +import { gql } from '@apollo/client'; + +export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql` + query GetPublicWorkspaceDataBySubdomain { + getPublicWorkspaceDataBySubdomain { + id + logo + displayName + subdomain + authProviders { + sso { + id + name + type + status + issuer + } + google + magicLink + password + microsoft + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx index 3ed726ac7..b994ec0cb 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx @@ -114,11 +114,11 @@ describe('useAuth', () => { expect(state.icons).toEqual({}); expect(state.authProviders).toEqual({ - google: false, + google: true, microsoft: false, magicLink: false, - password: false, - sso: false, + password: true, + sso: [], }); expect(state.billing).toBeNull(); expect(state.isDeveloperDefaultSignInPrefilled).toBe(false); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 4d50bb394..b563c5698 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -4,7 +4,7 @@ import { snapshot_UNSTABLE, useGotoRecoilSnapshot, useRecoilCallback, - useRecoilState, + useRecoilValue, useSetRecoilState, } from 'recoil'; import { iconsState } from 'twenty-ui'; @@ -42,10 +42,18 @@ import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDa import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { currentUserState } from '../states/currentUserState'; import { tokenPairState } from '../states/tokenPairState'; +import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; + +import { urlManagerState } from '@/url-manager/states/url-manager.state'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; export const useAuth = () => { - const [, setTokenPair] = useRecoilState(tokenPairState); + const setTokenPair = useSetRecoilState(tokenPairState); const setCurrentUser = useSetRecoilState(currentUserState); + const urlManager = useRecoilValue(urlManagerState); + const setLastAuthenticateWorkspaceState = useSetRecoilState( + lastAuthenticateWorkspaceState, + ); const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -60,6 +68,7 @@ export const useAuth = () => { const [challenge] = useChallengeMutation(); const [signUp] = useSignUpMutation(); const [verify] = useVerifyMutation(); + const { isTwentyWorkspaceSubdomain, getWorkspaceSubdomain } = useUrlManager(); const [checkUserExistsQuery, { data: checkUserExistsData }] = useCheckUserExistsLazyQuery(); @@ -203,6 +212,15 @@ export const useAuth = () => { const workspace = user.defaultWorkspace ?? null; setCurrentWorkspace(workspace); + if (isDefined(workspace) && isTwentyWorkspaceSubdomain) { + setLastAuthenticateWorkspaceState({ + id: workspace.id, + subdomain: workspace.subdomain, + cookieAttributes: { + domain: `.${urlManager.frontDomain}`, + }, + }); + } if (isDefined(verifyResult.data?.verify.user.workspaces)) { const validWorkspaces = verifyResult.data?.verify.user.workspaces @@ -227,9 +245,12 @@ export const useAuth = () => { setTokenPair, setCurrentUser, setCurrentWorkspace, + isTwentyWorkspaceSubdomain, setCurrentWorkspaceMembers, setCurrentWorkspaceMember, setDateTimeFormat, + setLastAuthenticateWorkspaceState, + urlManager.frontDomain, setWorkspaces, ], ); @@ -301,23 +322,34 @@ export const useAuth = () => { [setIsVerifyPendingState, signUp, handleVerify], ); - const buildRedirectUrl = ( - path: string, - params: { - workspacePersonalInviteToken?: string; - workspaceInviteHash?: string; + const buildRedirectUrl = useCallback( + ( + path: string, + params: { + workspacePersonalInviteToken?: string; + workspaceInviteHash?: string; + }, + ) => { + const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`); + if (isDefined(params.workspaceInviteHash)) { + url.searchParams.set('inviteHash', params.workspaceInviteHash); + } + if (isDefined(params.workspacePersonalInviteToken)) { + url.searchParams.set( + 'inviteToken', + params.workspacePersonalInviteToken, + ); + } + const subdomain = getWorkspaceSubdomain; + + if (isDefined(subdomain)) { + url.searchParams.set('workspaceSubdomain', subdomain); + } + + return url.toString(); }, - ) => { - const authServerUrl = REACT_APP_SERVER_BASE_URL; - const url = new URL(`${authServerUrl}${path}`); - if (isDefined(params.workspaceInviteHash)) { - url.searchParams.set('inviteHash', params.workspaceInviteHash); - } - if (isDefined(params.workspacePersonalInviteToken)) { - url.searchParams.set('inviteToken', params.workspacePersonalInviteToken); - } - return url.toString(); - }; + [getWorkspaceSubdomain], + ); const handleGoogleLogin = useCallback( (params: { @@ -326,7 +358,7 @@ export const useAuth = () => { }) => { window.location.href = buildRedirectUrl('/auth/google', params); }, - [], + [buildRedirectUrl], ); const handleMicrosoftLogin = useCallback( @@ -336,7 +368,7 @@ export const useAuth = () => { }) => { window.location.href = buildRedirectUrl('/auth/microsoft', params); }, - [], + [buildRedirectUrl], ); return { diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx new file mode 100644 index 000000000..a2ca5d1af --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx @@ -0,0 +1,60 @@ +import { TextInput } from '@/ui/input/components/TextInput'; +import { Controller, useFormContext } from 'react-hook-form'; +import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; +import { isDefined } from '~/utils/isDefined'; +import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; + +const StyledFullWidthMotionDiv = styled(motion.div)` + width: 100%; +`; + +const StyledInputContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(3)}; +`; + +export const SignInUpEmailField = ({ + showErrors, + onChange: onChangeFromProps, +}: { + showErrors: boolean; + onChange?: (value: string) => void; +}) => { + const form = useFormContext
(); + + return ( + + ( + + { + onChange(value); + if (isDefined(onChangeFromProps)) onChangeFromProps(value); + }} + error={showErrors ? error?.message : undefined} + fullWidth + /> + + )} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx deleted file mode 100644 index bfc3a36e8..000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; -import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; -import { - useSignInUpForm, - validationSchema, -} from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; -import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; -import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; -import { TextInput } from '@/ui/input/components/TextInput'; -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import { motion } from 'framer-motion'; -import { useMemo, useState } from 'react'; -import { Controller } from 'react-hook-form'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { Key } from 'ts-key-enum'; -import { - ActionLink, - HorizontalSeparator, - IconGoogle, - IconKey, - IconMicrosoft, - Loader, - MainButton, - StyledText, -} from 'twenty-ui'; -import { isDefined } from '~/utils/isDefined'; - -const StyledContentContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(8)}; - margin-top: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledForm = styled.form` - align-items: center; - display: flex; - flex-direction: column; - width: 100%; -`; - -const StyledFullWidthMotionDiv = styled(motion.div)` - width: 100%; -`; - -const StyledInputContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(3)}; -`; - -export const SignInUpForm = () => { - const captchaProvider = useRecoilValue(captchaProviderState); - const isRequestingCaptchaToken = useRecoilValue( - isRequestingCaptchaTokenState, - ); - const [authProviders] = useRecoilState(authProvidersState); - const [showErrors, setShowErrors] = useState(false); - const { signInWithGoogle } = useSignInWithGoogle(); - const { signInWithMicrosoft } = useSignInWithMicrosoft(); - const { form } = useSignInUpForm(); - const { handleResetPassword } = useHandleResetPassword(); - - const { - signInUpStep, - signInUpMode, - continueWithCredentials, - continueWithEmail, - continueWithSSO, - submitCredentials, - submitSSOEmail, - } = useSignInUp(form); - - if ( - signInUpStep === SignInUpStep.Init && - !authProviders.google && - !authProviders.microsoft && - !authProviders.sso - ) { - continueWithEmail(); - } - - const toggleSSOMode = () => { - if (signInUpStep === SignInUpStep.SSOEmail) { - continueWithEmail(); - } else { - continueWithSSO(); - } - }; - - const handleKeyDown = async ( - event: React.KeyboardEvent, - ) => { - if (event.key === Key.Enter) { - event.preventDefault(); - - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - } else if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - } else if (signInUpStep === SignInUpStep.Password) { - if (!form.formState.isSubmitting) { - setShowErrors(true); - form.handleSubmit(submitCredentials)(); - } - } else if (signInUpStep === SignInUpStep.SSOEmail) { - submitSSOEmail(form.getValues('email')); - } - } - }; - - const buttonTitle = useMemo(() => { - if (signInUpStep === SignInUpStep.Init) { - return 'Continue With Email'; - } - - if (signInUpStep === SignInUpStep.Email) { - return 'Continue'; - } - - if (signInUpStep === SignInUpStep.SSOEmail) { - return 'Continue with SSO'; - } - - return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up'; - }, [signInUpMode, signInUpStep]); - - const theme = useTheme(); - - const shouldWaitForCaptchaToken = - signInUpStep !== SignInUpStep.Init && - isDefined(captchaProvider?.provider) && - isRequestingCaptchaToken; - - const isEmailStepSubmitButtonDisabledCondition = - signInUpStep === SignInUpStep.Email && - (!validationSchema.shape.email.safeParse(form.watch('email')).success || - shouldWaitForCaptchaToken); - - // TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders - // We make the isValid check synchronous and update a reactState to make sure this does not happen - const isPasswordStepSubmitButtonDisabledCondition = - signInUpStep === SignInUpStep.Password && - (!form.formState.isValid || - form.formState.isSubmitting || - shouldWaitForCaptchaToken); - - const isSubmitButtonDisabled = - isEmailStepSubmitButtonDisabledCondition || - isPasswordStepSubmitButtonDisabledCondition; - - return ( - <> - - {authProviders.google && ( - <> - } - title="Continue with Google" - onClick={signInWithGoogle} - variant={ - signInUpStep === SignInUpStep.Init ? undefined : 'secondary' - } - fullWidth - /> - - - )} - - {authProviders.microsoft && ( - <> - } - title="Continue with Microsoft" - onClick={signInWithMicrosoft} - variant={ - signInUpStep === SignInUpStep.Init ? undefined : 'secondary' - } - fullWidth - /> - - - )} - {authProviders.sso && ( - <> - } - variant={ - signInUpStep === SignInUpStep.Init ? undefined : 'secondary' - } - title={ - signInUpStep === SignInUpStep.SSOEmail - ? 'Continue with email' - : 'Single sign-on (SSO)' - } - onClick={toggleSSOMode} - fullWidth - /> - - - )} - - {(authProviders.google || - authProviders.microsoft || - authProviders.sso) && } - - {authProviders.password && - (signInUpStep === SignInUpStep.Password || - signInUpStep === SignInUpStep.Email || - signInUpStep === SignInUpStep.Init) && ( - { - event.preventDefault(); - }} - > - {signInUpStep !== SignInUpStep.Init && ( - - ( - - { - onChange(value); - if (signInUpStep === SignInUpStep.Password) { - continueWithEmail(); - } - }} - error={showErrors ? error?.message : undefined} - fullWidth - disableHotkeys - onKeyDown={handleKeyDown} - /> - - )} - /> - - )} - {signInUpStep === SignInUpStep.Password && ( - - ( - - - {signInUpMode === SignInUpMode.SignUp && ( - - )} - - )} - /> - - )} - { - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - return; - } - if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - return; - } - setShowErrors(true); - form.handleSubmit(submitCredentials)(); - }} - Icon={() => (form.formState.isSubmitting ? : null)} - disabled={isSubmitButtonDisabled} - fullWidth - /> - - )} - { - event.preventDefault(); - }} - > - {signInUpStep === SignInUpStep.SSOEmail && ( - <> - - ( - - - - )} - /> - - { - setShowErrors(true); - submitSSOEmail(form.getValues('email')); - }} - Icon={() => form.formState.isSubmitting && } - disabled={isSubmitButtonDisabled} - fullWidth - /> - - )} - - - {signInUpStep === SignInUpStep.Password && ( - - Forgot your password? - - )} - {signInUpStep === SignInUpStep.Init && } - - ); -}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx new file mode 100644 index 000000000..a441b1ee1 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -0,0 +1,164 @@ +import styled from '@emotion/styled'; +import { + IconGoogle, + IconMicrosoft, + Loader, + MainButton, + HorizontalSeparator, +} from 'twenty-ui'; +import { useTheme } from '@emotion/react'; +import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; +import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; +import { FormProvider } from 'react-hook-form'; +import { motion } from 'framer-motion'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useLocation } from 'react-router-dom'; + +import { isDefined } from '~/utils/isDefined'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; +import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; +import { signInUpModeState } from '@/auth/states/signInUpModeState'; +import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { SignInUpMode } from '@/auth/types/signInUpMode.type'; + +const StyledContentContainer = styled(motion.div)` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledForm = styled.form` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +export const SignInUpGlobalScopeForm = () => { + const theme = useTheme(); + const signInUpStep = useRecoilValue(signInUpStepState); + + const { signInWithGoogle } = useSignInWithGoogle(); + const { signInWithMicrosoft } = useSignInWithMicrosoft(); + const { checkUserExists } = useAuth(); + const { readCaptchaToken } = useReadCaptchaToken(); + const { redirectToWorkspace } = useUrlManager(); + + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); + + const { enqueueSnackBar } = useSnackBar(); + const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken(); + + const [showErrors, setShowErrors] = useState(false); + + const { form } = useSignInUpForm(); + const { pathname } = useLocation(); + + const { submitCredentials } = useSignInUp(form); + + const handleSubmit = async () => { + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + + if (signInUpStep === SignInUpStep.Password) { + await submitCredentials(form.getValues()); + return; + } + + const token = await readCaptchaToken(); + await checkUserExists.checkUserExistsQuery({ + variables: { + email: form.getValues('email'), + captchaToken: token, + }, + onError: (error) => { + enqueueSnackBar(`${error.message}`, { + variant: SnackBarVariant.Error, + }); + }, + onCompleted: (data) => { + requestFreshCaptchaToken(); + if (data.checkUserExists.__typename === 'UserExists') { + if ( + isDefined(data?.checkUserExists.availableWorkspaces) && + data.checkUserExists.availableWorkspaces.length >= 1 + ) { + return redirectToWorkspace( + data?.checkUserExists.availableWorkspaces[0].subdomain, + pathname, + { + email: form.getValues('email'), + }, + ); + } + } + if (data.checkUserExists.__typename === 'UserNotExists') { + setSignInUpMode(SignInUpMode.SignUp); + setSignInUpStep(SignInUpStep.Password); + } + }, + }); + }; + + return ( + <> + + <> + } + title="Continue with Google" + onClick={signInWithGoogle} + fullWidth + /> + + + <> + } + title="Continue with Microsoft" + onClick={signInWithMicrosoft} + fullWidth + /> + + + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + + {signInUpStep === SignInUpStep.Password && ( + + )} + + (form.formState.isSubmitting ? : null)} + fullWidth + /> + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx new file mode 100644 index 000000000..44ba13b60 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx @@ -0,0 +1,67 @@ +import { TextInput } from '@/ui/input/components/TextInput'; +import { Controller, useFormContext } from 'react-hook-form'; +import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; +import { StyledText } from 'twenty-ui'; +import { useTheme } from '@emotion/react'; +import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpMode } from '@/auth/types/signInUpMode.type'; + +const StyledFullWidthMotionDiv = styled(motion.div)` + width: 100%; +`; + +const StyledInputContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(3)}; +`; + +export const SignInUpPasswordField = ({ + showErrors, + signInUpMode, +}: { + showErrors: boolean; + signInUpMode: SignInUpMode; +}) => { + const theme = useTheme(); + const form = useFormContext(); + + return ( + + ( + + + {signInUpMode === SignInUpMode.SignUp && ( + + )} + + )} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx new file mode 100644 index 000000000..8a0baa00c --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx @@ -0,0 +1,41 @@ +/* @license Enterprise */ + +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { MainButton, HorizontalSeparator } from 'twenty-ui'; + +import { isDefined } from '~/utils/isDefined'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; + +const StyledContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +export const SignInUpSSOIdentityProviderSelection = () => { + const authProviders = useRecoilValue(authProvidersState); + + const { redirectToSSOLoginPage } = useSSO(); + + return ( + <> + + {isDefined(authProviders?.sso) && + authProviders?.sso.map((idp) => ( + <> + redirectToSSOLoginPage(idp.id)} + Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} + fullWidth + /> + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx new file mode 100644 index 000000000..c238e05d0 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx @@ -0,0 +1,142 @@ +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { Loader, MainButton } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; +import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { useRecoilValue } from 'recoil'; +import styled from '@emotion/styled'; +import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; +import { useState, useMemo } from 'react'; +import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; +import { FormProvider } from 'react-hook-form'; +import { SignInUpMode } from '@/auth/types/signInUpMode.type'; + +const StyledForm = styled.form` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +export const SignInUpWithCredentials = () => { + const { form, validationSchema } = useSignInUpForm(); + + const signInUpStep = useRecoilValue(signInUpStepState); + const [showErrors, setShowErrors] = useState(false); + const captchaProvider = useRecoilValue(captchaProviderState); + const isRequestingCaptchaToken = useRecoilValue( + isRequestingCaptchaTokenState, + ); + + const { + signInUpMode, + continueWithEmail, + continueWithCredentials, + submitCredentials, + } = useSignInUp(form); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (isSubmitButtonDisabled) return; + + if (signInUpStep === SignInUpStep.Init) { + continueWithEmail(); + } else if (signInUpStep === SignInUpStep.Email) { + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + continueWithCredentials(); + } else if (signInUpStep === SignInUpStep.Password) { + if (!form.formState.isSubmitting) { + setShowErrors(true); + form.handleSubmit(submitCredentials)(); + } + } + }; + + const buttonTitle = useMemo(() => { + if (signInUpStep === SignInUpStep.Init) { + return 'Continue With Email'; + } + + if ( + signInUpMode === SignInUpMode.SignIn && + signInUpStep === SignInUpStep.Password + ) { + return 'Sign in'; + } + + if ( + signInUpMode === SignInUpMode.SignUp && + signInUpStep === SignInUpStep.Password + ) { + return 'Sign up'; + } + + return 'Continue'; + }, [signInUpMode, signInUpStep]); + + const shouldWaitForCaptchaToken = + signInUpStep !== SignInUpStep.Init && + isDefined(captchaProvider?.provider) && + isRequestingCaptchaToken; + + const isEmailStepSubmitButtonDisabledCondition = + signInUpStep === SignInUpStep.Email && + (!validationSchema.shape.email.safeParse(form.watch('email')).success || + shouldWaitForCaptchaToken); + + // TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders + // We make the isValid check synchronous and update a reactState to make sure this does not happen + const isPasswordStepSubmitButtonDisabledCondition = + signInUpStep === SignInUpStep.Password && + (!form.formState.isValid || + form.formState.isSubmitting || + shouldWaitForCaptchaToken); + + const isSubmitButtonDisabled = + isEmailStepSubmitButtonDisabledCondition || + isPasswordStepSubmitButtonDisabledCondition; + + return ( + <> + {(signInUpStep === SignInUpStep.Password || + signInUpStep === SignInUpStep.Email || + signInUpStep === SignInUpStep.Init) && ( + <> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + {signInUpStep !== SignInUpStep.Init && ( + + )} + {signInUpStep === SignInUpStep.Password && ( + + )} + (form.formState.isSubmitting ? : null)} + disabled={isSubmitButtonDisabled} + fullWidth + /> + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx new file mode 100644 index 000000000..7aba0a1f3 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx @@ -0,0 +1,32 @@ +import { IconGoogle, MainButton, HorizontalSeparator } from 'twenty-ui'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; +import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; +import { memo } from 'react'; + +const GoogleIcon = memo(() => { + const theme = useTheme(); + return ; +}); + +export const SignInUpWithGoogle = () => { + const signInUpStep = useRecoilValue(signInUpStepState); + const { signInWithGoogle } = useSignInWithGoogle(); + + return ( + <> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx new file mode 100644 index 000000000..652336b0d --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx @@ -0,0 +1,27 @@ +import { IconMicrosoft, MainButton, HorizontalSeparator } from 'twenty-ui'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useTheme } from '@emotion/react'; +import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; +import { useRecoilValue } from 'recoil'; + +export const SignInUpWithMicrosoft = () => { + const theme = useTheme(); + const signInUpStep = useRecoilValue(signInUpStepState); + const { signInWithMicrosoft } = useSignInWithMicrosoft(); + + return ( + <> + } + title="Continue with Microsoft" + onClick={signInWithMicrosoft} + variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} + fullWidth + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx new file mode 100644 index 000000000..2314a6414 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx @@ -0,0 +1,40 @@ +import { IconLock, MainButton, HorizontalSeparator } from 'twenty-ui'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useTheme } from '@emotion/react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; + +export const SignInUpWithSSO = () => { + const theme = useTheme(); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const authProviders = useRecoilValue(authProvidersState); + + const signInUpStep = useRecoilValue(signInUpStepState); + + const { redirectToSSOLoginPage } = useSSO(); + + const signInWithSSO = () => { + if (authProviders.sso.length === 1) { + return redirectToSSOLoginPage(authProviders.sso[0].id); + } + + setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection); + }; + + return ( + <> + } + title="Single sign-on (SSO)" + onClick={signInWithSSO} + variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} + fullWidth + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx new file mode 100644 index 000000000..b53c593d5 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -0,0 +1,86 @@ +import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import styled from '@emotion/styled'; +import { useCallback, useEffect } from 'react'; +import { useRecoilState } from 'recoil'; +import { ActionLink, HorizontalSeparator } from 'twenty-ui'; +import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle'; +import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft'; +import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO'; +import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials'; +import { useLocation } from 'react-router-dom'; +import { isDefined } from '~/utils/isDefined'; + +const StyledContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +export const SignInUpWorkspaceScopeForm = () => { + const [authProviders] = useRecoilState(authProvidersState); + + const { form } = useSignInUpForm(); + const { handleResetPassword } = useHandleResetPassword(); + + const { signInUpStep, continueWithEmail, continueWithCredentials } = + useSignInUp(form); + const location = useLocation(); + + const checkAuthProviders = useCallback(() => { + if ( + signInUpStep === SignInUpStep.Init && + !authProviders.google && + !authProviders.microsoft && + !authProviders.sso + ) { + return continueWithEmail(); + } + const searchParams = new URLSearchParams(location.search); + const email = searchParams.get('email'); + if (isDefined(email) && authProviders.password) { + return continueWithCredentials(); + } + }, [ + continueWithCredentials, + location.search, + authProviders.google, + authProviders.microsoft, + authProviders.password, + authProviders.sso, + continueWithEmail, + signInUpStep, + ]); + + useEffect(() => { + checkAuthProviders(); + }, [checkAuthProviders]); + + return ( + <> + + {authProviders.google && } + + {authProviders.microsoft && } + + {authProviders.sso.length > 0 && } + + {(authProviders.google || + authProviders.microsoft || + authProviders.sso.length > 0) && + authProviders.password ? ( + + ) : null} + + {authProviders.password && } + + {signInUpStep === SignInUpStep.Password && ( + + Forgot your password? + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts index 86a8b8392..f8228e4d1 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts @@ -3,9 +3,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { - FindAvailableSsoIdentityProvidersMutationVariables, GetAuthorizationUrlMutationVariables, - useFindAvailableSsoIdentityProvidersMutation, useGetAuthorizationUrlMutation, } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -13,20 +11,8 @@ import { isDefined } from '~/utils/isDefined'; export const useSSO = () => { const { enqueueSnackBar } = useSnackBar(); - const [findAvailableSSOProviderByEmailMutation] = - useFindAvailableSsoIdentityProvidersMutation(); const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation(); - const findAvailableSSOProviderByEmail = async ({ - email, - }: FindAvailableSsoIdentityProvidersMutationVariables['input']) => { - return await findAvailableSSOProviderByEmailMutation({ - variables: { - input: { email }, - }, - }); - }; - const getAuthorizationUrlForSSO = async ({ identityProviderId, }: GetAuthorizationUrlMutationVariables['input']) => { @@ -63,6 +49,5 @@ export const useSSO = () => { return { redirectToSSOLoginPage, getAuthorizationUrlForSSO, - findAvailableSSOProviderByEmail, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 53994713c..d76e06419 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -7,36 +7,25 @@ import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useRecoilState, useSetRecoilState } from 'recoil'; +import { useRecoilState } from 'recoil'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; -import { isDefined } from '~/utils/isDefined'; - -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; import { AppPath } from '@/types/AppPath'; import { useAuth } from '../../hooks/useAuth'; - -export enum SignInUpMode { - SignIn = 'sign-in', - SignUp = 'sign-up', -} +import { signInUpModeState } from '@/auth/states/signInUpModeState'; +import { SignInUpMode } from '@/auth/types/signInUpMode.type'; export const useSignInUp = (form: UseFormReturn) => { const { enqueueSnackBar } = useSnackBar(); const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState); + const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); const isMatchingLocation = useIsMatchingLocation(); - const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO(); - const setAvailableWorkspacesForSSOState = useSetRecoilState( - availableSSOIdentityProvidersState, - ); - const workspaceInviteHash = useParams().workspaceInviteHash; const [searchParams] = useSearchParams(); const workspacePersonalInviteToken = @@ -44,12 +33,6 @@ export const useSignInUp = (form: UseFormReturn) => { const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); - const [signInUpMode, setSignInUpMode] = useState(() => { - return isMatchingLocation(AppPath.SignInUp) - ? SignInUpMode.SignIn - : SignInUpMode.SignUp; - }); - const { signInWithCredentials, signUpWithCredentials, @@ -67,7 +50,12 @@ export const useSignInUp = (form: UseFormReturn) => { ? SignInUpMode.SignIn : SignInUpMode.SignUp, ); - }, [isMatchingLocation, requestFreshCaptchaToken, setSignInUpStep]); + }, [ + isMatchingLocation, + requestFreshCaptchaToken, + setSignInUpMode, + setSignInUpStep, + ]); const continueWithCredentials = useCallback(async () => { const token = await readCaptchaToken(); @@ -101,47 +89,9 @@ export const useSignInUp = (form: UseFormReturn) => { enqueueSnackBar, requestFreshCaptchaToken, setSignInUpStep, + setSignInUpMode, ]); - const continueWithSSO = () => { - setSignInUpStep(SignInUpStep.SSOEmail); - }; - - const submitSSOEmail = async (email: string) => { - const result = await findAvailableSSOProviderByEmail({ - email, - }); - - if (isDefined(result.errors)) { - return enqueueSnackBar(result.errors[0].message, { - variant: SnackBarVariant.Error, - }); - } - - if ( - !result.data?.findAvailableSSOIdentityProviders || - result.data?.findAvailableSSOIdentityProviders.length === 0 - ) { - enqueueSnackBar('No workspaces with SSO found', { - variant: SnackBarVariant.Error, - }); - return; - } - // If only one workspace, redirect to SSO - if (result.data?.findAvailableSSOIdentityProviders.length === 1) { - return redirectToSSOLoginPage( - result.data.findAvailableSSOIdentityProviders[0].id, - ); - } - - if (result.data?.findAvailableSSOIdentityProviders.length > 1) { - setAvailableWorkspacesForSSOState( - result.data.findAvailableSSOIdentityProviders, - ); - setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); - } - }; - const submitCredentials: SubmitHandler = useCallback( async (data) => { const token = await readCaptchaToken(); @@ -150,19 +100,21 @@ export const useSignInUp = (form: UseFormReturn) => { throw new Error('Email and password are required'); } - signInUpMode === SignInUpMode.SignIn && !isInviteMode - ? await signInWithCredentials( - data.email.toLowerCase().trim(), - data.password, - token, - ) - : await signUpWithCredentials( - data.email.toLowerCase().trim(), - data.password, - workspaceInviteHash, - workspacePersonalInviteToken, - token, - ); + if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) { + await signInWithCredentials( + data.email.toLowerCase().trim(), + data.password, + token, + ); + } else { + await signUpWithCredentials( + data.email.toLowerCase().trim(), + data.password, + workspaceInviteHash, + workspacePersonalInviteToken, + token, + ); + } } catch (err: any) { enqueueSnackBar(err?.message, { variant: SnackBarVariant.Error, @@ -189,8 +141,6 @@ export const useSignInUp = (form: UseFormReturn) => { signInUpMode, continueWithCredentials, continueWithEmail, - continueWithSSO, - submitSSOEmail, submitCredentials, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index 2c506f168..415c2be34 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -3,33 +3,50 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { useRecoilValue } from 'recoil'; import { z } from 'zod'; +import { useLocation } from 'react-router-dom'; import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { useSearchParams } from 'react-router-dom'; import { isDefined } from '~/utils/isDefined'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; -export const validationSchema = z - .object({ - exist: z.boolean(), - email: z.string().trim().email('Email must be a valid email'), - password: z - .string() - .regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'), - captchaToken: z.string().default(''), - }) - .required(); +const makeValidationSchema = (signInUpStep: SignInUpStep) => + z + .object({ + exist: z.boolean(), + email: z.string().trim().email('Email must be a valid email'), + password: + signInUpStep === SignInUpStep.Password + ? z + .string() + .regex( + PASSWORD_REGEX, + 'Password must contain at least 8 characters', + ) + : z.string().optional(), + captchaToken: z.string().default(''), + }) + .required(); -export type Form = z.infer; +export type Form = z.infer>; export const useSignInUpForm = () => { + const location = useLocation(); + const signInUpStep = useRecoilValue(signInUpStepState); + + const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step + const isDeveloperDefaultSignInPrefilled = useRecoilValue( isDeveloperDefaultSignInPrefilledState, ); const [searchParams] = useSearchParams(); - const invitationPrefilledEmail = searchParams.get('email'); + const prefilledEmail = searchParams.get('email'); const form = useForm({ - mode: 'onChange', + mode: 'onSubmit', defaultValues: { exist: false, email: '', @@ -40,12 +57,12 @@ export const useSignInUpForm = () => { }); useEffect(() => { - if (isDefined(invitationPrefilledEmail)) { - form.setValue('email', invitationPrefilledEmail); + if (isDefined(prefilledEmail)) { + form.setValue('email', prefilledEmail); } else if (isDeveloperDefaultSignInPrefilled === true) { form.setValue('email', 'tim@apple.dev'); form.setValue('password', 'Applecar2025'); } - }, [form, isDeveloperDefaultSignInPrefilled, invitationPrefilledEmail]); + }, [form, isDeveloperDefaultSignInPrefilled, prefilledEmail, location.search]); return { form: form }; }; diff --git a/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts new file mode 100644 index 000000000..d3100a826 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui'; +import { UserExists } from '~/generated/graphql'; + +export const availableSSOIdentityProvidersForAuthState = createState< + NonNullable[0]['sso'] +>({ + key: 'availableSSOIdentityProvidersForAuth', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts deleted file mode 100644 index e4c83c26e..000000000 --- a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createState } from 'twenty-ui'; -import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql'; - -export const availableSSOIdentityProvidersState = createState< - NonNullable< - FindAvailableSsoIdentityProvidersMutationResult['data'] - >['findAvailableSSOIdentityProviders'] ->({ - key: 'availableSSOIdentityProviders', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index fde976113..c06276c12 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -14,7 +14,11 @@ export type CurrentWorkspace = Pick< | 'currentBillingSubscription' | 'workspaceMembersCount' | 'isPublicInviteLinkEnabled' + | 'isGoogleAuthEnabled' + | 'isMicrosoftAuthEnabled' + | 'isPasswordAuthEnabled' | 'hasValidEntrepriseKey' + | 'subdomain' | 'metadataVersion' >; diff --git a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts new file mode 100644 index 000000000..53f55ef4b --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts @@ -0,0 +1,18 @@ +import { cookieStorageEffect } from '~/utils/recoil-effects'; +import { Workspace } from '~/generated/graphql'; +import { createState } from 'twenty-ui'; + +export const lastAuthenticateWorkspaceState = createState< + | (Pick & { + cookieAttributes?: Cookies.CookieAttributes; + }) + | null +>({ + key: 'lastAuthenticateWorkspaceState', + defaultValue: null, + effects: [ + cookieStorageEffect('lastAuthenticateWorkspace', { + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year + }), + ], +}); diff --git a/packages/twenty-front/src/modules/auth/states/signInUpModeState.ts b/packages/twenty-front/src/modules/auth/states/signInUpModeState.ts new file mode 100644 index 000000000..b96a5451e --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/signInUpModeState.ts @@ -0,0 +1,7 @@ +import { createState } from 'twenty-ui'; +import { SignInUpMode } from '@/auth/types/signInUpMode.type'; + +export const signInUpModeState = createState({ + key: 'signInUpModeState', + defaultValue: SignInUpMode.SignIn, +}); diff --git a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts index 71f359fde..b3f3c4081 100644 --- a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts +++ b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts @@ -4,8 +4,8 @@ export enum SignInUpStep { Init = 'init', Email = 'email', Password = 'password', - SSOEmail = 'SSOEmail', - SSOWorkspaceSelection = 'SSOWorkspaceSelection', + WorkspaceSelection = 'workspaceSelection', + SSOIdentityProviderSelection = 'SSOIdentityProviderSelection', } export const signInUpStepState = createState({ diff --git a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts index f6262b5ae..718e67ad6 100644 --- a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts +++ b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts @@ -2,9 +2,17 @@ import { createState } from 'twenty-ui'; import { AuthTokenPair } from '~/generated/graphql'; import { cookieStorageEffect } from '~/utils/recoil-effects'; - export const tokenPairState = createState({ key: 'tokenPairState', defaultValue: null, - effects: [cookieStorageEffect('tokenPair')], + effects: [ + cookieStorageEffect( + 'tokenPair', + {}, + { + validateInitFn: (payload: AuthTokenPair) => + Boolean(payload['accessToken']), + }, + ), + ], }); diff --git a/packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts b/packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts new file mode 100644 index 000000000..f4866fa65 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; +import { PublicWorkspaceDataOutput } from '~/generated/graphql'; + +export const workspacePublicDataState = + createState({ + key: 'workspacePublicDataState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/auth/states/workspaces.ts b/packages/twenty-front/src/modules/auth/states/workspaces.ts index d211351b0..da0d270bb 100644 --- a/packages/twenty-front/src/modules/auth/states/workspaces.ts +++ b/packages/twenty-front/src/modules/auth/states/workspaces.ts @@ -2,7 +2,10 @@ import { createState } from 'twenty-ui'; import { Workspace } from '~/generated/graphql'; -export type Workspaces = Pick; +export type Workspaces = Pick< + Workspace, + 'id' | 'logo' | 'displayName' | 'subdomain' +>; export const workspacesState = createState({ key: 'workspacesState', diff --git a/packages/twenty-front/src/modules/auth/types/signInUpMode.type.ts b/packages/twenty-front/src/modules/auth/types/signInUpMode.type.ts new file mode 100644 index 000000000..f1074272f --- /dev/null +++ b/packages/twenty-front/src/modules/auth/types/signInUpMode.type.ts @@ -0,0 +1,4 @@ +export enum SignInUpMode { + SignIn = 'sign-in', + SignUp = 'sign-up', +} diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 9291fa6e4..b706ba972 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -1,5 +1,4 @@ import { apiConfigState } from '@/client-config/states/apiConfigState'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; import { billingState } from '@/client-config/states/billingState'; import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; @@ -7,23 +6,28 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; -import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { sentryConfigState } from '@/client-config/states/sentryConfigState'; import { supportChatState } from '@/client-config/states/supportChatState'; import { useEffect } from 'react'; import { useRecoilState, useSetRecoilState } from 'recoil'; import { useGetClientConfigQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { urlManagerState } from '@/url-manager/states/url-manager.state'; +import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState'; export const ClientConfigProviderEffect = () => { - const setAuthProviders = useSetRecoilState(authProvidersState); const setIsDebugMode = useSetRecoilState(isDebugModeState); const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); + const setUrlManager = useSetRecoilState(urlManagerState); const setIsDeveloperDefaultSignInPrefilled = useSetRecoilState( isDeveloperDefaultSignInPrefilledState, ); - const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState); + const setIsMultiWorkspaceEnabled = useSetRecoilState( + isMultiWorkspaceEnabledState, + ); + const setIsSSOEnabledState = useSetRecoilState(isSSOEnabledState); const setBilling = useSetRecoilState(billingState); const setSupportChat = useSetRecoilState(supportChatState); @@ -69,17 +73,10 @@ export const ClientConfigProviderEffect = () => { error: undefined, })); - setAuthProviders({ - google: data?.clientConfig.authProviders.google, - microsoft: data?.clientConfig.authProviders.microsoft, - password: data?.clientConfig.authProviders.password, - magicLink: false, - sso: data?.clientConfig.authProviders.sso, - }); setIsDebugMode(data?.clientConfig.debugMode); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsDeveloperDefaultSignInPrefilled(data?.clientConfig.signInPrefilled); - setIsSignUpDisabled(data?.clientConfig.signUpDisabled); + setIsMultiWorkspaceEnabled(data?.clientConfig.isMultiWorkspaceEnabled); setBilling(data?.clientConfig.billing); setSupportChat(data?.clientConfig.support); @@ -97,12 +94,16 @@ export const ClientConfigProviderEffect = () => { setChromeExtensionId(data?.clientConfig?.chromeExtensionId); setApiConfig(data?.clientConfig?.api); + setIsSSOEnabledState(data?.clientConfig?.isSSOEnabled); + setUrlManager({ + defaultSubdomain: data?.clientConfig?.defaultSubdomain, + frontDomain: data?.clientConfig?.frontDomain, + }); }, [ data, - setAuthProviders, setIsDebugMode, setIsDeveloperDefaultSignInPrefilled, - setIsSignUpDisabled, + setIsMultiWorkspaceEnabled, setSupportChat, setBilling, setSentryConfig, @@ -113,6 +114,8 @@ export const ClientConfigProviderEffect = () => { setApiConfig, setIsAnalyticsEnabled, error, + setUrlManager, + setIsSSOEnabledState, ]); return <>; diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index eaf55e4d0..2c6da152e 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -3,19 +3,16 @@ import { gql } from '@apollo/client'; export const GET_CLIENT_CONFIG = gql` query GetClientConfig { clientConfig { - authProviders { - google - password - microsoft - sso - } billing { isBillingEnabled billingUrl billingFreeTrialDurationInDays } signInPrefilled - signUpDisabled + isMultiWorkspaceEnabled + isSSOEnabled + defaultSubdomain + frontDomain debugMode analyticsEnabled support { diff --git a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts index ef37f22cf..d56572b5a 100644 --- a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts +++ b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts @@ -5,10 +5,10 @@ import { AuthProviders } from '~/generated/graphql'; export const authProvidersState = createState({ key: 'authProvidersState', defaultValue: { - google: false, + google: true, magicLink: false, - password: false, + password: true, microsoft: false, - sso: false, + sso: [], }, }); diff --git a/packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts new file mode 100644 index 000000000..4749eeab9 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isMultiWorkspaceEnabledState = createState({ + key: 'isMultiWorkspaceEnabled', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/states/isSSOEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isSSOEnabledState.ts new file mode 100644 index 000000000..3242678c3 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isSSOEnabledState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isSSOEnabledState = createState({ + key: 'isSSOEnabledState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts b/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts deleted file mode 100644 index a82b1a821..000000000 --- a/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createState } from 'twenty-ui'; - -export const isSignUpDisabledState = createState({ - key: 'isSignUpDisabledState', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 2d26b7665..5d3344580 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -15,10 +15,14 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({ id: '1', featureFlags: [], allowImpersonation: false, + subdomain: 'test', activationStatus: WorkspaceActivationStatus.Active, hasValidEntrepriseKey: false, metadataVersion: 1, isPublicInviteLinkEnabled: false, + isGoogleAuthEnabled: true, + isMicrosoftAuthEnabled: false, + isPasswordAuthEnabled: true, }); }, }); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 4910e7a2a..2340ed2b1 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -58,7 +58,7 @@ const StyledIconContainer = styled.div` height: 75%; `; -const StyledDeveloperSection = styled.div` +const StyledContainer = styled.div` display: flex; width: 100%; gap: ${({ theme }) => theme.spacing(1)}; @@ -82,7 +82,6 @@ export const SettingsNavigationDrawerItems = () => { ); const isFreeAccessEnabled = useIsFeatureEnabled('IS_FREE_ACCESS_ENABLED'); const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED'); - const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED'); const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; @@ -192,14 +191,20 @@ export const SettingsNavigationDrawerItems = () => { Icon={IconCode} /> )} - {isSSOEnabled && ( - + {isAdvancedModeEnabled && ( + + + + + + )} + {isAdvancedModeEnabled && ( { exit="exit" variants={motionAnimationVariants} > - + @@ -228,7 +233,7 @@ export const SettingsNavigationDrawerItems = () => { /> )} - + )} diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx index 4d28c837f..05b5e9cdb 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx @@ -10,7 +10,7 @@ import styled from '@emotion/styled'; import { ReactElement } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { H2Title, IconComponent, IconKey, Section } from 'twenty-ui'; -import { IdpType } from '~/generated/graphql'; +import { IdentityProviderType } from '~/generated/graphql'; const StyledInputsContainer = styled.div` display: grid; @@ -30,8 +30,8 @@ export const SettingsSSOIdentitiesProvidersForm = () => { const { control, getValues } = useFormContext(); - const IdpMap: Record< - IdpType, + const IdentitiesProvidersMap: Record< + IdentityProviderType, { form: ReactElement; option: { @@ -62,12 +62,12 @@ export const SettingsSSOIdentitiesProvidersForm = () => { }, }; - const getFormByType = (type: Uppercase | undefined) => { + const getFormByType = (type: Uppercase | undefined) => { switch (type) { - case IdpType.Oidc: - return IdpMap.OIDC.form; - case IdpType.Saml: - return IdpMap.SAML.form; + case IdentityProviderType.Oidc: + return IdentitiesProvidersMap.OIDC.form; + case IdentityProviderType.Saml: + return IdentitiesProvidersMap.SAML.form; default: return null; } @@ -106,7 +106,7 @@ export const SettingsSSOIdentitiesProvidersForm = () => { render={({ field: { onChange, value } }) => ( identityProviderType.option, )} onChange={onChange} diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx index e9c72f05c..bcf0abef9 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx @@ -8,11 +8,14 @@ import { SettingsPath } from '@/types/SettingsPath'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper'; -import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; import isPropValid from '@emotion/is-prop-valid'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useRecoilState } from 'recoil'; import { IconKey } from 'twenty-ui'; +import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; const StyledLink = styled(Link, { shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'isDisabled', @@ -22,11 +25,29 @@ const StyledLink = styled(Link, { `; export const SettingsSSOIdentitiesProvidersListCard = () => { + const { enqueueSnackBar } = useSnackBar(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); - const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); + const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( + SSOIdentitiesProvidersState, + ); - return !SSOIdentitiesProviders.length ? ( + const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ + skip: currentWorkspace?.hasValidEntrepriseKey === false, + onCompleted: (data) => { + setSSOIdentitiesProviders( + data?.listSSOIdentityProvidersByWorkspaceId ?? [], + ); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return loading || !SSOIdentitiesProviders.length ? ( { - const { enqueueSnackBar } = useSnackBar(); const navigate = useNavigate(); - const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( - SSOIdentitiesProvidersState, - ); - - const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ - onCompleted: (data) => { - setSSOIdentitiesProviders( - data?.listSSOIdentityProvidersByWorkspaceId ?? [], - ); - }, - onError: (error: Error) => { - enqueueSnackBar(error.message, { - variant: SnackBarVariant.Error, - }); - }, - }); + const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); return ( { getItemLabel={(SSOIdentityProvider) => `${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}` } - isLoading={loading} RowIconFn={(SSOIdentityProvider) => guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer) } diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx index f346d5656..dcdd5056f 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -2,24 +2,98 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useRecoilState } from 'recoil'; -import { Card, IconLink, isDefined } from 'twenty-ui'; +import styled from '@emotion/styled'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { + IconLink, + Card, + IconGoogle, + IconMicrosoft, + IconPassword, +} from 'twenty-ui'; import { useUpdateWorkspaceMutation } from '~/generated/graphql'; +import { AuthProviders } from '~/generated-metadata/graphql'; +import { capitalize } from '~/utils/string/capitalize'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; + +const StyledSettingsSecurityOptionsList = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; export const SettingsSecurityOptionsList = () => { const { enqueueSnackBar } = useSnackBar(); + const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); - if (!isDefined(currentWorkspace)) { - throw new Error( - 'The current workspace must be defined to edit its security options.', - ); - } const [updateWorkspace] = useUpdateWorkspaceMutation(); + const isValidAuthProvider = ( + key: string, + ): key is Exclude => { + if (!currentWorkspace) return false; + return Reflect.has(currentWorkspace, key); + }; + + const toggleAuthMethod = async ( + authProvider: keyof Omit, + ) => { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + + const key = `is${capitalize(authProvider)}AuthEnabled`; + + if (!isValidAuthProvider(key)) { + throw new Error('Invalid auth provider'); + } + + const allAuthProvidersEnabled = [ + currentWorkspace.isGoogleAuthEnabled, + currentWorkspace.isMicrosoftAuthEnabled, + currentWorkspace.isPasswordAuthEnabled, + (SSOIdentitiesProviders?.length ?? 0) > 0, + ]; + + if ( + currentWorkspace[key] === true && + allAuthProvidersEnabled.filter((isAuthEnable) => isAuthEnable).length <= 1 + ) { + return enqueueSnackBar( + 'At least one authentication method must be enabled', + { + variant: SnackBarVariant.Error, + }, + ); + } + + setCurrentWorkspace({ + ...currentWorkspace, + [key]: !currentWorkspace[key], + }); + + updateWorkspace({ + variables: { + input: { + [key]: !currentWorkspace[key], + }, + }, + }).catch((err) => { + // rollback optimistic update if err + setCurrentWorkspace({ + ...currentWorkspace, + [key]: !currentWorkspace[key], + }); + enqueueSnackBar(err?.message, { + variant: SnackBarVariant.Error, + }); + }); + }; + const handleChange = async (value: boolean) => { try { if (!currentWorkspace?.id) { @@ -44,17 +118,49 @@ export const SettingsSecurityOptionsList = () => { }; return ( - - - handleChange(!currentWorkspace.isPublicInviteLinkEnabled) - } - /> - + + {currentWorkspace && ( + <> + + toggleAuthMethod('google')} + /> + toggleAuthMethod('microsoft')} + /> + toggleAuthMethod('password')} + /> + + + + handleChange(!currentWorkspace.isPublicInviteLinkEnabled) + } + /> + + + )} + ); }; diff --git a/packages/twenty-front/src/modules/settings/security/types/AuthProviders.type.ts b/packages/twenty-front/src/modules/settings/security/types/AuthProviders.type.ts new file mode 100644 index 000000000..496229ef5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/types/AuthProviders.type.ts @@ -0,0 +1,4 @@ +export type AuthProvidersKeys = + | 'isGoogleAuthEnabled' + | 'isMicrosoftAuthEnabled' + | 'isPasswordAuthEnabled'; diff --git a/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts index fe7226c9d..ff17752f0 100644 --- a/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts +++ b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts @@ -2,12 +2,15 @@ import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema'; import { z } from 'zod'; -import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql'; +import { + IdentityProviderType, + SsoIdentityProviderStatus, +} from '~/generated/graphql'; export type SSOIdentityProvider = { __typename: 'SSOIdentityProvider'; id: string; - type: IdpType; + type: IdentityProviderType; issuer: string; name?: string | null; status: SsoIdentityProviderStatus; diff --git a/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts index 2e4fdf294..6ed9924b0 100644 --- a/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts +++ b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts @@ -16,7 +16,6 @@ export const parseSAMLMetadataFromXMLFile = ( try { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlString, 'application/xml'); - if (xmlDoc.getElementsByTagName('parsererror').length > 0) { throw new Error('Error parsing XML'); } @@ -28,10 +27,10 @@ export const parseSAMLMetadataFromXMLFile = ( 'md:IDPSSODescriptor', )?.[0]; const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0]; - const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0]; - const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0]; + const keyInfo = keyDescriptor?.getElementsByTagName('ds:KeyInfo')[0]; + const x509Data = keyInfo?.getElementsByTagName('ds:X509Data')[0]; const x509Certificate = x509Data - .getElementsByTagName('ds:X509Certificate')?.[0] + ?.getElementsByTagName('ds:X509Certificate')?.[0] .textContent?.trim(); const singleSignOnServices = Array.from( diff --git a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts index a5358e948..319ec19b6 100644 --- a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts +++ b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts @@ -1,17 +1,25 @@ /* @license Enterprise */ import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; -import { IdpType } from '~/generated/graphql'; +import { IdentityProviderType } from '~/generated/graphql'; export const sSOIdentityProviderDefaultValues: Record< - IdpType, + IdentityProviderType, () => SettingSecurityNewSSOIdentityFormValues > = { SAML: () => ({ type: 'SAML', ssoURL: '', name: '', - id: crypto.randomUUID(), + id: + window.location.protocol === 'https:' + ? crypto.randomUUID() + : '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ), certificate: '', issuer: '', }), diff --git a/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx b/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx index 4cff97476..430205c52 100644 --- a/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx +++ b/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx @@ -10,7 +10,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const WorkspaceLogoUploader = () => { const [uploadLogo] = useUploadWorkspaceLogoMutation(); - const [updateWorkspce] = useUpdateWorkspaceMutation(); + const [updateWorkspace] = useUpdateWorkspaceMutation(); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); @@ -39,7 +39,7 @@ export const WorkspaceLogoUploader = () => { if (!currentWorkspace?.id) { throw new Error('Workspace id not found'); } - await updateWorkspce({ + await updateWorkspace({ variables: { input: { logo: null, diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 13abb1d82..af0b7c6ab 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -19,6 +19,7 @@ export enum SettingsPath { ServerlessFunctionDetail = 'functions/:serverlessFunctionId', WorkspaceMembersPage = 'workspace-members', Workspace = 'workspace', + Domain = 'domain', CRMMigration = 'crm-migration', Developers = 'developers', ServerlessFunctions = 'functions', diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx index 3a03e32f7..d329e4548 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx @@ -15,6 +15,13 @@ import { useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { IconChevronDown, MenuItemSelectAvatar } from 'twenty-ui'; import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; +import { Link } from 'react-router-dom'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; + +const StyledLink = styled(Link)` + text-decoration: none; + width: 100%; +`; const StyledLogo = styled.div<{ logo: string }>` background: url(${({ logo }) => logo}); @@ -72,6 +79,7 @@ export const MultiWorkspaceDropdownButton = ({ useState(false); const { switchWorkspace } = useWorkspaceSwitching(); + const { buildWorkspaceUrl } = useUrlManager(); const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID); @@ -96,13 +104,9 @@ export const MultiWorkspaceDropdownButton = ({ isNavigationDrawerExpanded={isNavigationDrawerExpanded} > {currentWorkspace?.displayName ?? ''} @@ -118,23 +122,26 @@ export const MultiWorkspaceDropdownButton = ({ dropdownComponents={ {workspaces.map((workspace) => ( - - } - selected={currentWorkspace?.id === workspace.id} - onClick={() => handleChange(workspace.id)} - /> + to={buildWorkspaceUrl(workspace.subdomain)} + > + + } + selected={currentWorkspace?.id === workspace.id} + onClick={(event) => { + event?.preventDefault(); + handleChange(workspace.id); + }} + /> + ))} } diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx index 52477ff39..4d7bf0fa4 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx @@ -11,6 +11,7 @@ import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigat import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { isNonEmptyString } from '@sniptt/guards'; import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; const StyledContainer = styled.div` align-items: center; @@ -60,14 +61,17 @@ export const NavigationDrawerHeader = ({ }: NavigationDrawerHeaderProps) => { const isMobile = useIsMobile(); const workspaces = useRecoilValue(workspacesState); - const isMultiWorkspace = workspaces !== null && workspaces.length > 1; + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const isNavigationDrawerExpanded = useRecoilValue( isNavigationDrawerExpandedState, ); return ( - {isMultiWorkspace ? ( + {isMultiWorkspaceEnabled && + workspaces !== null && + workspaces.length > 1 ? ( ) : ( diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index 976362e46..b9e064600 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -1,74 +1,44 @@ -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; -import { useAuth } from '@/auth/hooks/useAuth'; -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { - SignInUpStep, - signInUpStepState, -} from '@/auth/states/signInUpStepState'; -import { tokenPairState } from '@/auth/states/tokenPairState'; -import { AppPath } from '@/types/AppPath'; -import { useGenerateJwtMutation } from '~/generated/graphql'; + +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSwitchWorkspaceMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; -import { sleep } from '~/utils/sleep'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; export const useWorkspaceSwitching = () => { - const setTokenPair = useSetRecoilState(tokenPairState); - const [generateJWT] = useGenerateJwtMutation(); - const { redirectToSSOLoginPage } = useSSO(); + const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); const currentWorkspace = useRecoilValue(currentWorkspaceState); - const setAvailableWorkspacesForSSOState = useSetRecoilState( - availableSSOIdentityProvidersState, - ); - const setSignInUpStep = useSetRecoilState(signInUpStepState); - const { clearSession } = useAuth(); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const { enqueueSnackBar } = useSnackBar(); + const { redirectToHome, redirectToWorkspace } = useUrlManager(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; - const jwt = await generateJWT({ + + if (!isMultiWorkspaceEnabled) { + return enqueueSnackBar( + 'Switching workspace is not available in single workspace mode', + { + variant: SnackBarVariant.Error, + }, + ); + } + + const { data, errors } = await switchWorkspaceMutation({ variables: { workspaceId, }, }); - if (isDefined(jwt.errors)) { - throw jwt.errors; + if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) { + return redirectToHome(); } - if (!isDefined(jwt.data?.generateJWT)) { - throw new Error('could not create token'); - } - - if ( - jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' && - 'availableSSOIDPs' in jwt.data.generateJWT - ) { - if (jwt.data.generateJWT.availableSSOIDPs.length === 1) { - redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id); - } - - if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { - await clearSession(); - setAvailableWorkspacesForSSOState( - jwt.data.generateJWT.availableSSOIDPs, - ); - setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); - } - - return; - } - - if ( - jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' && - 'authTokens' in jwt.data.generateJWT - ) { - const { tokens } = jwt.data.generateJWT.authTokens; - setTokenPair(tokens); - await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. - window.location.href = AppPath.Index; - } + redirectToWorkspace(data.switchWorkspace.subdomain); }; return { switchWorkspace }; diff --git a/packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts b/packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts new file mode 100644 index 000000000..8fabcedbd --- /dev/null +++ b/packages/twenty-front/src/modules/url-manager/hooks/useUrlManager.ts @@ -0,0 +1,110 @@ +import { useMemo, useCallback } from 'react'; + +import { isDefined } from '~/utils/isDefined'; +import { urlManagerState } from '@/url-manager/states/url-manager.state'; +import { useRecoilValue } from 'recoil'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; + +export const useUrlManager = () => { + const urlManager = useRecoilValue(urlManagerState); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + + const homePageDomain = useMemo(() => { + return isMultiWorkspaceEnabled + ? `${urlManager.defaultSubdomain}.${urlManager.frontDomain}` + : urlManager.frontDomain; + }, [ + isMultiWorkspaceEnabled, + urlManager.defaultSubdomain, + urlManager.frontDomain, + ]); + + const isTwentyHomePage = useMemo(() => { + if (!isMultiWorkspaceEnabled) return true; + return window.location.hostname === homePageDomain; + }, [homePageDomain, isMultiWorkspaceEnabled]); + + const isTwentyWorkspaceSubdomain = useMemo(() => { + if (!isMultiWorkspaceEnabled) return false; + + if ( + !isDefined(urlManager.frontDomain) || + !isDefined(urlManager.defaultSubdomain) + ) { + throw new Error('frontDomain and defaultSubdomain are required'); + } + + return window.location.hostname !== homePageDomain; + }, [ + homePageDomain, + isMultiWorkspaceEnabled, + urlManager.defaultSubdomain, + urlManager.frontDomain, + ]); + + const getWorkspaceSubdomain = useMemo(() => { + if (!isDefined(urlManager.frontDomain)) { + throw new Error('frontDomain is not defined'); + } + + return isTwentyWorkspaceSubdomain + ? window.location.hostname.replace(`.${urlManager.frontDomain}`, '') + : null; + }, [isTwentyWorkspaceSubdomain, urlManager.frontDomain]); + + const buildWorkspaceUrl = useCallback( + ( + subdomain?: string, + onPage?: string, + searchParams?: Record, + ) => { + const url = new URL(window.location.href); + + if (isDefined(subdomain) && subdomain.length !== 0) { + url.hostname = `${subdomain}.${urlManager.frontDomain}`; + } + + if (isDefined(onPage)) { + url.pathname = onPage; + } + + if (isDefined(searchParams)) { + Object.entries(searchParams).forEach(([key, value]) => + url.searchParams.set(key, value), + ); + } + return url.toString(); + }, + [urlManager.frontDomain], + ); + + const redirectToWorkspace = useCallback( + ( + subdomain: string, + onPage?: string, + searchParams?: Record, + ) => { + if (!isMultiWorkspaceEnabled) return; + window.location.href = buildWorkspaceUrl(subdomain, onPage, searchParams); + }, + [buildWorkspaceUrl, isMultiWorkspaceEnabled], + ); + + const redirectToHome = useCallback(() => { + const url = new URL(window.location.href); + if (url.hostname !== homePageDomain) { + url.hostname = homePageDomain; + window.location.href = url.toString(); + } + }, [homePageDomain]); + + return { + redirectToHome, + redirectToWorkspace, + homePageDomain, + isTwentyHomePage, + buildWorkspaceUrl, + isTwentyWorkspaceSubdomain, + getWorkspaceSubdomain, + }; +}; diff --git a/packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts b/packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts new file mode 100644 index 000000000..460a0b0ce --- /dev/null +++ b/packages/twenty-front/src/modules/url-manager/states/url-manager.state.ts @@ -0,0 +1,12 @@ +import { createState } from 'twenty-ui'; +import { ClientConfig } from '~/generated/graphql'; + +export const urlManagerState = createState< + Pick +>({ + key: 'urlManager', + defaultValue: { + frontDomain: '', + defaultSubdomain: undefined, + }, +}); diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 9930b6d24..1d5e0201d 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -32,6 +32,10 @@ export const USER_QUERY_FRAGMENT = gql` allowImpersonation activationStatus isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled + subdomain hasValidEntrepriseKey featureFlags { id @@ -53,6 +57,7 @@ export const USER_QUERY_FRAGMENT = gql` logo displayName domainName + subdomain } } userVars diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx new file mode 100644 index 000000000..fe8d4f508 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -0,0 +1,96 @@ +import { useRecoilValue, useSetRecoilState, useRecoilState } from 'recoil'; + +import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; + +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { useEffect } from 'react'; +import { isDefined } from '~/utils/isDefined'; +import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; + +export const WorkspaceProviderEffect = () => { + const workspacePublicData = useRecoilValue(workspacePublicDataState); + + const setAuthProviders = useSetRecoilState(authProvidersState); + const setWorkspacePublicDataState = useSetRecoilState( + workspacePublicDataState, + ); + + const [lastAuthenticateWorkspace, setLastAuthenticateWorkspace] = + useRecoilState(lastAuthenticateWorkspaceState); + + const { + redirectToHome, + getWorkspaceSubdomain, + redirectToWorkspace, + isTwentyHomePage, + } = useUrlManager(); + + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + + useGetPublicWorkspaceDataBySubdomainQuery({ + skip: + (isMultiWorkspaceEnabled && isTwentyHomePage) || + isDefined(workspacePublicData), + onCompleted: (data) => { + setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders); + setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain); + }, + onError: (error) => { + // eslint-disable-next-line no-console + console.error(error); + setLastAuthenticateWorkspace(null); + redirectToHome(); + }, + }); + + useEffect(() => { + if ( + isMultiWorkspaceEnabled && + isDefined(workspacePublicData?.subdomain) && + workspacePublicData.subdomain !== getWorkspaceSubdomain + ) { + redirectToWorkspace(workspacePublicData.subdomain); + } + }, [ + getWorkspaceSubdomain, + isMultiWorkspaceEnabled, + redirectToWorkspace, + workspacePublicData, + ]); + + useEffect(() => { + if ( + isMultiWorkspaceEnabled && + isDefined(lastAuthenticateWorkspace?.subdomain) && + isTwentyHomePage + ) { + redirectToWorkspace(lastAuthenticateWorkspace.subdomain); + } + }, [ + isMultiWorkspaceEnabled, + isTwentyHomePage, + lastAuthenticateWorkspace, + redirectToWorkspace, + ]); + + useEffect(() => { + try { + if (isDefined(workspacePublicData?.logo)) { + const link: HTMLLinkElement = + document.querySelector("link[rel*='icon']") || + document.createElement('link'); + link.rel = 'icon'; + link.href = workspacePublicData.logo; + document.getElementsByTagName('head')[0].appendChild(link); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + }, [workspacePublicData]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/activateWorkspace.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/activateWorkspace.ts index b116a298a..41356c922 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/activateWorkspace.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/activateWorkspace.ts @@ -3,7 +3,13 @@ import { gql } from '@apollo/client'; export const ACTIVATE_WORKSPACE = gql` mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { activateWorkspace(data: $input) { - id + workspace { + id + subdomain + } + loginToken { + ...AuthTokenFragment + } } } `; diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts index 1d9a9b9fb..a8a97eecc 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts @@ -5,9 +5,14 @@ export const UPDATE_WORKSPACE = gql` updateWorkspace(data: $input) { id domainName + subdomain displayName logo allowImpersonation + isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled } } `; diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index d2488f480..4687fee7b 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -1,7 +1,7 @@ import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -16,6 +16,7 @@ import { useAddUserToWorkspaceMutation, } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { currentUserState } from '@/auth/states/currentUserState'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -28,6 +29,7 @@ export const Invite = () => { const { form } = useSignInUpForm(); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const currentUser = useRecoilValue(currentUserState); const [addUserToWorkspace] = useAddUserToWorkspaceMutation(); const [addUserToWorkspaceByInviteToken] = useAddUserToWorkspaceByInviteTokenMutation(); @@ -77,7 +79,7 @@ export const Invite = () => { {title} - {isDefined(currentWorkspace) ? ( + {isDefined(currentUser) ? ( <> { ) : ( - + )} ); diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index 4be8e0444..dd47cb896 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -19,7 +19,7 @@ import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { useNavigate, useParams } from 'react-router-dom'; -import { useSetRecoilState } from 'recoil'; +import { useSetRecoilState, useRecoilValue } from 'recoil'; import { AnimatedEaseIn, MainButton } from 'twenty-ui'; import { z } from 'zod'; import { @@ -27,6 +27,7 @@ import { useValidatePasswordResetTokenQuery, } from '~/generated/graphql'; import { logError } from '~/utils/logError'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; const validationSchema = z .object({ @@ -71,6 +72,8 @@ const StyledInputContainer = styled.div` export const PasswordReset = () => { const { enqueueSnackBar } = useSnackBar(); + const workspacePublicData = useRecoilValue(workspacePublicDataState); + const navigate = useNavigate(); const [email, setEmail] = useState(''); @@ -163,7 +166,7 @@ export const PasswordReset = () => { isTokenValid && ( - + Reset Password diff --git a/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx b/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx deleted file mode 100644 index 617728095..000000000 --- a/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* @license Enterprise */ - -import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { HorizontalSeparator, MainButton } from 'twenty-ui'; -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; -import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; -import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; - -const StyledContentContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(8)}; - margin-top: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledTitle = styled.h2` - color: ${({ theme }) => theme.font.color.primary}; - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; - margin: 0; -`; - -export const SSOWorkspaceSelection = () => { - const availableSSOIdentityProviders = useRecoilValue( - availableSSOIdentityProvidersState, - ); - - const { redirectToSSOLoginPage } = useSSO(); - - const availableWorkspacesForSSOGroupByWorkspace = - availableSSOIdentityProviders.reduce( - (acc, idp) => { - acc[idp.workspace.id] = [...(acc[idp.workspace.id] ?? []), idp]; - return acc; - }, - {} as Record, - ); - - return ( - <> - - {Object.values(availableWorkspacesForSSOGroupByWorkspace).map( - (idps) => ( - <> - - {idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME} - - - {idps.map((idp) => ( - <> - redirectToSSOLoginPage(idp.id)} - Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} - fullWidth - /> - - - ))} - - ), - )} - - - - ); -}; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 712712c01..bc3346fa2 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -1,58 +1,70 @@ -import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; + +import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; +import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; +import { AnimatedEaseIn } from 'twenty-ui'; import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; -import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; -import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; -import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { IconLockCustom } from '@ui/display/icon/components/IconLock'; -import { AnimatedEaseIn } from 'twenty-ui'; +import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm'; +import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; +import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { useMemo } from 'react'; import { isDefined } from '~/utils/isDefined'; -import { SSOWorkspaceSelection } from './SSOWorkspaceSelection'; export const SignInUp = () => { const { form } = useSignInUpForm(); - const currentWorkspace = useRecoilValue(currentWorkspaceState); + const { signInUpStep } = useSignInUp(form); + const { isTwentyHomePage, isTwentyWorkspaceSubdomain } = useUrlManager(); - const { signInUpStep, signInUpMode } = useSignInUp(form); + const workspacePublicData = useRecoilValue(workspacePublicDataState); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + + const signInUpForm = useMemo(() => { + if (isTwentyHomePage && isMultiWorkspaceEnabled) { + return ; + } - const title = useMemo(() => { if ( - signInUpStep === SignInUpStep.Init || - signInUpStep === SignInUpStep.Email + (!isMultiWorkspaceEnabled || + (isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) && + signInUpStep === SignInUpStep.SSOIdentityProviderSelection ) { - return 'Welcome to Twenty'; + return ; } - if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) { - return 'Choose SSO connection'; - } - return signInUpMode === SignInUpMode.SignIn - ? 'Sign in to Twenty' - : 'Sign up to Twenty'; - }, [signInUpMode, signInUpStep]); - if (isDefined(currentWorkspace)) { - return <>; - } + if ( + isDefined(workspacePublicData) && + (!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain) + ) { + return ; + } + + return ; + }, [ + isTwentyHomePage, + isMultiWorkspaceEnabled, + isTwentyWorkspaceSubdomain, + signInUpStep, + workspacePublicData, + ]); return ( <> - {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( - - ) : ( - - )} + - {title} - {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( - - ) : ( - - )} + + {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`} + + {signInUpForm} + {signInUpStep !== SignInUpStep.Password && } ); }; diff --git a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx index a0c861683..4f9b144f3 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback } from 'react'; import { Controller, SubmitHandler, useForm } from 'react-hook-form'; -import { useSetRecoilState } from 'recoil'; +import { useSetRecoilState, useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; import { H2Title, Loader, MainButton } from 'twenty-ui'; import { z } from 'zod'; @@ -22,6 +22,9 @@ import { useActivateWorkspaceMutation, } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { AppPath } from '@/types/AppPath'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; const StyledContentContainer = styled.div` width: 100%; @@ -47,6 +50,8 @@ type Form = z.infer; export const CreateWorkspace = () => { const { enqueueSnackBar } = useSnackBar(); const onboardingStatus = useOnboardingStatus(); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const { redirectToWorkspace } = useUrlManager(); const [activateWorkspace] = useActivateWorkspaceMutation(); const apolloMetadataClient = useApolloMetadataClient(); @@ -75,8 +80,19 @@ export const CreateWorkspace = () => { }, }, }); + setIsCurrentUserLoaded(false); + if (isDefined(result.data) && isMultiWorkspaceEnabled) { + return redirectToWorkspace( + result.data.activateWorkspace.workspace.subdomain, + AppPath.Verify, + { + loginToken: result.data.activateWorkspace.loginToken.token, + }, + ); + } + await apolloMetadataClient?.refetchQueries({ include: [FIND_MANY_OBJECT_METADATA_ITEMS], }); @@ -93,7 +109,9 @@ export const CreateWorkspace = () => { [ activateWorkspace, setIsCurrentUserLoaded, + isMultiWorkspaceEnabled, apolloMetadataClient, + redirectToWorkspace, enqueueSnackBar, ], ); diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx index 86d85e308..18e7eaf60 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx @@ -1,4 +1,8 @@ -import { GithubVersionLink, H2Title, Section } from 'twenty-ui'; +import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui'; +import { Link } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; + +import styled from '@emotion/styled'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; @@ -9,39 +13,61 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import packageJson from '../../../package.json'; -export const SettingsWorkspace = () => ( - - -
- - -
-
- - -
-
- } - description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time." - /> -
-
- -
-
- -
-
-
-); +import { SettingsCard } from '@/settings/components/SettingsCard'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; + +const StyledLink = styled(Link)` + text-decoration: none; +`; + +export const SettingsWorkspace = () => { + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + return ( + + +
+ + +
+
+ + +
+ {isMultiWorkspaceEnabled && ( +
+ + + } /> + +
+ )} + +
+ } + description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time." + /> +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index a0869c60a..e5ec912aa 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -9,6 +9,9 @@ import { SettingsSecurityOptionsList } from '@/settings/security/components/Sett import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isSSOEnabledState } from '@/client-config/states/isSSOEnabledState'; +import { useRecoilValue } from 'recoil'; const StyledContainer = styled.div` width: 100%; @@ -26,6 +29,10 @@ const StyledSSOSection = styled(Section)` `; export const SettingsSecurity = () => { + const isSSOEnabled = useRecoilValue(isSSOEnabledState); + const isSSOSectionDisplay = + useIsFeatureEnabled('IS_SSO_ENABLED') && isSSOEnabled; + return ( { > - - - } - /> - - + {isSSOSectionDisplay && ( + + + } + /> + + + )}
diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx new file mode 100644 index 000000000..89f68f716 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -0,0 +1,154 @@ +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { H2Title, Section } from 'twenty-ui'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import styled from '@emotion/styled'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useNavigate } from 'react-router-dom'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useUpdateWorkspaceMutation } from '~/generated/graphql'; +import { useUrlManager } from '@/url-manager/hooks/useUrlManager'; +import { urlManagerState } from '@/url-manager/states/url-manager.state'; +import { isDefined } from '~/utils/isDefined'; + +const validationSchema = z + .object({ + subdomain: z + .string() + .min(1, { message: 'Subdomain can not be empty' }) + .max(63, { message: 'Subdomain can not be longer than 63 characters' }), + }) + .required(); + +type Form = z.infer; + +const StyledDomainFromWrapper = styled.div` + align-items: center; + display: flex; +`; + +const StyledDomain = styled.h2` + color: ${({ theme }) => theme.font.color.secondary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin-left: 8px; +`; + +export const SettingsDomain = () => { + const navigate = useNavigate(); + + const urlManager = useRecoilValue(urlManagerState); + + const { enqueueSnackBar } = useSnackBar(); + const [updateWorkspace] = useUpdateWorkspaceMutation(); + const { buildWorkspaceUrl } = useUrlManager(); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + + const handleSave = async () => { + try { + const values = getValues(); + + if (!values || !isValid || !currentWorkspace) { + throw new Error('Invalid form values'); + } + + await updateWorkspace({ + variables: { + input: { + subdomain: values.subdomain, + }, + }, + }); + + setCurrentWorkspace({ + ...currentWorkspace, + subdomain: values.subdomain, + }); + + window.location.href = buildWorkspaceUrl(values.subdomain); + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + const { + control, + getValues, + formState: { isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + subdomain: currentWorkspace?.subdomain ?? '', + }, + resolver: zodResolver(validationSchema), + }); + + return ( + navigate(getSettingsPagePath(SettingsPath.Workspace))} + onSave={handleSave} + /> + } + > + +
+ + {currentWorkspace?.subdomain && ( + + ( + + )} + /> + {isDefined(urlManager) && isDefined(urlManager.frontDomain) && ( + .{urlManager.frontDomain} + )} + + )} +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx index 7405490de..97232ffc1 100644 --- a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx @@ -25,6 +25,7 @@ import { ObjectMetadataItemsProvider } from '@/object-metadata/components/Object import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider'; import { IconsProvider } from 'twenty-ui'; import { FullHeightStorybookLayout } from '../FullHeightStorybookLayout'; +import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect'; export type PageDecoratorArgs = { routePath: string; @@ -72,6 +73,7 @@ const Providers = () => { + diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index 151fc1d18..4fcc69adb 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -23,6 +23,7 @@ import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/gen import { mockedTasks } from '~/testing/mock-data/tasks'; import { mockedRemoteServers } from './mock-data/remote-servers'; import { mockedViewFieldsData } from './mock-data/view-fields'; +import { GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataBySubdomain'; const peopleMock = getPeopleMock(); const companiesMock = getCompaniesMock(); @@ -41,6 +42,28 @@ export const graphqlMocks = { }, }); }), + graphql.query( + getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN) ?? '', + () => { + return HttpResponse.json({ + data: { + getPublicWorkspaceDataBySubdomain: { + id: 'id', + logo: 'logo', + displayName: 'displayName', + subdomain: 'subdomain', + authProviders: { + google: true, + microsoft: false, + password: true, + magicLink: false, + sso: [], + }, + }, + }, + }); + }, + ), graphql.mutation(getOperationName(TRACK) ?? '', () => { return HttpResponse.json({ data: { diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 6e8ade28b..f3235dfc4 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -3,18 +3,13 @@ import { CaptchaDriverType } from '~/generated/graphql'; export const mockedClientConfig: ClientConfig = { signInPrefilled: true, - signUpDisabled: false, + isMultiWorkspaceEnabled: false, + isSSOEnabled: false, + frontDomain: 'localhost', + defaultSubdomain: 'app', chromeExtensionId: 'MOCKED_EXTENSION_ID', debugMode: false, analyticsEnabled: true, - authProviders: { - sso: false, - google: true, - password: true, - magicLink: false, - microsoft: false, - __typename: 'AuthProviders', - }, support: { supportDriver: 'front', supportFrontChatId: null, diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 7c80f64b8..c3839427b 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -36,6 +36,7 @@ export const workspaceLogoUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII='; export const mockDefaultWorkspace: Workspace = { + subdomain: 'acme.twenty.com', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w', displayName: 'Twenty', domainName: 'twenty.com', @@ -45,6 +46,9 @@ export const mockDefaultWorkspace: Workspace = { allowImpersonation: true, activationStatus: WorkspaceActivationStatus.Active, hasValidEntrepriseKey: false, + isGoogleAuthEnabled: true, + isPasswordAuthEnabled: true, + isMicrosoftAuthEnabled: false, featureFlags: [ { id: '1492de61-5018-4368-8923-4f1eeaf988c4', diff --git a/packages/twenty-front/src/utils/cookie-storage.ts b/packages/twenty-front/src/utils/cookie-storage.ts index 1261a209d..183d0da52 100644 --- a/packages/twenty-front/src/utils/cookie-storage.ts +++ b/packages/twenty-front/src/utils/cookie-storage.ts @@ -16,9 +16,9 @@ class CookieStorage { Cookies.set(key, value, attributes); } - removeItem(key: string): void { + removeItem(key: string, attributes?: Cookies.CookieAttributes): void { this.keys.delete(key); - Cookies.remove(key); + Cookies.remove(key, attributes); } clear(): void { diff --git a/packages/twenty-front/src/utils/recoil-effects.ts b/packages/twenty-front/src/utils/recoil-effects.ts index 7f20e24c5..7f6f58657 100644 --- a/packages/twenty-front/src/utils/recoil-effects.ts +++ b/packages/twenty-front/src/utils/recoil-effects.ts @@ -1,4 +1,5 @@ import { AtomEffect } from 'recoil'; +import omit from 'lodash.omit'; import { cookieStorage } from '~/utils/cookie-storage'; @@ -20,25 +21,50 @@ export const localStorageEffect = }; export const cookieStorageEffect = - (key: string): AtomEffect => + ( + key: string, + attributes?: Cookies.CookieAttributes, + hooks?: { + validateInitFn?: (payload: T) => boolean; + }, + ): AtomEffect => ({ setSelf, onSet }) => { const savedValue = cookieStorage.getItem(key); + if ( isDefined(savedValue) && - isDefined(JSON.parse(savedValue)['accessToken']) + (!isDefined(hooks?.validateInitFn) || + hooks.validateInitFn(JSON.parse(savedValue))) ) { setSelf(JSON.parse(savedValue)); } + const defaultAttributes = { + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + ...(attributes ?? {}), + }; + onSet((newValue, _, isReset) => { if (!newValue) { - cookieStorage.removeItem(key); + cookieStorage.removeItem(key, defaultAttributes); return; } + + const cookieAttributes = { + ...defaultAttributes, + ...(typeof newValue === 'object' && + 'cookieAttributes' in newValue && + typeof newValue.cookieAttributes === 'object' + ? newValue.cookieAttributes + : {}), + }; + isReset - ? cookieStorage.removeItem(key) - : cookieStorage.setItem(key, JSON.stringify(newValue), { - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), - }); + ? cookieStorage.removeItem(key, defaultAttributes) + : cookieStorage.setItem( + key, + JSON.stringify(omit(newValue, ['cookieAttributes'])), + cookieAttributes, + ); }); }; diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 5384eb365..d6fd69993 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -22,7 +22,7 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access # IS_BILLING_ENABLED=false # BILLING_PLAN_REQUIRED_LINK=https://twenty.com/stripe-redirection # AUTH_PASSWORD_ENABLED=false -# IS_SIGN_UP_DISABLED=false +# IS_MULTIWORKSPACE_ENABLED=false # AUTH_MICROSOFT_ENABLED=false # AUTH_MICROSOFT_CLIENT_ID=replace_me_with_azure_client_id # AUTH_MICROSOFT_TENANT_ID=replace_me_with_azure_tenant_id diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command.ts new file mode 100644 index 000000000..d7b6d386f --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command.ts @@ -0,0 +1,123 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository, In } from 'typeorm'; + +import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { BaseCommandOptions } from 'src/database/commands/base.command'; + +// For DX only +type WorkspaceId = string; + +type Subdomain = string; + +@Command({ + name: 'feat-0.34:add-subdomain-to-workspace', + description: 'Add a default subdomain to each workspace', +}) +export class GenerateDefaultSubdomainCommand extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + ) { + super(workspaceRepository); + } + + private generatePayloadForQuery({ + id, + subdomain, + domainName, + displayName, + }: Workspace) { + const result = { id, subdomain }; + + if (domainName) { + const subdomain = domainName.split('.')[0]; + + if (subdomain.length > 0) { + result.subdomain = subdomain; + } + } + + if (!domainName && displayName) { + const displayNameWords = displayName.match(/(\w| |\d)+/); + + if (displayNameWords) { + result.subdomain = displayNameWords + .join('-') + .replace(/ /g, '') + .toLowerCase(); + } + } + + return result; + } + + private groupBySubdomainName( + acc: Record>, + workspace: Workspace, + ) { + const payload = this.generatePayloadForQuery(workspace); + + acc[payload.subdomain] = acc[payload.subdomain] + ? acc[payload.subdomain].concat([payload.id]) + : [payload.id]; + + return acc; + } + + private async deduplicateAndSave( + subdomain: Subdomain, + workspaceIds: Array, + options: BaseCommandOptions, + ) { + for (const [index, workspaceId] of workspaceIds.entries()) { + const subdomainDeduplicated = + index === 0 ? subdomain : `${subdomain}-${index}`; + + this.logger.log( + `Updating workspace ${workspaceId} with subdomain ${subdomainDeduplicated}`, + ); + + if (!options.dryRun) { + await this.workspaceRepository.update(workspaceId, { + subdomain: subdomainDeduplicated, + }); + } + } + } + + async executeActiveWorkspacesCommand( + passedParam: string[], + options: BaseCommandOptions, + activeWorkspaceIds: string[], + ): Promise { + const workspaces = await this.workspaceRepository.find( + activeWorkspaceIds.length > 0 + ? { + where: { + id: In(activeWorkspaceIds), + }, + } + : undefined, + ); + + if (workspaces.length === 0) { + this.logger.log('No workspaces found'); + + return; + } + + const workspaceBySubdomain = Object.entries( + workspaces.reduce( + (acc, workspace) => this.groupBySubdomainName(acc, workspace), + {} as ReturnType, + ), + ); + + for (const [subdomain, workspaceIds] of workspaceBySubdomain) { + await this.deduplicateAndSave(subdomain, workspaceIds, options); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command.ts new file mode 100644 index 000000000..4817eb5d1 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command.ts @@ -0,0 +1,38 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { GenerateDefaultSubdomainCommand } from 'src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command'; + +interface UpdateTo0_34CommandOptions { + workspaceId?: string; +} + +@Command({ + name: 'upgrade-0.34', + description: 'Upgrade to 0.34', +}) +export class UpgradeTo0_34Command extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + private readonly generateDefaultSubdomainCommand: GenerateDefaultSubdomainCommand, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + passedParam: string[], + options: UpdateTo0_34CommandOptions, + workspaceIds: string[], + ): Promise { + await this.generateDefaultSubdomainCommand.executeActiveWorkspacesCommand( + passedParam, + options, + workspaceIds, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.module.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.module.ts new file mode 100644 index 000000000..e6f1af41a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-34/0-34-upgrade-version.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SearchModule } from 'src/engine/metadata-modules/search/search.module'; +import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module'; +import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module'; +import { UpgradeTo0_34Command } from 'src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature( + [ObjectMetadataEntity, FieldMetadataEntity], + 'metadata', + ), + WorkspaceSyncMetadataCommandsModule, + SearchModule, + WorkspaceMigrationRunnerModule, + ], + providers: [UpgradeTo0_34Command], +}) +export class UpgradeTo0_33CommandModule {} diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts b/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts index c7e547a8e..d3dceb8a8 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts @@ -23,6 +23,7 @@ export const seedWorkspaces = async ( | 'domainName' | 'inviteHash' | 'logo' + | 'subdomain' | 'activationStatus' >; } = { @@ -30,6 +31,7 @@ export const seedWorkspaces = async ( id: workspaceId, displayName: 'Apple', domainName: 'apple.dev', + subdomain: 'apple', inviteHash: 'apple.dev-invite-hash', logo: 'https://twentyhq.github.io/placeholder-images/workspaces/apple-logo.png', activationStatus: WorkspaceActivationStatus.ACTIVE, @@ -38,6 +40,7 @@ export const seedWorkspaces = async ( id: workspaceId, displayName: 'Acme', domainName: 'acme.dev', + subdomain: 'acme', inviteHash: 'acme.dev-invite-hash', logo: 'https://logos-world.net/wp-content/uploads/2022/05/Acme-Logo-700x394.png', activationStatus: WorkspaceActivationStatus.ACTIVE, @@ -51,6 +54,7 @@ export const seedWorkspaces = async ( 'id', 'displayName', 'domainName', + 'subdomain', 'inviteHash', 'logo', 'activationStatus', diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts new file mode 100644 index 000000000..e4798ce2b --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSubdomainToWorkspace1730137590546 + implements MigrationInterface +{ + name = 'AddSubdomainToWorkspace1730137590546'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "subdomain" varchar NULL`, + ); + await queryRunner.query(`UPDATE "core"."workspace" SET "subdomain" = "id"`); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "subdomain" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX workspace_subdomain_unique_index ON "core"."workspace" (subdomain)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "subdomain"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts new file mode 100644 index 000000000..e852657f6 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAuthProvidersColumnsToWorkspace1730298416367 + implements MigrationInterface +{ + name = 'AddAuthProvidersColumnsToWorkspace1730298416367'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isMicrosoftAuthEnabled" BOOLEAN DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isGoogleAuthEnabled" BOOLEAN DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isPasswordAuthEnabled" BOOLEAN DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isMicrosoftAuthEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isGoogleAuthEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isPasswordAuthEnabled"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts index 5f0d63966..7c1658fe9 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts @@ -19,9 +19,9 @@ import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; import { Query } from 'src/engine/api/rest/core/types/query.type'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() export class CoreQueryBuilderFactory { @@ -40,7 +40,7 @@ export class CoreQueryBuilderFactory { private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory, private readonly objectMetadataService: ObjectMetadataService, private readonly accessTokenService: AccessTokenService, - private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, ) {} async getObjectMetadata( @@ -50,16 +50,20 @@ export class CoreQueryBuilderFactory { objectMetadataItems: ObjectMetadataEntity[]; objectMetadataItem: ObjectMetadataEntity; }> { - const { workspace } = await this.accessTokenService.validateToken(request); + const { workspace } = + await this.accessTokenService.validateTokenByRequest(request); const objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); if (!objectMetadataItems.length) { throw new BadRequestException( - `No object was found for the workspace associated with this API key. You may generate a new one here ${this.environmentService.get( - 'FRONT_BASE_URL', - )}/settings/developers`, + `No object was found for the workspace associated with this API key. You may generate a new one here ${this.domainManagerService + .buildWorkspaceURL({ + subdomain: workspace.subdomain, + pathname: '/settings/developers', + }) + .toString()}`, ); } diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts index 38b5ec398..7dc9a0b08 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts @@ -4,9 +4,10 @@ import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/ import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; @Module({ - imports: [ObjectMetadataModule, AuthModule], + imports: [ObjectMetadataModule, AuthModule, DomainManagerModule], providers: [...coreQueryBuilderFactories, CoreQueryBuilderFactory], exports: [CoreQueryBuilderFactory], }) diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts index c53f82783..cad1df914 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts @@ -18,7 +18,7 @@ export class RestApiMetadataService { ) {} async get(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.get(request); return await this.restApiService.call( @@ -29,7 +29,7 @@ export class RestApiMetadataService { } async create(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.create(request); return await this.restApiService.call( @@ -40,7 +40,7 @@ export class RestApiMetadataService { } async update(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.update(request); return await this.restApiService.call( @@ -51,7 +51,7 @@ export class RestApiMetadataService { } async delete(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.delete(request); return await this.restApiService.call( diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index e3387c5c6..6c1a1263a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-imports */ import { HttpModule } from '@nestjs/axios'; -import { forwardRef, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; @@ -11,7 +11,6 @@ import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/g import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller'; import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; -import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller'; import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service'; @@ -24,7 +23,6 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; -import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; @@ -36,13 +34,15 @@ import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/worksp import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { UserModule } from 'src/engine/core-modules/user/user.module'; -import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; +import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { AuthResolver } from './auth.resolver'; @@ -54,7 +54,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; JwtModule, FileUploadModule, DataSourceModule, - forwardRef(() => UserModule), + DomainManagerModule, + TokenModule, + UserModule, WorkspaceManagerModule, TypeORMModule, TypeOrmModule.forFeature( @@ -69,22 +71,20 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; 'core', ), HttpModule, - TokenModule, UserWorkspaceModule, WorkspaceModule, OnboardingModule, WorkspaceDataSourceModule, - WorkspaceInvitationModule, ConnectedAccountModule, WorkspaceSSOModule, FeatureFlagModule, + WorkspaceInvitationModule, ], controllers: [ GoogleAuthController, MicrosoftAuthController, GoogleAPIsAuthController, MicrosoftAPIsAuthController, - VerifyAuthController, SSOAuthController, ], providers: [ diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 877cb8c04..398ce7638 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -7,6 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { AuthResolver } from './auth.resolver'; @@ -43,6 +44,14 @@ describe('AuthResolver', () => { provide: UserService, useValue: {}, }, + { + provide: DomainManagerService, + useValue: { + buildWorkspaceURL: jest + .fn() + .mockResolvedValue(new URL('http://localhost:3001')), + }, + }, { provide: UserWorkspaceService, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index c74afe573..78fd63ea3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -9,12 +9,6 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; -import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input'; -import { - GenerateJWTOutput, - GenerateJWTOutputWithAuthTokens, - GenerateJWTOutputWithSSOAUTH, -} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity'; import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; @@ -36,12 +30,22 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input'; +import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; +import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { ChallengeInput } from './dto/challenge.input'; import { LoginToken } from './dto/login-token.entity'; import { SignUpInput } from './dto/sign-up.input'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; -import { UserExists } from './dto/user-exists.entity'; +import { UserExistsOutput } from './dto/user-exists.entity'; import { CheckUserExistsInput } from './dto/user-exists.input'; import { Verify } from './dto/verify.entity'; import { VerifyInput } from './dto/verify.input'; @@ -62,18 +66,15 @@ export class AuthResolver { private switchWorkspaceService: SwitchWorkspaceService, private transientTokenService: TransientTokenService, private oauthService: OAuthService, + private domainManagerService: DomainManagerService, ) {} @UseGuards(CaptchaGuard) - @Query(() => UserExists) + @Query(() => UserExistsOutput) async checkUserExists( @Args() checkUserExistsInput: CheckUserExistsInput, - ): Promise { - const { exists } = await this.authService.checkUserExists( - checkUserExistsInput.email, - ); - - return { exists }; + ): Promise { + return await this.authService.checkUserExists(checkUserExistsInput.email); } @Query(() => WorkspaceInviteHashValid) @@ -96,8 +97,20 @@ export class AuthResolver { @UseGuards(CaptchaGuard) @Mutation(() => LoginToken) - async challenge(@Args() challengeInput: ChallengeInput): Promise { - const user = await this.authService.challenge(challengeInput); + async challenge( + @Args() challengeInput: ChallengeInput, + @OriginHeader() origin: string, + ): Promise { + const workspace = + await this.domainManagerService.getWorkspaceByOrigin(origin); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + const user = await this.authService.challenge(challengeInput, workspace); const loginToken = await this.loginTokenService.generateLoginToken( user.email, ); @@ -107,10 +120,22 @@ export class AuthResolver { @UseGuards(CaptchaGuard) @Mutation(() => LoginToken) - async signUp(@Args() signUpInput: SignUpInput): Promise { + async signUp( + @Args() signUpInput: SignUpInput, + @OriginHeader() origin: string, + ): Promise { const user = await this.authService.signInUp({ ...signUpInput, + targetWorkspaceSubdomain: + this.domainManagerService.getWorkspaceSubdomainByOrigin(origin), fromSSO: false, + isAuthEnabled: workspaceValidator.isAuthEnabled( + 'password', + new AuthException( + 'Password auth is not enabled for this workspace', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ), + ), }); const loginToken = await this.loginTokenService.generateLoginToken( @@ -124,11 +149,9 @@ export class AuthResolver { async exchangeAuthorizationCode( @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput, ) { - const tokens = await this.oauthService.verifyAuthorizationCode( + return await this.oauthService.verifyAuthorizationCode( exchangeAuthCodeInput, ); - - return tokens; } @Mutation(() => TransientToken) @@ -156,14 +179,18 @@ export class AuthResolver { } @Mutation(() => Verify) - async verify(@Args() verifyInput: VerifyInput): Promise { - const email = await this.loginTokenService.verifyLoginToken( + async verify( + @Args() verifyInput: VerifyInput, + @OriginHeader() origin: string, + ): Promise { + const workspace = + await this.domainManagerService.getWorkspaceByOrigin(origin); + + const { sub: email } = await this.loginTokenService.verifyLoginToken( verifyInput.loginToken, ); - const result = await this.authService.verify(email); - - return result; + return await this.authService.verify(email, workspace?.id); } @Mutation(() => AuthorizeApp) @@ -172,50 +199,22 @@ export class AuthResolver { @Args() authorizeAppInput: AuthorizeAppInput, @AuthUser() user: User, ): Promise { - const authorizedApp = await this.authService.generateAuthorizationCode( + return await this.authService.generateAuthorizationCode( authorizeAppInput, user, ); - - return authorizedApp; } - @Mutation(() => GenerateJWTOutput) + @Mutation(() => PublicWorkspaceDataOutput) @UseGuards(WorkspaceAuthGuard, UserAuthGuard) - async generateJWT( + async switchWorkspace( @AuthUser() user: User, - @Args() args: GenerateJwtInput, - ): Promise { - const result = await this.switchWorkspaceService.switchWorkspace( + @Args() args: SwitchWorkspaceInput, + ): Promise { + return await this.switchWorkspaceService.switchWorkspace( user, args.workspaceId, ); - - if (result.useSSOAuth) { - return { - success: true, - reason: 'WORKSPACE_USE_SSO_AUTH', - availableSSOIDPs: result.availableSSOIdentityProviders.map( - (identityProvider) => ({ - ...identityProvider, - workspace: { - id: result.workspace.id, - displayName: result.workspace.displayName, - }, - }), - ), - }; - } - - return { - success: true, - reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH', - authTokens: - await this.switchWorkspaceService.generateSwitchWorkspaceToken( - user, - result.workspace, - ), - }; } @Mutation(() => AuthTokens) @@ -278,4 +277,11 @@ export class AuthResolver { args.passwordResetToken, ); } + + @Query(() => [AvailableWorkspaceOutput]) + async findAvailableWorkspacesByEmail( + @Args('email') email: string, + ): Promise { + return this.authService.findAvailableWorkspacesByEmail(email); + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 13d6d83dc..203451a0c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -6,8 +6,10 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; +import { Repository } from 'typeorm'; import { AuthException, @@ -21,6 +23,8 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Controller('auth/google-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -30,6 +34,9 @@ export class GoogleAPIsAuthController { private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, private readonly onboardingService: OnboardingService, + private readonly domainManagerService: DomainManagerService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) {} @Get() @@ -96,10 +103,24 @@ export class GoogleAPIsAuthController { }); } + const workspace = await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + return res.redirect( - `${this.environmentService.get('FRONT_BASE_URL')}${ - redirectLocation || '/settings/accounts' - }`, + this.domainManagerService + .buildWorkspaceURL({ + subdomain: workspace.subdomain, + pathname: redirectLocation || '/settings/accounts', + }) + .toString(), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index f12e28237..df89aa55b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -6,7 +6,9 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { Response } from 'express'; import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter'; @@ -16,6 +18,14 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -23,6 +33,10 @@ export class GoogleAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly domainManagerService: DomainManagerService, + private readonly environmentService: EnvironmentService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) {} @Get() @@ -36,29 +50,81 @@ export class GoogleAuthController { @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard) @UseFilters(AuthOAuthExceptionFilter) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { - const { - firstName, - lastName, - email, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - } = req.user; + try { + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + } = req.user; - const user = await this.authService.signInUp({ - email, - firstName, - lastName, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - fromSSO: true, - }); + const signInUpParams = { + email, + firstName, + lastName, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + fromSSO: true, + isAuthEnabled: workspaceValidator.isAuthEnabled( + 'google', + new AuthException( + 'Google auth is not enabled for this workspace', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ), + ), + }; - const loginToken = await this.loginTokenService.generateLoginToken( - user.email, - ); + if ( + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && + targetWorkspaceSubdomain === + this.environmentService.get('DEFAULT_SUBDOMAIN') + ) { + const workspaceWithGoogleAuthActive = + await this.workspaceRepository.findOne({ + where: { + isGoogleAuthEnabled: true, + workspaceUsers: { + user: { + email, + }, + }, + }, + relations: ['userWorkspaces', 'userWorkspaces.user'], + }); - return res.redirect(this.authService.computeRedirectURI(loginToken.token)); + if (workspaceWithGoogleAuthActive) { + signInUpParams.targetWorkspaceSubdomain = + workspaceWithGoogleAuthActive.subdomain; + } + } + + const user = await this.authService.signInUp(signInUpParams); + + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); + + return res.redirect( + await this.authService.computeRedirectURI( + loginToken.token, + user.defaultWorkspace.subdomain, + ), + ); + } catch (err) { + if (err instanceof AuthException) { + return res.redirect( + this.domainManagerService.computeRedirectErrorUrl({ + subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + errorMessage: err.message, + }), + ); + } + throw err; + } } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index d0aec9e12..c1c2c65d4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -6,8 +6,10 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; +import { Repository } from 'typeorm'; import { AuthException, @@ -21,6 +23,9 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Controller('auth/microsoft-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -29,7 +34,11 @@ export class MicrosoftAPIsAuthController { private readonly microsoftAPIsService: MicrosoftAPIsService, private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, + private readonly workspaceService: WorkspaceService, + private readonly domainManagerService: DomainManagerService, private readonly onboardingService: OnboardingService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) {} @Get() @@ -96,10 +105,24 @@ export class MicrosoftAPIsAuthController { }); } + const workspace = await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + return res.redirect( - `${this.environmentService.get('FRONT_BASE_URL')}${ - redirectLocation || '/settings/accounts' - }`, + this.domainManagerService + .buildWorkspaceURL({ + subdomain: workspace.subdomain, + pathname: redirectLocation || '/settings/accounts', + }) + .toString(), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index fdfd319ff..ecdecbd59 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -15,6 +15,13 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -22,6 +29,8 @@ export class MicrosoftAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly domainManagerService: DomainManagerService, + private readonly environmentService: EnvironmentService, ) {} @Get() @@ -37,29 +46,55 @@ export class MicrosoftAuthController { @Req() req: MicrosoftRequest, @Res() res: Response, ) { - const { - firstName, - lastName, - email, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - } = req.user; + try { + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + } = req.user; - const user = await this.authService.signInUp({ - email, - firstName, - lastName, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - fromSSO: true, - }); + const user = await this.authService.signInUp({ + email, + firstName, + lastName, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + fromSSO: true, + isAuthEnabled: workspaceValidator.isAuthEnabled( + 'microsoft', + new AuthException( + 'Microsoft auth is not enabled for this workspace', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ), + ), + }); - const loginToken = await this.loginTokenService.generateLoginToken( - user.email, - ); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); - return res.redirect(this.authService.computeRedirectURI(loginToken.token)); + return res.redirect( + await this.authService.computeRedirectURI( + loginToken.token, + user.defaultWorkspace.subdomain, + ), + ); + } catch (err) { + if (err instanceof AuthException) { + return res.redirect( + this.domainManagerService.computeRedirectErrorUrl({ + subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + errorMessage: err.message, + }), + ); + } + throw err; + } } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 56dca7a3a..eeb0a5707 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -25,14 +25,14 @@ import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.gua import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { IdentityProviderType, WorkspaceSSOIdentityProvider, } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @Controller('auth') @UseFilters(AuthRestApiExceptionFilter) @@ -40,9 +40,9 @@ export class SSOAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, - private readonly workspaceInvitationService: WorkspaceInvitationService, - private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, private readonly userWorkspaceService: UserWorkspaceService, + private readonly environmentService: EnvironmentService, private readonly ssoService: SSOService, @InjectRepository(WorkspaceSSOIdentityProvider, 'core') private readonly workspaceSSOIdentityProviderRepository: Repository, @@ -50,7 +50,7 @@ export class SSOAuthController { @Get('saml/metadata/:identityProviderId') @UseGuards(SSOProviderEnabledGuard) - async generateMetadata(@Req() req: any): Promise { + async generateMetadata(@Req() req: any): Promise { return generateServiceProviderMetadata({ wantAssertionsSigned: false, issuer: this.ssoService.buildIssuerURL({ @@ -81,14 +81,26 @@ export class SSOAuthController { @UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard) async oidcAuthCallback(@Req() req: any, @Res() res: Response) { try { - const loginToken = await this.generateLoginToken(req.user); + const { loginToken, identityProvider } = await this.generateLoginToken( + req.user, + ); return res.redirect( - this.authService.computeRedirectURI(loginToken.token), + await this.authService.computeRedirectURI( + loginToken.token, + identityProvider.workspace.subdomain, + ), ); } catch (err) { - // TODO: improve error management - res.status(403).send(err.message); + if (err instanceof AuthException) { + return res.redirect( + this.domainManagerService.computeRedirectErrorUrl({ + subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + errorMessage: err.message, + }), + ); + } + throw err; } } @@ -96,16 +108,26 @@ export class SSOAuthController { @UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard) async samlAuthCallback(@Req() req: any, @Res() res: Response) { try { - const loginToken = await this.generateLoginToken(req.user); + const { loginToken, identityProvider } = await this.generateLoginToken( + req.user, + ); return res.redirect( - this.authService.computeRedirectURI(loginToken.token), + await this.authService.computeRedirectURI( + loginToken.token, + identityProvider.workspace.subdomain, + ), ); } catch (err) { - // TODO: improve error management - res - .status(403) - .redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`); + if (err instanceof AuthException) { + return res.redirect( + this.domainManagerService.computeRedirectErrorUrl({ + subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + errorMessage: err.message, + }), + ); + } + throw err; } } @@ -116,6 +138,13 @@ export class SSOAuthController { identityProviderId?: string; user: { email: string } & Record; }) { + if (!identityProviderId) { + throw new AuthException( + 'Identity provider ID is required', + AuthExceptionCode.INVALID_DATA, + ); + } + const identityProvider = await this.workspaceSSOIdentityProviderRepository.findOne({ where: { id: identityProviderId }, @@ -129,20 +158,15 @@ export class SSOAuthController { ); } - const invitation = - await this.workspaceInvitationService.getOneWorkspaceInvitation( - identityProvider.workspaceId, - user.email, - ); - - if (invitation) { - await this.authService.signInUp({ - ...user, - workspacePersonalInviteToken: invitation.value, - workspaceInviteHash: identityProvider.workspace.inviteHash, - fromSSO: true, - }); - } + await this.authService.signInUp({ + ...user, + ...(this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') + ? { + targetWorkspaceSubdomain: identityProvider.workspace.subdomain, + } + : {}), + fromSSO: true, + }); const isUserExistInWorkspace = await this.userWorkspaceService.checkUserWorkspaceExistsByEmail( @@ -157,6 +181,9 @@ export class SSOAuthController { ); } - return this.loginTokenService.generateLoginToken(user.email); + return { + identityProvider, + loginToken: await this.loginTokenService.generateLoginToken(user.email), + }; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts deleted file mode 100644 index 11dfd40b6..000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; - -import { VerifyAuthController } from './verify-auth.controller'; - -describe('VerifyAuthController', () => { - let controller: VerifyAuthController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [VerifyAuthController], - providers: [ - { - provide: AuthService, - useValue: {}, - }, - { - provide: LoginTokenService, - useValue: {}, - }, - ], - }).compile(); - - controller = module.get(VerifyAuthController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts deleted file mode 100644 index 9fcfbb0cf..000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Body, Controller, Post, UseFilters } from '@nestjs/common'; - -import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; -import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input'; -import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; -import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; - -@Controller('auth/verify') -@UseFilters(AuthRestApiExceptionFilter) -export class VerifyAuthController { - constructor( - private readonly authService: AuthService, - private readonly loginTokenService: LoginTokenService, - ) {} - - @Post() - async verify(@Body() verifyInput: VerifyInput): Promise { - const email = await this.loginTokenService.verifyLoginToken( - verifyInput.loginToken, - ); - const result = await this.authService.verify(email); - - return result; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts new file mode 100644 index 000000000..01c99f8af --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts @@ -0,0 +1,45 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@ObjectType() +class SSOConnection { + @Field(() => IdentityProviderType) + type: SSOConfiguration['type']; + + @Field(() => String) + id: string; + + @Field(() => String) + issuer: string; + + @Field(() => String) + name: string; + + @Field(() => SSOIdentityProviderStatus) + status: SSOConfiguration['status']; +} + +@ObjectType() +export class AvailableWorkspaceOutput { + @Field(() => String) + id: string; + + @Field(() => String, { nullable: true }) + displayName?: string; + + @Field(() => String) + subdomain: string; + + @Field(() => String, { nullable: true }) + logo?: string; + + @Field(() => [SSOConnection]) + sso: SSOConnection[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts deleted file mode 100644 index cc27d8c6c..000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; - -import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; -import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; - -@ObjectType() -export class GenerateJWTOutputWithAuthTokens { - @Field(() => Boolean) - success: boolean; - - @Field(() => String) - reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH'; - - @Field(() => AuthTokens) - authTokens: AuthTokens; -} - -@ObjectType() -export class GenerateJWTOutputWithSSOAUTH { - @Field(() => Boolean) - success: boolean; - - @Field(() => String) - reason: 'WORKSPACE_USE_SSO_AUTH'; - - @Field(() => [FindAvailableSSOIDPOutput]) - availableSSOIDPs: Array; -} - -export const GenerateJWTOutput = createUnionType({ - name: 'GenerateJWT', - types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH], - resolveType(value) { - if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') { - return GenerateJWTOutputWithAuthTokens; - } - if (value.reason === 'WORKSPACE_USE_SSO_AUTH') { - return GenerateJWTOutputWithSSOAUTH; - } - - return null; - }, -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/generate-jwt.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/switch-workspace.input.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/auth/dto/generate-jwt.input.ts rename to packages/twenty-server/src/engine/core-modules/auth/dto/switch-workspace.input.ts index cd8b4461e..4024deb7f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/generate-jwt.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/switch-workspace.input.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; @ArgsType() -export class GenerateJwtInput { +export class SwitchWorkspaceInput { @Field(() => String) @IsNotEmpty() @IsString() diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts index b4b70d0af..e8f7f9a3e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts @@ -1,7 +1,30 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; + +import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; @ObjectType() export class UserExists { @Field(() => Boolean) - exists: boolean; + exists: true; + + @Field(() => [AvailableWorkspaceOutput]) + availableWorkspaces: Array; } + +@ObjectType() +export class UserNotExists { + @Field(() => Boolean) + exists: false; +} + +export const UserExistsOutput = createUnionType({ + name: 'UserExistsOutput', + types: () => [UserExists, UserNotExists] as const, + resolveType(value) { + if (value.exists === true) { + return UserExists; + } + + return UserNotExists; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-oauth-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-oauth-exception.filter.ts index 008e7d110..24ac236a2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/filters/auth-oauth-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/filters/auth-oauth-exception.filter.ts @@ -11,11 +11,11 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Catch(AuthException) export class AuthOAuthExceptionFilter implements ExceptionFilter { - constructor(private readonly environmentService: EnvironmentService) {} + constructor(private readonly domainManagerService: DomainManagerService) {} catch(exception: AuthException, host: ArgumentsHost) { const ctx = host.switchToHttp(); @@ -25,7 +25,7 @@ export class AuthOAuthExceptionFilter implements ExceptionFilter { case AuthExceptionCode.OAUTH_ACCESS_DENIED: response .status(403) - .redirect(this.environmentService.get('FRONT_BASE_URL')); + .redirect(this.domainManagerService.getBaseUrl().toString()); break; default: throw new InternalServerErrorException(exception.message); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts index f4675888b..8f2f6b95c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts @@ -38,6 +38,13 @@ export class GoogleOauthGuard extends AuthGuard('google') { workspacePersonalInviteToken; } + if ( + request.query.workspaceSubdomain && + typeof request.query.workspaceSubdomain === 'string' + ) { + request.params.workspaceSubdomain = request.query.workspaceSubdomain; + } + return (await super.canActivate(context)) as boolean; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts index dd67b6768..049f14789 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -26,6 +26,13 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { workspacePersonalInviteToken; } + if ( + request.query.workspaceSubdomain && + typeof request.query.workspaceSubdomain === 'string' + ) { + request.params.workspaceSubdomain = request.query.workspaceSubdomain; + } + return (await super.canActivate(context)) as boolean; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index 5d7ebc9a4..d527add80 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -1,17 +1,34 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import bcrypt from 'bcrypt'; +import { expect, jest } from '@jest/globals'; + import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { AuthService } from './auth.service'; +jest.mock('bcrypt'); + +const UserFindOneMock = jest.fn(); +const UserWorkspaceFindOneByMock = jest.fn(); + +const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); +const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); +const workspaceInvitationValidateInvitationMock = jest.fn(); +const userWorkspaceAddUserToWorkspaceMock = jest.fn(); + describe('AuthService', () => { let service: AuthService; @@ -25,7 +42,9 @@ describe('AuthService', () => { }, { provide: getRepositoryToken(User, 'core'), - useValue: {}, + useValue: { + findOne: UserFindOneMock, + }, }, { provide: getRepositoryToken(AppToken, 'core'), @@ -39,6 +58,10 @@ describe('AuthService', () => { provide: EnvironmentService, useValue: {}, }, + { + provide: DomainManagerService, + useValue: {}, + }, { provide: EmailService, useValue: {}, @@ -51,13 +74,114 @@ describe('AuthService', () => { provide: RefreshTokenService, useValue: {}, }, + { + provide: UserWorkspaceService, + useValue: { + checkUserWorkspaceExists: + userWorkspaceServiceCheckUserWorkspaceExistsMock, + addUserToWorkspace: userWorkspaceAddUserToWorkspaceMock, + }, + }, + { + provide: UserService, + useValue: {}, + }, + { + provide: WorkspaceInvitationService, + useValue: { + getOneWorkspaceInvitation: + workspaceInvitationGetOneWorkspaceInvitationMock, + validateInvitation: workspaceInvitationValidateInvitationMock, + }, + }, ], }).compile(); service = module.get(AuthService); }); - it('should be defined', () => { + it('should be defined', async () => { expect(service).toBeDefined(); }); + + it('challenge - user already member of workspace', async () => { + const workspace = { isPasswordAuthEnabled: true } as Workspace; + const user = { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }; + + (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); + + UserFindOneMock.mockReturnValueOnce({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + + UserWorkspaceFindOneByMock.mockReturnValueOnce({}); + + userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); + + const response = await service.challenge( + { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }, + workspace, + ); + + expect(response).toStrictEqual({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + }); + + it('challenge - user who have an invitation', async () => { + const user = { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }; + + UserFindOneMock.mockReturnValueOnce({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + + (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); + userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false); + + workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({}); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({}); + userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({}); + + const response = await service.challenge( + { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }, + { + isPasswordAuthEnabled: true, + } as Workspace, + ); + + expect(response).toStrictEqual({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + + expect( + workspaceInvitationGetOneWorkspaceInvitationMock, + ).toHaveBeenCalledTimes(1); + expect(workspaceInvitationValidateInvitationMock).toHaveBeenCalledTimes(1); + expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); + expect(UserFindOneMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 7de331e5d..ea445d233 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -28,7 +28,10 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input'; import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity'; -import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity'; +import { + UserExists, + UserNotExists, +} from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; @@ -38,12 +41,24 @@ import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository export class AuthService { constructor( private readonly accessTokenService: AccessTokenService, + private readonly domainManagerService: DomainManagerService, private readonly refreshTokenService: RefreshTokenService, + private readonly userWorkspaceService: UserWorkspaceService, + private readonly userService: UserService, + private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -55,9 +70,54 @@ export class AuthService { private readonly appTokenRepository: Repository, ) {} - async challenge(challengeInput: ChallengeInput) { - const user = await this.userRepository.findOneBy({ - email: challengeInput.email, + private async checkAccessAndUseInvitationOrThrow( + workspace: Workspace, + user: User, + ) { + if ( + await this.userWorkspaceService.checkUserWorkspaceExists( + user.id, + workspace.id, + ) + ) { + return; + } + + const invitation = + await this.workspaceInvitationService.getOneWorkspaceInvitation( + workspace.id, + user.email, + ); + + if (invitation) { + await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: invitation.value, + email: user.email, + }); + await this.userWorkspaceService.addUserToWorkspace(user, workspace); + + return; + } + + throw new AuthException( + "You're not member of this workspace.", + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + async challenge(challengeInput: ChallengeInput, targetWorkspace: Workspace) { + if (!targetWorkspace.isPasswordAuthEnabled) { + throw new AuthException( + 'Email/Password auth is not enabled for this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const user = await this.userRepository.findOne({ + where: { + email: challengeInput.email, + }, + relations: ['workspaces'], }); if (!user) { @@ -67,6 +127,8 @@ export class AuthService { ); } + await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); + if (!user.passwordHash) { throw new AuthException( 'Incorrect login method', @@ -94,19 +156,23 @@ export class AuthService { password, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, firstName, lastName, picture, fromSSO, + isAuthEnabled, }: { email: string; password?: string; firstName?: string | null; lastName?: string | null; - workspaceInviteHash?: string | null; - workspacePersonalInviteToken?: string | null; + workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; picture?: string | null; fromSSO: boolean; + targetWorkspaceSubdomain?: string; + isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>; }) { return await this.signInUpService.signInUp({ email, @@ -115,12 +181,14 @@ export class AuthService { lastName, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, picture, fromSSO, + isAuthEnabled, }); } - async verify(email: string): Promise { + async verify(email: string, workspaceId?: string): Promise { if (!email) { throw new AuthException( 'Email is required', @@ -128,6 +196,26 @@ export class AuthService { ); } + const userWithIdAndDefaultWorkspaceId = await this.userRepository.findOne({ + select: ['defaultWorkspaceId', 'id'], + where: { email }, + }); + + userValidator.assertIsExist( + userWithIdAndDefaultWorkspaceId, + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); + + if ( + workspaceId && + userWithIdAndDefaultWorkspaceId.defaultWorkspaceId !== workspaceId + ) { + await this.userService.saveDefaultWorkspace( + userWithIdAndDefaultWorkspaceId.id, + workspaceId, + ); + } + const user = await this.userRepository.findOne({ where: { email, @@ -135,19 +223,10 @@ export class AuthService { relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], }); - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.USER_NOT_FOUND, - ); - } - - if (!user.defaultWorkspace) { - throw new AuthException( - 'User has no default workspace', - AuthExceptionCode.INVALID_DATA, - ); - } + userValidator.assertIsExist( + user, + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); // passwordHash is hidden for security reasons user.passwordHash = ''; @@ -170,12 +249,19 @@ export class AuthService { }; } - async checkUserExists(email: string): Promise { + async checkUserExists(email: string): Promise { const user = await this.userRepository.findOneBy({ email, }); - return { exists: !!user }; + if (userValidator.isExist(user)) { + return { + exists: true, + availableWorkspaces: await this.findAvailableWorkspacesByEmail(email), + }; + } + + return { exists: false }; } async checkWorkspaceInviteHashIsValid( @@ -312,7 +398,7 @@ export class AuthService { const emailTemplate = PasswordUpdateNotifyEmail({ userName: `${user.firstName} ${user.lastName}`, email: user.email, - link: this.environmentService.get('FRONT_BASE_URL'), + link: this.domainManagerService.getBaseUrl().toString(), }); const html = render(emailTemplate, { @@ -352,9 +438,55 @@ export class AuthService { return workspace; } - computeRedirectURI(loginToken: string): string { - return `${this.environmentService.get( - 'FRONT_BASE_URL', - )}/verify?loginToken=${loginToken}`; + async computeRedirectURI(loginToken: string, subdomain?: string) { + const url = this.domainManagerService.buildWorkspaceURL({ + subdomain, + pathname: '/verify', + searchParams: { loginToken }, + }); + + return url.toString(); + } + + async findAvailableWorkspacesByEmail(email: string) { + const user = await this.userRepository.findOne({ + where: { + email, + }, + relations: [ + 'workspaces', + 'workspaces.workspace', + 'workspaces.workspace.workspaceSSOIdentityProviders', + ], + }); + + userValidator.assertIsExist( + user, + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); + + return user.workspaces.map((userWorkspace) => ({ + id: userWorkspace.workspaceId, + displayName: userWorkspace.workspace.displayName, + subdomain: userWorkspace.workspace.subdomain, + logo: userWorkspace.workspace.logo, + sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce( + (acc, identityProvider) => + acc.concat( + identityProvider.status === 'Inactive' + ? [] + : [ + { + id: identityProvider.id, + name: identityProvider.name, + issuer: identityProvider.issuer, + type: identityProvider.type, + status: identityProvider.status, + }, + ], + ), + [] as AvailableWorkspaceOutput['sso'], + ), + })); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts index e0b81f69d..c056bbfaf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts @@ -13,6 +13,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { ResetPasswordService } from './reset-password.service'; @@ -45,6 +46,14 @@ describe('ResetPasswordService', () => { send: jest.fn().mockResolvedValue({ success: true }), }, }, + { + provide: DomainManagerService, + useValue: { + getBaseUrl: jest + .fn() + .mockResolvedValue(new URL('http://localhost:3001')), + }, + }, { provide: EnvironmentService, useValue: { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts index f07c45d7d..0f7a14da7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -24,11 +24,13 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() export class ResetPasswordService { constructor( private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, @InjectRepository(User, 'core') private readonly userRepository: Repository, @InjectRepository(AppToken, 'core') @@ -116,11 +118,12 @@ export class ResetPasswordService { ); } - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); - const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; + const frontBaseURL = this.domainManagerService.getBaseUrl(); + + frontBaseURL.pathname = `/reset-password/${resetToken.passwordResetToken}`; const emailData = { - link: resetLink, + link: frontBaseURL.toString(), duration: ms( differenceInMilliseconds( resetToken.passwordResetTokenExpiresAt, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 639f6cb68..368c5343c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -2,18 +2,45 @@ import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import bcrypt from 'bcrypt'; +import { expect, jest } from '@jest/globals'; + import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + Workspace, + WorkspaceActivationStatus, +} from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; + +jest.mock('bcrypt'); + +const UserFindOneMock = jest.fn(); +const workspaceInvitationValidateInvitationMock = jest.fn(); +const workspaceInvitationInvalidateWorkspaceInvitationMock = jest.fn(); +const workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock = + jest.fn(); +const userWorkspaceServiceAddUserToWorkspaceMock = jest.fn(); +const UserCreateMock = jest.fn(); +const UserSaveMock = jest.fn(); +const EnvironmentServiceGetMock = jest.fn(); +const WorkspaceCountMock = jest.fn(); +const WorkspaceCreateMock = jest.fn(); +const WorkspaceSaveMock = jest.fn(); describe('SignInUpService', () => { let service: SignInUpService; + afterEach(() => { + jest.clearAllMocks(); + }); + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -24,23 +51,48 @@ describe('SignInUpService', () => { }, { provide: getRepositoryToken(Workspace, 'core'), - useValue: {}, + useValue: { + count: WorkspaceCountMock, + create: WorkspaceCreateMock, + save: WorkspaceSaveMock, + }, }, { provide: getRepositoryToken(User, 'core'), - useValue: {}, + useValue: { + findOne: UserFindOneMock, + create: UserCreateMock, + save: UserSaveMock, + }, }, { provide: getRepositoryToken(AppToken, 'core'), useValue: {}, }, { - provide: UserWorkspaceService, + provide: WorkspaceInvitationService, useValue: {}, }, + { + provide: WorkspaceService, + useValue: { + generateSubdomain: jest.fn().mockReturnValue('tartanpion'), + }, + }, + { + provide: UserWorkspaceService, + useValue: { + addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock, + create: jest.fn(), + }, + }, { provide: OnboardingService, - useValue: {}, + useValue: { + setOnboardingConnectAccountPending: jest.fn(), + setOnboardingInviteTeamPending: jest.fn(), + setOnboardingCreateProfilePending: jest.fn(), + }, }, { provide: HttpService, @@ -48,7 +100,19 @@ describe('SignInUpService', () => { }, { provide: EnvironmentService, - useValue: {}, + useValue: { + get: EnvironmentServiceGetMock, + }, + }, + { + provide: WorkspaceInvitationService, + useValue: { + validateInvitation: workspaceInvitationValidateInvitationMock, + invalidateWorkspaceInvitation: + workspaceInvitationInvalidateWorkspaceInvitationMock, + findInvitationByWorkspaceSubdomainAndUserEmail: + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock, + }, }, ], }).compile(); @@ -59,4 +123,363 @@ describe('SignInUpService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('signInUp - sso - new user', async () => { + const email = 'test@test.com'; + + UserFindOneMock.mockReturnValueOnce(false); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + undefined, + ); + + const spy = jest + .spyOn(service, 'signUpOnNewWorkspace') + .mockResolvedValueOnce({} as User); + + await service.signInUp({ + email: 'test@test.com', + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + email, + passwordHash: undefined, + firstName: expect.any(String), + lastName: expect.any(String), + picture: undefined, + }), + ); + }); + it('signInUp - sso - existing user', async () => { + const email = 'existing@test.com'; + const existingUser = { + id: 'user-id', + email, + passwordHash: undefined, + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + undefined, + ); + + const result = await service.signInUp({ + email, + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(result).toEqual(existingUser); + }); + it('signInUp - sso - new user - existing invitation', async () => { + const email = 'newuser@test.com'; + const workspaceId = 'workspace-id'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + { + value: 'personal-token-value', + }, + ); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { id: workspaceId }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + const spySignInUpOnExistingWorkspace = jest + .spyOn(service, 'signInUpOnExistingWorkspace') + .mockResolvedValueOnce( + {} as Awaited< + ReturnType<(typeof service)['signInUpOnExistingWorkspace']> + >, + ); + + await service.signInUp({ + email, + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + email, + passwordHash: undefined, + workspace: expect.objectContaining({ + id: workspaceId, + }), + firstName: expect.any(String), + lastName: expect.any(String), + picture: undefined, + }), + ); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - sso - existing user - existing invitation', async () => { + const email = 'existinguser@test.com'; + const workspaceId = 'workspace-id'; + const existingUser = { + id: 'user-id', + email, + passwordHash: undefined, + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + { + value: 'personal-token-value', + }, + ); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({}); + + const result = await service.signInUp({ + email, + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(result).toEqual(existingUser); + expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - sso - new user - personal invitation token', async () => { + const email = 'newuser@test.com'; + const workspaceId = 'workspace-id'; + const workspacePersonalInviteToken = 'personal-token-value'; + + UserFindOneMock.mockReturnValueOnce(null); + + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + const spySignInUpOnExistingWorkspace = jest + .spyOn(service, 'signInUpOnExistingWorkspace') + .mockResolvedValueOnce( + {} as Awaited< + ReturnType<(typeof service)['signInUpOnExistingWorkspace']> + >, + ); + + await service.signInUp({ + email, + fromSSO: true, + workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + email, + passwordHash: undefined, + workspace: expect.objectContaining({ + id: workspaceId, + }), + firstName: expect.any(String), + lastName: expect.any(String), + picture: undefined, + }), + ); + + expect( + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock, + ).not.toHaveBeenCalled(); + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - sso - existing user - personal invitation token', async () => { + const email = 'existinguser@test.com'; + const workspaceId = 'workspace-id'; + const workspacePersonalInviteToken = 'personal-token-value'; + const existingUser = { + id: 'user-id', + email, + passwordHash: undefined, + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + await service.signInUp({ + email, + fromSSO: true, + workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - credentials - existing user', async () => { + const email = 'existinguser@test.com'; + const password = 'validPassword123'; + const existingUser = { + id: 'user-id', + email, + passwordHash: 'hash-of-validPassword123', + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + + EnvironmentServiceGetMock.mockReturnValueOnce(false); + + (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); + + await service.signInUp({ + email, + password, + fromSSO: false, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).not.toHaveBeenCalled(); + }); + it('signInUp - credentials - new user', async () => { + const email = 'newuser@test.com'; + const password = 'validPassword123'; + + UserFindOneMock.mockReturnValueOnce(null); + + UserCreateMock.mockReturnValueOnce({} as User); + UserSaveMock.mockReturnValueOnce({} as User); + + EnvironmentServiceGetMock.mockReturnValueOnce(true); + + WorkspaceCreateMock.mockReturnValueOnce({}); + WorkspaceSaveMock.mockReturnValueOnce({}); + + await service.signInUp({ + email, + password, + fromSSO: false, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(UserCreateMock).toHaveBeenCalledTimes(1); + expect(UserSaveMock).toHaveBeenCalledTimes(1); + + expect(WorkspaceSaveMock).toHaveBeenCalledTimes(1); + expect(WorkspaceCreateMock).toHaveBeenCalledTimes(1); + }); + it('signInUp - credentials - new user - personal invitation token', async () => { + const email = 'newuser@test.com'; + const password = 'validPassword123'; + const workspaceId = 'workspace-id'; + const workspacePersonalInviteToken = 'personal-token-value'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + UserCreateMock.mockReturnValueOnce({} as User); + UserSaveMock.mockReturnValueOnce({} as User); + + await service.signInUp({ + email, + password, + fromSSO: false, + workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(UserCreateMock).toHaveBeenCalledTimes(1); + expect(UserSaveMock).toHaveBeenCalledTimes(1); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - credentials - new user - public invitation token', async () => { + const email = 'newuser@test.com'; + const password = 'validPassword123'; + const workspaceId = 'workspace-id'; + const workspaceInviteHash = 'public-token-value'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + UserCreateMock.mockReturnValueOnce({} as User); + UserSaveMock.mockReturnValueOnce({} as User); + + await service.signInUp({ + email, + password, + fromSSO: false, + workspaceInviteHash, + }); + + expect(UserCreateMock).toHaveBeenCalledTimes(1); + expect(UserSaveMock).toHaveBeenCalledTimes(1); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 877cab663..2bb07c201 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -9,20 +9,15 @@ import { v4 } from 'uuid'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { - PASSWORD_REGEX, compareHash, hashPassword, + PASSWORD_REGEX, } from 'src/engine/core-modules/auth/auth.util'; -import { - EnvironmentException, - EnvironmentExceptionCode, -} from 'src/engine/core-modules/environment/environment.exception'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -33,44 +28,56 @@ import { WorkspaceActivationStatus, } from 'src/engine/core-modules/workspace/workspace.entity'; import { getImageBufferFromUrl } from 'src/utils/image'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { + EnvironmentException, + EnvironmentExceptionCode, +} from 'src/engine/core-modules/environment/environment.exception'; export type SignInUpServiceInput = { email: string; password?: string; firstName?: string | null; lastName?: string | null; - workspaceInviteHash?: string | null; - workspacePersonalInviteToken?: string | null; + workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; picture?: string | null; fromSSO: boolean; + targetWorkspaceSubdomain?: string; + isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>; }; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class SignInUpService { constructor( - private readonly fileUploadService: FileUploadService, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, - @InjectRepository(AppToken, 'core') - private readonly appTokenRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly fileUploadService: FileUploadService, + private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly userWorkspaceService: UserWorkspaceService, private readonly onboardingService: OnboardingService, private readonly httpService: HttpService, private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, ) {} async signInUp({ email, - workspaceInviteHash, workspacePersonalInviteToken, + workspaceInviteHash, password, firstName, lastName, picture, fromSSO, + targetWorkspaceSubdomain, + isAuthEnabled, }: SignInUpServiceInput) { if (!firstName) firstName = ''; if (!lastName) lastName = ''; @@ -96,9 +103,7 @@ export class SignInUpService { const passwordHash = password ? await hashPassword(password) : undefined; const existingUser = await this.userRepository.findOne({ - where: { - email: email, - }, + where: { email }, relations: ['defaultWorkspace'], }); @@ -116,18 +121,55 @@ export class SignInUpService { } } - if (workspaceInviteHash) { - return await this.signInUpOnExistingWorkspace({ - email, - passwordHash, - workspaceInviteHash, - workspacePersonalInviteToken, - firstName, - lastName, - picture, - existingUser, - }); + const maybeInvitation = + fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash + ? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail( + { + subdomain: targetWorkspaceSubdomain, + email, + }, + ) + : undefined; + + if ( + workspacePersonalInviteToken || + workspaceInviteHash || + maybeInvitation + ) { + const invitationValidation = + workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation + ? await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: + workspacePersonalInviteToken ?? maybeInvitation?.value, + workspaceInviteHash, + email, + }) + : null; + + if ( + invitationValidation?.isValid === true && + invitationValidation.workspace + ) { + const updatedUser = await this.signInUpOnExistingWorkspace({ + email, + passwordHash, + workspace: invitationValidation.workspace, + firstName, + lastName, + picture, + existingUser, + isAuthEnabled, + }); + + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + invitationValidation.workspace.id, + email, + ); + + return updatedUser; + } } + if (!existingUser) { return await this.signUpOnNewWorkspace({ email, @@ -141,47 +183,46 @@ export class SignInUpService { return existingUser; } - private async signInUpOnExistingWorkspace({ + async signInUpOnExistingWorkspace({ email, passwordHash, - workspaceInviteHash, - workspacePersonalInviteToken, + workspace, firstName, lastName, picture, existingUser, + isAuthEnabled, }: { email: string; passwordHash: string | undefined; - workspaceInviteHash: string | null; - workspacePersonalInviteToken: string | null | undefined; + workspace: Workspace; firstName: string; lastName: string; picture: SignInUpServiceInput['picture']; existingUser: User | null; + isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>; }) { const isNewUser = !isDefined(existingUser); let user = existingUser; - const workspace = await this.findWorkspaceAndValidateInvitation({ - workspacePersonalInviteToken, - workspaceInviteHash, - email, - }); - - if (!workspace) { - throw new AuthException( + workspaceValidator.assertIsExist( + workspace, + new AuthException( 'Workspace not found', AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + ), + ); - if (!(workspace.activationStatus === WorkspaceActivationStatus.ACTIVE)) { - throw new AuthException( + workspaceValidator.assertIsActive( + workspace, + new AuthException( 'Workspace is not ready to welcome new members', AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + ), + ); + + if (isAuthEnabled) + workspaceValidator.validateAuth(isAuthEnabled, workspace); if (isNewUser) { const imagePath = await this.uploadPicture(picture, workspace.id); @@ -199,19 +240,18 @@ export class SignInUpService { user = await this.userRepository.save(userToCreate); } - if (!user) { - throw new AuthException( + userValidator.assertIsExist( + user, + new AuthException( 'User not found', AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + ), + ); - const updatedUser = workspacePersonalInviteToken - ? await this.userWorkspaceService.addUserToWorkspaceByInviteToken( - workspacePersonalInviteToken, - user, - ) - : await this.userWorkspaceService.addUserToWorkspace(user, workspace); + const updatedUser = await this.userWorkspaceService.addUserToWorkspace( + user, + workspace, + ); await this.activateOnboardingForUser(user, workspace, { firstName, @@ -221,53 +261,6 @@ export class SignInUpService { return Object.assign(user, updatedUser); } - private async findWorkspaceAndValidateInvitation({ - workspacePersonalInviteToken, - workspaceInviteHash, - email, - }) { - if (!workspacePersonalInviteToken && !workspaceInviteHash) { - throw new AuthException( - 'No invite token or hash provided', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const workspace = await this.workspaceRepository.findOneBy({ - inviteHash: workspaceInviteHash, - }); - - if (!workspace) { - throw new AuthException( - 'Workspace not found', - AuthExceptionCode.WORKSPACE_NOT_FOUND, - ); - } - - if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) { - throw new AuthException( - 'Workspace does not allow public invites', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) { - try { - await this.userWorkspaceService.validateInvitation( - workspacePersonalInviteToken, - email, - ); - } catch (err) { - throw new AuthException( - err.message, - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - } - - return workspace; - } - private async activateOnboardingForUser( user: User, workspace: Workspace, @@ -288,7 +281,7 @@ export class SignInUpService { } } - private async signUpOnNewWorkspace({ + async signUpOnNewWorkspace({ email, passwordHash, firstName, @@ -301,14 +294,20 @@ export class SignInUpService { lastName: string; picture: SignInUpServiceInput['picture']; }) { - if (this.environmentService.get('IS_SIGN_UP_DISABLED')) { - throw new EnvironmentException( - 'Sign up is disabled', - EnvironmentExceptionCode.ENVIRONMENT_VARIABLES_NOT_FOUND, - ); + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + const workspacesCount = await this.workspaceRepository.count(); + + // let the creation of the first workspace + if (workspacesCount > 0) { + throw new EnvironmentException( + 'New workspace setup is disabled', + EnvironmentExceptionCode.ENVIRONMENT_VARIABLES_NOT_FOUND, + ); + } } const workspaceToCreate = this.workspaceRepository.create({ + subdomain: await this.domainManagerService.generateSubdomain(), displayName: '', domainName: '', inviteHash: v4(), diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts index e2981b2f2..16ba3e80f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts @@ -6,9 +6,9 @@ import { Repository } from 'typeorm'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { SwitchWorkspaceService } from './switch-workspace.service'; @@ -16,7 +16,7 @@ describe('SwitchWorkspaceService', () => { let service: SwitchWorkspaceService; let userRepository: Repository; let workspaceRepository: Repository; - let ssoService: SSOService; + let userService: UserService; let accessTokenService: AccessTokenService; let refreshTokenService: RefreshTokenService; @@ -32,12 +32,6 @@ describe('SwitchWorkspaceService', () => { provide: getRepositoryToken(Workspace, 'core'), useClass: Repository, }, - { - provide: SSOService, - useValue: { - listSSOIdentityProvidersByWorkspaceId: jest.fn(), - }, - }, { provide: AccessTokenService, useValue: { @@ -50,6 +44,12 @@ describe('SwitchWorkspaceService', () => { generateRefreshToken: jest.fn(), }, }, + { + provide: UserService, + useValue: { + saveDefaultWorkspace: jest.fn(), + }, + }, ], }).compile(); @@ -60,9 +60,9 @@ describe('SwitchWorkspaceService', () => { workspaceRepository = module.get>( getRepositoryToken(Workspace, 'core'), ); - ssoService = module.get(SSOService); accessTokenService = module.get(AccessTokenService); refreshTokenService = module.get(RefreshTokenService); + userService = module.get(UserService); }); it('should be defined', () => { @@ -101,7 +101,6 @@ describe('SwitchWorkspaceService', () => { const mockWorkspace = { id: 'workspace-id', workspaceUsers: [{ userId: 'other-user-id' }], - workspaceSSOIdentityProviders: [], }; jest @@ -121,19 +120,25 @@ describe('SwitchWorkspaceService', () => { const mockWorkspace = { id: 'workspace-id', workspaceUsers: [{ userId: 'user-id' }], - workspaceSSOIdentityProviders: [{}], + logo: 'logo', + displayName: 'displayName', + isGoogleAuthEnabled: true, + isPasswordAuthEnabled: true, + isMicrosoftAuthEnabled: false, + workspaceSSOIdentityProviders: [ + { + id: 'sso-id', + }, + ], }; - const mockSSOProviders = [{ id: 'sso-provider-id' }]; jest .spyOn(userRepository, 'findBy') .mockResolvedValue([mockUser as User]); + jest.spyOn(userRepository, 'save').mockResolvedValue(mockUser as User); jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace as any); - jest - .spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId') - .mockResolvedValue(mockSSOProviders as any); const result = await service.switchWorkspace( mockUser as User, @@ -141,9 +146,10 @@ describe('SwitchWorkspaceService', () => { ); expect(result).toEqual({ - useSSOAuth: true, - workspace: mockWorkspace, - availableSSOIdentityProviders: mockSSOProviders, + id: mockWorkspace.id, + logo: expect.any(String), + displayName: expect.any(String), + authProviders: expect.any(Object), }); }); @@ -153,6 +159,8 @@ describe('SwitchWorkspaceService', () => { id: 'workspace-id', workspaceUsers: [{ userId: 'user-id' }], workspaceSSOIdentityProviders: [], + logo: 'logo', + displayName: 'displayName', }; jest @@ -161,6 +169,7 @@ describe('SwitchWorkspaceService', () => { jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace as any); + jest.spyOn(userRepository, 'save').mockResolvedValue({} as User); const result = await service.switchWorkspace( mockUser as User, @@ -168,8 +177,10 @@ describe('SwitchWorkspaceService', () => { ); expect(result).toEqual({ - useSSOAuth: false, - workspace: mockWorkspace, + id: mockWorkspace.id, + logo: expect.any(String), + displayName: expect.any(String), + authProviders: expect.any(Object), }); }); }); @@ -200,10 +211,10 @@ describe('SwitchWorkspaceService', () => { refreshToken: mockRefreshToken, }, }); - expect(userRepository.save).toHaveBeenCalledWith({ - id: mockUser.id, - defaultWorkspace: mockWorkspace, - }); + expect(userService.saveDefaultWorkspace).toHaveBeenCalledWith( + mockUser.id, + mockWorkspace.id, + ); expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( mockUser.id, mockWorkspace.id, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts index 88dce7ce7..edbef71b3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts @@ -7,12 +7,13 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace'; @Injectable() export class SwitchWorkspaceService { @@ -21,7 +22,7 @@ export class SwitchWorkspaceService { private readonly userRepository: Repository, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, - private readonly ssoService: SSOService, + private readonly userService: UserService, private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, ) {} @@ -59,31 +60,17 @@ export class SwitchWorkspaceService { ); } - if (workspace.workspaceSSOIdentityProviders.length > 0) { - return { - useSSOAuth: true, - workspace, - availableSSOIdentityProviders: - await this.ssoService.listSSOIdentityProvidersByWorkspaceId( - workspaceId, - ), - } as { - useSSOAuth: true; - workspace: Workspace; - availableSSOIdentityProviders: Awaited< - ReturnType< - typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId - > - >; - }; - } + await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + }); return { - useSSOAuth: false, - workspace, - } as { - useSSOAuth: false; - workspace: Workspace; + id: workspace.id, + subdomain: workspace.subdomain, + logo: workspace.logo, + displayName: workspace.displayName, + authProviders: getAuthProvidersByWorkspace(workspace), }; } @@ -91,10 +78,7 @@ export class SwitchWorkspaceService { user: User, workspace: Workspace, ): Promise { - await this.userRepository.save({ - id: user.id, - defaultWorkspace: workspace, - }); + await this.userService.saveDefaultWorkspace(user.id, workspace.id); const token = await this.accessTokenService.generateAccessToken( user.id, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts index 932e4c4e3..5e20e1a60 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts @@ -17,6 +17,7 @@ export type GoogleRequest = Omit< picture: string | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; + targetWorkspaceSubdomain?: string; }; }; @@ -37,6 +38,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, + workspaceSubdomain: req.params.workspaceSubdomain, ...(req.params.workspacePersonalInviteToken ? { workspacePersonalInviteToken: @@ -69,6 +71,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, workspacePersonalInviteToken: state.workspacePersonalInviteToken, + targetWorkspaceSubdomain: state.workspaceSubdomain, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index babcf1540..39077fb6d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -21,6 +21,7 @@ export type MicrosoftRequest = Omit< picture: string | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; + targetWorkspaceSubdomain?: string; }; }; @@ -41,6 +42,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, + workspaceSubdomain: req.params.workspaceSubdomain, ...(req.params.workspacePersonalInviteToken ? { workspacePersonalInviteToken: @@ -83,6 +85,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, workspacePersonalInviteToken: state.workspacePersonalInviteToken, + targetWorkspaceSubdomain: state.workspaceSubdomain, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts index c1514c8f9..d18e0c79d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/saml.auth.strategy.ts @@ -69,30 +69,34 @@ export class SamlAuthStrategy extends PassportStrategy( } validate: VerifyWithRequest = async (request, profile, done) => { - if (!profile) { - return done(new Error('Profile is must be provided')); + try { + if (!profile) { + return done(new Error('Profile is must be provided')); + } + + const email = profile.email ?? profile.mail ?? profile.nameID; + + if (!isEmail(email)) { + return done(new Error('Invalid email')); + } + + const result: { + user: Record; + identityProviderId?: string; + } = { user: { email } }; + + if ( + 'RelayState' in request.body && + typeof request.body.RelayState === 'string' + ) { + const RelayState = JSON.parse(request.body.RelayState); + + result.identityProviderId = RelayState.identityProviderId; + } + + done(null, result); + } catch (err) { + done(err); } - - const email = profile.email ?? profile.mail ?? profile.nameID; - - if (!isEmail(email)) { - return done(new Error('Invalid email')); - } - - const result: { - user: Record; - identityProviderId?: string; - } = { user: { email } }; - - if ( - 'RelayState' in request.body && - typeof request.body.RelayState === 'string' - ) { - const RelayState = JSON.parse(request.body.RelayState); - - result.identityProviderId = RelayState.identityProviderId; - } - - done(null, result); }; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index 92e987d25..941fbef4c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -166,7 +166,7 @@ describe('AccessTokenService', () => { .spyOn(service['jwtStrategy'], 'validate') .mockReturnValue(mockAuthContext as any); - const result = await service.validateToken(mockRequest); + const result = await service.validateTokenByRequest(mockRequest); expect(result).toEqual(mockAuthContext); expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( @@ -184,7 +184,7 @@ describe('AccessTokenService', () => { headers: {}, } as Request; - await expect(service.validateToken(mockRequest)).rejects.toThrow( + await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow( AuthException, ); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index 5a47f274f..607484ff5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -116,16 +116,7 @@ export class AccessTokenService { }; } - async validateToken(request: Request): Promise { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - if (!token) { - throw new AuthException( - 'missing authentication token', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - + async validateToken(token: string): Promise { await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); const decoded = await this.jwtWrapperService.decode(token); @@ -135,4 +126,17 @@ export class AccessTokenService { return { user, apiKey, workspace, workspaceMemberId }; } + + async validateTokenByRequest(request: Request): Promise { + const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + + if (!token) { + throw new AuthException( + 'missing authentication token', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return this.validateToken(token); + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index 4876897fa..b15fd4a28 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -94,7 +94,7 @@ describe('LoginTokenService', () => { const result = await service.verifyLoginToken(mockToken); - expect(result).toEqual(mockEmail); + expect(result).toEqual({ sub: mockEmail }); expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( mockToken, 'LOGIN', diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index ece51a3a4..bd9e41c1e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -20,6 +20,7 @@ export class LoginTokenService { async generateLoginToken(email: string): Promise { const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); + const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); if (!expiresIn) { @@ -43,11 +44,11 @@ export class LoginTokenService { }; } - async verifyLoginToken(loginToken: string): Promise { + async verifyLoginToken(loginToken: string): Promise<{ sub: string }> { await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); return this.jwtWrapperService.decode(loginToken, { json: true, - }).sub; + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 2d8acfff4..182b71a40 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -16,11 +16,13 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; @Module({ imports: [ FeatureFlagModule, StripeModule, + DomainManagerModule, TypeOrmModule.forFeature( [ BillingSubscription, diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index dbad2efc7..a601108a8 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -11,12 +11,14 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { assert } from 'src/utils/assert'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() export class BillingPortalWorkspaceService { protected readonly logger = new Logger(BillingPortalWorkspaceService.name); constructor( private readonly stripeService: StripeService, + private readonly domainManagerService: DomainManagerService, private readonly environmentService: EnvironmentService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, @@ -31,7 +33,7 @@ export class BillingPortalWorkspaceService { priceId: string, successUrlPath?: string, ): Promise { - const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL'); + const frontBaseUrl = this.domainManagerService.getBaseUrl().toString(); const successUrl = successUrlPath ? frontBaseUrl + successUrlPath : frontBaseUrl; @@ -81,7 +83,7 @@ export class BillingPortalWorkspaceService { throw new Error('Error: missing stripeCustomerId'); } - const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL'); + const frontBaseUrl = this.domainManagerService.getBaseUrl().toString(); const returnUrl = returnUrlPath ? frontBaseUrl + returnUrlPath : frontBaseUrl; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts index 31d456008..f85a8930f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; @Module({ + imports: [DomainManagerModule], providers: [StripeService], exports: [StripeService], }) diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts index 2761415d0..7e4a7e257 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts @@ -7,17 +7,20 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() export class StripeService { protected readonly logger = new Logger(StripeService.name); private readonly stripe: Stripe; - constructor(private readonly environmentService: EnvironmentService) { + constructor( + private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, + ) { if (!this.environmentService.get('IS_BILLING_ENABLED')) { return; } - this.stripe = new Stripe( this.environmentService.get('BILLING_STRIPE_API_KEY'), {}, @@ -74,7 +77,8 @@ export class StripeService { ): Promise { return await this.stripe.billingPortal.sessions.create({ customer: stripeCustomerId, - return_url: returnUrl ?? this.environmentService.get('FRONT_BASE_URL'), + return_url: + returnUrl ?? this.domainManagerService.getBaseUrl().toString(), }); } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 00e2c3cb4..13ec58e69 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -2,30 +2,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; -@ObjectType() -class AuthProviders { - @Field(() => Boolean) - google: boolean; - - @Field(() => Boolean) - magicLink: boolean; - - @Field(() => Boolean) - password: boolean; - - @Field(() => Boolean) - microsoft: boolean; - - @Field(() => Boolean) - sso: boolean; -} - -@ObjectType() -class Telemetry { - @Field(() => Boolean) - enabled: boolean; -} - @ObjectType() class Billing { @Field(() => Boolean) @@ -76,9 +52,6 @@ class ApiConfig { @ObjectType() export class ClientConfig { - @Field(() => AuthProviders, { nullable: false }) - authProviders: AuthProviders; - @Field(() => Billing, { nullable: false }) billing: Billing; @@ -86,7 +59,16 @@ export class ClientConfig { signInPrefilled: boolean; @Field(() => Boolean) - signUpDisabled: boolean; + isMultiWorkspaceEnabled: boolean; + + @Field(() => Boolean) + isSSOEnabled: boolean; + + @Field(() => String, { nullable: true }) + defaultSubdomain: string; + + @Field(() => String) + frontDomain: string; @Field(() => Boolean) debugMode: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index 9f2660876..02d7412d4 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -11,13 +11,6 @@ export class ClientConfigResolver { @Query(() => ClientConfig) async clientConfig(): Promise { const clientConfig: ClientConfig = { - authProviders: { - google: this.environmentService.get('AUTH_GOOGLE_ENABLED'), - magicLink: false, - password: this.environmentService.get('AUTH_PASSWORD_ENABLED'), - microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'), - sso: this.environmentService.get('AUTH_SSO_ENABLED'), - }, billing: { isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'), billingUrl: this.environmentService.get('BILLING_PLAN_REQUIRED_LINK'), @@ -25,8 +18,13 @@ export class ClientConfigResolver { 'BILLING_FREE_TRIAL_DURATION_IN_DAYS', ), }, + isSSOEnabled: this.environmentService.get('AUTH_SSO_ENABLED'), signInPrefilled: this.environmentService.get('SIGN_IN_PREFILLED'), - signUpDisabled: this.environmentService.get('IS_SIGN_UP_DISABLED'), + isMultiWorkspaceEnabled: this.environmentService.get( + 'IS_MULTIWORKSPACE_ENABLED', + ), + defaultSubdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + frontDomain: this.environmentService.get('FRONT_DOMAIN'), debugMode: this.environmentService.get('DEBUG_MODE'), support: { supportDriver: this.environmentService.get('SUPPORT_DRIVER'), diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts new file mode 100644 index 000000000..093b96c40 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Module({ + imports: [NestjsQueryTypeOrmModule.forFeature([Workspace], 'core')], + providers: [DomainManagerService], + exports: [DomainManagerService], +}) +export class DomainManagerModule {} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.spec.ts new file mode 100644 index 000000000..7225c5445 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.spec.ts @@ -0,0 +1,157 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { DomainManagerService } from './domain-manager.service'; + +describe('DomainManagerService', () => { + let domainManagerService: DomainManagerService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DomainManagerService, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + domainManagerService = + module.get(DomainManagerService); + environmentService = module.get(EnvironmentService); + }); + + describe('buildBaseUrl', () => { + it('should build the base URL with protocol and domain from environment variables', () => { + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONT_PROTOCOL: 'https', + FRONT_DOMAIN: 'example.com', + }; + + return env[key]; + }); + + const result = domainManagerService.getBaseUrl(); + + expect(result.toString()).toBe('https://example.com/'); + }); + + it('should append default subdomain if multiworkspace is enabled', () => { + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONT_PROTOCOL: 'https', + FRONT_DOMAIN: 'example.com', + IS_MULTIWORKSPACE_ENABLED: true, + DEFAULT_SUBDOMAIN: 'test', + }; + + return env[key]; + }); + + const result = domainManagerService.getBaseUrl(); + + expect(result.toString()).toBe('https://test.example.com/'); + }); + + it('should append port if FRONT_PORT is set', () => { + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONT_PROTOCOL: 'https', + FRONT_DOMAIN: 'example.com', + FRONT_PORT: '8080', + }; + + return env[key]; + }); + + const result = domainManagerService.getBaseUrl(); + + expect(result.toString()).toBe('https://example.com:8080/'); + }); + }); + + describe('buildWorkspaceURL', () => { + it('should build workspace URL with given subdomain', () => { + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONT_PROTOCOL: 'https', + FRONT_DOMAIN: 'example.com', + IS_MULTIWORKSPACE_ENABLED: true, + DEFAULT_SUBDOMAIN: 'default', + }; + + return env[key]; + }); + + const result = domainManagerService.buildWorkspaceURL({ + subdomain: 'test', + }); + + expect(result.toString()).toBe('https://test.example.com/'); + }); + + it('should set the pathname if provided', () => { + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONT_PROTOCOL: 'https', + FRONT_DOMAIN: 'example.com', + }; + + return env[key]; + }); + + const result = domainManagerService.buildWorkspaceURL({ + pathname: '/path/to/resource', + }); + + expect(result.pathname).toBe('/path/to/resource'); + }); + + it('should set the search parameters if provided', () => { + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONT_PROTOCOL: 'https', + FRONT_DOMAIN: 'example.com', + }; + + return env[key]; + }); + + const result = domainManagerService.buildWorkspaceURL({ + searchParams: { + foo: 'bar', + baz: 123, + }, + }); + + expect(result.searchParams.get('foo')).toBe('bar'); + expect(result.searchParams.get('baz')).toBe('123'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts new file mode 100644 index 000000000..bc5991827 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts @@ -0,0 +1,264 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { isDefined } from 'src/utils/is-defined'; +import { + WorkspaceException, + WorkspaceExceptionCode, +} from 'src/engine/core-modules/workspace/workspace.exception'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { isWorkEmail } from 'src/utils/is-work-email'; +import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class DomainManagerService { + constructor( + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly environmentService: EnvironmentService, + ) {} + + getBaseUrl() { + const baseUrl = new URL( + `${this.environmentService.get('FRONT_PROTOCOL')}://${this.environmentService.get('FRONT_DOMAIN')}`, + ); + + if ( + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && + this.environmentService.get('DEFAULT_SUBDOMAIN') + ) { + baseUrl.hostname = `${this.environmentService.get('DEFAULT_SUBDOMAIN')}.${baseUrl.hostname}`; + } + + if (this.environmentService.get('FRONT_PORT')) { + baseUrl.port = this.environmentService.get('FRONT_PORT').toString(); + } + + return baseUrl; + } + + buildWorkspaceURL({ + subdomain, + pathname, + searchParams, + }: { + subdomain?: string; + pathname?: string; + searchParams?: Record; + }) { + const url = this.getBaseUrl(); + + if ( + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && + !subdomain + ) { + throw new Error('subdomain is required when multiworkspace is enable'); + } + + if ( + subdomain && + subdomain.length > 0 && + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') + ) { + url.hostname = url.hostname.replace( + this.environmentService.get('DEFAULT_SUBDOMAIN'), + subdomain, + ); + } + + if (pathname) { + url.pathname = pathname; + } + + if (searchParams) { + Object.entries(searchParams).forEach(([key, value]) => { + if (isDefined(value)) { + url.searchParams.set(key, value.toString()); + } + }); + } + + return url; + } + + getWorkspaceSubdomainByOrigin = (origin: string) => { + const { hostname: originHostname } = new URL(origin); + + const subdomain = originHostname.replace( + `.${this.environmentService.get('FRONT_DOMAIN')}`, + '', + ); + + if (this.isDefaultSubdomain(subdomain)) { + return; + } + + return subdomain; + }; + + isDefaultSubdomain(subdomain: string) { + return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN'); + } + + computeRedirectErrorUrl({ + errorMessage, + subdomain, + }: { + errorMessage: string; + subdomain?: string; + }) { + const url = this.buildWorkspaceURL({ + subdomain, + pathname: '/verify', + searchParams: { errorMessage }, + }); + + return url.toString(); + } + + async getDefaultWorkspace() { + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + const workspaces = await this.workspaceRepository.find({ + order: { + createdAt: 'DESC', + }, + }); + + if (workspaces.length > 1) { + // TODO AMOREAUX: this logger is trigger twice and the second time the message is undefined for an unknown reason + Logger.warn( + `In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`, + ); + } + + return workspaces[0]; + } + + throw new Error( + 'Default workspace not exist when multi-workspace is enabled', + ); + } + + async getWorkspaceByOrigin(origin: string) { + try { + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + return this.getDefaultWorkspace(); + } + + const subdomain = this.getWorkspaceSubdomainByOrigin(origin); + + if (!isDefined(subdomain)) return; + + return this.workspaceRepository.findOneBy({ subdomain }); + } catch (e) { + throw new WorkspaceException( + 'Workspace not found', + WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND, + ); + } + } + + private generateRandomSubdomain(): string { + const prefixes = [ + 'cool', + 'smart', + 'fast', + 'bright', + 'shiny', + 'happy', + 'funny', + 'clever', + 'brave', + 'kind', + 'gentle', + 'quick', + 'sharp', + 'calm', + 'silent', + 'lucky', + 'fierce', + 'swift', + 'mighty', + 'noble', + 'bold', + 'wise', + 'eager', + 'joyful', + 'glad', + 'zany', + 'witty', + 'bouncy', + 'graceful', + 'colorful', + ]; + const suffixes = [ + 'raccoon', + 'panda', + 'whale', + 'tiger', + 'dolphin', + 'eagle', + 'penguin', + 'owl', + 'fox', + 'wolf', + 'lion', + 'bear', + 'hawk', + 'shark', + 'sparrow', + 'moose', + 'lynx', + 'falcon', + 'rabbit', + 'hedgehog', + 'monkey', + 'horse', + 'koala', + 'kangaroo', + 'elephant', + 'giraffe', + 'panther', + 'crocodile', + 'seal', + 'octopus', + ]; + + const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)]; + const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)]; + + return `${randomPrefix}-${randomSuffix}`; + } + + private getSubdomainNameByEmail(email?: string) { + if (!isDefined(email) || !isWorkEmail(email)) return; + + return getDomainNameByEmail(email); + } + + private getSubdomainNameByDisplayName(displayName?: string) { + if (!isDefined(displayName)) return; + const displayNameWords = displayName.match(/(\w| |\d)+/g); + + if (displayNameWords) { + return displayNameWords.join('-').replace(/ /g, '').toLowerCase(); + } + } + + async generateSubdomain(params?: { email?: string; displayName?: string }) { + const subdomain = + this.getSubdomainNameByEmail(params?.email) ?? + this.getSubdomainNameByDisplayName(params?.displayName) ?? + this.generateRandomSubdomain(); + + const existingWorkspaceCount = await this.workspaceRepository.countBy({ + subdomain, + }); + + return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index 1fbc08d87..e7974a6fb 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -127,8 +127,22 @@ export class EnvironmentVariables { PG_SSL_ALLOW_SELF_SIGNED = false; // Frontend URL - @IsUrl({ require_tld: false, require_protocol: true }) - FRONT_BASE_URL: string; + @IsString() + @IsOptional() + FRONT_DOMAIN = 'localhost'; + + @IsString() + @ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED) + DEFAULT_SUBDOMAIN = 'app'; + + @IsString() + @IsOptional() + FRONT_PROTOCOL: 'http' | 'https' = 'http'; + + @CastToPositiveNumber() + @IsNumber() + @IsOptional() + FRONT_PORT = 3001; @IsUrl({ require_tld: false, require_protocol: true }) @IsOptional() @@ -227,6 +241,11 @@ export class EnvironmentVariables { @IsOptional() ENTERPRISE_KEY: string; + @CastToBoolean() + @IsOptional() + @IsBoolean() + IS_MULTIWORKSPACE_ENABLED = false; + // Custom Code Engine @IsEnum(ServerlessDriverType) @IsOptional() @@ -363,11 +382,6 @@ export class EnvironmentVariables { @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0) WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 60; - @CastToBoolean() - @IsOptional() - @IsBoolean() - IS_SIGN_UP_DISABLED = false; - @IsEnum(CaptchaDriverType) @IsOptional() CAPTCHA_DRIVER?: CaptchaDriverType; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 7a2828d0f..61d75608b 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -59,7 +59,7 @@ export class OpenApiService { try { const { workspace } = - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts index 3d0d04954..7f01265f7 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -11,7 +11,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; -import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; import { SSOException, SSOExceptionCode, @@ -149,44 +148,6 @@ export class SSOService { }; } - async findAvailableSSOIdentityProviders(email: string) { - const user = await this.userRepository.findOne({ - where: { email }, - relations: [ - 'workspaces', - 'workspaces.workspace', - 'workspaces.workspace.workspaceSSOIdentityProviders', - ], - }); - - if (!user) { - throw new SSOException('User not found', SSOExceptionCode.USER_NOT_FOUND); - } - - return user.workspaces.flatMap((userWorkspace) => - ( - userWorkspace.workspace - .workspaceSSOIdentityProviders as Array - ).reduce((acc, identityProvider) => { - if (identityProvider.status === 'Inactive') return acc; - - acc.push({ - id: identityProvider.id, - name: identityProvider.name ?? 'Unknown', - issuer: identityProvider.issuer, - type: identityProvider.type, - status: identityProvider.status, - workspace: { - id: userWorkspace.workspaceId, - displayName: userWorkspace.workspace.displayName, - }, - }); - - return acc; - }, [] as Array), - ); - } - async findSSOIdentityProviderById(identityProviderId?: string) { // if identityProviderId is not provide, typeorm return a random idp instead of undefined if (!identityProviderId) return undefined; diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts index e6e492b5b..0754a9939 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts @@ -8,7 +8,6 @@ import { DeleteSsoInput } from 'src/engine/core-modules/sso/dtos/delete-sso.inpu import { DeleteSsoOutput } from 'src/engine/core-modules/sso/dtos/delete-sso.output'; import { EditSsoInput } from 'src/engine/core-modules/sso/dtos/edit-sso.input'; import { EditSsoOutput } from 'src/engine/core-modules/sso/dtos/edit-sso.output'; -import { FindAvailableSSOIDPInput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input'; import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; import { GetAuthorizationUrlInput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.input'; import { GetAuthorizationUrlOutput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.output'; @@ -39,14 +38,6 @@ export class SSOResolver { ); } - @UseGuards(SSOProviderEnabledGuard) - @Mutation(() => [FindAvailableSSOIDPOutput]) - async findAvailableSSOIdentityProviders( - @Args('input') input: FindAvailableSSOIDPInput, - ): Promise> { - return this.sSOService.findAvailableSSOIdentityProviders(input.email); - } - @UseGuards(SSOProviderEnabledGuard) @Query(() => [FindAvailableSSOIDPOutput]) async listSSOIdentityProvidersByWorkspaceId( diff --git a/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts b/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts index b86035331..3247f4609 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts @@ -31,7 +31,7 @@ export enum OIDCResponseType { } registerEnumType(IdentityProviderType, { - name: 'IdpType', + name: 'IdentityProviderType', }); export enum SSOIdentityProviderStatus { diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index c192cd11d..fd47972e1 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -9,16 +9,17 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ imports: [ NestjsQueryGraphQLModule.forFeature({ imports: [ NestjsQueryTypeOrmModule.forFeature( - [User, UserWorkspace, AppToken], + [User, UserWorkspace, Workspace], 'core', ), NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), @@ -31,6 +32,6 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat }), ], exports: [UserWorkspaceService], - providers: [UserWorkspaceService], + providers: [UserWorkspaceService, UserWorkspaceResolver], }) export class UserWorkspaceModule {} diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 8fa5f815e..41d06d3c3 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -5,10 +5,6 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { Repository } from 'typeorm'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; -import { - AppToken, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; @@ -26,14 +22,12 @@ export class UserWorkspaceService extends TypeOrmQueryService { private readonly userWorkspaceRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, - @InjectRepository(AppToken, 'core') - private readonly appTokenRepository: Repository, @InjectRepository(ObjectMetadataEntity, 'metadata') private readonly objectMetadataRepository: Repository, private readonly dataSourceService: DataSourceService, private readonly typeORMService: TypeORMService, private readonly workspaceInvitationService: WorkspaceInvitationService, - private workspaceEventEmitter: WorkspaceEventEmitter, + private readonly workspaceEventEmitter: WorkspaceEventEmitter, ) { super(userWorkspaceRepository); } @@ -116,39 +110,25 @@ export class UserWorkspaceService extends TypeOrmQueryService { await this.createWorkspaceMember(workspace.id, user); } - return await this.userRepository.save({ + const savedUser = await this.userRepository.save({ id: user.id, defaultWorkspace: workspace, updatedAt: new Date().toISOString(), }); - } - async validateInvitation(inviteToken: string, email: string) { - const appToken = await this.appTokenRepository.findOne({ - where: { - value: inviteToken, - type: AppTokenType.InvitationToken, - }, - relations: ['workspace'], - }); + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + workspace.id, + user.email, + ); - if (!appToken) { - throw new Error('Invalid invitation token'); - } - - if (!appToken.context?.email && appToken.context?.email !== email) { - throw new Error('Email does not match the invitation'); - } - - if (new Date(appToken.expiresAt) < new Date()) { - throw new Error('Invitation expired'); - } - - return appToken; + return savedUser; } async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) { - const appToken = await this.validateInvitation(inviteToken, user.email); + const appToken = await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: inviteToken, + email: user.email, + }); await this.workspaceInvitationService.invalidateWorkspaceInvitation( appToken.workspace.id, @@ -158,7 +138,7 @@ export class UserWorkspaceService extends TypeOrmQueryService { return await this.addUserToWorkspace(user, appToken.workspace); } - public async getUserCount(workspaceId): Promise { + public async getUserCount(workspaceId: string): Promise { return await this.userWorkspaceRepository.countBy({ workspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 6bbc6e5fd..ec110a2b7 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -18,6 +18,11 @@ import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/worksp import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class UserService extends TypeOrmQueryService { @@ -64,9 +69,7 @@ export class UserService extends TypeOrmQueryService { 'workspaceMember', ); - const workspaceMembers = workspaceMemberRepository.find(); - - return workspaceMembers; + return workspaceMemberRepository.find(); } async deleteUser(userId: string): Promise { @@ -131,4 +134,29 @@ export class UserService extends TypeOrmQueryService { return user; } + + async saveDefaultWorkspace(userId: string, workspaceId: string) { + const user = await this.userRepository.findOne({ + where: { + id: userId, + workspaces: { + workspaceId, + }, + }, + relations: ['workspaces'], + }); + + userValidator.assertIsExist( + user, + new AuthException( + 'User does not have access to this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ), + ); + + return await this.userRepository.save({ + id: userId, + defaultWorkspaceId: workspaceId, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 58dc0830f..1aa3bf5f9 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -9,7 +9,6 @@ import { } from '@nestjs/graphql'; import { InjectRepository } from '@nestjs/typeorm'; -import assert from 'assert'; import crypto from 'crypto'; import { GraphQLJSONObject } from 'graphql-type-json'; @@ -40,6 +39,11 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { userValidator } from 'src/engine/core-modules/user/user.validate'; const getHMACKey = (email?: string, key?: string | null) => { if (!email || !key) return null; @@ -65,7 +69,17 @@ export class UserResolver { ) {} @Query(() => User) - async currentUser(@AuthUser() { id: userId }: User): Promise { + async currentUser( + @AuthUser() { id: userId }: User, + @AuthWorkspace() { id: workspaceId }: Workspace, + ): Promise { + if ( + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && + workspaceId + ) { + await this.userService.saveDefaultWorkspace(userId, workspaceId); + } + const user = await this.userRepository.findOne({ where: { id: userId, @@ -73,7 +87,10 @@ export class UserResolver { relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], }); - assert(user, 'User not found'); + userValidator.assertIsExist( + user, + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); return user; } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.validate.ts b/packages/twenty-server/src/engine/core-modules/user/user.validate.ts new file mode 100644 index 000000000..4fb4cec3b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/user.validate.ts @@ -0,0 +1,34 @@ +import { User } from 'src/engine/core-modules/user/user.entity'; +import { CustomException } from 'src/utils/custom-exception'; + +const assertIsExist = ( + user: User | undefined | null, + exceptionToThrow: CustomException, +): asserts user is User => { + if (!user) { + throw exceptionToThrow; + } +}; + +const isExist = (user: User | undefined | null): user is User => { + return !!user; +}; + +const assertHasDefaultWorkspace = ( + user: User, + exceptionToThrow?: CustomException, +): asserts user is User & { defaultWorkspaceId: string } => { + if (!user.defaultWorkspaceId) { + throw exceptionToThrow; + } +}; + +export const userValidator: { + assertIsExist: typeof assertIsExist; + assertHasDefaultWorkspace: typeof assertHasDefaultWorkspace; + isExist: typeof isExist; +} = { + assertIsExist, + assertHasDefaultWorkspace, + isExist, +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 5b05d0970..100c77ca6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -14,9 +14,14 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceInvitationService } from './workspace-invitation.service'; +// To fix a circular dependency issue +jest.mock('src/engine/core-modules/workspace/services/workspace.service'); + describe('WorkspaceInvitationService', () => { let service: WorkspaceInvitationService; let appTokenRepository: Repository; @@ -37,6 +42,18 @@ describe('WorkspaceInvitationService', () => { provide: getRepositoryToken(UserWorkspace, 'core'), useClass: Repository, }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: DomainManagerService, + useValue: { + buildWorkspaceURL: jest + .fn() + .mockResolvedValue(new URL('http://localhost:3001')), + }, + }, { provide: EnvironmentService, useValue: { @@ -55,6 +72,16 @@ describe('WorkspaceInvitationService', () => { setOnboardingInviteTeamPending: jest.fn(), }, }, + { + provide: WorkspaceService, + useValue: { + // Mock methods you expect WorkspaceInvitationService to call + getDefaultWorkspace: jest + .fn() + .mockResolvedValue({ id: 'default-workspace-id' }), + // Add other methods as needed + }, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index bfa21fb6f..f4c74a88c 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -28,6 +28,8 @@ import { WorkspaceInvitationExceptionCode, } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { castAppTokenToWorkspaceInvitationUtil } from 'src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -35,13 +37,122 @@ export class WorkspaceInvitationService { constructor( @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, - private readonly environmentService: EnvironmentService, - private readonly emailService: EmailService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, + private readonly environmentService: EnvironmentService, + private readonly emailService: EmailService, private readonly onboardingService: OnboardingService, + private readonly domainManagerService: DomainManagerService, ) {} + // VALIDATIONS METHODS + private async validatePublicInvitation(workspaceInviteHash: string) { + const workspace = await this.workspaceRepository.findOne({ + where: { + inviteHash: workspaceInviteHash, + }, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + if (!workspace.isPublicInviteLinkEnabled) { + throw new AuthException( + 'Workspace does not allow public invites', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return { isValid: true, workspace }; + } + + private async validatePersonalInvitation({ + workspacePersonalInviteToken, + email, + }: { + workspacePersonalInviteToken?: string; + email: string; + }) { + try { + const appToken = await this.appTokenRepository.findOne({ + where: { + value: workspacePersonalInviteToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], + }); + + if (!appToken) { + throw new Error('Invalid invitation token'); + } + + if (!appToken.context?.email || appToken.context?.email !== email) { + throw new Error('Email does not match the invitation'); + } + + if (new Date(appToken.expiresAt) < new Date()) { + throw new Error('Invitation expired'); + } + + return { isValid: true, workspace: appToken.workspace }; + } catch (err) { + throw new AuthException( + err.message, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } + + async validateInvitation({ + workspacePersonalInviteToken, + workspaceInviteHash, + email, + }: { + workspacePersonalInviteToken?: string; + workspaceInviteHash?: string; + email: string; + }) { + if (workspacePersonalInviteToken) { + return await this.validatePersonalInvitation({ + workspacePersonalInviteToken, + email, + }); + } + + if (workspaceInviteHash) { + return await this.validatePublicInvitation(workspaceInviteHash); + } + + throw new AuthException( + 'Invitation invalid', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + async findInvitationByWorkspaceSubdomainAndUserEmail({ + subdomain, + email, + }: { + subdomain?: string; + email: string; + }) { + const workspace = this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') + ? await this.workspaceRepository.findOneBy({ + subdomain, + }) + : await this.domainManagerService.getDefaultWorkspace(); + + if (!workspace) return; + + return await this.getOneWorkspaceInvitation(workspace.id, email); + } + async getOneWorkspaceInvitation(workspaceId: string, email: string) { return await this.appTokenRepository .createQueryBuilder('appToken') @@ -55,26 +166,38 @@ export class WorkspaceInvitationService { .getOne(); } - castAppTokenToWorkspaceInvitation(appToken: AppToken) { - if (appToken.type !== AppTokenType.InvitationToken) { + async getAppTokenByInvitationToken(invitationToken: string) { + const appToken = await this.appTokenRepository.findOne({ + where: { + value: invitationToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], + }); + + if (!appToken) { throw new WorkspaceInvitationException( - `Token type must be "${AppTokenType.InvitationToken}"`, - WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE, + 'Invalid invitation token', + WorkspaceInvitationExceptionCode.INVALID_INVITATION, ); } - if (!appToken.context?.email) { - throw new WorkspaceInvitationException( - `Invitation corrupted: Missing email in context`, - WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED, - ); - } + return appToken; + } - return { - id: appToken.id, - email: appToken.context.email, - expiresAt: appToken.expiresAt, - }; + async loadWorkspaceInvitations(workspace: Workspace) { + const appTokens = await this.appTokenRepository.find({ + where: { + workspaceId: workspace.id, + type: AppTokenType.InvitationToken, + deletedAt: IsNull(), + }, + select: { + value: false, + }, + }); + + return appTokens.map(castAppTokenToWorkspaceInvitationUtil); } async createWorkspaceInvitation(email: string, workspace: Workspace) { @@ -112,21 +235,6 @@ export class WorkspaceInvitationService { return this.generateInvitationToken(workspace.id, email); } - async loadWorkspaceInvitations(workspace: Workspace) { - const appTokens = await this.appTokenRepository.find({ - where: { - workspaceId: workspace.id, - type: AppTokenType.InvitationToken, - deletedAt: IsNull(), - }, - select: { - value: false, - }, - }); - - return appTokens.map(this.castAppTokenToWorkspaceInvitation); - } - async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) { const appToken = await this.appTokenRepository.findOne({ where: { @@ -221,16 +329,18 @@ export class WorkspaceInvitationService { }), ); - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); - for (const invitation of invitationsPr) { if (invitation.status === 'fulfilled') { - const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`); - - if (invitation.value.isPersonalInvitation) { - link.searchParams.set('inviteToken', invitation.value.appToken.value); - link.searchParams.set('email', invitation.value.email); - } + const link = this.domainManagerService.buildWorkspaceURL({ + subdomain: workspace.subdomain, + pathname: `invite/${workspace?.inviteHash}`, + searchParams: invitation.value.isPersonalInvitation + ? { + inviteToken: invitation.value.appToken.value, + email: invitation.value.email, + } + : {}, + }); const emailData = { link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, @@ -280,9 +390,7 @@ export class WorkspaceInvitationService { } else { acc.result.push( invitation.value.isPersonalInvitation - ? this.castAppTokenToWorkspaceInvitation( - invitation.value.appToken, - ) + ? castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken) : { email: invitation.value.email }, ); } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts new file mode 100644 index 000000000..b1d7ed420 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts @@ -0,0 +1,61 @@ +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + WorkspaceInvitationException, + WorkspaceInvitationExceptionCode, +} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; + +import { castAppTokenToWorkspaceInvitationUtil } from './cast-app-token-to-workspace-invitation.util'; + +describe('castAppTokenToWorkspaceInvitation', () => { + it('should throw an error if token type is not InvitationToken', () => { + const appToken = { + id: '1', + type: AppTokenType.RefreshToken, + context: { email: 'test@example.com' }, + expiresAt: new Date(), + } as AppToken; + + expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError( + new WorkspaceInvitationException( + `Token type must be "${AppTokenType.InvitationToken}"`, + WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE, + ), + ); + }); + + it('should throw an error if context email is missing', () => { + const appToken = { + id: '1', + type: AppTokenType.InvitationToken, + context: null, + expiresAt: new Date(), + } as AppToken; + + expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError( + new WorkspaceInvitationException( + `Invitation corrupted: Missing email in context`, + WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED, + ), + ); + }); + + it('should return the correct invitation object for valid inputs', () => { + const appToken = { + id: '1', + type: AppTokenType.InvitationToken, + context: { email: 'test@example.com' }, + expiresAt: new Date(), + } as AppToken; + + const invitation = castAppTokenToWorkspaceInvitationUtil(appToken); + + expect(invitation).toEqual({ + id: '1', + email: 'test@example.com', + expiresAt: appToken.expiresAt, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts new file mode 100644 index 000000000..f0df8968a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts @@ -0,0 +1,30 @@ +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + WorkspaceInvitationException, + WorkspaceInvitationExceptionCode, +} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; + +export const castAppTokenToWorkspaceInvitationUtil = (appToken: AppToken) => { + if (appToken.type !== AppTokenType.InvitationToken) { + throw new WorkspaceInvitationException( + `Token type must be "${AppTokenType.InvitationToken}"`, + WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE, + ); + } + + if (!appToken.context?.email) { + throw new WorkspaceInvitationException( + `Invitation corrupted: Missing email in context`, + WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED, + ); + } + + return { + id: appToken.id, + email: appToken.context.email, + expiresAt: appToken.expiresAt, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts index 6dce1ea3b..e52ad6691 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts @@ -1,7 +1,6 @@ import { CustomException } from 'src/utils/custom-exception'; export class WorkspaceInvitationException extends CustomException { - code: WorkspaceInvitationExceptionCode; constructor(message: string, code: WorkspaceInvitationExceptionCode) { super(message, code); } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts index f09bb770a..e1b09cb50 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts @@ -3,16 +3,20 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; @Module({ imports: [ - NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'), - TokenModule, + DomainManagerModule, + NestjsQueryTypeOrmModule.forFeature( + [AppToken, UserWorkspace, Workspace], + 'core', + ), OnboardingModule, ], exports: [WorkspaceInvitationService], diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/activate-workspace-output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/activate-workspace-output.ts new file mode 100644 index 000000000..6038f2ef0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/activate-workspace-output.ts @@ -0,0 +1,13 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; + +@ObjectType() +export class ActivateWorkspaceOutput { + @Field(() => Workspace) + workspace: Workspace; + + @Field(() => AuthToken) + loginToken: AuthToken; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts new file mode 100644 index 000000000..cc5786a29 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts @@ -0,0 +1,61 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@ObjectType() +export class SSOIdentityProvider { + @Field(() => String) + id: string; + + @Field(() => String) + name: string; + + @Field(() => IdentityProviderType) + type: IdentityProviderType; + + @Field(() => SSOIdentityProviderStatus) + status: SSOIdentityProviderStatus; + + @Field(() => String) + issuer: string; +} + +@ObjectType() +export class AuthProviders { + @Field(() => [SSOIdentityProvider]) + sso: Array; + + @Field(() => Boolean) + google: boolean; + + @Field(() => Boolean) + magicLink: boolean; + + @Field(() => Boolean) + password: boolean; + + @Field(() => Boolean) + microsoft: boolean; +} + +@ObjectType() +export class PublicWorkspaceDataOutput { + @Field(() => String) + id: string; + + @Field(() => AuthProviders) + authProviders: AuthProviders; + + @Field(() => String, { nullable: true }) + logo: Workspace['logo']; + + @Field(() => String, { nullable: true }) + displayName: Workspace['displayName']; + + @Field(() => String) + subdomain: Workspace['subdomain']; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index f56a8a54c..7c2ef7556 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -9,6 +9,11 @@ export class UpdateWorkspaceInput { @IsOptional() domainName?: string; + @Field({ nullable: true }) + @IsString() + @IsOptional() + subdomain?: string; + @Field({ nullable: true }) @IsString() @IsOptional() @@ -33,4 +38,19 @@ export class UpdateWorkspaceInput { @IsBoolean() @IsOptional() allowImpersonation?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isGoogleAuthEnabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isMicrosoftAuthEnabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isPasswordAuthEnabled?: boolean; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts index 0c53265e6..6a3d15c8f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts @@ -13,6 +13,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { WorkspaceService } from './workspace.service'; @@ -47,6 +48,10 @@ describe('WorkspaceService', () => { provide: UserService, useValue: {}, }, + { + provide: DomainManagerService, + useValue: {}, + }, { provide: BillingSubscriptionService, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 38befbf6e..0ae78f95e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -1,5 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import assert from 'assert'; @@ -20,9 +19,9 @@ import { import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags'; +@Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { - private userWorkspaceService: UserWorkspaceService; constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -33,12 +32,9 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly workspaceManagerService: WorkspaceManagerService, private readonly featureFlagService: FeatureFlagService, private readonly billingSubscriptionService: BillingSubscriptionService, - private moduleRef: ModuleRef, + private readonly userWorkspaceService: UserWorkspaceService, ) { super(workspaceRepository); - this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, { - strict: false, - }); } async activateWorkspace(user: User, data: ActivateWorkspaceInput) { @@ -82,12 +78,15 @@ export class WorkspaceService extends TypeOrmQueryService { user.defaultWorkspaceId, user, ); + await this.workspaceRepository.update(user.defaultWorkspaceId, { displayName: data.displayName, activationStatus: WorkspaceActivationStatus.ACTIVE, }); - return existingWorkspace; + return await this.workspaceRepository.findOneBy({ + id: user.defaultWorkspaceId, + }); } async softDeleteWorkspace(id: string) { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.spec.ts new file mode 100644 index 000000000..bd33fc0be --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.spec.ts @@ -0,0 +1,80 @@ +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { getAuthProvidersByWorkspace } from './getAuthProvidersByWorkspace'; + +describe('getAuthProvidersByWorkspace', () => { + const mockWorkspace = { + isGoogleAuthEnabled: true, + isPasswordAuthEnabled: true, + isMicrosoftAuthEnabled: false, + workspaceSSOIdentityProviders: [ + { + id: 'sso1', + name: 'SSO Provider 1', + type: 'SAML', + status: 'active', + issuer: 'sso1.example.com', + }, + ], + } as unknown as Workspace; + + it('should return correct auth providers for given workspace', () => { + const result = getAuthProvidersByWorkspace({ + ...mockWorkspace, + }); + + expect(result).toEqual({ + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [ + { + id: 'sso1', + name: 'SSO Provider 1', + type: 'SAML', + status: 'active', + issuer: 'sso1.example.com', + }, + ], + }); + }); + + it('should handle workspace with no SSO providers', () => { + const result = getAuthProvidersByWorkspace({ + ...mockWorkspace, + workspaceSSOIdentityProviders: [], + }); + + expect(result).toEqual({ + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [], + }); + }); + + it('should disable Microsoft auth if isMicrosoftAuthEnabled is false', () => { + const result = getAuthProvidersByWorkspace({ + ...mockWorkspace, + isMicrosoftAuthEnabled: false, + }); + + expect(result).toEqual({ + google: true, + magicLink: false, + password: true, + microsoft: false, + sso: [ + { + id: 'sso1', + name: 'SSO Provider 1', + type: 'SAML', + status: 'active', + issuer: 'sso1.example.com', + }, + ], + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.ts new file mode 100644 index 000000000..bd2751b79 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace.ts @@ -0,0 +1,17 @@ +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export const getAuthProvidersByWorkspace = (workspace: Workspace) => { + return { + google: workspace.isGoogleAuthEnabled, + magicLink: false, + password: workspace.isPasswordAuthEnabled, + microsoft: workspace.isMicrosoftAuthEnabled, + sso: workspace.workspaceSSOIdentityProviders.map((identityProvider) => ({ + id: identityProvider.id, + name: identityProvider.name, + type: identityProvider.type, + status: identityProvider.status, + issuer: identityProvider.issuer, + })), + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index ec75e081f..e353174e6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -150,4 +150,20 @@ export class Workspace { @Field() @Column({ default: '' }) databaseSchema: string; + + @Field() + @Column() + subdomain: string; + + @Field() + @Column({ default: true }) + isGoogleAuthEnabled: boolean; + + @Field() + @Column({ default: true }) + isPasswordAuthEnabled: boolean; + + @Field() + @Column({ default: false }) + isMicrosoftAuthEnabled: boolean; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts new file mode 100644 index 000000000..cd9d2f980 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts @@ -0,0 +1,12 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class WorkspaceException extends CustomException { + constructor(message: string, code: WorkspaceExceptionCode) { + super(message, code); + } +} + +export enum WorkspaceExceptionCode { + SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND', + WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index bd03d0e12..05d58624a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -11,14 +11,14 @@ import { FileModule } from 'src/engine/core-modules/file/file.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; -import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; @@ -30,8 +30,10 @@ import { WorkspaceService } from './services/workspace.service'; TypeORMModule, NestjsQueryGraphQLModule.forFeature({ imports: [ + DomainManagerModule, BillingModule, FileModule, + TokenModule, FileUploadModule, WorkspaceMetadataCacheModule, NestjsQueryTypeOrmModule.forFeature( @@ -44,7 +46,6 @@ import { WorkspaceService } from './services/workspace.service'; DataSourceModule, OnboardingModule, TypeORMModule, - WorkspaceInvitationModule, ], services: [WorkspaceService], resolvers: workspaceAutoResolverOpts, @@ -54,7 +55,6 @@ import { WorkspaceService } from './services/workspace.service'; providers: [ WorkspaceResolver, WorkspaceService, - UserWorkspaceResolver, WorkspaceWorkspaceMemberListener, ], }) diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index ae3798c20..cfd1db2c5 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -29,16 +29,28 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { assert } from 'src/utils/assert'; import { isDefined } from 'src/utils/is-defined'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { + WorkspaceException, + WorkspaceExceptionCode, +} from 'src/engine/core-modules/workspace/workspace.exception'; +import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; +import { ActivateWorkspaceOutput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-output'; +import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace'; import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; -@UseGuards(WorkspaceAuthGuard) @Resolver(() => Workspace) export class WorkspaceResolver { constructor( private readonly workspaceService: WorkspaceService, + private readonly loginTokenService: LoginTokenService, + private readonly domainManagerService: DomainManagerService, private readonly userWorkspaceService: UserWorkspaceService, private readonly environmentService: EnvironmentService, private readonly fileUploadService: FileUploadService, @@ -47,6 +59,7 @@ export class WorkspaceResolver { ) {} @Query(() => Workspace) + @UseGuards(WorkspaceAuthGuard) async currentWorkspace(@AuthWorkspace() { id }: Workspace) { const workspace = await this.workspaceService.findById(id); @@ -55,16 +68,25 @@ export class WorkspaceResolver { return workspace; } - @Mutation(() => Workspace) + @Mutation(() => ActivateWorkspaceOutput) @UseGuards(UserAuthGuard) async activateWorkspace( @Args('data') data: ActivateWorkspaceInput, @AuthUser() user: User, ) { - return await this.workspaceService.activateWorkspace(user, data); + const workspace = await this.workspaceService.activateWorkspace(user, data); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); + + return { + workspace, + loginToken, + }; } @Mutation(() => Workspace) + @UseGuards(WorkspaceAuthGuard) async updateWorkspace( @Args('data') data: UpdateWorkspaceInput, @AuthWorkspace() workspace: Workspace, @@ -73,6 +95,7 @@ export class WorkspaceResolver { } @Mutation(() => String) + @UseGuards(WorkspaceAuthGuard) async uploadWorkspaceLogo( @AuthWorkspace() { id }: Workspace, @Args({ name: 'file', type: () => GraphQLUpload }) @@ -101,8 +124,8 @@ export class WorkspaceResolver { return `${paths[0]}?token=${workspaceLogoToken}`; } - @UseGuards(DemoEnvGuard) @Mutation(() => Workspace) + @UseGuards(DemoEnvGuard, WorkspaceAuthGuard) async deleteCurrentWorkspace(@AuthWorkspace() { id }: Workspace) { return this.workspaceService.deleteWorkspace(id); } @@ -144,4 +167,26 @@ export class WorkspaceResolver { hasValidEntrepriseKey(): boolean { return isDefined(this.environmentService.get('ENTERPRISE_KEY')); } + + @Query(() => PublicWorkspaceDataOutput) + async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) { + const workspace = + await this.domainManagerService.getWorkspaceByOrigin(origin); + + workspaceValidator.assertIsExist( + workspace, + new WorkspaceException( + 'Workspace not found', + WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, + ), + ); + + return { + id: workspace.id, + logo: workspace.logo, + displayName: workspace.displayName, + subdomain: workspace.subdomain, + authProviders: getAuthProvidersByWorkspace(workspace), + }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts new file mode 100644 index 000000000..72bebb48e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts @@ -0,0 +1,64 @@ +import { + Workspace, + WorkspaceActivationStatus, +} from 'src/engine/core-modules/workspace/workspace.entity'; +import { CustomException } from 'src/utils/custom-exception'; + +type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password'; + +const assertIsExist = ( + workspace: Workspace | undefined | null, + exceptionToThrow?: CustomException, +): asserts workspace is Workspace => { + if (!workspace) { + throw exceptionToThrow; + } +}; + +const assertIsActive = ( + workspace: Workspace, + exceptionToThrow: CustomException, +): asserts workspace is Workspace & { + activationStatus: WorkspaceActivationStatus.ACTIVE; +} => { + if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) return; + throw exceptionToThrow; +}; + +type IsAuthEnabled =

( + provider: P, + exceptionToThrow: CustomException, +) => ( + workspace: Workspace, + exceptionToThrowCustom?: CustomException, +) => boolean; + +const isAuthEnabled: IsAuthEnabled = (provider, exceptionToThrow) => { + return (workspace, exceptionToThrowCustom = exceptionToThrow) => { + if (provider === 'google' && workspace.isGoogleAuthEnabled) return true; + if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) + return true; + if (provider === 'password' && workspace.isPasswordAuthEnabled) return true; + + if (exceptionToThrowCustom) { + throw exceptionToThrowCustom; + } + + return false; + }; +}; + +const validateAuth = (fn: ReturnType, workspace: Workspace) => + fn(workspace); + +export const workspaceValidator: { + assertIsExist: typeof assertIsExist; + assertIsActive: typeof assertIsActive; + isAuthEnabled: IsAuthEnabled; + validateAuth: typeof validateAuth; +} = { + assertIsExist: assertIsExist, + assertIsActive: assertIsActive, + isAuthEnabled, + validateAuth, +}; diff --git a/packages/twenty-server/src/engine/decorators/auth/origin-header.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/origin-header.decorator.ts new file mode 100644 index 000000000..eda5286c5 --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/auth/origin-header.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +import { getRequest } from 'src/utils/extract-request'; + +export const OriginHeader = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = getRequest(ctx); + + return request.headers['origin']; + }, +); diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index cd173f03c..fb7edf644 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -14,7 +14,8 @@ export class JwtAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); try { - const data = await this.accessTokenService.validateToken(request); + const data = + await this.accessTokenService.validateTokenByRequest(request); const metadataVersion = await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 1b6b19e7a..f1eb58a07 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -18,7 +18,7 @@ class GraphqlTokenValidationProxy { async validateToken(req: Request) { try { - return await this.accessTokenService.validateToken(req); + return await this.accessTokenService.validateTokenByRequest(req); } catch (error) { const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter(); @@ -56,7 +56,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'IntrospectionQuery', 'ExchangeAuthorizationCode', 'GetAuthorizationUrl', - 'FindAvailableSSOIdentityProviders', + 'GetPublicWorkspaceDataBySubdomain', ]; if ( diff --git a/packages/twenty-server/src/utils/__test__/get-domain-name-by-email.spec.ts b/packages/twenty-server/src/utils/__test__/get-domain-name-by-email.spec.ts new file mode 100644 index 000000000..7eb92c9fd --- /dev/null +++ b/packages/twenty-server/src/utils/__test__/get-domain-name-by-email.spec.ts @@ -0,0 +1,27 @@ +import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; + +describe('getDomainNameByEmail', () => { + it('should return the domain name for a valid email', () => { + expect(getDomainNameByEmail('user@example.com')).toBe('example.com'); + }); + + it('should throw an error if email is empty', () => { + expect(() => getDomainNameByEmail('')).toThrow('Email is required'); + }); + + it('should throw an error if email does not contain "@"', () => { + expect(() => getDomainNameByEmail('userexample.com')).toThrow( + 'Invalid email format', + ); + }); + + it('should throw an error if email has more than one "@"', () => { + expect(() => getDomainNameByEmail('user@example@com')).toThrow( + 'Invalid email format', + ); + }); + + it('should throw an error if domain part is empty', () => { + expect(() => getDomainNameByEmail('user@')).toThrow('Invalid email format'); + }); +}); diff --git a/packages/twenty-server/src/utils/__test__/is-work-email.spec.ts b/packages/twenty-server/src/utils/__test__/is-work-email.spec.ts new file mode 100644 index 000000000..9ce57487b --- /dev/null +++ b/packages/twenty-server/src/utils/__test__/is-work-email.spec.ts @@ -0,0 +1,24 @@ +import { isWorkEmail } from 'src/utils/is-work-email'; + +describe('isWorkEmail', () => { + it('should return true for a work email', () => { + expect(isWorkEmail('user@company.com')).toBe(true); + }); + + it('should return false for a personal email', () => { + expect(isWorkEmail('user@gmail.com')).toBe(false); + }); + + it('should return false for an empty email string', () => { + expect(isWorkEmail('')).toBe(false); + }); + + it('should return false for an email with undefined domain', () => { + // Assuming getDomainNameByEmail(email) returns undefined if no domain. + expect(isWorkEmail('user@')).toBe(false); + }); + + it('should return false for an invalid email format', () => { + expect(isWorkEmail('invalid-email')).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/utils/get-domain-name-by-email.ts b/packages/twenty-server/src/utils/get-domain-name-by-email.ts new file mode 100644 index 000000000..ed1b8a7c2 --- /dev/null +++ b/packages/twenty-server/src/utils/get-domain-name-by-email.ts @@ -0,0 +1,19 @@ +export const getDomainNameByEmail = (email: string) => { + if (!email) { + throw new Error('Email is required'); + } + + const fields = email.split('@'); + + if (fields.length !== 2) { + throw new Error('Invalid email format'); + } + + const domain = fields[1]; + + if (!domain) { + throw new Error('Invalid email format'); + } + + return domain; +}; diff --git a/packages/twenty-server/src/utils/is-work-email.ts b/packages/twenty-server/src/utils/is-work-email.ts index c8ebcf358..f5f8c3b75 100644 --- a/packages/twenty-server/src/utils/is-work-email.ts +++ b/packages/twenty-server/src/utils/is-work-email.ts @@ -1,21 +1,10 @@ import { emailProvidersSet } from 'src/utils/email-providers'; +import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; export const isWorkEmail = (email: string) => { - if (!email) { + try { + return !emailProvidersSet.has(getDomainNameByEmail(email)); + } catch (err) { return false; } - - const fields = email.split('@'); - - if (fields.length !== 2) { - return false; - } - - const domain = fields[1]; - - if (!domain) { - return false; - } - - return !emailProvidersSet.has(domain); }; diff --git a/packages/twenty-server/src/utils/workspace-url.util.ts b/packages/twenty-server/src/utils/workspace-url.util.ts new file mode 100644 index 000000000..d93f95a07 --- /dev/null +++ b/packages/twenty-server/src/utils/workspace-url.util.ts @@ -0,0 +1,33 @@ +import { isDefined } from 'src/utils/is-defined'; + +export const buildWorkspaceURL = ( + baseUrl: string, + subdomain: string | null, + { + withPathname, + withSearchParams, + }: { + withPathname?: string; + withSearchParams?: Record; + } = {}, +) => { + const url = new URL(baseUrl); + + if (subdomain && subdomain.length > 0) { + url.hostname = subdomain + '.' + url.hostname; + } + + if (withPathname) { + url.pathname = withPathname; + } + + if (withSearchParams) { + Object.entries(withSearchParams).forEach(([key, value]) => { + if (isDefined(value)) { + url.searchParams.set(key, value.toString()); + } + }); + } + + return url; +}; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 25fa6ff29..7f61145c5 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -254,6 +254,7 @@ export { IconWand, IconWorld, IconX, + IconPassword, } from '@tabler/icons-react'; export type { TablerIconsProps } from '@tabler/icons-react'; diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx index 477f6c037..4df9e2800 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx @@ -14,7 +14,7 @@ type MenuItemSelectAvatarProps = { selected: boolean; text: string; className?: string; - onClick?: () => void; + onClick?: (event?: React.MouseEvent) => void; disabled?: boolean; hovered?: boolean; testId?: string; diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index cebd8a6a6..7ba1242d5 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -37,9 +37,12 @@ yarn command:prod cron:calendar:ongoing-stale ['PG_DATABASE_URL', 'postgres://user:pw@localhost:5432/default?connection_limit=1', 'Database connection'], ['PG_SSL_ALLOW_SELF_SIGNED', 'false', 'Allow self signed certificates'], ['REDIS_URL', 'redis://localhost:6379', 'Redis connection url'], - ['FRONT_BASE_URL', 'http://localhost:3001', 'Url to the hosted frontend'], + ['FRONT_DOMAIN', 'localhost', 'Domain of the hosted frontend'], + ['DEFAULT_SUBDOMAIN', 'app', 'The default subdomain name when multiworkspace mode is enabled'], ['SERVER_URL', 'http://localhost:3000', 'Url to the hosted server'], - ['PORT', '3000', 'Port'], + ['FRONT_PROTOCOL', 'http', 'protocol of the frontend server. Could be `http` or `https`'], + ['FRONT_PORT', '3001', 'Port of the frontend server.'], + ['PORT', '3000', 'Port of the backend server'], ['CACHE_STORAGE_TYPE', 'redis', 'Cache type (memory, redis...)'], ['CACHE_STORAGE_TTL', '3600 * 24 * 7', 'Cache TTL in seconds'] ]}> @@ -78,7 +81,7 @@ yarn command:prod cron:calendar:ongoing-stale ['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'], ['AUTH_MICROSOFT_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft/redirect', 'Microsoft auth callback'], ['AUTH_MICROSOFT_APIS_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft-apis/get-access-token', 'Microsoft APIs auth callback'], - ['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'], + ['IS_MULTIWORKSPACE_ENABLED', 'false', 'Allows the use of multiple workspaces. Requires a web server that can manage wildcards for subdomains.'], ['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'], ]}> diff --git a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx index 5c8c9e740..fc3d785c4 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx @@ -5,7 +5,7 @@ image: /images/user-guide/notes/notes_header.png --- -## General guidelines +## General guidelines Always make sure to back up your database before starting the upgrade process. @@ -16,10 +16,24 @@ If you used Docker Compose, follow these steps: 2. Upgrade the version by changing the `TAG` value in the .env file near your docker-compose. 3. Bring Twenty back online with `docker-compose up -d` - + ## Version-specific upgrade steps +### v0.33.0 to v0.34.0 + +Upgrade your Twenty instance to use v0.34.0 image + +``` +yarn database:migrate:prod +yarn command:prod upgrade-0.34 +``` + +The `yarn database:migrate:prod` command will apply the migrations to the database structure (core and metadata schemas) +The `yarn command:prod upgrade-0.34` takes care of the data migration of all workspaces. + + + ### v0.32.0 to v0.33.0 Upgrade your Twenty instance to use v0.33.0 image @@ -39,7 +53,7 @@ The `yarn command:prod upgrade-0.33` takes care of the data migration of all wor Upgrade your Twenty instance to use v0.32.0 image -**Schema and data migration** +**Schema and data migration** ``` yarn database:migrate:prod yarn command:prod upgrade-0.32 @@ -73,7 +87,7 @@ If you are using connected account to synchronize your Google emails and calenda Upgrade your Twenty instance to use v0.31.0 image -**Schema and data migration**: +**Schema and data migration**: ``` yarn database:migrate:prod yarn command:prod upgrade-0.31 @@ -125,11 +139,11 @@ Upgrade your Twenty instance to use v0.23.0 image Run the following commands: ``` -yarn database:migrate:prod +yarn database:migrate:prod yarn command:prod upgrade-0.23 ``` -The `yarn database:migrate:prod` command will apply the migrations to the Database. +The `yarn database:migrate:prod` command will apply the migrations to the Database. The `yarn command:prod upgrade-0.23` takes care of the data migration, including transferring activities to tasks/notes. ### v0.21.0 to v0.22.0 @@ -139,13 +153,13 @@ Upgrade your Twenty instance to use v0.22.0 image Run the following commands: ``` -yarn database:migrate:prod -yarn command:prod workspace:sync-metadata -f +yarn database:migrate:prod +yarn command:prod workspace:sync-metadata -f yarn command:prod upgrade-0.22 ``` -The `yarn database:migrate:prod` command will apply the migrations to the Database. -The `yarn command:prod workspace:sync-metadata -f` command will sync the definition of standard objects to the metadata tables and apply to required migrations to existing workspaces. +The `yarn database:migrate:prod` command will apply the migrations to the Database. +The `yarn command:prod workspace:sync-metadata -f` command will sync the definition of standard objects to the metadata tables and apply to required migrations to existing workspaces. The `yarn command:prod upgrade-0.22` command will apply specific data transformations to adapt to the new object defaultRequestInstrumentationOptions.