feat(): enable custom domain usage (#9911)

# Content
- Introduce the `workspaceUrls` property. It contains two
sub-properties: `customUrl, subdomainUrl`. These endpoints are used to
access the workspace. Even if the `workspaceUrls` is invalid for
multiple reasons, the `subdomainUrl` remains valid.
- Introduce `ResolveField` workspaceEndpoints to avoid unnecessary URL
computation on the frontend part.
- Add a `forceSubdomainUrl` to avoid custom URL using a query parameter
This commit is contained in:
Antoine Moreaux
2025-02-07 14:34:26 +01:00
committed by GitHub
parent 3cc66fe712
commit 68183b7c85
87 changed files with 645 additions and 373 deletions

View File

@ -81,7 +81,6 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': [ '@typescript-eslint/no-empty-interface': [
'error', 'error',
{ {

View File

@ -18,7 +18,7 @@ module.exports = {
}, },
config: { config: {
namingConvention: { enumValues: 'keep' }, namingConvention: { enumValues: 'keep' },
} },
}, },
}, },
}; };

View File

@ -113,7 +113,7 @@ export type AvailableWorkspaceOutput = {
id: Scalars['String']['output']; id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>; logo?: Maybe<Scalars['String']['output']>;
sso: Array<SsoConnection>; sso: Array<SsoConnection>;
subdomain: Scalars['String']['output']; workspaceUrls: WorkspaceUrls;
}; };
export type Billing = { export type Billing = {
@ -475,19 +475,11 @@ export type EnvironmentVariable = {
}; };
export enum EnvironmentVariablesGroup { export enum EnvironmentVariablesGroup {
Analytics = 'Analytics',
Authentication = 'Authentication', Authentication = 'Authentication',
Billing = 'Billing',
Cache = 'Cache',
Database = 'Database',
Email = 'Email', Email = 'Email',
Frontend = 'Frontend',
Logging = 'Logging', Logging = 'Logging',
Other = 'Other', Other = 'Other',
QueueConfig = 'QueueConfig',
ServerConfig = 'ServerConfig', ServerConfig = 'ServerConfig',
Storage = 'Storage',
Support = 'Support',
Workspace = 'Workspace' Workspace = 'Workspace'
} }
@ -706,7 +698,7 @@ export enum IdentityProviderType {
export type ImpersonateOutput = { export type ImpersonateOutput = {
__typename?: 'ImpersonateOutput'; __typename?: 'ImpersonateOutput';
loginToken: AuthToken; loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId; workspace: WorkspaceUrlsAndId;
}; };
export type Index = { export type Index = {
@ -1360,10 +1352,9 @@ export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput'; __typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders; authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']['output']>; displayName?: Maybe<Scalars['String']['output']>;
hostname?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output']; id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>; logo?: Maybe<Scalars['String']['output']>;
subdomain: Scalars['String']['output']; workspaceUrls: WorkspaceUrls;
}; };
export type PublishServerlessFunctionInput = { export type PublishServerlessFunctionInput = {
@ -1394,7 +1385,7 @@ export type Query = {
getHostnameDetails?: Maybe<CustomHostnameDetails>; getHostnameDetails?: Maybe<CustomHostnameDetails>;
getPostgresCredentials?: Maybe<PostgresCredentials>; getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput; getProductPrices: BillingProductPricesOutput;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
getRoles: Array<Role>; getRoles: Array<Role>;
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']['output']>; getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']['output']>;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
@ -1792,7 +1783,7 @@ export type SetupSsoOutput = {
export type SignUpOutput = { export type SignUpOutput = {
__typename?: 'SignUpOutput'; __typename?: 'SignUpOutput';
loginToken: AuthToken; loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId; workspace: WorkspaceUrlsAndId;
}; };
export enum SubscriptionInterval { export enum SubscriptionInterval {
@ -1994,7 +1985,7 @@ export type User = {
analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>; analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>;
canImpersonate: Scalars['Boolean']['output']; canImpersonate: Scalars['Boolean']['output'];
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
currentWorkspace?: Maybe<Workspace>; currentWorkspace: Workspace;
defaultAvatarUrl?: Maybe<Scalars['String']['output']>; defaultAvatarUrl?: Maybe<Scalars['String']['output']>;
deletedAt?: Maybe<Scalars['DateTime']['output']>; deletedAt?: Maybe<Scalars['DateTime']['output']>;
disabled?: Maybe<Scalars['Boolean']['output']>; disabled?: Maybe<Scalars['Boolean']['output']>;
@ -2126,6 +2117,7 @@ export type Workspace = {
subdomain: Scalars['String']['output']; subdomain: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output']; updatedAt: Scalars['DateTime']['output'];
workspaceMembersCount?: Maybe<Scalars['Float']['output']>; workspaceMembersCount?: Maybe<Scalars['Float']['output']>;
workspaceUrls: WorkspaceUrls;
}; };
export enum WorkspaceActivationStatus { export enum WorkspaceActivationStatus {
@ -2202,10 +2194,16 @@ export type WorkspaceNameAndId = {
id: Scalars['String']['output']; id: Scalars['String']['output'];
}; };
export type WorkspaceSubdomainAndId = { export type WorkspaceUrls = {
__typename?: 'WorkspaceSubdomainAndId'; __typename?: 'workspaceUrls';
customUrl?: Maybe<Scalars['String']['output']>;
subdomainUrl: Scalars['String']['output'];
};
export type WorkspaceUrlsAndId = {
__typename?: 'workspaceUrlsAndId';
id: Scalars['String']['output']; id: Scalars['String']['output'];
subdomain: Scalars['String']['output']; workspaceUrls: WorkspaceUrls;
}; };
export type RemoteServerFieldsFragment = { __typename?: 'RemoteServer', id: string, createdAt: any, foreignDataWrapperId: string, foreignDataWrapperOptions?: any | null, foreignDataWrapperType: string, updatedAt: any, schema?: string | null, label: string, userMappingOptions?: { __typename?: 'UserMappingOptionsUser', user?: string | null } | null }; export type RemoteServerFieldsFragment = { __typename?: 'RemoteServer', id: string, createdAt: any, foreignDataWrapperId: string, foreignDataWrapperOptions?: any | null, foreignDataWrapperType: string, updatedAt: any, schema?: string | null, label: string, userMappingOptions?: { __typename?: 'UserMappingOptionsUser', user?: string | null } | null };

View File

@ -106,7 +106,7 @@ export type AvailableWorkspaceOutput = {
id: Scalars['String']; id: Scalars['String'];
logo?: Maybe<Scalars['String']>; logo?: Maybe<Scalars['String']>;
sso: Array<SsoConnection>; sso: Array<SsoConnection>;
subdomain: Scalars['String']; workspaceUrls: WorkspaceUrls;
}; };
export type Billing = { export type Billing = {
@ -407,19 +407,11 @@ export type EnvironmentVariable = {
}; };
export enum EnvironmentVariablesGroup { export enum EnvironmentVariablesGroup {
Analytics = 'Analytics',
Authentication = 'Authentication', Authentication = 'Authentication',
Billing = 'Billing',
Cache = 'Cache',
Database = 'Database',
Email = 'Email', Email = 'Email',
Frontend = 'Frontend',
Logging = 'Logging', Logging = 'Logging',
Other = 'Other', Other = 'Other',
QueueConfig = 'QueueConfig',
ServerConfig = 'ServerConfig', ServerConfig = 'ServerConfig',
Storage = 'Storage',
Support = 'Support',
Workspace = 'Workspace' Workspace = 'Workspace'
} }
@ -631,7 +623,7 @@ export enum IdentityProviderType {
export type ImpersonateOutput = { export type ImpersonateOutput = {
__typename?: 'ImpersonateOutput'; __typename?: 'ImpersonateOutput';
loginToken: AuthToken; loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId; workspace: WorkspaceUrlsAndId;
}; };
export type Index = { export type Index = {
@ -1227,10 +1219,9 @@ export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput'; __typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders; authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']>; displayName?: Maybe<Scalars['String']>;
hostname?: Maybe<Scalars['String']>;
id: Scalars['String']; id: Scalars['String'];
logo?: Maybe<Scalars['String']>; logo?: Maybe<Scalars['String']>;
subdomain: Scalars['String']; workspaceUrls: WorkspaceUrls;
}; };
export type PublishServerlessFunctionInput = { export type PublishServerlessFunctionInput = {
@ -1258,7 +1249,7 @@ export type Query = {
getHostnameDetails?: Maybe<CustomHostnameDetails>; getHostnameDetails?: Maybe<CustomHostnameDetails>;
getPostgresCredentials?: Maybe<PostgresCredentials>; getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput; getProductPrices: BillingProductPricesOutput;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
getRoles: Array<Role>; getRoles: Array<Role>;
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']>; getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']>;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
@ -1588,7 +1579,7 @@ export type SetupSsoOutput = {
export type SignUpOutput = { export type SignUpOutput = {
__typename?: 'SignUpOutput'; __typename?: 'SignUpOutput';
loginToken: AuthToken; loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId; workspace: WorkspaceUrlsAndId;
}; };
export enum SubscriptionInterval { export enum SubscriptionInterval {
@ -1782,7 +1773,7 @@ export type User = {
analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>; analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>;
canImpersonate: Scalars['Boolean']; canImpersonate: Scalars['Boolean'];
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
currentWorkspace?: Maybe<Workspace>; currentWorkspace: Workspace;
defaultAvatarUrl?: Maybe<Scalars['String']>; defaultAvatarUrl?: Maybe<Scalars['String']>;
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
disabled?: Maybe<Scalars['Boolean']>; disabled?: Maybe<Scalars['Boolean']>;
@ -1904,6 +1895,7 @@ export type Workspace = {
subdomain: Scalars['String']; subdomain: Scalars['String'];
updatedAt: Scalars['DateTime']; updatedAt: Scalars['DateTime'];
workspaceMembersCount?: Maybe<Scalars['Float']>; workspaceMembersCount?: Maybe<Scalars['Float']>;
workspaceUrls: WorkspaceUrls;
}; };
export enum WorkspaceActivationStatus { export enum WorkspaceActivationStatus {
@ -1980,10 +1972,16 @@ export type WorkspaceNameAndId = {
id: Scalars['String']; id: Scalars['String'];
}; };
export type WorkspaceSubdomainAndId = { export type WorkspaceUrls = {
__typename?: 'WorkspaceSubdomainAndId'; __typename?: 'workspaceUrls';
customUrl?: Maybe<Scalars['String']>;
subdomainUrl: Scalars['String'];
};
export type WorkspaceUrlsAndId = {
__typename?: 'workspaceUrlsAndId';
id: Scalars['String']; id: Scalars['String'];
subdomain: Scalars['String']; workspaceUrls: WorkspaceUrls;
}; };
export type TimelineCalendarEventFragmentFragment = { __typename?: 'TimelineCalendarEvent', id: any, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, visibility: CalendarChannelVisibility, participants: Array<{ __typename?: 'TimelineCalendarEventParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }; export type TimelineCalendarEventFragmentFragment = { __typename?: 'TimelineCalendarEvent', id: any, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, visibility: CalendarChannelVisibility, participants: Array<{ __typename?: 'TimelineCalendarEventParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> };
@ -2130,7 +2128,7 @@ export type ImpersonateMutationVariables = Exact<{
}>; }>;
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'ImpersonateOutput', workspace: { __typename?: 'WorkspaceSubdomainAndId', subdomain: string, id: string }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'ImpersonateOutput', workspace: { __typename?: 'workspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
export type RenewTokenMutationVariables = Exact<{ export type RenewTokenMutationVariables = Exact<{
appToken: Scalars['String']; appToken: Scalars['String'];
@ -2156,7 +2154,7 @@ export type SignUpMutationVariables = Exact<{
}>; }>;
export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'WorkspaceSubdomainAndId', id: string, subdomain: string } } }; export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'SignUpOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspace: { __typename?: 'workspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } } };
export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
token: Scalars['String']; token: Scalars['String'];
@ -2172,12 +2170,12 @@ export type CheckUserExistsQueryVariables = Exact<{
}>; }>;
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, hostname?: string | null, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } }; export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: 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 GetPublicWorkspaceDataByDomainQueryVariables = Exact<{ [key: string]: never; }>;
export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, hostname?: string | null, 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 GetPublicWorkspaceDataByDomainQuery = { __typename?: 'Query', getPublicWorkspaceDataByDomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, 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<{ export type ValidatePasswordResetTokenQueryVariables = Exact<{
token: Scalars['String']; token: Scalars['String'];
@ -2291,7 +2289,7 @@ export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key:
export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, 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, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, hostname?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string } | 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, currentWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, hostname?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, hostname?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2308,7 +2306,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; 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, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, hostname?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string } | 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, currentWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, hostname?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, hostname?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{ export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String']; workflowVersionId: Scalars['String'];
@ -2399,7 +2397,7 @@ export type ActivateWorkspaceMutationVariables = Exact<{
}>; }>;
export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'Workspace', id: any, subdomain: string } }; export type ActivateWorkspaceMutation = { __typename?: 'Mutation', activateWorkspace: { __typename?: 'Workspace', id: any } };
export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>;
@ -2430,7 +2428,7 @@ export type GetWorkspaceFromInviteHashQueryVariables = Exact<{
}>; }>;
export type GetWorkspaceFromInviteHashQuery = { __typename?: 'Query', findWorkspaceFromInviteHash: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, subdomain: string } }; export type GetWorkspaceFromInviteHashQuery = { __typename?: 'Query', findWorkspaceFromInviteHash: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } };
export const TimelineCalendarEventParticipantFragmentFragmentDoc = gql` export const TimelineCalendarEventParticipantFragmentFragmentDoc = gql`
fragment TimelineCalendarEventParticipantFragment on TimelineCalendarEventParticipant { fragment TimelineCalendarEventParticipantFragment on TimelineCalendarEventParticipant {
@ -2583,6 +2581,10 @@ export const UserQueryFragmentFragmentDoc = gql`
subdomain subdomain
hasValidEnterpriseKey hasValidEnterpriseKey
hostname hostname
workspaceUrls {
subdomainUrl
customUrl
}
featureFlags { featureFlags {
id id
key key
@ -2607,6 +2609,11 @@ export const UserQueryFragmentFragmentDoc = gql`
logo logo
displayName displayName
subdomain subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
} }
} }
userVars userVars
@ -3168,7 +3175,10 @@ export const ImpersonateDocument = gql`
mutation Impersonate($userId: String!, $workspaceId: String!) { mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) { impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace { workspace {
subdomain workspaceUrls {
subdomainUrl
customUrl
}
id id
} }
loginToken { loginToken {
@ -3287,7 +3297,10 @@ export const SignUpDocument = gql`
} }
workspace { workspace {
id id
subdomain workspaceUrls {
subdomainUrl
customUrl
}
} }
} }
} }
@ -3369,8 +3382,10 @@ export const CheckUserExistsDocument = gql`
availableWorkspaces { availableWorkspaces {
id id
displayName displayName
subdomain workspaceUrls {
hostname subdomainUrl
customUrl
}
logo logo
sso { sso {
type type
@ -3417,14 +3432,16 @@ export function useCheckUserExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOp
export type CheckUserExistsQueryHookResult = ReturnType<typeof useCheckUserExistsQuery>; export type CheckUserExistsQueryHookResult = ReturnType<typeof useCheckUserExistsQuery>;
export type CheckUserExistsLazyQueryHookResult = ReturnType<typeof useCheckUserExistsLazyQuery>; export type CheckUserExistsLazyQueryHookResult = ReturnType<typeof useCheckUserExistsLazyQuery>;
export type CheckUserExistsQueryResult = Apollo.QueryResult<CheckUserExistsQuery, CheckUserExistsQueryVariables>; export type CheckUserExistsQueryResult = Apollo.QueryResult<CheckUserExistsQuery, CheckUserExistsQueryVariables>;
export const GetPublicWorkspaceDataBySubdomainDocument = gql` export const GetPublicWorkspaceDataByDomainDocument = gql`
query GetPublicWorkspaceDataBySubdomain { query GetPublicWorkspaceDataByDomain {
getPublicWorkspaceDataBySubdomain { getPublicWorkspaceDataByDomain {
id id
logo logo
displayName displayName
subdomain workspaceUrls {
hostname subdomainUrl
customUrl
}
authProviders { authProviders {
sso { sso {
id id
@ -3443,31 +3460,31 @@ export const GetPublicWorkspaceDataBySubdomainDocument = gql`
`; `;
/** /**
* __useGetPublicWorkspaceDataBySubdomainQuery__ * __useGetPublicWorkspaceDataByDomainQuery__
* *
* To run a query within a React component, call `useGetPublicWorkspaceDataBySubdomainQuery` and pass it any options that fit your needs. * To run a query within a React component, call `useGetPublicWorkspaceDataByDomainQuery` 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 * When your component renders, `useGetPublicWorkspaceDataByDomainQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI. * 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; * @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 * @example
* const { data, loading, error } = useGetPublicWorkspaceDataBySubdomainQuery({ * const { data, loading, error } = useGetPublicWorkspaceDataByDomainQuery({
* variables: { * variables: {
* }, * },
* }); * });
*/ */
export function useGetPublicWorkspaceDataBySubdomainQuery(baseOptions?: Apollo.QueryHookOptions<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>) { export function useGetPublicWorkspaceDataByDomainQuery(baseOptions?: Apollo.QueryHookOptions<GetPublicWorkspaceDataByDomainQuery, GetPublicWorkspaceDataByDomainQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>(GetPublicWorkspaceDataBySubdomainDocument, options); return Apollo.useQuery<GetPublicWorkspaceDataByDomainQuery, GetPublicWorkspaceDataByDomainQueryVariables>(GetPublicWorkspaceDataByDomainDocument, options);
} }
export function useGetPublicWorkspaceDataBySubdomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>) { export function useGetPublicWorkspaceDataByDomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetPublicWorkspaceDataByDomainQuery, GetPublicWorkspaceDataByDomainQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>(GetPublicWorkspaceDataBySubdomainDocument, options); return Apollo.useLazyQuery<GetPublicWorkspaceDataByDomainQuery, GetPublicWorkspaceDataByDomainQueryVariables>(GetPublicWorkspaceDataByDomainDocument, options);
} }
export type GetPublicWorkspaceDataBySubdomainQueryHookResult = ReturnType<typeof useGetPublicWorkspaceDataBySubdomainQuery>; export type GetPublicWorkspaceDataByDomainQueryHookResult = ReturnType<typeof useGetPublicWorkspaceDataByDomainQuery>;
export type GetPublicWorkspaceDataBySubdomainLazyQueryHookResult = ReturnType<typeof useGetPublicWorkspaceDataBySubdomainLazyQuery>; export type GetPublicWorkspaceDataByDomainLazyQueryHookResult = ReturnType<typeof useGetPublicWorkspaceDataByDomainLazyQuery>;
export type GetPublicWorkspaceDataBySubdomainQueryResult = Apollo.QueryResult<GetPublicWorkspaceDataBySubdomainQuery, GetPublicWorkspaceDataBySubdomainQueryVariables>; export type GetPublicWorkspaceDataByDomainQueryResult = Apollo.QueryResult<GetPublicWorkspaceDataByDomainQuery, GetPublicWorkspaceDataByDomainQueryVariables>;
export const ValidatePasswordResetTokenDocument = gql` export const ValidatePasswordResetTokenDocument = gql`
query ValidatePasswordResetToken($token: String!) { query ValidatePasswordResetToken($token: String!) {
validatePasswordResetToken(passwordResetToken: $token) { validatePasswordResetToken(passwordResetToken: $token) {
@ -4698,7 +4715,6 @@ export const ActivateWorkspaceDocument = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) { activateWorkspace(data: $input) {
id id
subdomain
} }
} }
`; `;
@ -4887,7 +4903,6 @@ export const GetWorkspaceFromInviteHashDocument = gql`
displayName displayName
logo logo
allowImpersonation allowImpersonation
subdomain
} }
} }
`; `;

View File

@ -4,7 +4,7 @@ import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw'; import { HttpResponse, graphql } from 'msw';
import { GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataBySubdomain'; import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig'; import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries'; import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser'; import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
@ -36,7 +36,7 @@ const userMetadataLoaderMocks = {
}); });
}), }),
graphql.query( graphql.query(
getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN) ?? '', getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN) ?? '',
() => { () => {
return HttpResponse.json({ return HttpResponse.json({
data: { data: {

View File

@ -5,7 +5,10 @@ export const IMPERSONATE = gql`
mutation Impersonate($userId: String!, $workspaceId: String!) { mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) { impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace { workspace {
subdomain workspaceUrls {
subdomainUrl
customUrl
}
id id
} }
loginToken { loginToken {

View File

@ -22,7 +22,10 @@ export const SIGN_UP = gql`
} }
workspace { workspace {
id id
subdomain workspaceUrls {
subdomainUrl
customUrl
}
} }
} }
} }

View File

@ -9,8 +9,10 @@ export const CHECK_USER_EXISTS = gql`
availableWorkspaces { availableWorkspaces {
id id
displayName displayName
subdomain workspaceUrls {
hostname subdomainUrl
customUrl
}
logo logo
sso { sso {
type type

View File

@ -1,13 +1,15 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql` export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
query GetPublicWorkspaceDataBySubdomain { query GetPublicWorkspaceDataByDomain {
getPublicWorkspaceDataBySubdomain { getPublicWorkspaceDataByDomain {
id id
logo logo
displayName displayName
subdomain workspaceUrls {
hostname subdomainUrl
customUrl
}
authProviders { authProviders {
sso { sso {
id id

View File

@ -53,7 +53,7 @@ import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type
import { captchaState } from '@/client-config/states/captchaState'; import { captchaState } from '@/client-config/states/captchaState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
@ -62,6 +62,7 @@ import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/state
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const useAuth = () => { export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
@ -96,8 +97,7 @@ export const useAuth = () => {
useGetLoginTokenFromEmailVerificationTokenMutation(); useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery(); const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } = const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
useIsCurrentLocationOnAWorkspaceSubdomain();
const workspacePublicData = useRecoilValue(workspacePublicDataState); const workspacePublicData = useRecoilValue(workspacePublicDataState);
@ -289,10 +289,10 @@ export const useAuth = () => {
setCurrentWorkspace(workspace); setCurrentWorkspace(workspace);
if (isDefined(workspace) && isOnAWorkspaceSubdomain) { if (isDefined(workspace) && isOnAWorkspace) {
setLastAuthenticateWorkspaceDomain({ setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id, workspaceId: workspace.id,
subdomain: workspace.subdomain, workspaceUrl: getWorkspaceUrl(workspace.workspaceUrls),
}); });
} }
@ -315,7 +315,7 @@ export const useAuth = () => {
}; };
}, [ }, [
getCurrentUser, getCurrentUser,
isOnAWorkspaceSubdomain, isOnAWorkspace,
setCurrentUser, setCurrentUser,
setCurrentWorkspace, setCurrentWorkspace,
setCurrentWorkspaceMember, setCurrentWorkspaceMember,
@ -413,7 +413,8 @@ export const useAuth = () => {
if (isMultiWorkspaceEnabled) { if (isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain( return redirectToWorkspaceDomain(
signUpResult.data.signUp.workspace.subdomain, getWorkspaceUrl(signUpResult.data.signUp.workspace.workspaceUrls),
isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify, isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{ {
...(!isEmailVerificationRequired && { ...(!isEmailVerificationRequired && {

View File

@ -27,6 +27,7 @@ import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirect
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledContentContainer = styled(motion.div)` const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)}; margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -92,9 +93,13 @@ export const SignInUpGlobalScopeForm = () => {
if (response.__typename === 'UserExists') { if (response.__typename === 'UserExists') {
if (response.availableWorkspaces.length >= 1) { if (response.availableWorkspaces.length >= 1) {
const workspace = response.availableWorkspaces[0]; const workspace = response.availableWorkspaces[0];
return redirectToWorkspaceDomain(workspace.subdomain, pathname, { return redirectToWorkspaceDomain(
email: form.getValues('email'), getWorkspaceUrl(workspace.workspaceUrls),
}); pathname,
{
email: form.getValues('email'),
},
);
} }
} }
if (response.__typename === 'UserNotExists') { if (response.__typename === 'UserNotExists') {

View File

@ -3,6 +3,7 @@ import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar'); jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar');
@ -52,9 +53,11 @@ const apolloMocks = [
]; ];
const Wrapper = ({ children }: { children: React.ReactNode }) => ( const Wrapper = ({ children }: { children: React.ReactNode }) => (
<MockedProvider mocks={apolloMocks} addTypename={false}> <MemoryRouter>
{children} <MockedProvider mocks={apolloMocks} addTypename={false}>
</MockedProvider> {children}
</MockedProvider>
</MemoryRouter>
); );
describe('useSSO', () => { describe('useSSO', () => {

View File

@ -13,14 +13,16 @@ export const useSSO = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const { redirect } = useRedirect(); const { redirect } = useRedirect();
const redirectToSSOLoginPage = async (identityProviderId: string) => { const redirectToSSOLoginPage = async (identityProviderId: string) => {
let authorizationUrlForSSOResult; let authorizationUrlForSSOResult;
try { try {
authorizationUrlForSSOResult = await apolloClient.mutate({ authorizationUrlForSSOResult = await apolloClient.mutate({
mutation: GET_AUTHORIZATION_URL, mutation: GET_AUTHORIZATION_URL,
variables: { variables: {
input: { identityProviderId, workspaceInviteHash }, input: {
identityProviderId,
workspaceInviteHash,
},
}, },
}); });
} catch (error: any) { } catch (error: any) {

View File

@ -21,6 +21,7 @@ export type CurrentWorkspace = Pick<
| 'hasValidEnterpriseKey' | 'hasValidEnterpriseKey'
| 'subdomain' | 'subdomain'
| 'hostname' | 'hostname'
| 'workspaceUrls'
| 'metadataVersion' | 'metadataVersion'
>; >;

View File

@ -1,10 +1,9 @@
import { createState } from '@ui/utilities/state/utils/createState'; import { createState } from '@ui/utilities/state/utils/createState';
import { Workspace } from '~/generated/graphql'; import { Workspace } from '~/generated/graphql';
export type Workspaces = Pick< export type Workspaces = Pick<
Workspace, Workspace,
'id' | 'logo' | 'displayName' | 'subdomain' 'id' | 'logo' | 'displayName' | 'workspaceUrls'
>[]; >[];
export const workspacesState = createState<Workspaces>({ export const workspacesState = createState<Workspaces>({

View File

@ -1,20 +1,12 @@
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
export const useBuildWorkspaceUrl = () => { export const useBuildWorkspaceUrl = () => {
const domainConfiguration = useRecoilValue(domainConfigurationState);
const buildWorkspaceUrl = ( const buildWorkspaceUrl = (
subdomain: string, endpoint: string,
pathname?: string, pathname?: string,
searchParams?: Record<string, string>, searchParams?: Record<string, string | boolean>,
) => { ) => {
const url = new URL(window.location.href); const url = new URL(endpoint);
if (subdomain.length !== 0) {
url.hostname = `${subdomain}.${domainConfiguration.frontDomain}`;
}
if (isDefined(pathname)) { if (isDefined(pathname)) {
url.pathname = pathname; url.pathname = pathname;
@ -22,7 +14,7 @@ export const useBuildWorkspaceUrl = () => {
if (isDefined(searchParams)) { if (isDefined(searchParams)) {
Object.entries(searchParams).forEach(([key, value]) => Object.entries(searchParams).forEach(([key, value]) =>
url.searchParams.set(key, value), url.searchParams.set(key, value.toString()),
); );
} }
return url.toString(); return url.toString();

View File

@ -5,9 +5,9 @@ import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectTo
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated/graphql';
export const useGetPublicWorkspaceDataBySubdomain = () => { export const useGetPublicWorkspaceDataByDomain = () => {
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const setWorkspaceAuthProviders = useSetRecoilState( const setWorkspaceAuthProviders = useSetRecoilState(
@ -19,15 +19,15 @@ export const useGetPublicWorkspaceDataBySubdomain = () => {
workspacePublicDataState, workspacePublicDataState,
); );
const { loading, data, error } = useGetPublicWorkspaceDataBySubdomainQuery({ const { loading, data, error } = useGetPublicWorkspaceDataByDomainQuery({
skip: skip:
(isMultiWorkspaceEnabled && isDefaultDomain) || (isMultiWorkspaceEnabled && isDefaultDomain) ||
isDefined(workspacePublicData), isDefined(workspacePublicData),
onCompleted: (data) => { onCompleted: (data) => {
setWorkspaceAuthProviders( setWorkspaceAuthProviders(
data.getPublicWorkspaceDataBySubdomain.authProviders, data.getPublicWorkspaceDataByDomain.authProviders,
); );
setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain); setWorkspacePublicDataState(data.getPublicWorkspaceDataByDomain);
}, },
onError: (error) => { onError: (error) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -38,7 +38,7 @@ export const useGetPublicWorkspaceDataBySubdomain = () => {
return { return {
loading, loading,
data: data?.getPublicWorkspaceDataBySubdomain, data: data?.getPublicWorkspaceDataByDomain,
error, error,
}; };
}; };

View File

@ -4,7 +4,7 @@ import { domainConfigurationState } from '@/domain-manager/states/domainConfigur
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
export const useIsCurrentLocationOnAWorkspaceSubdomain = () => { export const useIsCurrentLocationOnAWorkspace = () => {
const { defaultDomain } = useReadDefaultDomainFromConfiguration(); const { defaultDomain } = useReadDefaultDomainFromConfiguration();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
@ -18,10 +18,10 @@ export const useIsCurrentLocationOnAWorkspaceSubdomain = () => {
throw new Error('frontDomain and defaultSubdomain are required'); throw new Error('frontDomain and defaultSubdomain are required');
} }
const isOnAWorkspaceSubdomain = const isOnAWorkspace =
isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain; isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain;
return { return {
isOnAWorkspaceSubdomain, isOnAWorkspace,
}; };
}; };

View File

@ -8,7 +8,7 @@ export const useLastAuthenticatedWorkspaceDomain = () => {
lastAuthenticatedWorkspaceDomainState, lastAuthenticatedWorkspaceDomainState,
); );
const setLastAuthenticateWorkspaceDomainWithCookieAttributes = ( const setLastAuthenticateWorkspaceDomainWithCookieAttributes = (
params: { workspaceId: string; subdomain: string } | null, params: { workspaceId: string; workspaceUrl: string } | null,
) => { ) => {
setLastAuthenticatedWorkspaceDomain({ setLastAuthenticatedWorkspaceDomain({
...(params ? params : {}), ...(params ? params : {}),

View File

@ -1,25 +0,0 @@
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useReadWorkspaceSubdomainFromCurrentLocation = () => {
const domainConfiguration = useRecoilValue(domainConfigurationState);
const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
if (!isDefined(domainConfiguration.frontDomain)) {
throw new Error('frontDomain is not defined');
}
const workspaceSubdomain = isOnAWorkspaceSubdomain
? window.location.hostname.replace(
`.${domainConfiguration.frontDomain}`,
'',
)
: null;
return {
workspaceSubdomain,
};
};

View File

@ -0,0 +1,11 @@
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
export const useReadWorkspaceUrlFromCurrentLocation = () => {
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
return {
currentLocationHostname: isOnAWorkspace
? window.location.hostname
: undefined,
};
};

View File

@ -9,12 +9,12 @@ export const useRedirectToWorkspaceDomain = () => {
const { redirect } = useRedirect(); const { redirect } = useRedirect();
const redirectToWorkspaceDomain = ( const redirectToWorkspaceDomain = (
subdomain: string, baseUrl: string,
pathname?: string, pathname?: string,
searchParams?: Record<string, string>, searchParams?: Record<string, string | boolean>,
) => { ) => {
if (!isMultiWorkspaceEnabled) return; if (!isMultiWorkspaceEnabled) return;
redirect(buildWorkspaceUrl(subdomain, pathname, searchParams)); redirect(buildWorkspaceUrl(baseUrl, pathname, searchParams));
}; };
return { return {

View File

@ -3,7 +3,7 @@ import { cookieStorageEffect } from '~/utils/recoil-effects';
export const lastAuthenticatedWorkspaceDomainState = createState< export const lastAuthenticatedWorkspaceDomainState = createState<
| { | {
subdomain: string; workspaceUrl: string;
workspaceId: string; workspaceId: string;
cookieAttributes?: Cookies.CookieAttributes; cookieAttributes?: Cookies.CookieAttributes;
} }

View File

@ -159,6 +159,10 @@ export const queries = {
subdomain subdomain
hasValidEnterpriseKey hasValidEnterpriseKey
hostname hostname
workspaceUrls {
subdomainUrl
customUrl
}
featureFlags { featureFlags {
id id
key key
@ -183,6 +187,11 @@ export const queries = {
logo logo
displayName displayName
subdomain subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
} }
} }
userVars userVars
@ -309,6 +318,10 @@ export const responseData = {
isPasswordAuthEnabled: true, isPasswordAuthEnabled: true,
subdomain: 'test', subdomain: 'test',
hostname: null, hostname: null,
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://test.twenty.com/',
},
featureFlags: [], featureFlags: [],
metadataVersion: 1, metadataVersion: 1,
currentBillingSubscription: null, currentBillingSubscription: null,

View File

@ -27,6 +27,10 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
isGoogleAuthEnabled: true, isGoogleAuthEnabled: true,
isMicrosoftAuthEnabled: false, isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true, isPasswordAuthEnabled: true,
workspaceUrls: {
subdomainUrl: 'https://twenty.twenty.com',
customUrl: 'https://my-custom-domain.com',
},
currentBillingSubscription: { currentBillingSubscription: {
id: '1', id: '1',
interval: SubscriptionInterval.Month, interval: SubscriptionInterval.Month,

View File

@ -8,6 +8,7 @@ import { useState } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { useImpersonateMutation } from '~/generated/graphql'; import { useImpersonateMutation } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const useImpersonate = () => { export const useImpersonate = () => {
const [currentUser] = useRecoilState(currentUserState); const [currentUser] = useRecoilState(currentUserState);
@ -55,9 +56,13 @@ export const useImpersonate = () => {
return; return;
} }
return redirectToWorkspaceDomain(workspace.subdomain, AppPath.Verify, { return redirectToWorkspaceDomain(
loginToken: loginToken.token, getWorkspaceUrl(workspace.workspaceUrls),
}); AppPath.Verify,
{
loginToken: loginToken.token,
},
);
} catch (error) { } catch (error) {
setError('Failed to impersonate user. Please try again.'); setError('Failed to impersonate user. Please try again.');
setIsLoading(false); setIsLoading(false);

View File

@ -18,6 +18,7 @@ import {
UndecoratedLink, UndecoratedLink,
} from 'twenty-ui'; } from 'twenty-ui';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledContainer = styled.div<{ isNavigationDrawerExpanded: boolean }>` const StyledContainer = styled.div<{ isNavigationDrawerExpanded: boolean }>`
align-items: center; align-items: center;
@ -67,7 +68,7 @@ export const MultiWorkspaceDropdownButton = ({
const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const handleChange = async (workspace: Workspaces[0]) => { const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(workspace.subdomain); redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
}; };
const [isNavigationDrawerExpanded] = useRecoilState( const [isNavigationDrawerExpanded] = useRecoilState(
isNavigationDrawerExpandedState, isNavigationDrawerExpandedState,
@ -104,7 +105,7 @@ export const MultiWorkspaceDropdownButton = ({
{workspaces.map((workspace) => ( {workspaces.map((workspace) => (
<UndecoratedLink <UndecoratedLink
key={workspace.id} key={workspace.id}
to={buildWorkspaceUrl(workspace.subdomain)} to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => { onClick={(event) => {
event?.preventDefault(); event?.preventDefault();
handleChange(workspace); handleChange(workspace);

View File

@ -38,6 +38,10 @@ export const USER_QUERY_FRAGMENT = gql`
subdomain subdomain
hasValidEnterpriseKey hasValidEnterpriseKey
hostname hostname
workspaceUrls {
subdomainUrl
customUrl
}
featureFlags { featureFlags {
id id
key key
@ -62,6 +66,11 @@ export const USER_QUERY_FRAGMENT = gql`
logo logo
displayName displayName
subdomain subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
} }
} }
userVars userVars

View File

@ -1,17 +1,18 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState';
import { useReadWorkspaceUrlFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceUrlFromCurrentLocation';
import { useGetPublicWorkspaceDataBySubdomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain';
import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain';
import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain';
import { WorkspaceUrls } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const WorkspaceProviderEffect = () => { export const WorkspaceProviderEffect = () => {
const { data: getPublicWorkspaceData } = const { data: getPublicWorkspaceData } = useGetPublicWorkspaceDataByDomain();
useGetPublicWorkspaceDataBySubdomain();
const lastAuthenticatedWorkspaceDomain = useRecoilValue( const lastAuthenticatedWorkspaceDomain = useRecoilValue(
lastAuthenticatedWorkspaceDomainState, lastAuthenticatedWorkspaceDomainState,
@ -20,23 +21,38 @@ export const WorkspaceProviderEffect = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation(); const { currentLocationHostname } = useReadWorkspaceUrlFromCurrentLocation();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const getHostnamesFromWorkspaceUrls = (workspaceUrls: WorkspaceUrls) => {
return {
customUrlHostname: workspaceUrls.customUrl
? new URL(workspaceUrls.customUrl).hostname
: undefined,
subdomainUrlHostname: new URL(workspaceUrls.subdomainUrl).hostname,
};
};
useEffect(() => { useEffect(() => {
const hostnames = getPublicWorkspaceData
? getHostnamesFromWorkspaceUrls(getPublicWorkspaceData?.workspaceUrls)
: null;
if ( if (
isMultiWorkspaceEnabled && isMultiWorkspaceEnabled &&
isDefined(getPublicWorkspaceData?.subdomain) && isDefined(getPublicWorkspaceData) &&
getPublicWorkspaceData.subdomain !== workspaceSubdomain currentLocationHostname !== hostnames?.customUrlHostname &&
currentLocationHostname !== hostnames?.subdomainUrlHostname
) { ) {
redirectToWorkspaceDomain(getPublicWorkspaceData.subdomain); redirectToWorkspaceDomain(
getWorkspaceUrl(getPublicWorkspaceData.workspaceUrls),
);
} }
}, [ }, [
workspaceSubdomain,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
redirectToWorkspaceDomain, redirectToWorkspaceDomain,
getPublicWorkspaceData, getPublicWorkspaceData,
currentLocationHostname,
]); ]);
useEffect(() => { useEffect(() => {
@ -44,10 +60,10 @@ export const WorkspaceProviderEffect = () => {
isMultiWorkspaceEnabled && isMultiWorkspaceEnabled &&
isDefaultDomain && isDefaultDomain &&
isDefined(lastAuthenticatedWorkspaceDomain) && isDefined(lastAuthenticatedWorkspaceDomain) &&
'subdomain' in lastAuthenticatedWorkspaceDomain && 'workspaceUrl' in lastAuthenticatedWorkspaceDomain &&
isDefined(lastAuthenticatedWorkspaceDomain?.subdomain) isDefined(lastAuthenticatedWorkspaceDomain?.workspaceUrl)
) { ) {
redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain); redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.workspaceUrl);
} }
}, [ }, [
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,

View File

@ -4,7 +4,6 @@ export const ACTIVATE_WORKSPACE = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) { activateWorkspace(data: $input) {
id id
subdomain
} }
} }
`; `;

View File

@ -7,7 +7,6 @@ export const GET_WORKSPACE_FROM_INVITE_HASH = gql`
displayName displayName
logo logo
allowImpersonation allowImpersonation
subdomain
} }
} }
`; `;

View File

@ -13,8 +13,8 @@ import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/componen
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm'; import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect'; import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeFormEffect';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useGetPublicWorkspaceDataBySubdomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain'; import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -24,7 +24,7 @@ import { AnimatedEaseIn } from 'twenty-ui';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { PublicWorkspaceDataOutput } from '~/generated-metadata/graphql'; import { PublicWorkspaceDataOutput } from '~/generated/graphql';
const StandardContent = ({ const StandardContent = ({
workspacePublicData, workspacePublicData,
@ -55,10 +55,9 @@ export const SignInUp = () => {
const { form } = useSignInUpForm(); const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form); const { signInUpStep } = useSignInUp(form);
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain(); const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const { isOnAWorkspaceSubdomain } = const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
useIsCurrentLocationOnAWorkspaceSubdomain();
const workspacePublicData = useRecoilValue(workspacePublicDataState); const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { loading } = useGetPublicWorkspaceDataBySubdomain(); const { loading } = useGetPublicWorkspaceDataByDomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { workspaceInviteHash, workspace: workspaceFromInviteHash } = const { workspaceInviteHash, workspace: workspaceFromInviteHash } =
useWorkspaceFromInviteHash(); useWorkspaceFromInviteHash();
@ -91,7 +90,7 @@ export const SignInUp = () => {
if ( if (
(!isMultiWorkspaceEnabled || (!isMultiWorkspaceEnabled ||
(isMultiWorkspaceEnabled && isOnAWorkspaceSubdomain)) && (isMultiWorkspaceEnabled && isOnAWorkspace)) &&
signInUpStep === SignInUpStep.SSOIdentityProviderSelection signInUpStep === SignInUpStep.SSOIdentityProviderSelection
) { ) {
return <SignInUpSSOIdentityProviderSelection />; return <SignInUpSSOIdentityProviderSelection />;
@ -99,7 +98,7 @@ export const SignInUp = () => {
if ( if (
isDefined(workspacePublicData) && isDefined(workspacePublicData) &&
(!isMultiWorkspaceEnabled || isOnAWorkspaceSubdomain) (!isMultiWorkspaceEnabled || isOnAWorkspace)
) { ) {
return ( return (
<> <>
@ -113,7 +112,7 @@ export const SignInUp = () => {
}, [ }, [
isDefaultDomain, isDefaultDomain,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
isOnAWorkspaceSubdomain, isOnAWorkspace,
loading, loading,
signInUpStep, signInUpStep,
workspacePublicData, workspacePublicData,

View File

@ -1,3 +1,4 @@
import { ApolloError } from '@apollo/client';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
@ -13,7 +14,6 @@ import { SettingsSubdomain } from '~/pages/settings/workspace/SettingsSubdomain'
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { z } from 'zod'; import { z } from 'zod';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@ -94,12 +94,18 @@ export const SettingsDomain = () => {
}); });
}, },
onCompleted: () => { onCompleted: () => {
const currentUrl = new URL(window.location.href);
currentUrl.hostname = new URL(
currentWorkspace.workspaceUrls.subdomainUrl,
).hostname.replace(currentWorkspace.subdomain, values.subdomain);
setCurrentWorkspace({ setCurrentWorkspace({
...currentWorkspace, ...currentWorkspace,
subdomain: values.subdomain, subdomain: values.subdomain,
}); });
redirectToWorkspaceDomain(values.subdomain); redirectToWorkspaceDomain(currentUrl.toString());
}, },
}); });
}; };

View File

@ -1,5 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -41,7 +40,6 @@ export const SettingsHostname = () => {
const [updateWorkspace] = useUpdateWorkspaceMutation(); const [updateWorkspace] = useUpdateWorkspaceMutation();
const { data: getHostnameDetailsData } = useGetHostnameDetailsQuery(); const { data: getHostnameDetailsData } = useGetHostnameDetailsQuery();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { t } = useLingui(); const { t } = useLingui();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState( const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
@ -75,8 +73,6 @@ export const SettingsHostname = () => {
}, },
}, },
}); });
redirectToWorkspaceDomain(currentWorkspace.subdomain);
} catch (error) { } catch (error) {
control.setError('hostname', { control.setError('hostname', {
type: 'manual', type: 'manual',
@ -106,8 +102,6 @@ export const SettingsHostname = () => {
...currentWorkspace, ...currentWorkspace,
hostname: values.hostname, hostname: values.hostname,
}); });
// redirectToWorkspaceDomain(values.subdomain);
} catch (error) { } catch (error) {
control.setError('hostname', { control.setError('hostname', {
type: 'manual', type: 'manual',
@ -139,12 +133,36 @@ export const SettingsHostname = () => {
{isDefined(getHostnameDetailsData?.getHostnameDetails?.hostname) && ( {isDefined(getHostnameDetailsData?.getHostnameDetails?.hostname) && (
<pre> <pre>
{getHostnameDetailsData.getHostnameDetails.hostname} CNAME {getHostnameDetailsData.getHostnameDetails.hostname} CNAME
app.twenty-main.com twenty-main.com
</pre> </pre>
)} )}
{getHostnameDetailsData && ( {getHostnameDetailsData?.getHostnameDetails &&
<pre>{JSON.stringify(getHostnameDetailsData, null, 4)}</pre> getHostnameDetailsData.getHostnameDetails.ownershipVerifications.map(
)} (ownershipVerification) => {
if (
ownershipVerification.__typename ===
'CustomHostnameOwnershipVerificationTxt'
) {
return (
<pre>
{ownershipVerification.name} TXT {ownershipVerification.value}
</pre>
);
}
if (
ownershipVerification.__typename ===
'CustomHostnameOwnershipVerificationHttp'
) {
return (
<pre>
{ownershipVerification.url} HTTP {ownershipVerification.body}
</pre>
);
}
return <></>;
},
)}
</Section> </Section>
); );
}; };

View File

@ -20,7 +20,7 @@ import { mockedUserData } from '~/testing/mock-data/users';
import { mockedViewsData } from '~/testing/mock-data/views'; import { mockedViewsData } from '~/testing/mock-data/views';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
import { GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataBySubdomain'; import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/mock-metadata-query-result'; import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/mock-metadata-query-result';
import { mockedTasks } from '~/testing/mock-data/tasks'; import { mockedTasks } from '~/testing/mock-data/tasks';
import { import {
@ -49,15 +49,18 @@ export const graphqlMocks = {
}); });
}), }),
graphql.query( graphql.query(
getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN) ?? '', getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN) ?? '',
() => { () => {
return HttpResponse.json({ return HttpResponse.json({
data: { data: {
getPublicWorkspaceDataBySubdomain: { getPublicWorkspaceDataByDomain: {
id: 'id', id: 'id',
logo: 'logo', logo: 'logo',
displayName: 'displayName', displayName: 'displayName',
subdomain: 'subdomain', workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://twenty.com',
},
authProviders: { authProviders: {
google: true, google: true,
microsoft: false, microsoft: false,

View File

@ -1,12 +1,15 @@
import { GetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; import { GetPublicWorkspaceDataByDomainQuery } from '~/generated/graphql';
export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataBySubdomainQuery['getPublicWorkspaceDataBySubdomain'] = export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataByDomainQuery['getPublicWorkspaceDataByDomain'] =
{ {
__typename: 'PublicWorkspaceDataOutput', __typename: 'PublicWorkspaceDataOutput',
id: '9870323e-22c3-4d14-9b7f-5bdc84f7d6ee', id: '9870323e-22c3-4d14-9b7f-5bdc84f7d6ee',
logo: 'workspace-logo/original/c88deb49-7636-4560-918d-08c3265ffb20.49?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6Ijk4NzAzMjNlLTIyYzMtNGQxNC05YjdmLTViZGM4NGY3ZDZlZSIsImlhdCI6MTczNjU0MDU0MywiZXhwIjoxNzM2NjI2OTQzfQ.C8cnHu09VGseRbQAMM4nhiO6z4TLG03ntFTvxm53-xg', logo: 'workspace-logo/original/c88deb49-7636-4560-918d-08c3265ffb20.49?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6Ijk4NzAzMjNlLTIyYzMtNGQxNC05YjdmLTViZGM4NGY3ZDZlZSIsImlhdCI6MTczNjU0MDU0MywiZXhwIjoxNzM2NjI2OTQzfQ.C8cnHu09VGseRbQAMM4nhiO6z4TLG03ntFTvxm53-xg',
displayName: 'Twenty Eng', displayName: 'Twenty Eng',
subdomain: 'twenty-eng', workspaceUrls: {
customUrl: 'https://twenty-eng.com',
subdomainUrl: 'https://custom.twenty.com',
},
authProviders: { authProviders: {
__typename: 'AuthProviders', __typename: 'AuthProviders',
sso: [], sso: [],

View File

@ -48,6 +48,10 @@ export const mockCurrentWorkspace: Workspace = {
hasValidEnterpriseKey: false, hasValidEnterpriseKey: false,
isGoogleAuthEnabled: true, isGoogleAuthEnabled: true,
isPasswordAuthEnabled: true, isPasswordAuthEnabled: true,
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'twenty.twenty.com',
},
isMicrosoftAuthEnabled: false, isMicrosoftAuthEnabled: false,
featureFlags: [ featureFlags: [
{ {

View File

@ -0,0 +1,5 @@
import { WorkspaceUrls } from '~/generated/graphql';
export const getWorkspaceUrl = (workspaceUrls: WorkspaceUrls) => {
return workspaceUrls.customUrl ?? workspaceUrls.subdomainUrl;
};

View File

@ -48,7 +48,7 @@ export const seedFeatureFlags = async (
{ {
key: FeatureFlagKey.IsCustomDomainEnabled, key: FeatureFlagKey.IsCustomDomainEnabled,
workspaceId: workspaceId, workspaceId: workspaceId,
value: true, value: false,
}, },
{ {
key: FeatureFlagKey.IsBillingPlansEnabled, key: FeatureFlagKey.IsBillingPlansEnabled,

View File

@ -60,7 +60,7 @@ export class CoreQueryBuilderFactory {
throw new BadRequestException( throw new BadRequestException(
`No object was found for the workspace associated with this API key. You may generate a new one here ${this.domainManagerService `No object was found for the workspace associated with this API key. You may generate a new one here ${this.domainManagerService
.buildWorkspaceURL({ .buildWorkspaceURL({
subdomain: workspace.subdomain, workspace,
pathname: '/settings/developers', pathname: '/settings/developers',
}) })
.toString()}`, .toString()}`,

View File

@ -13,6 +13,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
const UserFindOneMock = jest.fn(); const UserFindOneMock = jest.fn();
const WorkspaceFindOneMock = jest.fn(); const WorkspaceFindOneMock = jest.fn();
@ -95,6 +96,15 @@ describe('AdminPanelService', () => {
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock, generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
}, },
}, },
{
provide: DomainManagerService,
useValue: {
getworkspaceUrls: jest.fn().mockReturnValue({
customUrl: undefined,
subdomainUrl: 'https://twenty.twenty.com',
}),
},
},
{ {
provide: EnvironmentService, provide: EnvironmentService,
useValue: { useValue: {
@ -230,7 +240,10 @@ describe('AdminPanelService', () => {
expect.objectContaining({ expect.objectContaining({
workspace: { workspace: {
id: 'workspace-id', id: 'workspace-id',
subdomain: 'example-subdomain', workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://twenty.twenty.com',
},
}, },
loginToken: expect.objectContaining({ loginToken: expect.objectContaining({
token: 'mock-login-token', token: 'mock-login-token',

View File

@ -7,11 +7,13 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/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({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([User, Workspace, FeatureFlag], 'core'), TypeOrmModule.forFeature([User, Workspace, FeatureFlag], 'core'),
AuthModule, AuthModule,
DomainManagerModule,
], ],
providers: [AdminPanelResolver, AdminPanelService], providers: [AdminPanelResolver, AdminPanelService],
exports: [AdminPanelService], exports: [AdminPanelService],

View File

@ -28,12 +28,14 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class AdminPanelService { export class AdminPanelService {
constructor( constructor(
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
@ -72,7 +74,9 @@ export class AdminPanelService {
return { return {
workspace: { workspace: {
id: user.workspaces[0].workspace.id, id: user.workspaces[0].workspace.id,
subdomain: user.workspaces[0].workspace.subdomain, workspaceUrls: this.domainManagerService.getworkspaceUrls(
user.workspaces[0].workspace,
),
}, },
loginToken, loginToken,
}; };

View File

@ -1,13 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto'; import { workspaceUrlsAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
@ObjectType() @ObjectType()
export class ImpersonateOutput { export class ImpersonateOutput {
@Field(() => AuthToken) @Field(() => AuthToken)
loginToken: AuthToken; loginToken: AuthToken;
@Field(() => WorkspaceSubdomainAndId) @Field(() => workspaceUrlsAndId)
workspace: WorkspaceSubdomainAndId; workspace: workspaceUrlsAndId;
} }

View File

@ -221,7 +221,7 @@ export class AuthResolver {
await this.emailVerificationService.sendVerificationEmail( await this.emailVerificationService.sendVerificationEmail(
user.id, user.id,
user.email, user.email,
workspace.subdomain, workspace,
); );
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
@ -233,7 +233,7 @@ export class AuthResolver {
loginToken, loginToken,
workspace: { workspace: {
id: workspace.id, id: workspace.id,
subdomain: workspace.subdomain, workspaceUrls: this.domainManagerService.getworkspaceUrls(workspace),
}, },
}; };
} }

View File

@ -113,7 +113,7 @@ export class GoogleAPIsAuthController {
return res.redirect( return res.redirect(
this.domainManagerService this.domainManagerService
.buildWorkspaceURL({ .buildWorkspaceURL({
subdomain: workspace.subdomain, workspace,
pathname: redirectLocation || '/settings/accounts', pathname: redirectLocation || '/settings/accounts',
}) })
.toString(), .toString(),

View File

@ -19,7 +19,6 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/google') @Controller('auth/google')
@ -28,7 +27,6 @@ export class GoogleAuthController {
constructor( constructor(
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly environmentService: EnvironmentService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@ -110,7 +108,7 @@ export class GoogleAuthController {
return res.redirect( return res.redirect(
this.authService.computeRedirectURI({ this.authService.computeRedirectURI({
loginToken: loginToken.token, loginToken: loginToken.token,
subdomain: workspace.subdomain, workspace,
billingCheckoutSessionState, billingCheckoutSessionState,
}), }),
); );
@ -118,9 +116,9 @@ export class GoogleAuthController {
return res.redirect( return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err, err,
currentWorkspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), currentWorkspace,
}, ),
), ),
); );
} }

View File

@ -120,7 +120,7 @@ export class MicrosoftAPIsAuthController {
return res.redirect( return res.redirect(
this.domainManagerService this.domainManagerService
.buildWorkspaceURL({ .buildWorkspaceURL({
subdomain: workspace.subdomain, workspace,
pathname: redirectLocation || '/settings/accounts', pathname: redirectLocation || '/settings/accounts',
}) })
.toString(), .toString(),

View File

@ -18,7 +18,6 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/microsoft') @Controller('auth/microsoft')
@ -28,7 +27,6 @@ export class MicrosoftAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
) {} ) {}
@ -111,8 +109,7 @@ export class MicrosoftAuthController {
return res.redirect( return res.redirect(
this.authService.computeRedirectURI({ this.authService.computeRedirectURI({
loginToken: loginToken.token, loginToken: loginToken.token,
subdomain: workspace.subdomain, workspace,
billingCheckoutSessionState, billingCheckoutSessionState,
}), }),
); );
@ -120,9 +117,9 @@ export class MicrosoftAuthController {
return res.redirect( return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err, err,
currentWorkspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), currentWorkspace,
}, ),
), ),
); );
} }

View File

@ -32,7 +32,6 @@ import {
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter'; import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
@ -45,7 +44,6 @@ export class SSOAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
private readonly sSOService: SSOService, private readonly sSOService: SSOService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@ -152,16 +150,16 @@ export class SSOAuthController {
return res.redirect( return res.redirect(
this.authService.computeRedirectURI({ this.authService.computeRedirectURI({
loginToken: loginToken.token, loginToken: loginToken.token,
subdomain: currentWorkspace.subdomain, workspace: currentWorkspace,
}), }),
); );
} catch (err) { } catch (err) {
return res.redirect( return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err, err,
workspaceIdentityProvider?.workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), workspaceIdentityProvider?.workspace,
}, ),
), ),
); );
} }

View File

@ -7,6 +7,7 @@ import {
IdentityProviderType, IdentityProviderType,
SSOIdentityProviderStatus, SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
@ObjectType() @ObjectType()
class SSOConnection { class SSOConnection {
@ -34,8 +35,8 @@ export class AvailableWorkspaceOutput {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
displayName?: string; displayName?: string;
@Field(() => String) @Field(() => workspaceUrls)
subdomain: string; workspaceUrls: workspaceUrls;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
hostname?: string; hostname?: string;

View File

@ -1,6 +1,6 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto'; import { workspaceUrlsAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
import { AuthToken } from './token.entity'; import { AuthToken } from './token.entity';
@ -9,6 +9,6 @@ export class SignUpOutput {
@Field(() => AuthToken) @Field(() => AuthToken)
loginToken: AuthToken; loginToken: AuthToken;
@Field(() => WorkspaceSubdomainAndId) @Field(() => workspaceUrlsAndId)
workspace: WorkspaceSubdomainAndId; workspace: workspaceUrlsAndId;
} }

View File

@ -27,9 +27,11 @@ export class EnterpriseFeaturesEnabledGuard implements CanActivate {
return true; return true;
} catch (err) { } catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, { this.guardRedirectService.dispatchErrorFromGuard(
subdomain: this.guardRedirectService.getSubdomainFromContext(context), context,
}); err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false; return false;
} }

View File

@ -50,9 +50,11 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
return (await super.canActivate(context)) as boolean; return (await super.canActivate(context)) as boolean;
} catch (err) { } catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, { this.guardRedirectService.dispatchErrorFromGuard(
subdomain: this.guardRedirectService.getSubdomainFromContext(context), context,
}); err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false; return false;
} }

View File

@ -71,9 +71,9 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), workspace,
}, ),
); );
return false; return false;

View File

@ -11,13 +11,11 @@ import {
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable() @Injectable()
export class GoogleOauthGuard extends AuthGuard('google') { export class GoogleOauthGuard extends AuthGuard('google') {
constructor( constructor(
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
) { ) {
@ -53,9 +51,9 @@ export class GoogleOauthGuard extends AuthGuard('google') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), workspace,
}, ),
); );
return false; return false;

View File

@ -28,9 +28,11 @@ export class GoogleProviderEnabledGuard implements CanActivate {
return true; return true;
} catch (err) { } catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, { this.guardRedirectService.dispatchErrorFromGuard(
subdomain: this.guardRedirectService.getSubdomainFromContext(context), context,
}); err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false; return false;
} }

View File

@ -57,9 +57,7 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
AuthExceptionCode.INSUFFICIENT_SCOPES, AuthExceptionCode.INSUFFICIENT_SCOPES,
) )
: error, : error,
{ this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
},
); );
return false; return false;

View File

@ -72,9 +72,9 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), workspace,
}, ),
); );
return false; return false;

View File

@ -5,14 +5,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable() @Injectable()
export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
constructor( constructor(
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
) { ) {
@ -41,9 +39,9 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), workspace,
}, ),
); );
return false; return false;

View File

@ -28,9 +28,11 @@ export class MicrosoftProviderEnabledGuard implements CanActivate {
return true; return true;
} catch (err) { } catch (err) {
this.guardRedirectService.dispatchErrorFromGuard(context, err, { this.guardRedirectService.dispatchErrorFromGuard(
subdomain: this.guardRedirectService.getSubdomainFromContext(context), context,
}); err,
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
);
return false; return false;
} }

View File

@ -12,7 +12,6 @@ import {
import { OIDCAuthStrategy } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; import { OIDCAuthStrategy } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ -21,12 +20,13 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
constructor( constructor(
private readonly sSOService: SSOService, private readonly sSOService: SSOService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService,
) { ) {
super(); super();
} }
private getIdentityProviderId(request: any): string { private getStateByRequest(request: any): {
identityProviderId: string;
} {
if (request.params.identityProviderId) { if (request.params.identityProviderId) {
return request.params.identityProviderId; return request.params.identityProviderId;
} }
@ -39,24 +39,27 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
) { ) {
const state = JSON.parse(request.query.state); const state = JSON.parse(request.query.state);
return state.identityProviderId; return {
identityProviderId: state.identityProviderId,
};
} }
throw new Error('Invalid OIDC identity provider params'); throw new Error('Invalid OIDC identity provider params');
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest<Request>();
let identityProvider: let identityProvider:
| (SSOConfiguration & WorkspaceSSOIdentityProvider) | (SSOConfiguration & WorkspaceSSOIdentityProvider)
| null = null; | null = null;
try { try {
const identityProviderId = this.getIdentityProviderId(request); const state = this.getStateByRequest(request);
identityProvider = identityProvider = await this.sSOService.findSSOIdentityProviderById(
await this.sSOService.findSSOIdentityProviderById(identityProviderId); state.identityProviderId,
);
if (!identityProvider) { if (!identityProvider) {
throw new AuthException( throw new AuthException(
@ -77,9 +80,9 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
identityProvider?.workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), identityProvider?.workspace,
}, ),
); );
return false; return false;

View File

@ -3,6 +3,8 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import { import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
@ -10,22 +12,34 @@ import {
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@Injectable() @Injectable()
export class SAMLAuthGuard extends AuthGuard('saml') { export class SAMLAuthGuard extends AuthGuard('saml') {
constructor( constructor(
private readonly sSOService: SSOService, private readonly sSOService: SSOService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly environmentService: EnvironmentService, private readonly exceptionHandlerService: ExceptionHandlerService,
) { ) {
super(); super();
} }
private getRelayStateByRequest(request: Request) {
try {
const relayStateRaw = request.body.RelayState || request.query.RelayState;
if (relayStateRaw) {
return JSON.parse(relayStateRaw);
}
} catch (error) {
this.exceptionHandlerService.captureExceptions(error);
}
}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest<Request>();
let identityProvider: let identityProvider:
| (SSOConfiguration & WorkspaceSSOIdentityProvider) | (SSOConfiguration & WorkspaceSSOIdentityProvider)
@ -49,9 +63,9 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
identityProvider?.workspace ?? { this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), identityProvider?.workspace,
}, ),
); );
return false; return false;

View File

@ -455,15 +455,15 @@ export class AuthService {
computeRedirectURI({ computeRedirectURI({
loginToken, loginToken,
subdomain, workspace,
billingCheckoutSessionState, billingCheckoutSessionState,
}: { }: {
loginToken: string; loginToken: string;
subdomain: string; workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
billingCheckoutSessionState?: string; billingCheckoutSessionState?: string;
}) { }) {
const url = this.domainManagerService.buildWorkspaceURL({ const url = this.domainManagerService.buildWorkspaceURL({
subdomain, workspace,
pathname: '/verify', pathname: '/verify',
searchParams: { searchParams: {
loginToken, loginToken,

View File

@ -50,7 +50,6 @@ export class OIDCAuthStrategy extends PassportStrategy(
...options, ...options,
state: JSON.stringify({ state: JSON.stringify({
identityProviderId: req.params.identityProviderId, identityProviderId: req.params.identityProviderId,
...(req.query.forceSubdomainUrl ? { forceSubdomainUrl: true } : {}),
...(req.query.workspaceInviteHash ...(req.query.workspaceInviteHash
? { workspaceInviteHash: req.query.workspaceInviteHash } ? { workspaceInviteHash: req.query.workspaceInviteHash }
: {}), : {}),

View File

@ -2,4 +2,5 @@
export enum BillingEntitlementKey { export enum BillingEntitlementKey {
SSO = 'SSO', SSO = 'SSO',
CUSTOM_DOMAIN = 'CUSTOM_DOMAIN',
} }

View File

@ -47,7 +47,7 @@ export class BillingPortalWorkspaceService {
requirePaymentMethod, requirePaymentMethod,
}: BillingPortalCheckoutSessionParameters): Promise<string> { }: BillingPortalCheckoutSessionParameters): Promise<string> {
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({ const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
subdomain: workspace.subdomain, workspace,
}); });
const cancelUrl = frontBaseUrl.toString(); const cancelUrl = frontBaseUrl.toString();
@ -118,7 +118,7 @@ export class BillingPortalWorkspaceService {
} }
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({ const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
subdomain: workspace.subdomain, workspace,
}); });
if (returnUrlPath) { if (returnUrlPath) {

View File

@ -41,6 +41,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
value: true, value: true,
stripeCustomerId: 'cus_123', stripeCustomerId: 'cus_123',
}, },
{
key: BillingEntitlementKey.CUSTOM_DOMAIN,
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
},
]); ]);
}); });
@ -79,6 +85,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
value: false, value: false,
stripeCustomerId: 'cus_123', stripeCustomerId: 'cus_123',
}, },
{
key: 'CUSTOM_DOMAIN',
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
},
]); ]);
}); });
}); });

View File

@ -9,6 +9,53 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from './domain-manager.service'; import { DomainManagerService } from './domain-manager.service';
describe('DomainManagerService', () => { describe('DomainManagerService', () => {
describe('getworkspaceUrls', () => {
it('should return a URL containing the correct hostname if hostname is provided', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
};
return env[key];
});
const result = domainManagerService.getworkspaceUrls({
subdomain: 'subdomain',
hostname: 'custom-host.com',
});
expect(result).toEqual({
customUrl: 'https://custom-host.com/',
subdomainUrl: 'https://subdomain.example.com/',
});
});
it('should return a URL containing the correct subdomain if hostname is not provided but subdomain is', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
};
return env[key];
});
const result = domainManagerService.getworkspaceUrls({
subdomain: 'subdomain',
hostname: undefined,
});
expect(result).toEqual({
customUrl: undefined,
subdomainUrl: 'https://subdomain.example.com/',
});
});
});
let domainManagerService: DomainManagerService; let domainManagerService: DomainManagerService;
let environmentService: EnvironmentService; let environmentService: EnvironmentService;
@ -106,7 +153,10 @@ describe('DomainManagerService', () => {
}); });
const result = domainManagerService.buildWorkspaceURL({ const result = domainManagerService.buildWorkspaceURL({
subdomain: 'test', workspace: {
subdomain: 'test',
hostname: undefined,
},
}); });
expect(result.toString()).toBe('https://test.example.com/'); expect(result.toString()).toBe('https://test.example.com/');
@ -125,7 +175,10 @@ describe('DomainManagerService', () => {
}); });
const result = domainManagerService.buildWorkspaceURL({ const result = domainManagerService.buildWorkspaceURL({
subdomain: 'subdomain', workspace: {
subdomain: 'test',
hostname: undefined,
},
pathname: '/path/to/resource', pathname: '/path/to/resource',
}); });
@ -145,8 +198,10 @@ describe('DomainManagerService', () => {
}); });
const result = domainManagerService.buildWorkspaceURL({ const result = domainManagerService.buildWorkspaceURL({
subdomain: 'subdomain', workspace: {
subdomain: 'test',
hostname: undefined,
},
searchParams: { searchParams: {
foo: 'bar', foo: 'bar',
baz: 123, baz: 123,

View File

@ -74,33 +74,31 @@ export class DomainManagerService {
buildEmailVerificationURL({ buildEmailVerificationURL({
emailVerificationToken, emailVerificationToken,
email, email,
subdomain, workspace,
}: { }: {
emailVerificationToken: string; emailVerificationToken: string;
email: string; email: string;
subdomain: string; workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
}) { }) {
return this.buildWorkspaceURL({ return this.buildWorkspaceURL({
subdomain, workspace,
pathname: 'verify-email', pathname: 'verify-email',
searchParams: { emailVerificationToken, email }, searchParams: { emailVerificationToken, email },
}); });
} }
buildWorkspaceURL({ buildWorkspaceURL({
subdomain, workspace,
pathname, pathname,
searchParams, searchParams,
}: { }: {
subdomain: string; workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
pathname?: string; pathname?: string;
searchParams?: Record<string, string | number>; searchParams?: Record<string, string | number>;
}) { }) {
const url = this.getFrontUrl(); const workspaceUrls = this.getworkspaceUrls(workspace);
if (this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { const url = new URL(workspaceUrls.customUrl ?? workspaceUrls.subdomainUrl);
url.hostname = `${subdomain}.${url.hostname}`;
}
if (pathname) { if (pathname) {
url.pathname = pathname; url.pathname = pathname;
@ -117,21 +115,6 @@ export class DomainManagerService {
return url; return url;
} }
// @Deprecated
getWorkspaceSubdomainFromUrl = (url: string) => {
const { hostname: originHostname } = new URL(url);
if (!originHostname.endsWith(this.getFrontUrl().hostname)) {
return null;
}
const frontDomain = this.getFrontUrl().hostname;
const subdomain = originHostname.replace(`.${frontDomain}`, '');
return this.isDefaultSubdomain(subdomain) ? null : subdomain;
};
getSubdomainAndHostnameFromUrl = (url: string) => { getSubdomainAndHostnameFromUrl = (url: string) => {
const { hostname: originHostname } = new URL(url); const { hostname: originHostname } = new URL(url);
@ -162,9 +145,12 @@ export class DomainManagerService {
return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN'); return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN');
} }
computeRedirectErrorUrl(errorMessage: string, subdomain: string) { computeRedirectErrorUrl(
errorMessage: string,
workspace: Pick<Workspace, 'subdomain' | 'hostname'>,
) {
const url = this.buildWorkspaceURL({ const url = this.buildWorkspaceURL({
subdomain: subdomain, workspace,
pathname: '/verify', pathname: '/verify',
searchParams: { errorMessage }, searchParams: { errorMessage },
}); });
@ -352,7 +338,7 @@ export class DomainManagerService {
await this.deleteCustomHostname(fromCustomHostname.id); await this.deleteCustomHostname(fromCustomHostname.id);
} }
return await this.registerCustomHostname(toHostname); return this.registerCustomHostname(toHostname);
} }
async deleteCustomHostnameByHostnameSilently(hostname: string) { async deleteCustomHostnameByHostnameSilently(hostname: string) {
@ -378,4 +364,32 @@ export class DomainManagerService {
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
}); });
} }
private getCustomWorkspaceEndpoint(hostname: string) {
const url = this.getFrontUrl();
url.hostname = hostname;
return url.toString();
}
private getTwentyWorkspaceEndpoint(subdomain: string) {
const url = this.getFrontUrl();
url.hostname = `${subdomain}.${url.hostname}`;
return url.toString();
}
getworkspaceUrls({
subdomain,
hostname,
}: Pick<Workspace, 'subdomain' | 'hostname'>) {
return {
customUrl: hostname
? this.getCustomWorkspaceEndpoint(hostname)
: undefined,
subdomainUrl: this.getTwentyWorkspaceEndpoint(subdomain),
};
}
} }

View File

@ -29,7 +29,7 @@ export class EmailVerificationResolver {
return await this.emailVerificationService.resendEmailVerificationToken( return await this.emailVerificationService.resendEmailVerificationToken(
resendEmailVerificationTokenInput.email, resendEmailVerificationTokenInput.email,
workspace.subdomain, workspace,
); );
} }
} }

View File

@ -21,6 +21,7 @@ import {
import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -38,7 +39,7 @@ export class EmailVerificationService {
async sendVerificationEmail( async sendVerificationEmail(
userId: string, userId: string,
email: string, email: string,
subdomain: string, workspace: Pick<Workspace, 'subdomain' | 'hostname'>,
) { ) {
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
return { success: false }; return { success: false };
@ -51,7 +52,7 @@ export class EmailVerificationService {
this.domainManagerService.buildEmailVerificationURL({ this.domainManagerService.buildEmailVerificationURL({
emailVerificationToken, emailVerificationToken,
email, email,
subdomain, workspace,
}); });
const emailData = { const emailData = {
@ -80,7 +81,10 @@ export class EmailVerificationService {
return { success: true }; return { success: true };
} }
async resendEmailVerificationToken(email: string, subdomain: string) { async resendEmailVerificationToken(
email: string,
workspace: Pick<Workspace, 'subdomain' | 'hostname'>,
) {
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
throw new EmailVerificationException( throw new EmailVerificationException(
'Email verification token cannot be sent because email verification is not required', 'Email verification token cannot be sent because email verification is not required',
@ -121,7 +125,7 @@ export class EmailVerificationService {
await this.appTokenRepository.delete(existingToken.id); await this.appTokenRepository.delete(existingToken.id);
} }
await this.sendVerificationEmail(user.id, email, subdomain); await this.sendVerificationEmail(user.id, email, workspace);
return { success: true }; return { success: true };
} }

View File

@ -1,10 +1,13 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { CustomException } from 'src/utils/custom-exception'; import { CustomException } from 'src/utils/custom-exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable() @Injectable()
export class GuardRedirectService { export class GuardRedirectService {
@ -17,7 +20,7 @@ export class GuardRedirectService {
dispatchErrorFromGuard( dispatchErrorFromGuard(
context: ExecutionContext, context: ExecutionContext,
error: Error | CustomException, error: Error | CustomException,
workspace: { id?: string; subdomain: string }, workspace: { id?: string; subdomain: string; hostname?: string },
) { ) {
if ('contextType' in context && context.contextType === 'graphql') { if ('contextType' in context && context.contextType === 'graphql') {
throw error; throw error;
@ -29,15 +32,36 @@ export class GuardRedirectService {
.redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace)); .redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace));
} }
getSubdomainFromContext(context: ExecutionContext) { getSubdomainAndHostnameFromWorkspace(
const request = context.switchToHttp().getRequest(); workspace?: Pick<Workspace, 'subdomain' | 'hostname'> | null,
) {
if (!workspace) {
return {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
};
}
const subdomainFromUrl = return workspace;
this.domainManagerService.getWorkspaceSubdomainFromUrl( }
request.headers.referer,
);
return subdomainFromUrl ?? this.environmentService.get('DEFAULT_SUBDOMAIN'); getSubdomainAndHostnameFromContext(context: ExecutionContext): {
subdomain: string;
hostname?: string;
} {
const request = context.switchToHttp().getRequest<Request>();
const subdomainAndHostnameFromReferer = request.headers.referer
? this.domainManagerService.getSubdomainAndHostnameFromUrl(
request.headers.referer,
)
: null;
return {
subdomain:
subdomainAndHostnameFromReferer?.subdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
hostname: subdomainAndHostnameFromReferer?.hostname,
};
} }
private captureException(err: Error | CustomException, workspaceId?: string) { private captureException(err: Error | CustomException, workspaceId?: string) {
@ -52,13 +76,13 @@ export class GuardRedirectService {
getRedirectErrorUrlAndCaptureExceptions( getRedirectErrorUrlAndCaptureExceptions(
err: Error | CustomException, err: Error | CustomException,
workspace: { id?: string; subdomain: string }, workspace: { id?: string; subdomain: string; hostname?: string },
) { ) {
this.captureException(err, workspace.id); this.captureException(err, workspace.id);
return this.domainManagerService.computeRedirectErrorUrl( return this.domainManagerService.computeRedirectErrorUrl(
err instanceof AuthException ? err.message : 'Unknown error', err instanceof AuthException ? err.message : 'Unknown error',
workspace.subdomain, workspace,
); );
} }
} }

View File

@ -2,7 +2,7 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsBoolean, IsString } from 'class-validator';
@InputType() @InputType()
export class GetAuthorizationUrlInput { export class GetAuthorizationUrlInput {

View File

@ -15,6 +15,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({ @Module({
imports: [ imports: [
@ -29,6 +30,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
DataSourceModule, DataSourceModule,
WorkspaceDataSourceModule, WorkspaceDataSourceModule,
WorkspaceInvitationModule, WorkspaceInvitationModule,
DomainManagerModule,
TwentyORMModule, TwentyORMModule,
], ],
services: [UserWorkspaceService], services: [UserWorkspaceService],

View File

@ -24,6 +24,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> { export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor( constructor(
@ -37,6 +38,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
private readonly typeORMService: TypeORMService, private readonly typeORMService: TypeORMService,
private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly domainManagerService: DomainManagerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) { ) {
super(userWorkspaceRepository); super(userWorkspaceRepository);
@ -179,7 +181,9 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({ return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
id: userWorkspace.workspaceId, id: userWorkspace.workspaceId,
displayName: userWorkspace.workspace.displayName, displayName: userWorkspace.workspace.displayName,
subdomain: userWorkspace.workspace.subdomain, workspaceUrls: this.domainManagerService.getworkspaceUrls(
userWorkspace.workspace,
),
logo: userWorkspace.workspace.logo, logo: userWorkspace.workspace.logo,
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce( sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
(acc, identityProvider) => (acc, identityProvider) =>

View File

@ -19,7 +19,6 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
registerEnumType(OnboardingStatus, { registerEnumType(OnboardingStatus, {
name: 'OnboardingStatus', name: 'OnboardingStatus',
@ -100,7 +99,4 @@ export class User {
@Field(() => OnboardingStatus, { nullable: true }) @Field(() => OnboardingStatus, { nullable: true })
onboardingStatus: OnboardingStatus; onboardingStatus: OnboardingStatus;
@Field(() => Workspace, { nullable: true })
currentWorkspace: Relation<Workspace>;
} }

View File

@ -81,22 +81,7 @@ export class UserResolver {
) {} ) {}
@Query(() => User) @Query(() => User)
async currentUser( async currentUser(@AuthUser() { id: userId }: User): Promise<User> {
@AuthUser() { id: userId }: User,
@OriginHeader() origin: string,
): Promise<User> {
const workspace =
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
await this.userService.hasUserAccessToWorkspaceOrThrow(
userId,
workspace.id,
);
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
id: userId, id: userId,
@ -109,7 +94,7 @@ export class UserResolver {
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
); );
return { ...user, currentWorkspace: workspace }; return user;
} }
@ResolveField(() => GraphQLJSONObject) @ResolveField(() => GraphQLJSONObject)
@ -314,4 +299,9 @@ export class UserResolver {
return this.onboardingService.getOnboardingStatus(user, workspace); return this.onboardingService.getOnboardingStatus(user, workspace);
} }
@ResolveField(() => Workspace)
async currentWorkspace(@AuthWorkspace() workspace: Workspace) {
return workspace;
}
} }

View File

@ -279,7 +279,7 @@ export class WorkspaceInvitationService {
for (const invitation of invitationsPr) { for (const invitation of invitationsPr) {
if (invitation.status === 'fulfilled') { if (invitation.status === 'fulfilled') {
const link = this.domainManagerService.buildWorkspaceURL({ const link = this.domainManagerService.buildWorkspaceURL({
subdomain: workspace.subdomain, workspace,
pathname: `invite/${workspace?.inviteHash}`, pathname: `invite/${workspace?.inviteHash}`,
searchParams: invitation.value.isPersonalInvitation searchParams: invitation.value.isPersonalInvitation
? { ? {

View File

@ -5,6 +5,7 @@ import {
IdentityProviderType, IdentityProviderType,
SSOIdentityProviderStatus, SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
@ObjectType() @ObjectType()
export class SSOIdentityProvider { export class SSOIdentityProvider {
@ -56,9 +57,6 @@ export class PublicWorkspaceDataOutput {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
displayName: Workspace['displayName']; displayName: Workspace['displayName'];
@Field(() => String) @Field(() => workspaceUrls)
subdomain: Workspace['subdomain']; workspaceUrls: workspaceUrls;
@Field(() => String, { nullable: true })
hostname: Workspace['hostname'];
} }

View File

@ -0,0 +1,10 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class workspaceUrls {
@Field(() => String, { nullable: true })
customUrl?: string;
@Field(() => String)
subdomainUrl: string;
}

View File

@ -1,9 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
@ObjectType() @ObjectType()
export class WorkspaceSubdomainAndId { export class workspaceUrlsAndId {
@Field() @Field(() => workspaceUrls)
subdomain: string; workspaceUrls: workspaceUrls;
@Field() @Field()
id: string; id: string;

View File

@ -15,6 +15,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 { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { WorkspaceService } from './workspace.service'; import { WorkspaceService } from './workspace.service';
@ -81,6 +82,10 @@ describe('WorkspaceService', () => {
provide: FeatureFlagService, provide: FeatureFlagService,
useValue: {}, useValue: {},
}, },
{
provide: ExceptionHandlerService,
useValue: {},
},
], ],
}).compile(); }).compile();

View File

@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert'; import assert from 'assert';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { WorkspaceActivationStatus, isDefined } from 'twenty-shared'; import { isDefined, WorkspaceActivationStatus } from 'twenty-shared';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
@ -24,10 +24,14 @@ import {
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; 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 { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class WorkspaceService extends TypeOrmQueryService<Workspace> { export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly featureLookUpKey = BillingEntitlementKey.CUSTOM_DOMAIN;
constructor( constructor(
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
@ -42,10 +46,26 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly userWorkspaceService: UserWorkspaceService, private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) { ) {
super(workspaceRepository); super(workspaceRepository);
} }
private async isCustomDomainEnabled(workspaceId: string) {
const isCustomDomainBillingEnabled =
await this.billingService.hasEntitlement(
workspaceId,
this.featureLookUpKey,
);
if (!isCustomDomainBillingEnabled) {
throw new WorkspaceException(
`No entitlement found for this workspace`,
WorkspaceExceptionCode.WORKSPACE_CUSTOM_DOMAIN_DISABLED,
);
}
}
private async validateSubdomainUpdate(newSubdomain: string) { private async validateSubdomainUpdate(newSubdomain: string) {
const subdomainAvailable = await this.isSubdomainAvailable(newSubdomain); const subdomainAvailable = await this.isSubdomainAvailable(newSubdomain);
@ -61,6 +81,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
} }
private async setCustomDomain(workspace: Workspace, hostname: string) { private async setCustomDomain(workspace: Workspace, hostname: string) {
await this.isCustomDomainEnabled(workspace.id);
const existingWorkspace = await this.workspaceRepository.findOne({ const existingWorkspace = await this.workspaceRepository.findOne({
where: { hostname }, where: { hostname },
}); });
@ -126,10 +148,11 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
if (payload.hostname && customDomainRegistered) { if (payload.hostname && customDomainRegistered) {
this.domainManagerService this.domainManagerService
.deleteCustomHostnameByHostnameSilently(payload.hostname) .deleteCustomHostnameByHostnameSilently(payload.hostname)
.catch(() => { .catch((err) => {
// send to sentry this.exceptionHandlerService.captureExceptions([err]);
}); });
} }
throw error;
} }
} }

View File

@ -11,4 +11,5 @@ export enum WorkspaceExceptionCode {
SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN', SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN',
DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN', DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
WORKSPACE_CUSTOM_DOMAIN_DISABLED = 'WORKSPACE_CUSTOM_DOMAIN_DISABLED',
} }

View File

@ -17,7 +17,6 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; 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 { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -44,6 +43,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter'; import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
import { Workspace } from './workspace.entity'; import { Workspace } from './workspace.entity';
@ -214,6 +215,11 @@ export class WorkspaceResolver {
return isDefined(this.environmentService.get('ENTERPRISE_KEY')); return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
} }
@ResolveField(() => workspaceUrls)
workspaceUrls(@Parent() workspace: Workspace) {
return this.domainManagerService.getworkspaceUrls(workspace);
}
@Query(() => CustomHostnameDetails, { nullable: true }) @Query(() => CustomHostnameDetails, { nullable: true })
@UseGuards(WorkspaceAuthGuard) @UseGuards(WorkspaceAuthGuard)
async getHostnameDetails( async getHostnameDetails(
@ -225,7 +231,7 @@ export class WorkspaceResolver {
} }
@Query(() => PublicWorkspaceDataOutput) @Query(() => PublicWorkspaceDataOutput)
async getPublicWorkspaceDataBySubdomain( async getPublicWorkspaceDataByDomain(
@OriginHeader() origin: string, @OriginHeader() origin: string,
): Promise<PublicWorkspaceDataOutput | undefined> { ): Promise<PublicWorkspaceDataOutput | undefined> {
try { try {
@ -262,8 +268,7 @@ export class WorkspaceResolver {
id: workspace.id, id: workspace.id,
logo: workspaceLogoWithToken, logo: workspaceLogoWithToken,
displayName: workspace.displayName, displayName: workspace.displayName,
subdomain: workspace.subdomain, workspaceUrls: this.domainManagerService.getworkspaceUrls(workspace),
hostname: workspace.hostname,
authProviders: getAuthProvidersByWorkspace({ authProviders: getAuthProvidersByWorkspace({
workspace, workspace,
systemEnabledProviders, systemEnabledProviders,

View File

@ -15,5 +15,5 @@ export const EXCLUDED_MIDDLEWARE_OPERATIONS = [
'IntrospectionQuery', 'IntrospectionQuery',
'ExchangeAuthorizationCode', 'ExchangeAuthorizationCode',
'GetAuthorizationUrl', 'GetAuthorizationUrl',
'GetPublicWorkspaceDataBySubdomain', 'GetPublicWorkspaceDataByDomain',
] as const; ] as const;