diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3f0c60694..e4ac71868 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -109,6 +109,7 @@ export type AuthorizeApp = { export type AvailableWorkspaceOutput = { __typename?: 'AvailableWorkspaceOutput'; displayName?: Maybe; + hostname?: Maybe; id: Scalars['String']['output']; logo?: Maybe; sso: Array; @@ -375,6 +376,28 @@ export type CursorPaging = { last?: InputMaybe; }; +export type CustomHostnameDetails = { + __typename?: 'CustomHostnameDetails'; + hostname: Scalars['String']['output']; + id: Scalars['String']['output']; + ownershipVerifications: Array; + status?: Maybe; +}; + +export type CustomHostnameOwnershipVerificationHttp = { + __typename?: 'CustomHostnameOwnershipVerificationHttp'; + body: Scalars['String']['output']; + type: Scalars['String']['output']; + url: Scalars['String']['output']; +}; + +export type CustomHostnameOwnershipVerificationTxt = { + __typename?: 'CustomHostnameOwnershipVerificationTxt'; + name: Scalars['String']['output']; + type: Scalars['String']['output']; + value: Scalars['String']['output']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']['input']; @@ -1209,6 +1232,8 @@ export type OnboardingStepSuccess = { success: Scalars['Boolean']['output']; }; +export type OwnershipVerification = CustomHostnameOwnershipVerificationHttp | CustomHostnameOwnershipVerificationTxt; + export type PageInfo = { __typename?: 'PageInfo'; /** The cursor of the last returned record. */ @@ -1246,6 +1271,7 @@ export type PublicWorkspaceDataOutput = { __typename?: 'PublicWorkspaceDataOutput'; authProviders: AuthProviders; displayName?: Maybe; + hostname?: Maybe; id: Scalars['String']['output']; logo?: Maybe; subdomain: Scalars['String']['output']; @@ -1275,6 +1301,7 @@ export type Query = { findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']['output']; + getHostnameDetails?: Maybe; getPostgresCredentials?: Maybe; getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; @@ -1850,7 +1877,7 @@ export type UpdateWorkflowVersionStepInput = { export type UpdateWorkspaceInput = { allowImpersonation?: InputMaybe; displayName?: InputMaybe; - domainName?: InputMaybe; + hostname?: InputMaybe; inviteHash?: InputMaybe; isGoogleAuthEnabled?: InputMaybe; isMicrosoftAuthEnabled?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 8df9a05fd..f435d3e22 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -102,6 +102,7 @@ export type AuthorizeApp = { export type AvailableWorkspaceOutput = { __typename?: 'AvailableWorkspaceOutput'; displayName?: Maybe; + hostname?: Maybe; id: Scalars['String']; logo?: Maybe; sso: Array; @@ -312,6 +313,28 @@ export type CursorPaging = { last?: InputMaybe; }; +export type CustomHostnameDetails = { + __typename?: 'CustomHostnameDetails'; + hostname: Scalars['String']; + id: Scalars['String']; + ownershipVerifications: Array; + status?: Maybe; +}; + +export type CustomHostnameOwnershipVerificationHttp = { + __typename?: 'CustomHostnameOwnershipVerificationHttp'; + body: Scalars['String']; + type: Scalars['String']; + url: Scalars['String']; +}; + +export type CustomHostnameOwnershipVerificationTxt = { + __typename?: 'CustomHostnameOwnershipVerificationTxt'; + name: Scalars['String']; + type: Scalars['String']; + value: Scalars['String']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']; @@ -1076,6 +1099,8 @@ export type OnboardingStepSuccess = { success: Scalars['Boolean']; }; +export type OwnershipVerification = CustomHostnameOwnershipVerificationHttp | CustomHostnameOwnershipVerificationTxt; + export type PageInfo = { __typename?: 'PageInfo'; /** The cursor of the last returned record. */ @@ -1113,6 +1138,7 @@ export type PublicWorkspaceDataOutput = { __typename?: 'PublicWorkspaceDataOutput'; authProviders: AuthProviders; displayName?: Maybe; + hostname?: Maybe; id: Scalars['String']; logo?: Maybe; subdomain: Scalars['String']; @@ -1139,6 +1165,7 @@ export type Query = { findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']; + getHostnameDetails?: Maybe; getPostgresCredentials?: Maybe; getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; @@ -1638,7 +1665,7 @@ export type UpdateWorkflowVersionStepInput = { export type UpdateWorkspaceInput = { allowImpersonation?: InputMaybe; displayName?: InputMaybe; - domainName?: InputMaybe; + hostname?: InputMaybe; inviteHash?: InputMaybe; isGoogleAuthEnabled?: InputMaybe; isMicrosoftAuthEnabled?: InputMaybe; @@ -2041,12 +2068,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, 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, 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 GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>; -export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } }; +export type 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 ValidatePasswordResetTokenQueryVariables = Exact<{ token: Scalars['String']; @@ -2150,7 +2177,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 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, 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, 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 DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -2167,7 +2194,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, 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, 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, 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 ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -2270,7 +2297,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{ }>; -export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } }; +export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, hostname?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } }; export type UploadWorkspaceLogoMutationVariables = Exact<{ file: Scalars['Upload']; @@ -2279,6 +2306,11 @@ export type UploadWorkspaceLogoMutationVariables = Exact<{ export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: string }; +export type GetHostnameDetailsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetHostnameDetailsQuery = { __typename?: 'Query', getHostnameDetails?: { __typename?: 'CustomHostnameDetails', hostname: string, status?: string | null, ownershipVerifications: Array<{ __typename?: 'CustomHostnameOwnershipVerificationHttp', type: string, body: string, url: string } | { __typename?: 'CustomHostnameOwnershipVerificationTxt', type: string, name: string, value: string }> } | null }; + export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ inviteHash: Scalars['String']; }>; @@ -2436,6 +2468,7 @@ export const UserQueryFragmentFragmentDoc = gql` isPasswordAuthEnabled subdomain hasValidEnterpriseKey + hostname featureFlags { id key @@ -3223,6 +3256,7 @@ export const CheckUserExistsDocument = gql` id displayName subdomain + hostname logo sso { type @@ -3276,6 +3310,7 @@ export const GetPublicWorkspaceDataBySubdomainDocument = gql` logo displayName subdomain + hostname authProviders { sso { id @@ -4512,6 +4547,7 @@ export const UpdateWorkspaceDocument = gql` mutation UpdateWorkspace($input: UpdateWorkspaceInput!) { updateWorkspace(data: $input) { id + hostname subdomain displayName logo @@ -4580,6 +4616,53 @@ export function useUploadWorkspaceLogoMutation(baseOptions?: Apollo.MutationHook export type UploadWorkspaceLogoMutationHookResult = ReturnType; export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult; export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions; +export const GetHostnameDetailsDocument = gql` + query GetHostnameDetails { + getHostnameDetails { + hostname + ownershipVerifications { + ... on CustomHostnameOwnershipVerificationTxt { + type + name + value + } + ... on CustomHostnameOwnershipVerificationHttp { + type + body + url + } + } + status + } +} + `; + +/** + * __useGetHostnameDetailsQuery__ + * + * To run a query within a React component, call `useGetHostnameDetailsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetHostnameDetailsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetHostnameDetailsQuery({ + * variables: { + * }, + * }); + */ +export function useGetHostnameDetailsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetHostnameDetailsDocument, options); + } +export function useGetHostnameDetailsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetHostnameDetailsDocument, options); + } +export type GetHostnameDetailsQueryHookResult = ReturnType; +export type GetHostnameDetailsLazyQueryHookResult = ReturnType; +export type GetHostnameDetailsQueryResult = Apollo.QueryResult; export const GetWorkspaceFromInviteHashDocument = gql` query GetWorkspaceFromInviteHash($inviteHash: String!) { findWorkspaceFromInviteHash(inviteHash: $inviteHash) { diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts index 9ecd200ab..389a67061 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts @@ -10,6 +10,7 @@ export const CHECK_USER_EXISTS = gql` id displayName subdomain + hostname logo sso { type diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts index b91ccf5fd..4b5ad7efd 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts @@ -7,6 +7,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql` logo displayName subdomain + hostname authProviders { sso { id diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 1f17d8214..d299d2246 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -20,6 +20,7 @@ export type CurrentWorkspace = Pick< | 'isPasswordAuthEnabled' | 'hasValidEnterpriseKey' | 'subdomain' + | 'hostname' | 'metadataVersion' >; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts index 91cd19554..d0a51a08f 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts @@ -158,6 +158,7 @@ export const queries = { isPasswordAuthEnabled subdomain hasValidEnterpriseKey + hostname featureFlags { id key @@ -307,6 +308,7 @@ export const responseData = { isMicrosoftAuthEnabled: false, isPasswordAuthEnabled: true, subdomain: 'test', + hostname: null, featureFlags: [], metadataVersion: 1, currentBillingSubscription: null, diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index c6352d7c0..607afc377 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -37,6 +37,7 @@ export const USER_QUERY_FRAGMENT = gql` isPasswordAuthEnabled subdomain hasValidEnterpriseKey + hostname featureFlags { id key diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts index 8810a052a..6809dc783 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts @@ -4,6 +4,7 @@ export const UPDATE_WORKSPACE = gql` mutation UpdateWorkspace($input: UpdateWorkspaceInput!) { updateWorkspace(data: $input) { id + hostname subdomain displayName logo diff --git a/packages/twenty-front/src/modules/workspace/graphql/queries/getHostnameDetails.ts b/packages/twenty-front/src/modules/workspace/graphql/queries/getHostnameDetails.ts new file mode 100644 index 000000000..113318e45 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/graphql/queries/getHostnameDetails.ts @@ -0,0 +1,22 @@ +import { gql } from '@apollo/client'; + +export const GET_HOSTNAME_DETAILS = gql` + query GetHostnameDetails { + getHostnameDetails { + hostname + ownershipVerifications { + ... on CustomHostnameOwnershipVerificationTxt { + type + name + value + } + ... on CustomHostnameOwnershipVerificationHttp { + type + body + url + } + } + status + } + } +`; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index dc7560ccc..f474a581e 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -1,42 +1,27 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; -import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { useRecoilState } from 'recoil'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; -import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { SettingsPath } from '@/types/SettingsPath'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { TextInputV2 } from '@/ui/input/components/TextInputV2'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { ApolloError } from '@apollo/client'; -import styled from '@emotion/styled'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { Controller, useForm } from 'react-hook-form'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { H2Title, Section } from 'twenty-ui'; -import { z } from 'zod'; -import { useUpdateWorkspaceMutation } from '~/generated/graphql'; +import { + FeatureFlagKey, + useUpdateWorkspaceMutation, +} from '~/generated/graphql'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { SettingsHostname } from '~/pages/settings/workspace/SettingsHostname'; +import { SettingsSubdomain } from '~/pages/settings/workspace/SettingsSubdomain'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { isDefined } from '~/utils/isDefined'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; - -type Form = { - subdomain: string; -}; - -const StyledDomainFromWrapper = styled.div` - align-items: center; - display: flex; -`; - -const StyledDomain = styled.h2` - align-self: flex-start; - color: ${({ theme }) => theme.font.color.secondary}; - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - margin: ${({ theme }) => theme.spacing(2)}; -`; +import { ApolloError } from '@apollo/client'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { z } from 'zod'; +import { FormProvider, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { SettingsPath } from '@/types/SettingsPath'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsHostnameEffect } from '~/pages/settings/workspace/SettingsHostnameEffect'; export const SettingsDomain = () => { const navigate = useNavigateSettings(); @@ -54,22 +39,21 @@ export const SettingsDomain = () => { }) .required(); - const domainConfiguration = useRecoilValue(domainConfigurationState); - const { enqueueSnackBar } = useSnackBar(); const [updateWorkspace] = useUpdateWorkspaceMutation(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const isCustomDomainEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsCustomDomainEnabled, + ); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); - const { - control, - watch, - getValues, - formState: { isValid }, - } = useForm
({ + const form = useForm<{ + subdomain: string; + }>({ mode: 'onChange', delayError: 500, defaultValues: { @@ -78,12 +62,12 @@ export const SettingsDomain = () => { resolver: zodResolver(validationSchema), }); - const subdomainValue = watch('subdomain'); + const subdomainValue = form.watch('subdomain'); const handleSave = async () => { - const values = getValues(); + const values = form.getValues(); - if (!values || !isValid || !currentWorkspace) { + if (!values || !form.formState.isValid || !currentWorkspace) { return enqueueSnackBar(t`Invalid form values`, { variant: SnackBarVariant.Error, }); @@ -100,7 +84,7 @@ export const SettingsDomain = () => { error instanceof ApolloError && error.graphQLErrors[0]?.extensions?.code === 'CONFLICT' ) { - return control.setError('subdomain', { + return form.control.setError('subdomain', { type: 'manual', message: t`Subdomain already taken`, }); @@ -137,7 +121,8 @@ export const SettingsDomain = () => { actionButton={ navigate(SettingsPath.Workspace)} onSave={handleSave} @@ -145,39 +130,18 @@ export const SettingsDomain = () => { } > -
- - {currentWorkspace?.subdomain && ( - - ( - <> - - {isDefined(domainConfiguration.frontDomain) && ( - - .{domainConfiguration.frontDomain} - - )} - - )} - /> - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + {isCustomDomainEnabled && ( + <> + + + )} -
+ {(!currentWorkspace?.hostname || !isCustomDomainEnabled) && ( + + )} +
); diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsHostname.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsHostname.tsx new file mode 100644 index 000000000..54799534e --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsHostname.tsx @@ -0,0 +1,150 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, H2Title, Section } from 'twenty-ui'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilState } from 'recoil'; +import styled from '@emotion/styled'; +import { + useUpdateWorkspaceMutation, + useGetHostnameDetailsQuery, +} from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { useLingui } from '@lingui/react/macro'; + +const validationSchema = z + .object({ + hostname: z + .string() + .regex( + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, + { + message: + "Invalid custom hostname. Custom hostnames have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~`!@#$%^*()=+{}[]|\\;:'\",<>/? and cannot begin or end with a '-' character.", + }, + ) + .max(256) + .nullable(), + }) + .required(); + +type Form = z.infer; + +const StyledDomainFromWrapper = styled.div` + align-items: center; + display: flex; +`; + +export const SettingsHostname = () => { + const [updateWorkspace] = useUpdateWorkspaceMutation(); + const { data: getHostnameDetailsData } = useGetHostnameDetailsQuery(); + + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const { t } = useLingui(); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + + const { + control, + getValues, + clearErrors, + handleSubmit, + formState: { isValid }, + } = useForm({ + mode: 'onSubmit', + defaultValues: { + hostname: currentWorkspace?.hostname ?? '', + }, + resolver: zodResolver(validationSchema), + }); + + const handleDelete = async () => { + try { + if (!currentWorkspace) { + throw new Error('Invalid form values'); + } + + await updateWorkspace({ + variables: { + input: { + hostname: null, + }, + }, + }); + + redirectToWorkspaceDomain(currentWorkspace.subdomain); + } catch (error) { + control.setError('hostname', { + type: 'manual', + message: (error as Error).message, + }); + } + }; + + const handleSave = async () => { + const values = getValues(); + try { + clearErrors(); + + if (!values || !isValid || !currentWorkspace) { + throw new Error('Invalid form values'); + } + + await updateWorkspace({ + variables: { + input: { + hostname: values.hostname, + }, + }, + }); + + setCurrentWorkspace({ + ...currentWorkspace, + hostname: values.hostname, + }); + + // redirectToWorkspaceDomain(values.subdomain); + } catch (error) { + control.setError('hostname', { + type: 'manual', + message: (error as Error).message, + }); + } + }; + + return ( +
+ + + ( + + )} + /> + + + + {isDefined(getHostnameDetailsData?.getHostnameDetails?.hostname) && ( +
+          {getHostnameDetailsData.getHostnameDetails.hostname} CNAME
+          app.twenty-main.com
+        
+ )} + {getHostnameDetailsData && ( +
{JSON.stringify(getHostnameDetailsData, null, 4)}
+ )} +
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsHostnameEffect.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsHostnameEffect.tsx new file mode 100644 index 000000000..099ccdab4 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsHostnameEffect.tsx @@ -0,0 +1,28 @@ +import { isDefined } from '~/utils/isDefined'; +import { useEffect } from 'react'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilValue } from 'recoil'; +import { useGetHostnameDetailsQuery } from '~/generated/graphql'; + +export const SettingsHostnameEffect = () => { + const { refetch } = useGetHostnameDetailsQuery(); + + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + useEffect(() => { + let pollIntervalFn: null | ReturnType = null; + if (isDefined(currentWorkspace?.hostname)) { + pollIntervalFn = setInterval(async () => { + refetch(); + }, 3000); + } + + return () => { + if (isDefined(pollIntervalFn)) { + clearInterval(pollIntervalFn); + } + }; + }, [currentWorkspace?.hostname, refetch]); + + return <>; +}; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx new file mode 100644 index 000000000..3b96e295c --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx @@ -0,0 +1,63 @@ +import { H2Title, Section } from 'twenty-ui'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { Controller, useFormContext } from 'react-hook-form'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; + +import { isDefined } from '~/utils/isDefined'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; +import { useRecoilValue } from 'recoil'; + +const StyledDomainFormWrapper = styled.div` + align-items: center; + display: flex; +`; + +const StyledDomain = styled.h2` + align-self: flex-start; + color: ${({ theme }) => theme.font.color.secondary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin: ${({ theme }) => theme.spacing(2)}; + white-space: nowrap; +`; + +export const SettingsSubdomain = () => { + const domainConfiguration = useRecoilValue(domainConfigurationState); + const { t } = useLingui(); + + const { control } = useFormContext<{ + subdomain: string; + }>(); + + return ( +
+ + + ( + <> + + {isDefined(domainConfiguration.frontDomain) && ( + + {`.${domainConfiguration.frontDomain}`} + + )} + + )} + /> + +
+ ); +}; diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 76c24492d..7311886a3 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -77,4 +77,6 @@ FRONT_PORT=3001 # SESSION_STORE_SECRET=replace_me_with_a_random_string_session # ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key # SSL_KEY_PATH="./certs/your-cert.key" -# SSL_CERT_PATH="./certs/your-cert.crt" \ No newline at end of file +# SSL_CERT_PATH="./certs/your-cert.crt" +# CLOUDFLARE_API_KEY= +# CLOUDFLARE_ZONE_ID= \ No newline at end of file diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 8ff02ed91..ba3ed9a20 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -30,6 +30,7 @@ "cache-manager": "^5.4.0", "cache-manager-redis-yet": "^4.1.2", "class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch", + "cloudflare": "^3.5.0", "connect-redis": "^7.1.1", "express-session": "^1.18.1", "graphql-middleware": "^6.1.35", diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts index 01c99f8af..45d01281b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts @@ -37,6 +37,9 @@ export class AvailableWorkspaceOutput { @Field(() => String) subdomain: string; + @Field(() => String, { nullable: true }) + hostname?: string; + @Field(() => String, { nullable: true }) logo?: string; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts index e8bff0f28..99e0b45a1 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts @@ -7,5 +7,7 @@ export class DomainManagerException extends CustomException { } export enum DomainManagerExceptionCode { + CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED', + HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED', SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED', } diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-hostname-details.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-hostname-details.ts new file mode 100644 index 000000000..c133b2357 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-hostname-details.ts @@ -0,0 +1,75 @@ +import { createUnionType, Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +class CustomHostnameOwnershipVerificationTxt { + @Field(() => String) + type: 'txt'; + + @Field(() => String) + name: string; + + @Field(() => String) + value: string; +} + +@ObjectType() +class CustomHostnameOwnershipVerificationHttp { + @Field() + type: 'http'; + + @Field(() => String) + body: string; + + @Field(() => String) + url: string; +} + +const CustomHostnameOwnershipVerification = createUnionType({ + name: 'OwnershipVerification', + types: () => + [ + CustomHostnameOwnershipVerificationTxt, + CustomHostnameOwnershipVerificationHttp, + ] as const, + resolveType(value) { + if ('type' in value && value.type === 'txt') { + return CustomHostnameOwnershipVerificationTxt; + } + if ('type' in value && value.type === 'http') { + return CustomHostnameOwnershipVerificationHttp; + } + + return null; + }, +}); + +@ObjectType() +export class CustomHostnameDetails { + @Field(() => String) + id: string; + + @Field(() => String) + hostname: string; + + @Field(() => [CustomHostnameOwnershipVerification]) + ownershipVerifications: Array; + + @Field(() => String, { nullable: true }) + status?: + | 'active' + | 'pending' + | 'active_redeploying' + | 'moved' + | 'pending_deletion' + | 'deleted' + | 'pending_blocked' + | 'pending_migration' + | 'pending_provisioned' + | 'test_pending' + | 'test_active' + | 'test_active_apex' + | 'test_blocked' + | 'test_failed' + | 'provisioned' + | 'blocked'; +} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts index 54595e567..f0577cff2 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts @@ -2,24 +2,36 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import Cloudflare from 'cloudflare'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { - WorkspaceException, - WorkspaceExceptionCode, -} from 'src/engine/core-modules/workspace/workspace.exception'; -import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { isDefined } from 'src/utils/is-defined'; -import { isWorkEmail } from 'src/utils/is-work-email'; +import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate'; +import { + DomainManagerException, + DomainManagerExceptionCode, +} from 'src/engine/core-modules/domain-manager/domain-manager.exception'; +import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain'; +import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name'; +import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email'; +import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details'; @Injectable() export class DomainManagerService { + cloudflareClient?: Cloudflare; + constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, private readonly environmentService: EnvironmentService, - ) {} + ) { + if (this.environmentService.get('CLOUDFLARE_API_KEY')) { + this.cloudflareClient = new Cloudflare({ + apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'), + }); + } + } getFrontUrl() { let baseUrl: URL; @@ -105,6 +117,7 @@ export class DomainManagerService { return url; } + // @Deprecated getWorkspaceSubdomainFromUrl = (url: string) => { const { hostname: originHostname } = new URL(url); @@ -119,6 +132,24 @@ export class DomainManagerService { return this.isDefaultSubdomain(subdomain) ? null : subdomain; }; + getSubdomainAndHostnameFromUrl = (url: string) => { + const { hostname: originHostname } = new URL(url); + + const frontDomain = this.getFrontUrl().hostname; + + const isFrontdomain = originHostname.endsWith(`.${frontDomain}`); + + const subdomain = originHostname.replace(`.${frontDomain}`, ''); + + return { + subdomain: + isFrontdomain && !this.isDefaultSubdomain(subdomain) + ? subdomain + : undefined, + hostname: isFrontdomain ? undefined : originHostname, + }; + }; + async getWorkspaceBySubdomainOrDefaultWorkspace(subdomain?: string) { return subdomain ? await this.workspaceRepository.findOne({ @@ -180,121 +211,37 @@ export class DomainManagerService { } async getWorkspaceByOriginOrDefaultWorkspace(origin: string) { - try { - if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { - return this.getDefaultWorkspace(); - } - - const subdomain = this.getWorkspaceSubdomainFromUrl(origin); - - if (!isDefined(subdomain)) return; - - return ( - (await this.workspaceRepository.findOne({ - where: { subdomain }, - relations: ['workspaceSSOIdentityProviders'], - })) ?? undefined - ); - } catch (e) { - throw new WorkspaceException( - 'Workspace not found', - WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND, - ); + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + return this.getDefaultWorkspace(); } + + const { subdomain, hostname } = this.getSubdomainAndHostnameFromUrl(origin); + + if (!hostname && !subdomain) return; + + const where = isDefined(hostname) ? { hostname } : { subdomain }; + + return ( + (await this.workspaceRepository.findOne({ + where, + relations: ['workspaceSSOIdentityProviders'], + })) ?? undefined + ); } - private generateRandomSubdomain(): string { - const prefixes = [ - 'cool', - 'smart', - 'fast', - 'bright', - 'shiny', - 'happy', - 'funny', - 'clever', - 'brave', - 'kind', - 'gentle', - 'quick', - 'sharp', - 'calm', - 'silent', - 'lucky', - 'fierce', - 'swift', - 'mighty', - 'noble', - 'bold', - 'wise', - 'eager', - 'joyful', - 'glad', - 'zany', - 'witty', - 'bouncy', - 'graceful', - 'colorful', - ]; - const suffixes = [ - 'raccoon', - 'panda', - 'whale', - 'tiger', - 'dolphin', - 'eagle', - 'penguin', - 'owl', - 'fox', - 'wolf', - 'lion', - 'bear', - 'hawk', - 'shark', - 'sparrow', - 'moose', - 'lynx', - 'falcon', - 'rabbit', - 'hedgehog', - 'monkey', - 'horse', - 'koala', - 'kangaroo', - 'elephant', - 'giraffe', - 'panther', - 'crocodile', - 'seal', - 'octopus', - ]; + private extractSubdomain(params?: { email?: string; displayName?: string }) { + if (params?.email) { + return getSubdomainFromEmail(params.email); + } - const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)]; - const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)]; - - return `${randomPrefix}-${randomSuffix}`; - } - - private getSubdomainNameByEmail(email?: string) { - if (!isDefined(email) || !isWorkEmail(email)) return; - - return getDomainNameByEmail(email); - } - - private getSubdomainNameByDisplayName(displayName?: string) { - if (!isDefined(displayName)) return; - const displayNameWords = displayName.match(/(\w| |\d)+/g); - - if (displayNameWords) { - return displayNameWords.join('-').replace(/ /g, '').toLowerCase(); + if (params?.displayName) { + return getSubdomainNameFromDisplayName(params.displayName); } } async generateSubdomain(params?: { email?: string; displayName?: string }) { const subdomain = - this.getSubdomainNameByEmail(params?.email) ?? - this.getSubdomainNameByDisplayName(params?.displayName) ?? - this.generateRandomSubdomain(); + this.extractSubdomain(params) ?? generateRandomSubdomain(); const existingWorkspaceCount = await this.workspaceRepository.countBy({ subdomain, @@ -302,4 +249,133 @@ export class DomainManagerService { return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`; } + + async registerCustomHostname(hostname: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + if (await this.getCustomHostnameDetails(hostname)) { + throw new DomainManagerException( + 'Hostname already registered', + DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED, + ); + } + + return await this.cloudflareClient.customHostnames.create({ + zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), + hostname, + ssl: { + method: 'txt', + type: 'dv', + settings: { + http2: 'on', + min_tls_version: '1.2', + tls_1_3: 'on', + ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'], + early_hints: 'on', + }, + bundle_method: 'ubiquitous', + wildcard: false, + }, + }); + } + + async getCustomHostnameDetails( + hostname: string, + ): Promise { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + const response = await this.cloudflareClient.customHostnames.list({ + zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), + hostname, + }); + + if (response.result.length === 0) { + return undefined; + } + + if (response.result.length === 1) { + return { + id: response.result[0].id, + hostname: response.result[0].hostname, + status: response.result[0].status, + ownershipVerifications: [ + response.result[0].ownership_verification, + response.result[0].ownership_verification_http, + ].reduce( + (acc, ownershipVerification) => { + if (!ownershipVerification) return acc; + + if ( + 'http_body' in ownershipVerification && + 'http_url' in ownershipVerification && + ownershipVerification.http_body && + ownershipVerification.http_url + ) { + acc.push({ + type: 'http', + body: ownershipVerification.http_body, + url: ownershipVerification.http_url, + }); + } + + if ( + 'type' in ownershipVerification && + ownershipVerification.type === 'txt' && + ownershipVerification.value && + ownershipVerification.name + ) { + acc.push({ + type: 'txt', + value: ownershipVerification.value, + name: ownershipVerification.name, + }); + } + + return acc; + }, + [] as CustomHostnameDetails['ownershipVerifications'], + ), + }; + } + + // should never append. error 5xx + throw new Error('More than one custom hostname found in cloudflare'); + } + + async updateCustomHostname(fromHostname: string, toHostname: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + const fromCustomHostname = + await this.getCustomHostnameDetails(fromHostname); + + if (fromCustomHostname) { + await this.deleteCustomHostname(fromCustomHostname.id); + } + + return await this.registerCustomHostname(toHostname); + } + + async deleteCustomHostnameByHostnameSilently(hostname: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + try { + const customHostname = await this.getCustomHostnameDetails(hostname); + + if (customHostname) { + await this.cloudflareClient.customHostnames.delete(customHostname.id, { + zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), + }); + } + } catch (err) { + return; + } + } + + async deleteCustomHostname(customHostnameId: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + return this.cloudflareClient.customHostnames.delete(customHostnameId, { + zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/generate-random-subdomain.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/generate-random-subdomain.spec.ts new file mode 100644 index 000000000..534fea809 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/generate-random-subdomain.spec.ts @@ -0,0 +1,16 @@ +import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain'; + +describe('generateRandomSubdomain', () => { + it('should return a string in the format "prefix-suffix"', () => { + const result = generateRandomSubdomain(); + + expect(result).toMatch(/^[a-z]+-[a-z]+$/); + }); + + it('should generate different results on consecutive calls', () => { + const result1 = generateRandomSubdomain(); + const result2 = generateRandomSubdomain(); + + expect(result1).not.toEqual(result2); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/get-subdomain-from-email.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/get-subdomain-from-email.spec.ts new file mode 100644 index 000000000..342c9dce1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/get-subdomain-from-email.spec.ts @@ -0,0 +1,25 @@ +import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email'; + +describe('getSubdomainFromEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined if email is not defined', () => { + const result = getSubdomainFromEmail(undefined); + + expect(result).toBeUndefined(); + }); + + it('should return undefined if email is not a work email', () => { + const result = getSubdomainFromEmail('test@gmail.com'); + + expect(result).toBeUndefined(); + }); + + it('should return the domain name if email is valid and a work email', () => { + const result = getSubdomainFromEmail('test@twenty.com'); + + expect(result).toBe('twenty'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/get-subdomain-name-from-display-name.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/get-subdomain-name-from-display-name.spec.ts new file mode 100644 index 000000000..5cd28198e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/__test__/get-subdomain-name-from-display-name.spec.ts @@ -0,0 +1,41 @@ +import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name'; + +describe('getSubdomainNameFromDisplayName', () => { + it('should return a hyphen-separated, lowercase subdomain name without spaces for a valid display name', () => { + const result = getSubdomainNameFromDisplayName('My Display Name 123'); + + expect(result).toBe('my-display-name-123'); + }); + + it('should return undefined if displayName is undefined', () => { + const result = getSubdomainNameFromDisplayName(undefined); + + expect(result).toBeUndefined(); + }); + + it('should handle display names with special characters by removing them but keeping words and numbers', () => { + const result = getSubdomainNameFromDisplayName('Hello!@# World$%^ 2023'); + + expect(result).toBe('hello-world-2023'); + }); + + it('should return a single word in lowercase if displayName consists of one valid word', () => { + const result = getSubdomainNameFromDisplayName('SingleWord'); + + expect(result).toBe('singleword'); + }); + + it('should return undefined when displayName contains only special characters', () => { + const result = getSubdomainNameFromDisplayName('!@#$%^&*()'); + + expect(result).toBeUndefined(); + }); + + it('should handle display names with multiple spaces by removing them', () => { + const result = getSubdomainNameFromDisplayName( + ' Spaced Out Name ', + ); + + expect(result).toBe('spaced-out-name'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/utils/generate-random-subdomain.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/generate-random-subdomain.ts new file mode 100644 index 000000000..4eccfe55b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/generate-random-subdomain.ts @@ -0,0 +1,71 @@ +export const generateRandomSubdomain = () => { + const prefixes = [ + 'cool', + 'smart', + 'fast', + 'bright', + 'shiny', + 'happy', + 'funny', + 'clever', + 'brave', + 'kind', + 'gentle', + 'quick', + 'sharp', + 'calm', + 'silent', + 'lucky', + 'fierce', + 'swift', + 'mighty', + 'noble', + 'bold', + 'wise', + 'eager', + 'joyful', + 'glad', + 'zany', + 'witty', + 'bouncy', + 'graceful', + 'colorful', + ]; + const suffixes = [ + 'raccoon', + 'panda', + 'whale', + 'tiger', + 'dolphin', + 'eagle', + 'penguin', + 'owl', + 'fox', + 'wolf', + 'lion', + 'bear', + 'hawk', + 'shark', + 'sparrow', + 'moose', + 'lynx', + 'falcon', + 'rabbit', + 'hedgehog', + 'monkey', + 'horse', + 'koala', + 'kangaroo', + 'elephant', + 'giraffe', + 'panther', + 'crocodile', + 'seal', + 'octopus', + ]; + + const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)]; + const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)]; + + return `${randomPrefix}-${randomSuffix}`; +}; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/utils/get-subdomain-from-email.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/get-subdomain-from-email.ts new file mode 100644 index 000000000..9e5a3ea11 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/get-subdomain-from-email.ts @@ -0,0 +1,11 @@ +import { isDefined } from 'src/utils/is-defined'; +import { isWorkEmail } from 'src/utils/is-work-email'; +import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; + +export const getSubdomainFromEmail = (email?: string) => { + if (!isDefined(email) || !isWorkEmail(email)) return; + + const domain = getDomainNameByEmail(email); + + return domain.split('.')[0]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name.ts new file mode 100644 index 000000000..e53b20eac --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name.ts @@ -0,0 +1,10 @@ +import { isDefined } from 'src/utils/is-defined'; + +export const getSubdomainNameFromDisplayName = (displayName?: string) => { + if (!isDefined(displayName)) return; + const displayNameWords = displayName.match(/(\w|\d)+/g); + + if (displayNameWords) { + return displayNameWords.join('-').replace(/ /g, '').toLowerCase(); + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/validator/cloudflare.validate.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/validator/cloudflare.validate.ts new file mode 100644 index 000000000..2926e285c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/validator/cloudflare.validate.ts @@ -0,0 +1,23 @@ +import Cloudflare from 'cloudflare'; + +import { + DomainManagerException, + DomainManagerExceptionCode, +} from 'src/engine/core-modules/domain-manager/domain-manager.exception'; + +const isCloudflareInstanceDefined = ( + cloudflareInstance: Cloudflare | undefined | null, +): asserts cloudflareInstance is Cloudflare => { + if (!cloudflareInstance) { + throw new DomainManagerException( + 'Cloudflare instance is not defined', + DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED, + ); + } +}; + +export const domainManagerValidator: { + isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined; +} = { + isCloudflareInstanceDefined, +}; diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index f605ea9f4..5547a1638 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -242,6 +242,14 @@ export class EnvironmentVariables { @IsBoolean() IS_MULTIWORKSPACE_ENABLED = false; + @IsString() + @ValidateIf((env) => env.CLOUDFLARE_ZONE_ID) + CLOUDFLARE_API_KEY: string; + + @IsString() + @ValidateIf((env) => env.CLOUDFLARE_API_KEY) + CLOUDFLARE_ZONE_ID: string; + // Custom Code Engine @IsEnum(ServerlessDriverType) @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts index cc5786a29..9b6fa8c21 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts @@ -58,4 +58,7 @@ export class PublicWorkspaceDataOutput { @Field(() => String) subdomain: Workspace['subdomain']; + + @Field(() => String, { nullable: true }) + hostname: Workspace['hostname']; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index 0b78cb537..2bcc3ee2f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -10,11 +10,6 @@ import { @InputType() export class UpdateWorkspaceInput { - @Field({ nullable: true }) - @IsString() - @IsOptional() - domainName?: string; - @Field({ nullable: true }) @IsString() @IsOptional() @@ -105,6 +100,14 @@ export class UpdateWorkspaceInput { ]) subdomain?: string; + @Field({ nullable: true }) + @IsString() + @IsOptional() + @Matches( + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, + ) + hostname?: string; + @Field({ nullable: true }) @IsString() @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 46bf66260..ed724900e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -23,6 +23,8 @@ import { import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; 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 { isDefined } from 'src/utils/is-defined'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -40,43 +42,96 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly billingService: BillingService, private readonly userWorkspaceService: UserWorkspaceService, private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, ) { super(workspaceRepository); } + private async validateSubdomainUpdate(newSubdomain: string) { + const subdomainAvailable = await this.isSubdomainAvailable(newSubdomain); + + if ( + !subdomainAvailable || + this.environmentService.get('DEFAULT_SUBDOMAIN') === newSubdomain + ) { + throw new WorkspaceException( + 'Subdomain already taken', + WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN, + ); + } + } + + private async setCustomDomain(workspace: Workspace, hostname: string) { + const existingWorkspace = await this.workspaceRepository.findOne({ + where: { hostname }, + }); + + if (existingWorkspace && existingWorkspace.id !== workspace.id) { + throw new WorkspaceException( + 'Domain already taken', + WorkspaceExceptionCode.DOMAIN_ALREADY_TAKEN, + ); + } + + if ( + hostname && + workspace.hostname !== hostname && + isDefined(workspace.hostname) + ) { + await this.domainManagerService.updateCustomHostname( + workspace.hostname, + hostname, + ); + } + + if ( + hostname && + workspace.hostname !== hostname && + !isDefined(workspace.hostname) + ) { + await this.domainManagerService.registerCustomHostname(hostname); + } + } + async updateWorkspaceById(payload: Partial & { id: string }) { const workspace = await this.workspaceRepository.findOneBy({ id: payload.id, }); - workspaceValidator.assertIsDefinedOrThrow( - workspace, - new WorkspaceException( - 'Workspace not found', - WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, - ), - ); + workspaceValidator.assertIsDefinedOrThrow(workspace); if (payload.subdomain && workspace.subdomain !== payload.subdomain) { - const subdomainAvailable = await this.isSubdomainAvailable( - payload.subdomain, - ); - - if ( - !subdomainAvailable || - this.environmentService.get('DEFAULT_SUBDOMAIN') === payload.subdomain - ) { - throw new WorkspaceException( - 'Subdomain already taken', - WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN, - ); - } + await this.validateSubdomainUpdate(payload.subdomain); } - return this.workspaceRepository.save({ - ...workspace, - ...payload, - }); + let customDomainRegistered = false; + + if (payload.hostname === null && isDefined(workspace.hostname)) { + await this.domainManagerService.deleteCustomHostnameByHostnameSilently( + workspace.hostname, + ); + } + + if (payload.hostname && workspace.hostname !== payload.hostname) { + await this.setCustomDomain(workspace, payload.hostname); + customDomainRegistered = true; + } + + try { + return await this.workspaceRepository.save({ + ...workspace, + ...payload, + }); + } catch (error) { + // revert custom domain registration on error + if (payload.hostname && customDomainRegistered) { + this.domainManagerService + .deleteCustomHostnameByHostnameSilently(payload.hostname) + .catch(() => { + // send to sentry + }); + } + } } async activateWorkspace( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts index 00d05dfa3..933a4fee9 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts @@ -9,5 +9,6 @@ export class WorkspaceException extends CustomException { export enum WorkspaceExceptionCode { SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND', SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN', + DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN', WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 83cc2401f..ba92894be 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -33,10 +33,6 @@ import { import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input'; import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util'; import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util'; -import { - WorkspaceException, - WorkspaceExceptionCode, -} from 'src/engine/core-modules/workspace/workspace.exception'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -48,6 +44,7 @@ import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation import { assert } from 'src/utils/assert'; import { isDefined } from 'src/utils/is-defined'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details'; import { Workspace } from './workspace.entity'; @@ -218,21 +215,27 @@ export class WorkspaceResolver { return isDefined(this.environmentService.get('ENTERPRISE_KEY')); } + @Query(() => CustomHostnameDetails, { nullable: true }) + @UseGuards(WorkspaceAuthGuard) + async getHostnameDetails( + @AuthWorkspace() { hostname }: Workspace, + ): Promise { + if (!hostname) return undefined; + + return await this.domainManagerService.getCustomHostnameDetails(hostname); + } + @Query(() => PublicWorkspaceDataOutput) - async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) { + async getPublicWorkspaceDataBySubdomain( + @OriginHeader() origin: string, + ): Promise { try { const workspace = await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( origin, ); - workspaceValidator.assertIsDefinedOrThrow( - workspace, - new WorkspaceException( - 'Workspace not found', - WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, - ), - ); + workspaceValidator.assertIsDefinedOrThrow(workspace); let workspaceLogoWithToken = ''; @@ -261,6 +264,7 @@ export class WorkspaceResolver { logo: workspaceLogoWithToken, displayName: workspace.displayName, subdomain: workspace.subdomain, + hostname: workspace.hostname, authProviders: getAuthProvidersByWorkspace({ workspace, systemEnabledProviders, diff --git a/yarn.lock b/yarn.lock index 2e904b426..ca3eab983 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17533,6 +17533,13 @@ __metadata: languageName: node linkType: hard +"@types/qs@npm:^6.9.7": + version: 6.9.17 + resolution: "@types/qs@npm:6.9.17" + checksum: 10c0/a183fa0b3464267f8f421e2d66d960815080e8aab12b9aadab60479ba84183b1cdba8f4eff3c06f76675a8e42fe6a3b1313ea76c74f2885c3e25d32499c17d1b + languageName: node + linkType: hard + "@types/range-parser@npm:*": version: 1.2.7 resolution: "@types/range-parser@npm:1.2.7" @@ -23089,6 +23096,24 @@ __metadata: languageName: node linkType: hard +"cloudflare@npm:^3.5.0": + version: 3.5.0 + resolution: "cloudflare@npm:3.5.0" + dependencies: + "@types/node": "npm:^18.11.18" + "@types/node-fetch": "npm:^2.6.4" + "@types/qs": "npm:^6.9.7" + abort-controller: "npm:^3.0.0" + agentkeepalive: "npm:^4.2.1" + form-data-encoder: "npm:1.7.2" + formdata-node: "npm:^4.3.2" + node-fetch: "npm:^2.6.7" + qs: "npm:^6.10.3" + web-streams-polyfill: "npm:^3.2.1" + checksum: 10c0/bb48ff68a4f5b7e945ceec570e7e17251ad167d8d2dadf8099fd74df46582356382b8cd3f62a9da74cd3a208f8f5181a40c561bc5873592d6a4930458c0e7b86 + languageName: node + linkType: hard + "clsx@npm:^1.1.1, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" @@ -40533,6 +40558,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.10.3": + version: 6.13.1 + resolution: "qs@npm:6.13.1" + dependencies: + side-channel: "npm:^1.0.6" + checksum: 10c0/5ef527c0d62ffca5501322f0832d800ddc78eeb00da3b906f1b260ca0492721f8cdc13ee4b8fd8ac314a6ec37b948798c7b603ccc167e954088df392092f160c + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -45876,6 +45910,7 @@ __metadata: cache-manager: "npm:^5.4.0" cache-manager-redis-yet: "npm:^4.1.2" class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch" + cloudflare: "npm:^3.5.0" connect-redis: "npm:^7.1.1" express-session: "npm:^1.18.1" graphql-middleware: "npm:^6.1.35"