diff --git a/packages/twenty-e2e-testing/drivers/env_variables.ts b/packages/twenty-e2e-testing/drivers/env_variables.ts index 7fd2860cb68b..768b1872c9ef 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 e7acebb765eb..b049d399f545 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -81,7 +81,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 +106,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; @@ -165,9 +174,12 @@ export type ClientConfig = { 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']; - isMultiWorkspaceEnabled: Scalars['Boolean']['output']; support: Support; }; @@ -316,7 +328,7 @@ export type EditSsoOutput = { issuer: Scalars['String']['output']; name: Scalars['String']['output']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; export type EmailPasswordResetLink = { @@ -408,17 +420,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; }; @@ -435,22 +443,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']; }; @@ -469,7 +461,7 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']['input']; }; -export enum IdpType { +export enum IdentityProviderType { Oidc = 'OIDC', Saml = 'SAML' } @@ -568,9 +560,7 @@ export type Mutation = { enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; - findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: GenerateJwt; generateTransientToken: TransientToken; getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; @@ -581,6 +571,7 @@ export type Mutation = { sendInvitations: SendInvitationsOutput; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; + switchWorkspace: PublicWorkspaceDataOutput; syncRemoteTable: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable; track: Analytics; @@ -747,22 +738,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; }; @@ -807,6 +788,11 @@ export type MutationSignUpArgs = { }; +export type MutationSwitchWorkspaceArgs = { + workspaceId: Scalars['String']['input']; +}; + + export type MutationSyncRemoteTableArgs = { input: RemoteTableInput; }; @@ -959,6 +945,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']; @@ -967,13 +962,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; @@ -984,6 +980,7 @@ export type Query = { getAvailablePackages: Scalars['JSON']['output']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; + getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; @@ -1027,6 +1024,11 @@ export type QueryFieldsArgs = { }; +export type QueryFindAvailableWorkspacesByEmailArgs = { + email: Scalars['String']['input']; +}; + + export type QueryFindDistantTablesWithStatusArgs = { input: FindManyRemoteTablesInput; }; @@ -1209,6 +1211,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', @@ -1300,7 +1320,7 @@ export type SetupSsoOutput = { issuer: Scalars['String']['output']; name: Scalars['String']['output']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; /** Sort Directions */ @@ -1494,8 +1514,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 = { @@ -1533,9 +1557,12 @@ export type UserEdge = { export type UserExists = { __typename?: 'UserExists'; + availableWorkspaces: Array; exists: Scalars['Boolean']['output']; }; +export type UserExistsOutput = UserExists | UserNotExists; + export type UserMappingOptions = { password?: InputMaybe; user?: InputMaybe; @@ -1551,6 +1578,11 @@ export type UserMappingOptionsUser = { user?: Maybe; }; +export type UserNotExists = { + __typename?: 'UserNotExists'; + exists: Scalars['Boolean']['output']; +}; + export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']['output']; @@ -1584,6 +1616,7 @@ export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; allowImpersonation: Scalars['Boolean']['output']; + billingEntitlements?: Maybe>; billingSubscriptions?: Maybe>; createdAt: Scalars['DateTime']['output']; currentBillingSubscription?: Maybe; @@ -1596,14 +1629,24 @@ 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; }; +export type WorkspaceBillingEntitlementsArgs = { + filter?: BillingEntitlementFilter; + sorting?: Array; +}; + + export type WorkspaceBillingSubscriptionsArgs = { filter?: BillingSubscriptionFilter; sorting?: Array; @@ -1675,6 +1718,30 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; +export type BillingEntitlement = { + __typename?: 'billingEntitlement'; + id: Scalars['UUID']['output']; + key: Scalars['String']['output']; + value: Scalars['Boolean']['output']; + workspaceId: Scalars['String']['output']; +}; + +export type BillingEntitlementFilter = { + and?: InputMaybe>; + id?: InputMaybe; + or?: InputMaybe>; +}; + +export type BillingEntitlementSort = { + direction: SortDirection; + field: BillingEntitlementSortFields; + nulls?: InputMaybe; +}; + +export enum BillingEntitlementSortFields { + Id = 'id' +} + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index f3a092a0f930..b091d7f71b72 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; }; @@ -219,7 +236,7 @@ export type EditSsoOutput = { issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; export type EmailPasswordResetLink = { @@ -311,17 +328,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; }; @@ -331,22 +344,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']; }; @@ -365,7 +362,7 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; -export enum IdpType { +export enum IdentityProviderType { Oidc = 'OIDC', Saml = 'SAML' } @@ -433,7 +430,7 @@ export enum MessageChannelVisibility { export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']; - activateWorkspace: Workspace; + activateWorkspace: ActivateWorkspaceOutput; addUserToWorkspace: User; addUserToWorkspaceByInviteToken: User; authorizeApp: AuthorizeApp; @@ -458,9 +455,7 @@ export type Mutation = { enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; - findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: GenerateJwt; generateTransientToken: TransientToken; getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; @@ -471,6 +466,7 @@ export type Mutation = { sendInvitations: SendInvitationsOutput; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; + switchWorkspace: PublicWorkspaceDataOutput; track: Analytics; updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; @@ -594,22 +590,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; }; @@ -654,6 +640,11 @@ export type MutationSignUpArgs = { }; +export type MutationSwitchWorkspaceArgs = { + workspaceId: Scalars['String']; +}; + + export type MutationTrackArgs = { action: Scalars['String']; payload: Scalars['JSON']; @@ -793,6 +784,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']; @@ -801,11 +801,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; @@ -813,6 +814,7 @@ export type Query = { getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; + getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; @@ -843,6 +845,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryFindAvailableWorkspacesByEmailArgs = { + email: Scalars['String']; +}; + + export type QueryFindOneServerlessFunctionArgs = { input: ServerlessFunctionIdInput; }; @@ -964,6 +971,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', @@ -1055,7 +1080,7 @@ export type SetupSsoOutput = { issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; /** Sort Directions */ @@ -1219,8 +1244,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 = { @@ -1258,9 +1287,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']; @@ -1280,6 +1312,11 @@ export type UserMappingOptionsUser = { user?: Maybe; }; +export type UserNotExists = { + __typename?: 'UserNotExists'; + exists: Scalars['Boolean']; +}; + export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']; @@ -1326,9 +1363,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; }; @@ -1730,13 +1771,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']; @@ -1745,13 +1779,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; }>; @@ -1769,7 +1796,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']; @@ -1789,6 +1816,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']; @@ -1802,7 +1836,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']; @@ -1810,7 +1844,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']; @@ -1849,7 +1888,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; }>; @@ -1877,14 +1916,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; @@ -1898,14 +1937,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; }>; @@ -1922,7 +1961,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']; @@ -1999,7 +2038,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; }>; @@ -2011,7 +2050,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']; @@ -2173,6 +2212,10 @@ export const UserQueryFragmentFragmentDoc = gql` allowImpersonation activationStatus isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled + subdomain hasValidEntrepriseKey featureFlags { id @@ -2194,6 +2237,7 @@ export const UserQueryFragmentFragmentDoc = gql` logo displayName domainName + subdomain } } userVars @@ -2570,39 +2614,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) { @@ -2637,55 +2648,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 { @@ -2874,6 +2836,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( @@ -2953,7 +2962,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) { @@ -3169,19 +3247,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 { @@ -3977,10 +4052,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; /** @@ -4044,9 +4125,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/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index f8286c398b7f..23ee77505116 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/switchWorkspace.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts index 077a0cd6990f..e4b604ded31c 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts @@ -4,6 +4,7 @@ export const SWITCH_WORKSPACE = gql` mutation SwitchWorkspace($workspaceId: String!) { switchWorkspace(workspaceId: $workspaceId) { id + subdomain authProviders { sso { id 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 cd3e25f8f1d7..0a3c9b0aca4e 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts @@ -9,6 +9,7 @@ export const CHECK_USER_EXISTS = gql` availableWorkspaces { id displayName + subdomain logo sso { type diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts index a3a7385471ba..b91ccf5fdcec 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts @@ -6,6 +6,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql` id logo displayName + subdomain authProviders { sso { id diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 33b15e83d6d3..af1d3e4645db 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(); @@ -200,6 +209,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 @@ -224,9 +242,12 @@ export const useAuth = () => { setTokenPair, setCurrentUser, setCurrentWorkspace, + isTwentyWorkspaceSubdomain, setCurrentWorkspaceMembers, setCurrentWorkspaceMember, setDateTimeFormat, + setLastAuthenticateWorkspaceState, + urlManager.frontDomain, setWorkspaces, ], ); @@ -298,23 +319,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: { @@ -323,7 +355,7 @@ export const useAuth = () => { }) => { window.location.href = buildRedirectUrl('/auth/google', params); }, - [], + [buildRedirectUrl], ); const handleMicrosoftLogin = useCallback( @@ -333,7 +365,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/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index feb2d5f2714e..a441b1ee19a2 100644 --- 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 @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; import { - HorizontalSeparator, IconGoogle, IconMicrosoft, Loader, MainButton, + HorizontalSeparator, } from 'twenty-ui'; import { useTheme } from '@emotion/react'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; @@ -13,12 +13,13 @@ 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 { 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 { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; @@ -29,6 +30,7 @@ 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)` @@ -45,13 +47,13 @@ const StyledForm = styled.form` export const SignInUpGlobalScopeForm = () => { const theme = useTheme(); - const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); 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); @@ -62,6 +64,7 @@ export const SignInUpGlobalScopeForm = () => { const [showErrors, setShowErrors] = useState(false); const { form } = useSignInUpForm(); + const { pathname } = useLocation(); const { submitCredentials } = useSignInUp(form); @@ -89,31 +92,21 @@ export const SignInUpGlobalScopeForm = () => { }, onCompleted: (data) => { requestFreshCaptchaToken(); - if ( - data?.checkUserExists.exists && - data.checkUserExists.__typename === 'UserExists' - ) { + if (data.checkUserExists.__typename === 'UserExists') { if ( isDefined(data?.checkUserExists.availableWorkspaces) && data.checkUserExists.availableWorkspaces.length >= 1 ) { - // return redirectToWorkspace( - // data?.checkUserExists.availableWorkspaces[0].subdomain, - // { - // email: form.getValues('email'), - // }, - // ); + return redirectToWorkspace( + data?.checkUserExists.availableWorkspaces[0].subdomain, + pathname, + { + email: form.getValues('email'), + }, + ); } } - if ( - isDefined(data?.checkUserExists.exists) && - data.checkUserExists.__typename === 'UserNotExists' - ) { - if (!isMultiWorkspaceEnabled) { - return enqueueSnackBar('User not found', { - variant: SnackBarVariant.Error, - }); - } + if (data.checkUserExists.__typename === 'UserNotExists') { setSignInUpMode(SignInUpMode.SignUp); setSignInUpStep(SignInUpStep.Password); } 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 index c4a97d1575b4..c238e05d05c0 100644 --- 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 @@ -6,10 +6,7 @@ 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, - validationSchema, -} from '@/auth/sign-in-up/hooks/useSignInUpForm'; +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'; @@ -27,7 +24,7 @@ const StyledForm = styled.form` `; export const SignInUpWithCredentials = () => { - const { form } = useSignInUpForm(); + const { form, validationSchema } = useSignInUpForm(); const signInUpStep = useRecoilValue(signInUpStepState); const [showErrors, setShowErrors] = useState(false); 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 index e2e454ef52b1..b53c593d51fa 100644 --- 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 @@ -11,6 +11,8 @@ import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoo 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)}; @@ -23,7 +25,9 @@ export const SignInUpWorkspaceScopeForm = () => { const { form } = useSignInUpForm(); const { handleResetPassword } = useHandleResetPassword(); - const { signInUpStep, continueWithEmail } = useSignInUp(form); + const { signInUpStep, continueWithEmail, continueWithCredentials } = + useSignInUp(form); + const location = useLocation(); const checkAuthProviders = useCallback(() => { if ( @@ -32,9 +36,23 @@ export const SignInUpWorkspaceScopeForm = () => { !authProviders.microsoft && !authProviders.sso ) { - continueWithEmail(); + return continueWithEmail(); } - }, [authProviders, continueWithEmail, signInUpStep]); + 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(); 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 7bdf969a276d..4e5112db5811 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 @@ -8,22 +8,36 @@ import { useLocation } from 'react-router-dom'; import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; 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 isSignInPrefilled = useRecoilValue(isSignInPrefilledState); + const signInUpStep = useRecoilValue(signInUpStepState); + + const validationSchema = makeValidationSchema(signInUpStep); // Create schema based on the current step const form = useForm
({ mode: 'onSubmit', defaultValues: { @@ -40,10 +54,12 @@ export const useSignInUpForm = () => { const email = searchParams.get('email'); if (isDefined(email)) { form.setValue('email', email); - } else if (isSignInPrefilled === true) { + } else if (isSignInPrefilled) { form.setValue('email', 'tim@apple.dev'); + } + if (isSignInPrefilled && form.getValues('email') === 'tim@apple.dev') { form.setValue('password', 'Applecar2025'); } }, [form, isSignInPrefilled, location.search]); - return { form: form }; + return { form: form, validationSchema }; }; diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 91cd7c0811e2..c06276c126ed 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -18,6 +18,7 @@ export type CurrentWorkspace = Pick< | '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 000000000000..53f55ef4b462 --- /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/tokenPairState.ts b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts index f6262b5aef32..718e67ad6277 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/workspaces.ts b/packages/twenty-front/src/modules/auth/states/workspaces.ts index d211351b08d2..da0d270bb39d 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/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 27702788d5b5..63956a8f2493 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -13,15 +13,19 @@ 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 setIsDebugMode = useSetRecoilState(isDebugModeState); const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); + const setUrlManager = useSetRecoilState(urlManagerState); const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState); const setIsMultiWorkspaceEnabled = useSetRecoilState( isMultiWorkspaceEnabledState, ); + const setIsSSOEnabledState = useSetRecoilState(isSSOEnabledState); const setBilling = useSetRecoilState(billingState); const setSupportChat = useSetRecoilState(supportChatState); @@ -88,6 +92,11 @@ 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, setIsDebugMode, @@ -103,6 +112,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 9b95ef690ecc..2c6da152eaf4 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 @@ -10,6 +10,9 @@ export const GET_CLIENT_CONFIG = gql` } signInPrefilled isMultiWorkspaceEnabled + isSSOEnabled + defaultSubdomain + frontDomain debugMode analyticsEnabled support { 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 000000000000..3242678c3d3f --- /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/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index da65163e318c..5d33445801d9 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,6 +15,7 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({ id: '1', featureFlags: [], allowImpersonation: false, + subdomain: 'test', activationStatus: WorkspaceActivationStatus.Active, hasValidEntrepriseKey: false, metadataVersion: 1, 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 877253bff344..bcf0abef9732 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx @@ -34,7 +34,7 @@ export const SettingsSSOIdentitiesProvidersListCard = () => { ); const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ - skip: currentWorkspace?.hasValidEntrepriseKey === true, + skip: currentWorkspace?.hasValidEntrepriseKey === false, onCompleted: (data) => { setSSOIdentitiesProviders( data?.listSSOIdentityProvidersByWorkspaceId ?? [], 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 8b44fa0337b8..dcdd5056f605 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -61,7 +61,7 @@ export const SettingsSecurityOptionsList = () => { if ( currentWorkspace[key] === true && - allAuthProvidersEnabled.filter(Boolean).length <= 1 + allAuthProvidersEnabled.filter((isAuthEnable) => isAuthEnable).length <= 1 ) { return enqueueSnackBar( 'At least one authentication method must be enabled', diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 13abb1d82d58..af0b7c6abd68 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 3a03e32f7def..d329e4548c36 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 355519a3aa72..4d7bf0fa4727 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 @@ -62,15 +62,16 @@ export const NavigationDrawerHeader = ({ const isMobile = useIsMobile(); const workspaces = useRecoilValue(workspacesState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); - const isMultiWorkspace = - workspaces !== null && workspaces.length > 1 && isMultiWorkspaceEnabled; + 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 b8ee5fb09db2..b9e0646001a2 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 @@ -5,11 +5,16 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; 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 { useUrlManager } from '@/url-manager/hooks/useUrlManager'; export const useWorkspaceSwitching = () => { + const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); const currentWorkspace = useRecoilValue(currentWorkspaceState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const { enqueueSnackBar } = useSnackBar(); + const { redirectToHome, redirectToWorkspace } = useUrlManager(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; @@ -22,6 +27,18 @@ export const useWorkspaceSwitching = () => { }, ); } + + const { data, errors } = await switchWorkspaceMutation({ + variables: { + workspaceId, + }, + }); + + if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) { + return redirectToHome(); + } + + 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 000000000000..8fabcedbd1b1 --- /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 000000000000..460a0b0ce3bd --- /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 da8248b15414..1d5e0201db29 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -35,6 +35,7 @@ export const USER_QUERY_FRAGMENT = gql` isGoogleAuthEnabled isMicrosoftAuthEnabled isPasswordAuthEnabled + subdomain hasValidEntrepriseKey featureFlags { id @@ -56,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 index 860434297bb9..fe8d4f508a3e 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -1,4 +1,4 @@ -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState, useRecoilState } from 'recoil'; import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; @@ -6,6 +6,9 @@ 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); @@ -15,17 +18,64 @@ export const WorkspaceProviderEffect = () => { 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: (err) => { + onError: (error) => { // eslint-disable-next-line no-console - console.error(err); + 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)) { 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 b116a298a625..41356c92244c 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 7daf397162b4..a8a97eecc3b6 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts @@ -5,6 +5,7 @@ export const UPDATE_WORKSPACE = gql` updateWorkspace(data: $input) { id domainName + subdomain displayName logo allowImpersonation diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index 4be8e04446f6..dd47cb8962a3 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/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 82d7ec974e5f..bc3346fa2226 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -4,10 +4,7 @@ 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 { - isTwentyHomePage, - isTwentyWorkspaceSubdomain, -} from '~/utils/workspace-url.helper'; + import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { AnimatedEaseIn } from 'twenty-ui'; @@ -17,33 +14,57 @@ import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInU 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'; export const SignInUp = () => { const { form } = useSignInUpForm(); const { signInUpStep } = useSignInUp(form); + const { isTwentyHomePage, isTwentyWorkspaceSubdomain } = useUrlManager(); const workspacePublicData = useRecoilValue(workspacePublicDataState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const signInUpForm = useMemo(() => { + if (isTwentyHomePage && isMultiWorkspaceEnabled) { + return ; + } + + if ( + (!isMultiWorkspaceEnabled || + (isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) && + signInUpStep === SignInUpStep.SSOIdentityProviderSelection + ) { + return ; + } + + if ( + isDefined(workspacePublicData) && + (!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain) + ) { + return ; + } + + return ; + }, [ + isTwentyHomePage, + isMultiWorkspaceEnabled, + isTwentyWorkspaceSubdomain, + signInUpStep, + workspacePublicData, + ]); + return ( <> - + {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`} - {isTwentyHomePage || isMultiWorkspaceEnabled ? ( - - ) : (!isMultiWorkspaceEnabled || - (isMultiWorkspaceEnabled && isTwentyWorkspaceSubdomain)) && - signInUpStep === SignInUpStep.SSOIdentityProviderSelection ? ( - - ) : workspacePublicData && - (!isMultiWorkspaceEnabled || isTwentyWorkspaceSubdomain) ? ( - - ) : null} - + {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 a0c861683f9b..4f9b144f3fe4 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 86d85e308787..18e7eaf60f00 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 bd9ffabd0f64..e5ec912aab47 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -10,6 +10,8 @@ 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%; @@ -27,7 +29,9 @@ const StyledSSOSection = styled(Section)` `; export const SettingsSecurity = () => { - const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED'); + const isSSOEnabled = useRecoilValue(isSSOEnabledState); + const isSSOSectionDisplay = + useIsFeatureEnabled('IS_SSO_ENABLED') && isSSOEnabled; return ( { > - {isSSOEnabled && ( + {isSSOSectionDisplay && ( ; + +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/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index c9a258f2ed0c..4fcc69adb223 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -51,6 +51,7 @@ export const graphqlMocks = { id: 'id', logo: 'logo', displayName: 'displayName', + subdomain: 'subdomain', authProviders: { google: true, microsoft: false, diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 1e1f56392bd1..f3235dfc4389 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -4,6 +4,9 @@ import { CaptchaDriverType } from '~/generated/graphql'; export const mockedClientConfig: ClientConfig = { signInPrefilled: true, isMultiWorkspaceEnabled: false, + isSSOEnabled: false, + frontDomain: 'localhost', + defaultSubdomain: 'app', chromeExtensionId: 'MOCKED_EXTENSION_ID', debugMode: false, analyticsEnabled: true, diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 4b8024fc0168..c3839427b047 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', diff --git a/packages/twenty-front/src/utils/cookie-storage.ts b/packages/twenty-front/src/utils/cookie-storage.ts index 1261a209dc63..183d0da52f1f 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 7f20e24c5062..7f6f586574c6 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-front/src/utils/workspace-url.helper.ts b/packages/twenty-front/src/utils/workspace-url.helper.ts deleted file mode 100644 index 15a612310f78..000000000000 --- a/packages/twenty-front/src/utils/workspace-url.helper.ts +++ /dev/null @@ -1,64 +0,0 @@ -// THIS FILE WILL BE REMOVED IN THIS PR: -// https://github.com/twentyhq/twenty/pull/8680 -import { isDefined } from '~/utils/isDefined'; - -export const twentyHostname = window.location.hostname; - -export const twentyHomePageHostname = `app.${twentyHostname}`; - -export const twentyHomePageUrl = `${window.location.protocol}//${twentyHomePageHostname}`; - -export const isTwentyHosting = - window.location.hostname.endsWith(twentyHostname); - -export const isTwentyHomePage = - window.location.hostname === twentyHomePageHostname; - -export const isTwentyWorkspaceSubdomain = isTwentyHosting && !isTwentyHomePage; - -export const getWorkspaceMainDomain = () => { - return isTwentyHosting ? twentyHostname : window.location.hostname; -}; - -export const getWorkspaceSubdomain = () => { - return isTwentyWorkspaceSubdomain - ? window.location.hostname.replace(`.${twentyHostname}`, '') - : null; -}; - -export const buildWorkspaceUrl = ( - withSubdomain?: string, - searchParams?: Record, -) => { - const url = new URL(window.location.href); - - if ( - isTwentyHosting && - isDefined(withSubdomain) && - withSubdomain.length !== 0 - ) { - url.hostname = `${withSubdomain}.${twentyHostname}`; - } - - if (isDefined(searchParams)) { - Object.entries(searchParams).forEach(([key, value]) => - url.searchParams.set(key, value), - ); - } - return url.toString(); -}; - -export const redirectToHome = () => { - const url = new URL(window.location.href); - if (url.hostname !== twentyHomePageHostname) { - url.hostname = twentyHomePageHostname; - window.location.href = url.toString(); - } -}; - -export const redirectToWorkspace = ( - subdomain: string, - searchParams?: Record, -) => { - window.location.href = buildWorkspaceUrl(subdomain, searchParams); -}; 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 000000000000..d7b6d386fe56 --- /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 000000000000..4817eb5d1a11 --- /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 000000000000..e6f1af41a244 --- /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 c7e547a8e871..d3dceb8a883e 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 000000000000..e4798ce2b835 --- /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/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 5f0d63966b1c..7c1658fe9cd9 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 38b5ec398c34..7dc9a0b0809b 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 c53f82783889..cad1df914039 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 e3387c5c6100..6c1a1263a153 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 877cb8c049c2..398ce7638565 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 40a25062be3f..78fd63ea312a 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 @@ -32,12 +32,14 @@ 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 { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; -import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; 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'; @@ -64,6 +66,7 @@ export class AuthResolver { private switchWorkspaceService: SwitchWorkspaceService, private transientTokenService: TransientTokenService, private oauthService: OAuthService, + private domainManagerService: DomainManagerService, ) {} @UseGuards(CaptchaGuard) @@ -94,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, ); @@ -105,9 +120,14 @@ 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', @@ -159,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) 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 13d6d83dc65a..203451a0c3e5 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 fa314d47247a..df89aa55bedb 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'; @@ -20,7 +22,10 @@ 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) @@ -28,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() @@ -41,36 +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, - isAuthEnabled: workspaceValidator.isAuthEnabled( - 'google', - new AuthException( - 'Google auth is not enabled for this workspace', - AuthExceptionCode.OAUTH_ACCESS_DENIED, + 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 d0aec9e129bb..c1c2c65d42e9 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 b4e7f7f94d22..ecdecbd59928 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 @@ -19,7 +19,9 @@ 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) @@ -27,6 +29,8 @@ export class MicrosoftAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly domainManagerService: DomainManagerService, + private readonly environmentService: EnvironmentService, ) {} @Get() @@ -42,36 +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, - isAuthEnabled: workspaceValidator.isAuthEnabled( - 'microsoft', - new AuthException( - 'Microsoft auth is not enabled for this workspace', - AuthExceptionCode.OAUTH_ACCESS_DENIED, + 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 36b26cbfee25..eeb0a5707167 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; } } @@ -136,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( @@ -164,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 11dfd40b6d4d..000000000000 --- 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 9fcfbb0cf91d..000000000000 --- 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 index d2e318b428cd..01c99f8afa28 100644 --- 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 @@ -34,6 +34,9 @@ export class AvailableWorkspaceOutput { @Field(() => String, { nullable: true }) displayName?: string; + @Field(() => String) + subdomain: string; + @Field(() => String, { nullable: true }) logo?: string; 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 008e7d11032f..24ac236a2d9e 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 f4675888b2e8..8f2f6b95c02f 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 dd67b676832e..049f147898da 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 2dba5f95a7c4..d527add80f81 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,18 +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 { 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 { 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 { 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; @@ -26,7 +42,9 @@ describe('AuthService', () => { }, { provide: getRepositoryToken(User, 'core'), - useValue: {}, + useValue: { + findOne: UserFindOneMock, + }, }, { provide: getRepositoryToken(AppToken, 'core'), @@ -37,11 +55,11 @@ describe('AuthService', () => { useValue: {}, }, { - provide: UserService, + provide: EnvironmentService, useValue: {}, }, { - provide: EnvironmentService, + provide: DomainManagerService, useValue: {}, }, { @@ -56,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 fae05a3a32bd..ea445d23314d 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 @@ -41,18 +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 { buildWorkspaceURL } from 'src/utils/workspace-url.util'; -import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; -import { userValidator } from 'src/engine/core-modules/user/user.validate'; +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, @@ -64,11 +70,54 @@ export class AuthService { private readonly appTokenRepository: Repository, ) {} - async challenge(challengeInput: ChallengeInput) { + 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) { @@ -78,6 +127,8 @@ export class AuthService { ); } + await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); + if (!user.passwordHash) { throw new AuthException( 'Incorrect login method', @@ -105,6 +156,7 @@ export class AuthService { password, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, firstName, lastName, picture, @@ -119,6 +171,7 @@ export class AuthService { workspacePersonalInviteToken?: string; picture?: string | null; fromSSO: boolean; + targetWorkspaceSubdomain?: string; isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>; }) { return await this.signInUpService.signInUp({ @@ -128,6 +181,7 @@ export class AuthService { lastName, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, picture, fromSSO, isAuthEnabled, @@ -344,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, { @@ -384,15 +438,12 @@ export class AuthService { return workspace; } - computeRedirectURI(loginToken: string) { - const url = buildWorkspaceURL( - this.environmentService.get('FRONT_BASE_URL'), - null, - { - withPathname: '/verify', - withSearchParams: { loginToken }, - }, - ); + async computeRedirectURI(loginToken: string, subdomain?: string) { + const url = this.domainManagerService.buildWorkspaceURL({ + subdomain, + pathname: '/verify', + searchParams: { loginToken }, + }); return url.toString(); } @@ -417,6 +468,7 @@ export class AuthService { 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) => 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 e0b81f69d418..c056bbfaf874 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 f07c45d7d698..0f7a14da7e74 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 370166a05325..368c5343c6aa 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 @@ -17,6 +17,7 @@ import { 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'); @@ -28,10 +29,18 @@ const workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock = 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: [ @@ -42,7 +51,11 @@ describe('SignInUpService', () => { }, { provide: getRepositoryToken(Workspace, 'core'), - useValue: {}, + useValue: { + count: WorkspaceCountMock, + create: WorkspaceCreateMock, + save: WorkspaceSaveMock, + }, }, { provide: getRepositoryToken(User, 'core'), @@ -56,16 +69,28 @@ describe('SignInUpService', () => { provide: getRepositoryToken(AppToken, 'core'), useValue: {}, }, + { + provide: WorkspaceInvitationService, + useValue: {}, + }, + { + provide: WorkspaceService, + useValue: { + generateSubdomain: jest.fn().mockReturnValue('tartanpion'), + }, + }, { provide: UserWorkspaceService, useValue: { addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock, + create: jest.fn(), }, }, { provide: OnboardingService, useValue: { setOnboardingConnectAccountPending: jest.fn(), + setOnboardingInviteTeamPending: jest.fn(), setOnboardingCreateProfilePending: jest.fn(), }, }, @@ -75,7 +100,9 @@ describe('SignInUpService', () => { }, { provide: EnvironmentService, - useValue: {}, + useValue: { + get: EnvironmentServiceGetMock, + }, }, { provide: WorkspaceInvitationService, @@ -100,7 +127,7 @@ describe('SignInUpService', () => { it('signInUp - sso - new user', async () => { const email = 'test@test.com'; - UserFindOneMock.mockReturnValueOnce(null); + UserFindOneMock.mockReturnValueOnce(false); workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( undefined, ); @@ -112,6 +139,7 @@ describe('SignInUpService', () => { await service.signInUp({ email: 'test@test.com', fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', }); expect(spy).toHaveBeenCalledWith( @@ -141,11 +169,12 @@ describe('SignInUpService', () => { const result = await service.signInUp({ email, fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', }); expect(result).toEqual(existingUser); }); - it.skip('signInUp - sso - new user - existing invitation', async () => { + it('signInUp - sso - new user - existing invitation', async () => { const email = 'newuser@test.com'; const workspaceId = 'workspace-id'; @@ -175,6 +204,7 @@ describe('SignInUpService', () => { await service.signInUp({ email, fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', }); expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( @@ -194,7 +224,7 @@ describe('SignInUpService', () => { workspaceInvitationInvalidateWorkspaceInvitationMock, ).toHaveBeenCalledWith(workspaceId, email); }); - it.skip('signInUp - sso - existing user - existing invitation', async () => { + it('signInUp - sso - existing user - existing invitation', async () => { const email = 'existinguser@test.com'; const workspaceId = 'workspace-id'; const existingUser = { @@ -227,6 +257,7 @@ describe('SignInUpService', () => { const result = await service.signInUp({ email, fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', }); expect(result).toEqual(existingUser); @@ -266,6 +297,7 @@ describe('SignInUpService', () => { email, fromSSO: true, workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', }); expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( @@ -316,16 +348,16 @@ describe('SignInUpService', () => { email, fromSSO: true, workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', }); expect( workspaceInvitationInvalidateWorkspaceInvitationMock, ).toHaveBeenCalledWith(workspaceId, email); }); - it.skip('signInUp - credentials - existing user - invitation', async () => { + it('signInUp - credentials - existing user', async () => { const email = 'existinguser@test.com'; const password = 'validPassword123'; - const workspaceId = 'workspace-id'; const existingUser = { id: 'user-id', email, @@ -334,22 +366,8 @@ describe('SignInUpService', () => { }; UserFindOneMock.mockReturnValueOnce(existingUser); - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( - { - value: 'personal-token-value', - }, - ); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace: { - id: workspaceId, - activationStatus: WorkspaceActivationStatus.ACTIVE, - }, - }); - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); + EnvironmentServiceGetMock.mockReturnValueOnce(false); (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); @@ -357,50 +375,39 @@ describe('SignInUpService', () => { email, password, fromSSO: false, + targetWorkspaceSubdomain: 'tartanpion', }); expect( workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); + ).not.toHaveBeenCalled(); }); - it.skip('signInUp - credentials - new user - invitation', async () => { + it('signInUp - credentials - new user', async () => { const email = 'newuser@test.com'; const password = 'validPassword123'; - const workspaceId = 'workspace-id'; UserFindOneMock.mockReturnValueOnce(null); - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( - { - value: 'personal-token-value', - }, - ); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace: { - id: workspaceId, - activationStatus: WorkspaceActivationStatus.ACTIVE, - }, - }); - - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); 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( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); + expect(WorkspaceSaveMock).toHaveBeenCalledTimes(1); + expect(WorkspaceCreateMock).toHaveBeenCalledTimes(1); }); it('signInUp - credentials - new user - personal invitation token', async () => { const email = 'newuser@test.com'; @@ -429,6 +436,7 @@ describe('SignInUpService', () => { password, fromSSO: false, workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', }); expect(UserCreateMock).toHaveBeenCalledTimes(1); 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 171242ee28e2..abbecee03536 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 @@ -28,9 +28,10 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { getImageBufferFromUrl } from 'src/utils/image'; -import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; -import { userValidator } from 'src/engine/core-modules/user/user.validate'; 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'; export type SignInUpServiceInput = { email: string; @@ -41,6 +42,7 @@ export type SignInUpServiceInput = { workspacePersonalInviteToken?: string; picture?: string | null; fromSSO: boolean; + targetWorkspaceSubdomain?: string; isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>; }; @@ -58,6 +60,7 @@ export class SignInUpService { private readonly onboardingService: OnboardingService, private readonly httpService: HttpService, private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, ) {} async signInUp({ @@ -69,6 +72,7 @@ export class SignInUpService { lastName, picture, fromSSO, + targetWorkspaceSubdomain, isAuthEnabled, }: SignInUpServiceInput) { if (!firstName) firstName = ''; @@ -113,13 +117,30 @@ export class SignInUpService { } } - if (workspacePersonalInviteToken || workspaceInviteHash) { + const maybeInvitation = + fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash + ? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail( + { + subdomain: targetWorkspaceSubdomain, + email, + }, + ) + : undefined; + + if ( + workspacePersonalInviteToken || + workspaceInviteHash || + maybeInvitation + ) { const invitationValidation = - await this.workspaceInvitationService.validateInvitation({ - workspacePersonalInviteToken: workspacePersonalInviteToken, - workspaceInviteHash, - email, - }); + workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation + ? await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: + workspacePersonalInviteToken ?? maybeInvitation?.value, + workspaceInviteHash, + email, + }) + : null; if ( invitationValidation?.isValid === true && @@ -282,6 +303,7 @@ export class SignInUpService { } 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 8a07ba9d499c..16ba3e80f1f4 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 @@ -9,7 +9,6 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services 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 { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { SwitchWorkspaceService } from './switch-workspace.service'; @@ -20,7 +19,6 @@ describe('SwitchWorkspaceService', () => { let userService: UserService; let accessTokenService: AccessTokenService; let refreshTokenService: RefreshTokenService; - let workspaceService: WorkspaceService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -52,12 +50,6 @@ describe('SwitchWorkspaceService', () => { saveDefaultWorkspace: jest.fn(), }, }, - { - provide: WorkspaceService, - useValue: { - getAuthProvidersByWorkspaceId: jest.fn(), - }, - }, ], }).compile(); @@ -71,7 +63,6 @@ describe('SwitchWorkspaceService', () => { accessTokenService = module.get(AccessTokenService); refreshTokenService = module.get(RefreshTokenService); userService = module.get(UserService); - workspaceService = module.get(WorkspaceService); }); it('should be defined', () => { @@ -131,14 +122,10 @@ describe('SwitchWorkspaceService', () => { workspaceUsers: [{ userId: 'user-id' }], logo: 'logo', displayName: 'displayName', - }; - - const mockAuthProviders = { - google: true, - magicLink: false, - password: true, - microsoft: false, - sso: [ + isGoogleAuthEnabled: true, + isPasswordAuthEnabled: true, + isMicrosoftAuthEnabled: false, + workspaceSSOIdentityProviders: [ { id: 'sso-id', }, @@ -152,9 +139,6 @@ describe('SwitchWorkspaceService', () => { jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace as any); - jest - .spyOn(workspaceService, 'getAuthProvidersByWorkspaceId') - .mockResolvedValue(mockAuthProviders as any); const result = await service.switchWorkspace( mockUser as User, @@ -165,7 +149,7 @@ describe('SwitchWorkspaceService', () => { id: mockWorkspace.id, logo: expect.any(String), displayName: expect.any(String), - authProviders: mockAuthProviders, + authProviders: expect.any(Object), }); }); @@ -179,14 +163,6 @@ describe('SwitchWorkspaceService', () => { displayName: 'displayName', }; - const mockAuthProviders = { - google: true, - magicLink: false, - password: true, - microsoft: false, - sso: [], - }; - jest .spyOn(userRepository, 'findBy') .mockResolvedValue([mockUser as User]); @@ -194,9 +170,6 @@ describe('SwitchWorkspaceService', () => { .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace as any); jest.spyOn(userRepository, 'save').mockResolvedValue({} as User); - jest - .spyOn(workspaceService, 'getAuthProvidersByWorkspaceId') - .mockResolvedValue(mockAuthProviders); const result = await service.switchWorkspace( mockUser as User, @@ -207,7 +180,7 @@ describe('SwitchWorkspaceService', () => { id: mockWorkspace.id, logo: expect.any(String), displayName: expect.any(String), - authProviders: mockAuthProviders, + authProviders: expect.any(Object), }); }); }); 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 eb8bdae72748..edbef71b31b1 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 @@ -11,9 +11,9 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; 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 { @@ -23,7 +23,6 @@ export class SwitchWorkspaceService { @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, private readonly userService: UserService, - private readonly workspaceService: WorkspaceService, private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, ) {} @@ -61,15 +60,17 @@ export class SwitchWorkspaceService { ); } - await this.userService.saveDefaultWorkspace(user.id, workspace.id); + await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + }); return { id: workspace.id, + subdomain: workspace.subdomain, logo: workspace.logo, displayName: workspace.displayName, - authProviders: await this.workspaceService.getAuthProvidersByWorkspaceId( - workspace.id, - ), + authProviders: getAuthProvidersByWorkspace(workspace), }; } 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 932e4c4e3e37..5e20e1a600f7 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 babcf1540b71..39077fb6d06c 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 c1514c8f9977..d18e0c79d180 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; + const email = profile.email ?? profile.mail ?? profile.nameID; - if (!isEmail(email)) { - return done(new Error('Invalid email')); - } + if (!isEmail(email)) { + return done(new Error('Invalid email')); + } - const result: { - user: Record; - identityProviderId?: string; - } = { user: { 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); + if ( + 'RelayState' in request.body && + typeof request.body.RelayState === 'string' + ) { + const RelayState = JSON.parse(request.body.RelayState); - result.identityProviderId = RelayState.identityProviderId; - } + result.identityProviderId = RelayState.identityProviderId; + } - done(null, result); + done(null, result); + } catch (err) { + done(err); + } }; } 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 92e987d25fa4..941fbef4c912 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 1443ee7a6bad..5497a2c36e82 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 @@ -112,7 +112,18 @@ export class AccessTokenService { }; } - async validateToken(request: Request): Promise { + async validateToken(token: string): Promise { + await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); + + const decoded = await this.jwtWrapperService.decode(token); + + const { user, apiKey, workspace, workspaceMemberId } = + await this.jwtStrategy.validate(decoded as JwtPayload); + + return { user, apiKey, workspace, workspaceMemberId }; + } + + async validateTokenByRequest(request: Request): Promise { const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); if (!token) { @@ -122,13 +133,6 @@ export class AccessTokenService { ); } - await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); - - const decoded = await this.jwtWrapperService.decode(token); - - const { user, apiKey, workspace, workspaceMemberId } = - await this.jwtStrategy.validate(decoded as JwtPayload); - - return { user, apiKey, workspace, workspaceMemberId }; + 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 62d21a673d45..16aaffb809de 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 24c96b4e42c5..b45b6c34676c 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 2d8acfff49f8..182b71a404c8 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 dbad2efc76ef..a601108a8f51 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 31d456008301..f85a8930fe64 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 2761415d0bca..7e4a7e2570e7 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 eb1e18d211fb..13ec58e69b5b 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 @@ -61,6 +61,15 @@ export class ClientConfig { @Field(() => 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 cd5c7a96e28d..02d7412d4d8f 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 @@ -18,11 +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'), 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 000000000000..093b96c405a1 --- /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 000000000000..7225c5445be2 --- /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 000000000000..bc59918278aa --- /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 13212328adf0..e7974a6fbf84 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() @@ -473,11 +487,11 @@ export class EnvironmentVariables { // SSL @IsString() - @ValidateIf((env) => env.SERVER_URL.startsWith('https')) + @IsOptional() SSL_KEY_PATH: string; @IsString() - @ValidateIf((env) => env.SERVER_URL.startsWith('https')) + @IsOptional() SSL_CERT_PATH: string; } 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 7a2828d0f364..61d75608be19 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 886c263bea3d..7f01265f7d69 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, 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 1f96744dedfe..0754a9939d3a 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'; 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 c192cd11dcfd..fd47972e1011 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 8fa5f815e787..41d06d3c3dba 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'], - }); - - 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'); - } + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + workspace.id, + user.email, + ); - 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/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 58dc0830fda2..1aa3bf5f9e5a 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/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 a8459ddbeb3e..100c77ca6a12 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; @@ -41,6 +46,14 @@ describe('WorkspaceInvitationService', () => { provide: getRepositoryToken(Workspace, 'core'), useClass: Repository, }, + { + provide: DomainManagerService, + useValue: { + buildWorkspaceURL: jest + .fn() + .mockResolvedValue(new URL('http://localhost:3001')), + }, + }, { provide: EnvironmentService, useValue: { @@ -59,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 44aa710fd574..3abd4a386c14 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,7 @@ 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() @@ -43,6 +44,7 @@ export class WorkspaceInvitationService { private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, private readonly onboardingService: OnboardingService, + private readonly domainManagerService: DomainManagerService, ) {} // VALIDATIONS METHODS @@ -133,7 +135,24 @@ export class WorkspaceInvitationService { ); } - // QUERY METHODS + 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') @@ -181,7 +200,6 @@ export class WorkspaceInvitationService { return appTokens.map(castAppTokenToWorkspaceInvitationUtil); } - // MUTATIONS METHODS async createWorkspaceInvitation(email: string, workspace: Workspace) { const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation( workspace.id, @@ -311,16 +329,17 @@ 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); - } - + const link = this.domainManagerService.buildWorkspaceURL({ + subdomain: workspace.subdomain, + pathname: `invite/${workspace?.inviteHash}`, + searchParams: invitation.value.isPersonalInvitation + ? { + inviteToken: invitation.value.appToken.value, + } + : {}, + }); const emailData = { link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, 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 9efac12fcbe4..e1b09cb50cef 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 @@ -8,9 +8,11 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works 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: [ + DomainManagerModule, NestjsQueryTypeOrmModule.forFeature( [AppToken, UserWorkspace, Workspace], 'core', 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 000000000000..6038f2ef020f --- /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 index 12d7987fea55..cc5786a298b7 100644 --- 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 @@ -55,4 +55,7 @@ export class PublicWorkspaceDataOutput { @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 17da2d8d8c54..7c2ef7556f85 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() 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 0c53265e6302..6a3d15c8fae8 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 95f32a42d721..0ae78f95eae5 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, Logger } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import assert from 'assert'; @@ -19,16 +18,10 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; 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'; -import { - WorkspaceException, - WorkspaceExceptionCode, -} from 'src/engine/core-modules/workspace/workspace.exception'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +@Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { - private readonly userWorkspaceService: UserWorkspaceService; constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -38,14 +31,10 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly userWorkspaceRepository: Repository, private readonly workspaceManagerService: WorkspaceManagerService, private readonly featureFlagService: FeatureFlagService, - private readonly environmentService: EnvironmentService, 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) { @@ -170,73 +159,4 @@ export class WorkspaceService extends TypeOrmQueryService { ); } } - - async getAuthProvidersByWorkspaceId(workspaceId: string) { - const workspace = await this.workspaceRepository.findOne({ - where: { - id: workspaceId, - }, - relations: ['workspaceSSOIdentityProviders'], - }); - - workspaceValidator.assertIsExist( - workspace, - new WorkspaceException( - 'Workspace not found', - WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, - ), - ); - - 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, - })), - }; - } - - async getWorkspaceByOrigin() { - try { - 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]; - } else { - // TODO AMOREAUX: change that with subdomains - throw new Error('New workspace not implemented in this PR'); - } - - // const subdomain = getWorkspaceSubdomainByOrigin( - // origin, - // this.environmentService.get('FRONT_BASE_URL'), - // ); - // - // if (!subdomain) return; - // - // return this.workspaceRepository.findOneBy({ subdomain }); - } catch (e) { - throw new WorkspaceException( - 'Workspace not found', - WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND, - ); - } - } } 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 000000000000..bd33fc0be47f --- /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 000000000000..bd2751b79067 --- /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 d8a5c8dd1884..e353174e6f63 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 @@ -151,6 +151,10 @@ export class Workspace { @Column({ default: '' }) databaseSchema: string; + @Field() + @Column() + subdomain: string; + @Field() @Column({ default: true }) isGoogleAuthEnabled: boolean; 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 310c00ecb99f..05d58624ac37 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 { 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 { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.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 f353ac09cb6f..cfd1db2c53cf 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 @@ -34,7 +34,12 @@ import { 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'; @@ -44,6 +49,8 @@ import { WorkspaceService } from './services/workspace.service'; 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, @@ -61,13 +68,21 @@ 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) @@ -154,8 +169,9 @@ export class WorkspaceResolver { } @Query(() => PublicWorkspaceDataOutput) - async getPublicWorkspaceDataBySubdomain() { - const workspace = await this.workspaceService.getWorkspaceByOrigin(); + async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) { + const workspace = + await this.domainManagerService.getWorkspaceByOrigin(origin); workspaceValidator.assertIsExist( workspace, @@ -169,9 +185,8 @@ export class WorkspaceResolver { id: workspace.id, logo: workspace.logo, displayName: workspace.displayName, - authProviders: await this.workspaceService.getAuthProvidersByWorkspaceId( - workspace.id, - ), + subdomain: workspace.subdomain, + authProviders: getAuthProvidersByWorkspace(workspace), }; } } 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 000000000000..eda5286c5748 --- /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 cd173f03c55a..fb7edf644ba4 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 7cab7ce1b064..f1eb58a07e21 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(); 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 000000000000..7eb92c9fd213 --- /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 000000000000..9ce57487b432 --- /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 000000000000..ed1b8a7c2f9c --- /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 c8ebcf358844..f5f8c3b75bf8 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-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx index 477f6c037dcb..4df9e280084b 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 4a1dd58d0979..7ba1242d5e2e 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'] ]}> 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 5c8c9e740b69..fc3d785c4eb8 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.