diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index eaf7622bd..8ced32d24 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -384,15 +384,8 @@ export type CursorPaging = { last?: InputMaybe; }; -export type CustomDomainDetails = { - __typename?: 'CustomDomainDetails'; - customDomain: Scalars['String']['output']; - id: Scalars['String']['output']; - records: Array; -}; - -export type CustomDomainVerification = { - __typename?: 'CustomDomainVerification'; +export type CustomDomainRecord = { + __typename?: 'CustomDomainRecord'; key: Scalars['String']['output']; status: Scalars['String']['output']; type: Scalars['String']['output']; @@ -400,6 +393,13 @@ export type CustomDomainVerification = { value: Scalars['String']['output']; }; +export type CustomDomainValidRecords = { + __typename?: 'CustomDomainValidRecords'; + customDomain: Scalars['String']['output']; + id: Scalars['String']['output']; + records: Array; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']['input']; @@ -1343,6 +1343,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: BillingSessionOutput; + checkCustomDomainValidRecords?: Maybe; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -1359,7 +1360,6 @@ export type Query = { findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']['output']; - getCustomDomainDetails?: Maybe; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getPostgresCredentials?: Maybe; getProductPrices: BillingProductPricesOutput; @@ -2099,6 +2099,7 @@ export type Workspace = { hasValidEnterpriseKey: Scalars['Boolean']['output']; id: Scalars['UUID']['output']; inviteHash?: Maybe; + isCustomDomainEnabled: Scalars['Boolean']['output']; isGoogleAuthEnabled: Scalars['Boolean']['output']; isMicrosoftAuthEnabled: Scalars['Boolean']['output']; isPasswordAuthEnabled: Scalars['Boolean']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2a31968f2..fd711907a 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -321,15 +321,8 @@ export type CursorPaging = { last?: InputMaybe; }; -export type CustomDomainDetails = { - __typename?: 'CustomDomainDetails'; - customDomain: Scalars['String']; - id: Scalars['String']; - records: Array; -}; - -export type CustomDomainVerification = { - __typename?: 'CustomDomainVerification'; +export type CustomDomainRecord = { + __typename?: 'CustomDomainRecord'; key: Scalars['String']; status: Scalars['String']; type: Scalars['String']; @@ -337,6 +330,13 @@ export type CustomDomainVerification = { value: Scalars['String']; }; +export type CustomDomainValidRecords = { + __typename?: 'CustomDomainValidRecords'; + customDomain: Scalars['String']; + id: Scalars['String']; + records: Array; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']; @@ -734,6 +734,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; buildDraftServerlessFunction: ServerlessFunction; + checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createDraftFromWorkflowVersion: WorkflowVersion; @@ -1223,7 +1224,6 @@ export type Query = { findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']; - getCustomDomainDetails?: Maybe; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getPostgresCredentials?: Maybe; getProductPrices: BillingProductPricesOutput; @@ -1877,6 +1877,7 @@ export type Workspace = { hasValidEnterpriseKey: Scalars['Boolean']; id: Scalars['UUID']; inviteHash?: Maybe; + isCustomDomainEnabled: Scalars['Boolean']; isGoogleAuthEnabled: Scalars['Boolean']; isMicrosoftAuthEnabled: Scalars['Boolean']; isPasswordAuthEnabled: Scalars['Boolean']; @@ -2421,10 +2422,10 @@ export type UploadWorkspaceLogoMutationVariables = Exact<{ export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: string }; -export type GetCustomDomainDetailsQueryVariables = Exact<{ [key: string]: never; }>; +export type CheckCustomDomainValidRecordsMutationVariables = Exact<{ [key: string]: never; }>; -export type GetCustomDomainDetailsQuery = { __typename?: 'Query', getCustomDomainDetails?: { __typename?: 'CustomDomainDetails', customDomain: string, records: Array<{ __typename?: 'CustomDomainVerification', type: string, key: string, value: string, validationType: string, status: string }> } | null }; +export type CheckCustomDomainValidRecordsMutation = { __typename?: 'Mutation', checkCustomDomainValidRecords?: { __typename?: 'CustomDomainValidRecords', id: string, customDomain: string, records: Array<{ __typename?: 'CustomDomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null }; export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ inviteHash: Scalars['String']; @@ -4895,9 +4896,10 @@ export function useUploadWorkspaceLogoMutation(baseOptions?: Apollo.MutationHook export type UploadWorkspaceLogoMutationHookResult = ReturnType; export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult; export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions; -export const GetCustomDomainDetailsDocument = gql` - query GetCustomDomainDetails { - getCustomDomainDetails { +export const CheckCustomDomainValidRecordsDocument = gql` + mutation CheckCustomDomainValidRecords { + checkCustomDomainValidRecords { + id customDomain records { type @@ -4909,33 +4911,31 @@ export const GetCustomDomainDetailsDocument = gql` } } `; +export type CheckCustomDomainValidRecordsMutationFn = Apollo.MutationFunction; /** - * __useGetCustomDomainDetailsQuery__ + * __useCheckCustomDomainValidRecordsMutation__ * - * To run a query within a React component, call `useGetCustomDomainDetailsQuery` and pass it any options that fit your needs. - * When your component renders, `useGetCustomDomainDetailsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. + * To run a mutation, you first call `useCheckCustomDomainValidRecordsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCheckCustomDomainValidRecordsMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution * - * @param baseOptions options that will be passed into the 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 mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * * @example - * const { data, loading, error } = useGetCustomDomainDetailsQuery({ + * const [checkCustomDomainValidRecordsMutation, { data, loading, error }] = useCheckCustomDomainValidRecordsMutation({ * variables: { * }, * }); */ -export function useGetCustomDomainDetailsQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useCheckCustomDomainValidRecordsMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetCustomDomainDetailsDocument, options); + return Apollo.useMutation(CheckCustomDomainValidRecordsDocument, options); } -export function useGetCustomDomainDetailsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetCustomDomainDetailsDocument, options); - } -export type GetCustomDomainDetailsQueryHookResult = ReturnType; -export type GetCustomDomainDetailsLazyQueryHookResult = ReturnType; -export type GetCustomDomainDetailsQueryResult = Apollo.QueryResult; +export type CheckCustomDomainValidRecordsMutationHookResult = ReturnType; +export type CheckCustomDomainValidRecordsMutationResult = Apollo.MutationResult; +export type CheckCustomDomainValidRecordsMutationOptions = Apollo.BaseMutationOptions; export const GetWorkspaceFromInviteHashDocument = gql` query GetWorkspaceFromInviteHash($inviteHash: String!) { findWorkspaceFromInviteHash(inviteHash: $inviteHash) { diff --git a/packages/twenty-front/src/modules/workspace/graphql/queries/getCustomDomainDetails.ts b/packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts similarity index 54% rename from packages/twenty-front/src/modules/workspace/graphql/queries/getCustomDomainDetails.ts rename to packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts index 151f05f2b..5740f776b 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/queries/getCustomDomainDetails.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts @@ -1,8 +1,9 @@ import { gql } from '@apollo/client'; -export const GET_CUSTOM_DOMAIN_DETAILS = gql` - query GetCustomDomainDetails { - getCustomDomainDetails { +export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS = gql` + mutation CheckCustomDomainValidRecords { + checkCustomDomainValidRecords { + id customDomain records { type diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx index 0d51bb6ac..c11aa3d05 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx @@ -1,35 +1,48 @@ +/* @license Enterprise */ import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { Controller, useFormContext } from 'react-hook-form'; import { H2Title, Section } from 'twenty-ui'; -import { useGetCustomDomainDetailsQuery } from '~/generated/graphql'; import { SettingsCustomDomainRecords } from '~/pages/settings/workspace/SettingsCustomDomainRecords'; +import { SettingsCustomDomainRecordsStatus } from '~/pages/settings/workspace/SettingsCustomDomainRecordsStatus'; +import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState'; +import { useRecoilValue } from 'recoil'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; const StyledDomainFormWrapper = styled.div` align-items: center; display: flex; `; +const StyledRecordsWrapper = styled.div` + margin-top: ${({ theme }) => theme.spacing(2)}; +`; + export const SettingsCustomDomain = () => { - const { data: getCustomDomainDetailsData } = useGetCustomDomainDetailsQuery(); + const customDomainRecords = useRecoilValue(customDomainRecordsState); + + const currentWorkspace = useRecoilValue(currentWorkspaceState); const { t } = useLingui(); - const { control, getValues } = useFormContext<{ + const { control } = useFormContext<{ customDomain: string; }>(); return (
- + ( { )} /> - {getCustomDomainDetailsData?.getCustomDomainDetails && - getValues('customDomain') === - getCustomDomainDetailsData?.getCustomDomainDetails?.customDomain && ( - + {customDomainRecords && + currentWorkspace?.customDomain && + currentWorkspace.customDomain === customDomainRecords?.customDomain && ( + + + + )}
); diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx index 33d04680d..7d185b915 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx @@ -1,20 +1,37 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useEffect, useCallback } from 'react'; +import { useSetRecoilState, useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared'; -import { useGetCustomDomainDetailsQuery } from '~/generated/graphql'; +import { useCheckCustomDomainValidRecordsMutation } from '~/generated/graphql'; +import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState'; export const SettingsCustomDomainEffect = () => { - const { refetch } = useGetCustomDomainDetailsQuery(); + const [checkCustomDomainValidRecords, { data: customDomainRecords }] = + useCheckCustomDomainValidRecordsMutation(); + + const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const initInterval = useCallback(() => { + return setInterval(async () => { + await checkCustomDomainValidRecords(); + if (isDefined(customDomainRecords?.checkCustomDomainValidRecords)) { + setCustomDomainRecords( + customDomainRecords.checkCustomDomainValidRecords, + ); + } + }, 3000); + }, [ + checkCustomDomainValidRecords, + customDomainRecords, + setCustomDomainRecords, + ]); + useEffect(() => { let pollIntervalFn: null | ReturnType = null; if (isDefined(currentWorkspace?.customDomain)) { - pollIntervalFn = setInterval(async () => { - refetch(); - }, 3000); + pollIntervalFn = initInterval(); } return () => { @@ -22,7 +39,7 @@ export const SettingsCustomDomainEffect = () => { clearInterval(pollIntervalFn); } }; - }, [currentWorkspace?.customDomain, refetch]); + }, [currentWorkspace?.customDomain, initInterval]); return <>; }; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx index 63a18c8fd..f66fcf4fc 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx @@ -1,75 +1,90 @@ import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; -import { Separator } from '@/settings/components/Separator'; import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableCell } from '@/ui/layout/table/components/TableCell'; -import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { Button } from 'twenty-ui'; import { Table } from '@/ui/layout/table/components/Table'; -import { CustomDomainDetails } from '~/generated/graphql'; +import { CustomDomainValidRecords } from '~/generated/graphql'; +import styled from '@emotion/styled'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useDebouncedCallback } from 'use-debounce'; + +const StyledTable = styled(Table)` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; +`; + +const StyledTableCell = styled(TableCell)` + overflow: hidden; +`; + +const StyledButton = styled(Button)` + -moz-user-select: text; + -ms-user-select: text; + -webkit-user-select: text; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + font-family: ${({ theme }) => theme.font.family}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + height: ${({ theme }) => theme.spacing(7)}; + overflow: hidden; + user-select: text; + width: 100%; +`; export const SettingsCustomDomainRecords = ({ records, }: { - records: CustomDomainDetails['records']; + records: CustomDomainValidRecords['records']; }) => { + const { enqueueSnackBar } = useSnackBar(); + + const copyToClipboard = (value: string) => { + navigator.clipboard.writeText(value); + enqueueSnackBar('Copied to clipboard!', { + variant: SnackBarVariant.Success, + }); + }; + + const copyToClipboardDebounced = useDebouncedCallback(copyToClipboard, 200); + return ( - - + + Name - Record Type + Type Value - Validation Type - Status - {records.map((record) => { return ( - - - + + copyToClipboardDebounced(record.key)} /> - - - + + + copyToClipboardDebounced(record.type.toUpperCase()) + } /> - - - + + copyToClipboardDebounced(record.value)} /> - - - - - - - + ); })} -
+ ); }; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx new file mode 100644 index 000000000..6a7b843c5 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx @@ -0,0 +1,76 @@ +import { Table } from '@/ui/layout/table/components/Table'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { Status, ThemeColor } from 'twenty-ui'; +import styled from '@emotion/styled'; +import { CustomDomainValidRecords } from '~/generated/graphql'; + +const StyledTable = styled(Table)` + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + border: 1px solid ${({ theme }) => theme.border.color.light}; +`; + +const StyledTableRow = styled(TableRow)` + display: flex; + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + align-items: center; + justify-content: space-between; + &:last-child { + border-bottom: none; + } +`; + +export const SettingsCustomDomainRecordsStatus = ({ + records, +}: { + records: CustomDomainValidRecords['records']; +}) => { + const rows = records.reduce( + (acc, record) => { + acc[record.validationType] = { + name: acc[record.validationType].name, + status: record.status, + color: + record.status === 'error' + ? 'red' + : record.status === 'pending' + ? 'yellow' + : 'green', + }; + return acc; + }, + { + ssl: { + name: 'SSL', + status: 'success', + color: 'green', + }, + redirection: { + name: 'Redirection', + status: 'success', + color: 'green', + }, + ownership: { + name: 'Ownership', + status: 'success', + color: 'green', + }, + } as Record, + ); + + return ( + + {Object.values(rows).map((row) => { + return ( + + {row.name} + + + + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index eeda33e78..45801dadd 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -74,7 +74,7 @@ export const SettingsDomain = () => { delayError: 500, defaultValues: { subdomain: currentWorkspace?.subdomain ?? '', - customDomain: currentWorkspace?.customDomain ?? null, + customDomain: currentWorkspace?.customDomain ?? '', }, resolver: zodResolver(validationSchema), }); @@ -83,7 +83,7 @@ export const SettingsDomain = () => { const customDomainValue = form.watch('customDomain'); const updateCustomDomain = ( - customDomain: string | null | undefined, + customDomain: string | null, currentWorkspace: CurrentWorkspace, ) => { updateWorkspace({ @@ -98,7 +98,8 @@ export const SettingsDomain = () => { onCompleted: () => { setCurrentWorkspace({ ...currentWorkspace, - customDomain, + customDomain: + customDomain && customDomain.length > 0 ? customDomain : null, }); }, onError: (error) => { @@ -209,9 +210,7 @@ export const SettingsDomain = () => { {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {(!currentWorkspace?.customDomain || !isCustomDomainEnabled) && ( - - )} + {isCustomDomainEnabled && ( <> diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx index fd2b4a48c..dfc725c66 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx @@ -7,6 +7,7 @@ import { H2Title, Section } from 'twenty-ui'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; const StyledDomainFormWrapper = styled.div` align-items: center; @@ -26,6 +27,8 @@ export const SettingsSubdomain = () => { const domainConfiguration = useRecoilValue(domainConfigurationState); const { t } = useLingui(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const { control } = useFormContext<{ subdomain: string; }>(); @@ -47,6 +50,7 @@ export const SettingsSubdomain = () => { type="text" onChange={onChange} error={error?.message} + disabled={!!currentWorkspace?.customDomain} fullWidth /> {isDefined(domainConfiguration.frontDomain) && ( diff --git a/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts b/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts new file mode 100644 index 000000000..2068d8b1c --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts @@ -0,0 +1,8 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { CustomDomainValidRecords } from '~/generated/graphql'; + +export const customDomainRecordsState = + createState({ + key: 'customDomainRecordsState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 29154aa82..d50a7cda5 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -48,6 +48,7 @@ export const mockCurrentWorkspace: Workspace = { hasValidEnterpriseKey: false, isGoogleAuthEnabled: true, isPasswordAuthEnabled: true, + isCustomDomainEnabled: false, workspaceUrls: { customUrl: undefined, subdomainUrl: 'twenty.twenty.com', diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 759c45fbf..dd7aa3378 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -74,4 +74,5 @@ FRONT_PORT=3001 # SSL_KEY_PATH="./certs/your-cert.key" # SSL_CERT_PATH="./certs/your-cert.crt" # CLOUDFLARE_API_KEY= -# CLOUDFLARE_ZONE_ID= \ No newline at end of file +# CLOUDFLARE_ZONE_ID= +# CLOUDFLARE_WEBHOOK_SECRET= \ No newline at end of file diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739203087254-add-is-custom-domain-enable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739203087254-add-is-custom-domain-enable.ts new file mode 100644 index 000000000..8bb6d8da3 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739203087254-add-is-custom-domain-enable.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIsCustomDomainEnable1739203087254 + implements MigrationInterface +{ + name = 'AddIsCustomDomainEnable1739203087254'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isCustomDomainEnabled" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isCustomDomainEnabled"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 65c3e0843..cbac8c65d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -124,6 +124,7 @@ export class GoogleAPIsAuthController { err, workspace ?? { subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + customDomain: null, }, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 551d14e7e..8c33e9ef1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -20,6 +20,7 @@ import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.au import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -28,6 +29,7 @@ export class GoogleAuthController { private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly guardRedirectService: GuardRedirectService, + private readonly domainManagerService: DomainManagerService, @InjectRepository(User, 'core') private readonly userRepository: Repository, ) {} @@ -118,7 +120,7 @@ export class GoogleAuthController { return res.redirect( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( currentWorkspace, ), ), diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index 8a415807f..d7ca0509a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -131,6 +131,7 @@ export class MicrosoftAPIsAuthController { err, workspace ?? { subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + customDomain: null, }, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 56e7bba21..7553c6ac0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -19,6 +19,7 @@ import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/micros import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -29,6 +30,7 @@ export class MicrosoftAuthController { private readonly guardRedirectService: GuardRedirectService, @InjectRepository(User, 'core') private readonly userRepository: Repository, + private readonly domainManagerService: DomainManagerService, ) {} @Get() @@ -119,7 +121,7 @@ export class MicrosoftAuthController { return res.redirect( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( currentWorkspace, ), ), diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index f6e572e12..5754ba719 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -37,6 +37,7 @@ import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.s import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Controller('auth') export class SSOAuthController { @@ -44,6 +45,8 @@ export class SSOAuthController { private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly guardRedirectService: GuardRedirectService, + private readonly domainManagerService: DomainManagerService, + private readonly sSOService: SSOService, @InjectRepository(User, 'core') private readonly userRepository: Repository, @@ -157,7 +160,7 @@ export class SSOAuthController { return res.redirect( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( workspaceIdentityProvider?.workspace, ), ), diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts index 4eff55959..834fdd7d8 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts @@ -14,6 +14,7 @@ import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { @@ -23,6 +24,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { private readonly guardRedirectService: GuardRedirectService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, + private readonly domainManagerService: DomainManagerService, ) { super({ prompt: 'select_account', @@ -71,7 +73,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { this.guardRedirectService.dispatchErrorFromGuard( context, err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( workspace, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts index 9866a4b12..bfb5433d1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts @@ -11,6 +11,7 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() export class GoogleOauthGuard extends AuthGuard('google') { @@ -18,6 +19,7 @@ export class GoogleOauthGuard extends AuthGuard('google') { private readonly guardRedirectService: GuardRedirectService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, + private readonly domainManagerService: DomainManagerService, ) { super({ prompt: 'select_account', @@ -51,7 +53,7 @@ export class GoogleOauthGuard extends AuthGuard('google') { this.guardRedirectService.dispatchErrorFromGuard( context, err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( workspace, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-request-code.guard.ts index fd0b9640a..877a1d007 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-request-code.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-apis-oauth-request-code.guard.ts @@ -12,9 +12,9 @@ import { MicrosoftAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/a import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard( @@ -22,11 +22,11 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard( ) { constructor( private readonly environmentService: EnvironmentService, - private readonly featureFlagService: FeatureFlagService, private readonly transientTokenService: TransientTokenService, private readonly guardRedirectService: GuardRedirectService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, + private readonly domainManagerService: DomainManagerService, ) { super({ prompt: 'select_account', @@ -72,7 +72,7 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard( this.guardRedirectService.dispatchErrorFromGuard( context, err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( workspace, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts index 373719894..d0b94a6eb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { @@ -13,6 +14,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { private readonly guardRedirectService: GuardRedirectService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, + private readonly domainManagerService: DomainManagerService, ) { super({ prompt: 'select_account', @@ -39,7 +41,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { this.guardRedirectService.dispatchErrorFromGuard( context, err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( workspace, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts index da594c10b..a2760f308 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.guard.ts @@ -14,12 +14,14 @@ 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 { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() export class OIDCAuthGuard extends AuthGuard('openidconnect') { constructor( private readonly sSOService: SSOService, private readonly guardRedirectService: GuardRedirectService, + private readonly domainManagerService: DomainManagerService, ) { super(); } @@ -88,7 +90,7 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') { this.guardRedirectService.dispatchErrorFromGuard( context, err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( identityProvider?.workspace, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.spec.ts index 02139a05f..50000e9f2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/oidc-auth.spec.ts @@ -9,6 +9,7 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard'; 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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; const createMockExecutionContext = (mockedRequest: any): ExecutionContext => { return { @@ -58,6 +59,13 @@ describe('OIDCAuthGuard', () => { getSubdomainAndCustomDomainFromWorkspace: jest.fn(), }, }, + { + provide: DomainManagerService, + useValue: { + getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain: + jest.fn(), + }, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts index d6b697d5f..d8727c219 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/saml-auth.guard.ts @@ -14,12 +14,14 @@ 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 { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Injectable() export class SAMLAuthGuard extends AuthGuard('saml') { constructor( private readonly sSOService: SSOService, private readonly guardRedirectService: GuardRedirectService, + private readonly domainManagerService: DomainManagerService, ) { super(); } @@ -49,7 +51,7 @@ export class SAMLAuthGuard extends AuthGuard('saml') { this.guardRedirectService.dispatchErrorFromGuard( context, err, - this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( + this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( identityProvider?.workspace, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 8fd15f190..202b44ed9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -56,6 +56,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -459,7 +460,7 @@ export class AuthService { billingCheckoutSessionState, }: { loginToken: string; - workspace: Pick; + workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType; billingCheckoutSessionState?: string; }) { const url = this.domainManagerService.buildWorkspaceURL({ diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts new file mode 100644 index 000000000..ae8dd44c6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts @@ -0,0 +1,90 @@ +/* @license Enterprise */ + +import { + Controller, + Post, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Response, Request } from 'express'; +import { Repository } from 'typeorm'; + +import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + DomainManagerException, + DomainManagerExceptionCode, +} from 'src/engine/core-modules/domain-manager/domain-manager.exception'; +import { handleException } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard'; +import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service'; + +@Controller('cloudflare') +@UseFilters(AuthRestApiExceptionFilter) +export class CloudflareController { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + private readonly domainManagerService: DomainManagerService, + private readonly customDomainService: CustomDomainService, + private readonly exceptionHandlerService: ExceptionHandlerService, + ) {} + + @Post('custom-hostname-webhooks') + @UseGuards(CloudflareSecretMatchGuard) + async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) { + if (!req.body?.data?.data?.hostname) { + handleException( + new DomainManagerException( + 'Hostname missing', + DomainManagerExceptionCode.INVALID_INPUT_DATA, + ), + this.exceptionHandlerService, + ); + + return res.status(200).send(); + } + + const workspace = await this.workspaceRepository.findOneBy({ + customDomain: req.body.data.data.hostname, + }); + + if (!workspace) return; + + const customDomainDetails = + await this.customDomainService.getCustomDomainDetails( + req.body.data.data.hostname, + ); + + const workspaceUpdated: Partial = { + customDomain: workspace.customDomain, + }; + + if (!customDomainDetails && workspace) { + workspaceUpdated.customDomain = null; + } + + workspaceUpdated.isCustomDomainEnabled = customDomainDetails + ? this.domainManagerService.isCustomDomainWorking(customDomainDetails) + : false; + + if ( + workspaceUpdated.isCustomDomainEnabled !== + workspace.isCustomDomainEnabled || + workspaceUpdated.customDomain !== workspace.customDomain + ) { + await this.workspaceRepository.save({ + ...workspace, + ...workspaceUpdated, + }); + } + + return res.status(200).send(); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts new file mode 100644 index 000000000..fe847fb05 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts @@ -0,0 +1,210 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; +import { Request, Response } from 'express'; + +import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records'; +import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service'; + +describe('CloudflareController - customHostnameWebhooks', () => { + let controller: CloudflareController; + let WorkspaceRepository: Repository; + let environmentService: EnvironmentService; + let domainManagerService: DomainManagerService; + let customDomainService: CustomDomainService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CloudflareController], + providers: [ + { + provide: getRepositoryToken(Workspace, 'core'), + useValue: { + findOneBy: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + isCustomDomainWorking: jest.fn(), + }, + }, + { + provide: CustomDomainService, + useValue: { + getCustomDomainDetails: jest.fn(), + }, + }, + { + provide: HttpExceptionHandlerService, + useValue: { + handleError: jest.fn(), + }, + }, + { + provide: ExceptionHandlerService, + useValue: { + captureExceptions: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(CloudflareController); + WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core')); + environmentService = module.get(EnvironmentService); + domainManagerService = + module.get(DomainManagerService); + customDomainService = module.get(CustomDomainService); + }); + + it('should handle exception and return status 200 if hostname is missing', async () => { + const req = { + headers: { 'cf-webhook-auth': 'correct-secret' }, + body: { data: { data: {} } }, + } as unknown as Request; + const sendMock = jest.fn(); + const res = { + status: jest.fn().mockReturnThis(), + send: sendMock, + } as unknown as Response; + + jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret'); + + await controller.customHostnameWebhooks(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(sendMock).toHaveBeenCalled(); + }); + + it('should update workspace for a valid hostname and save changes', async () => { + const req = { + headers: { 'cf-webhook-auth': 'correct-secret' }, + body: { data: { data: { hostname: 'example.com' } } }, + } as unknown as Request; + const sendMock = jest.fn(); + const res = { + status: jest.fn().mockReturnThis(), + send: sendMock, + } as unknown as Response; + + jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret'); + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValue({ + records: [ + { + success: true, + }, + ], + } as unknown as CustomDomainValidRecords); + jest + .spyOn(domainManagerService, 'isCustomDomainWorking') + .mockReturnValue(true); + jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({ + customDomain: 'example.com', + isCustomDomainEnabled: false, + } as Workspace); + + await controller.customHostnameWebhooks(req, res); + + expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({ + customDomain: 'example.com', + }); + expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith( + 'example.com', + ); + expect(WorkspaceRepository.save).toHaveBeenCalledWith({ + customDomain: 'example.com', + isCustomDomainEnabled: true, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(sendMock).toHaveBeenCalled(); + }); + + it('should remove customDomain if no hostname found', async () => { + const req = { + headers: { 'cf-webhook-auth': 'correct-secret' }, + body: { data: { data: { hostname: 'notfound.com' } } }, + } as unknown as Request; + const sendMock = jest.fn(); + const res = { + status: jest.fn().mockReturnThis(), + send: sendMock, + } as unknown as Response; + + jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret'); + jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({ + customDomain: 'notfound.com', + isCustomDomainEnabled: true, + } as Workspace); + + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValue(undefined); + + await controller.customHostnameWebhooks(req, res); + + expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({ + customDomain: 'notfound.com', + }); + expect(WorkspaceRepository.save).toHaveBeenCalledWith({ + customDomain: null, + isCustomDomainEnabled: false, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(sendMock).toHaveBeenCalled(); + }); + it('should do nothing if nothing changes', async () => { + const req = { + headers: { 'cf-webhook-auth': 'correct-secret' }, + body: { data: { data: { hostname: 'nothing-change.com' } } }, + } as unknown as Request; + const sendMock = jest.fn(); + const res = { + status: jest.fn().mockReturnThis(), + send: sendMock, + } as unknown as Response; + + jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret'); + jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({ + customDomain: 'nothing-change.com', + isCustomDomainEnabled: true, + } as Workspace); + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValue({ + records: [ + { + success: true, + }, + ], + } as unknown as CustomDomainValidRecords); + jest + .spyOn(domainManagerService, 'isCustomDomainWorking') + .mockReturnValue(true); + + await controller.customHostnameWebhooks(req, res); + + expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({ + customDomain: 'nothing-change.com', + }); + expect(WorkspaceRepository.save).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(sendMock).toHaveBeenCalled(); + }); +}); 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 99e0b45a1..3637cea58 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 @@ -10,4 +10,5 @@ export enum DomainManagerExceptionCode { CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED', HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED', SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED', + INVALID_INPUT_DATA = 'INVALID_INPUT_DATA', } diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts index 4273a58f9..5def9ce30 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts @@ -3,10 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller'; +import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service'; @Module({ imports: [TypeOrmModule.forFeature([Workspace], 'core')], - providers: [DomainManagerService], - exports: [DomainManagerService], + providers: [DomainManagerService, CustomDomainService], + exports: [DomainManagerService, CustomDomainService], + controllers: [CloudflareController], }) export class DomainManagerModule {} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.type.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.type.ts new file mode 100644 index 000000000..2a773e3ae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.type.ts @@ -0,0 +1,6 @@ +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType = Pick< + Workspace, + 'subdomain' | 'customDomain' | 'isCustomDomainEnabled' +>; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-details.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records.ts similarity index 73% rename from packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-details.ts rename to packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records.ts index 2cb161c00..296f6501c 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-details.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() -class CustomDomainVerification { +class CustomDomainRecord { @Field(() => String) validationType: 'ownership' | 'ssl' | 'redirection'; @@ -19,13 +19,13 @@ class CustomDomainVerification { } @ObjectType() -export class CustomDomainDetails { +export class CustomDomainValidRecords { @Field(() => String) id: string; @Field(() => String) customDomain: string; - @Field(() => [CustomDomainVerification]) - records: Array; + @Field(() => [CustomDomainRecord]) + records: Array; } diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard.ts new file mode 100644 index 000000000..62d6b33bd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard.ts @@ -0,0 +1,38 @@ +/* @license Enterprise */ + +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; + +import { timingSafeEqual } from 'crypto'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class CloudflareSecretMatchGuard implements CanActivate { + constructor(private readonly environmentService: EnvironmentService) {} + + canActivate(context: ExecutionContext): boolean { + try { + const request = context.switchToHttp().getRequest(); + + const cloudflareWebhookSecret = this.environmentService.get( + 'CLOUDFLARE_WEBHOOK_SECRET', + ); + + if ( + !cloudflareWebhookSecret || + (cloudflareWebhookSecret && + (typeof request.headers['cf-webhook-auth'] === 'string' || + timingSafeEqual( + Buffer.from(request.headers['cf-webhook-auth']), + Buffer.from(cloudflareWebhookSecret), + ))) + ) { + return true; + } + + return false; + } catch (err) { + return false; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts new file mode 100644 index 000000000..75c4a5db2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts @@ -0,0 +1,65 @@ +import { ExecutionContext } from '@nestjs/common'; + +import * as crypto from 'crypto'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +import { CloudflareSecretMatchGuard } from './cloudflare-secret.guard'; + +describe('CloudflareSecretMatchGuard.canActivate', () => { + let guard: CloudflareSecretMatchGuard; + let environmentService: EnvironmentService; + + beforeEach(() => { + environmentService = { + get: jest.fn(), + } as unknown as EnvironmentService; + guard = new CloudflareSecretMatchGuard(environmentService); + }); + + it('should return true when the webhook secret matches', () => { + const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } }; + + jest.spyOn(environmentService, 'get').mockReturnValue('valid-secret'); + + const mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + + jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it('should return true when env is not set', () => { + const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } }; + + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + const mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + + jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it('should return false if an error occurs', () => { + const mockRequest = { headers: {} }; + + jest.spyOn(environmentService, 'get').mockReturnValue('valid-secret'); + + const mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + + expect(guard.canActivate(mockContext)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts new file mode 100644 index 000000000..7737d9017 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts @@ -0,0 +1,271 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames'; +import Cloudflare from 'cloudflare'; + +import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.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 { DomainManagerException } from 'src/engine/core-modules/domain-manager/domain-manager.exception'; + +jest.mock('cloudflare'); + +describe('CustomDomainService', () => { + let customDomainService: CustomDomainService; + let environmentService: EnvironmentService; + let domainManagerService: DomainManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomDomainService, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + getFrontUrl: jest.fn(), + }, + }, + ], + }).compile(); + + customDomainService = module.get(CustomDomainService); + environmentService = module.get(EnvironmentService); + domainManagerService = + module.get(DomainManagerService); + + (customDomainService as any).cloudflareClient = { + customHostnames: { + list: jest.fn(), + create: jest.fn(), + }, + }; + + jest.clearAllMocks(); + }); + + it('should initialize cloudflareClient when CLOUDFLARE_API_KEY is defined', () => { + const mockApiKey = 'test-api-key'; + + jest.spyOn(environmentService, 'get').mockReturnValue(mockApiKey); + + const instance = new CustomDomainService(environmentService, {} as any); + + expect(environmentService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY'); + expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey }); + expect(instance.cloudflareClient).toBeDefined(); + }); + + describe('registerCustomDomain', () => { + it('should throw an error when the hostname is already registered', async () => { + const customDomain = 'example.com'; + + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValueOnce({} as any); + + await expect( + customDomainService.registerCustomDomain(customDomain), + ).rejects.toThrow(DomainManagerException); + expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith( + customDomain, + ); + }); + + it('should register a custom domain successfully', async () => { + const customDomain = 'example.com'; + const createMock = jest.fn().mockResolvedValueOnce({}); + const cloudflareMock = { + customHostnames: { + create: createMock, + }, + }; + + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValueOnce(undefined); + jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id'); + (customDomainService as any).cloudflareClient = cloudflareMock; + + await customDomainService.registerCustomDomain(customDomain); + + expect(createMock).toHaveBeenCalledWith({ + zone_id: 'test-zone-id', + hostname: customDomain, + ssl: expect.any(Object), + }); + }); + }); + + describe('getCustomDomainDetails', () => { + it('should return undefined if no custom domain details are found', async () => { + const customDomain = 'example.com'; + const cloudflareMock = { + customHostnames: { + list: jest.fn().mockResolvedValueOnce({ result: [] }), + }, + }; + + jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id'); + (customDomainService as any).cloudflareClient = cloudflareMock; + + const result = + await customDomainService.getCustomDomainDetails(customDomain); + + expect(result).toBeUndefined(); + expect(cloudflareMock.customHostnames.list).toHaveBeenCalledWith({ + zone_id: 'test-zone-id', + hostname: customDomain, + }); + }); + + it('should return domain details if a single result is found', async () => { + const customDomain = 'example.com'; + const mockResult = { + id: 'custom-id', + hostname: customDomain, + ownership_verification: { + type: 'txt', + name: 'ownership', + value: 'value', + }, + ssl: { + validation_records: [{ txt_name: 'ssl', txt_value: 'validation' }], + }, + verification_errors: [], + }; + const cloudflareMock = { + customHostnames: { + list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }), + }, + }; + + jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id'); + jest + .spyOn(domainManagerService, 'getFrontUrl') + .mockReturnValue(new URL('https://front.domain')); + (customDomainService as any).cloudflareClient = cloudflareMock; + + const result = + await customDomainService.getCustomDomainDetails(customDomain); + + expect(result).toEqual({ + id: 'custom-id', + customDomain: customDomain, + records: expect.any(Array), + }); + }); + + it('should throw an error if multiple results are found', async () => { + const customDomain = 'example.com'; + const cloudflareMock = { + customHostnames: { + list: jest.fn().mockResolvedValueOnce({ result: [{}, {}] }), + }, + }; + + jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id'); + (customDomainService as any).cloudflareClient = cloudflareMock; + + await expect( + customDomainService.getCustomDomainDetails(customDomain), + ).rejects.toThrow(Error); + }); + }); + + describe('updateCustomDomain', () => { + it('should update a custom domain and register a new one', async () => { + const fromHostname = 'old.com'; + const toHostname = 'new.com'; + + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValueOnce({ id: 'old-id' } as any); + jest + .spyOn(customDomainService, 'deleteCustomHostname') + .mockResolvedValueOnce(undefined); + const registerSpy = jest + .spyOn(customDomainService, 'registerCustomDomain') + .mockResolvedValueOnce({} as unknown as CustomHostnameCreateResponse); + + await customDomainService.updateCustomDomain(fromHostname, toHostname); + + expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith( + fromHostname, + ); + expect(customDomainService.deleteCustomHostname).toHaveBeenCalledWith( + 'old-id', + ); + expect(registerSpy).toHaveBeenCalledWith(toHostname); + }); + }); + + describe('deleteCustomHostnameByHostnameSilently', () => { + it('should delete the custom hostname silently', async () => { + const customDomain = 'example.com'; + + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockResolvedValueOnce({ id: 'custom-id' } as any); + const deleteMock = jest.fn(); + const cloudflareMock = { + customHostnames: { + delete: deleteMock, + }, + }; + + jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id'); + (customDomainService as any).cloudflareClient = cloudflareMock; + + await expect( + customDomainService.deleteCustomHostnameByHostnameSilently( + customDomain, + ), + ).resolves.toBeUndefined(); + expect(deleteMock).toHaveBeenCalledWith('custom-id', { + zone_id: 'test-zone-id', + }); + }); + + it('should silently handle errors', async () => { + const customDomain = 'example.com'; + + jest + .spyOn(customDomainService, 'getCustomDomainDetails') + .mockRejectedValueOnce(new Error('Failure')); + + await expect( + customDomainService.deleteCustomHostnameByHostnameSilently( + customDomain, + ), + ).resolves.toBeUndefined(); + }); + }); + + describe('isCustomDomainWorking', () => { + it('should return true if all records have success status', () => { + const customDomainDetails = { + records: [{ status: 'success' }, { status: 'success' }], + } as any; + + expect( + customDomainService.isCustomDomainWorking(customDomainDetails), + ).toBe(true); + }); + + it('should return false if any record does not have success status', () => { + const customDomainDetails = { + records: [{ status: 'success' }, { status: 'pending' }], + } as any; + + expect( + customDomainService.isCustomDomainWorking(customDomainDetails), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts new file mode 100644 index 000000000..e3274c4dd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts @@ -0,0 +1,179 @@ +/* @license Enterprise */ +import { Injectable } from '@nestjs/common'; + +import Cloudflare from 'cloudflare'; +import { isDefined } from 'twenty-shared'; + +import { + DomainManagerException, + DomainManagerExceptionCode, +} from 'src/engine/core-modules/domain-manager/domain-manager.exception'; +import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records'; +import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; + +@Injectable() +export class CustomDomainService { + cloudflareClient?: Cloudflare; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, + ) { + if (this.environmentService.get('CLOUDFLARE_API_KEY')) { + this.cloudflareClient = new Cloudflare({ + apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'), + }); + } + } + + async registerCustomDomain(customDomain: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + if (isDefined(await this.getCustomDomainDetails(customDomain))) { + 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: customDomain, + 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 getCustomDomainDetails( + customDomain: string, + ): Promise { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + const response = await this.cloudflareClient.customHostnames.list({ + zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), + hostname: customDomain, + }); + + if (response.result.length === 0) { + return undefined; + } + + if (response.result.length === 1) { + return { + id: response.result[0].id, + customDomain: response.result[0].hostname, + records: [ + response.result[0].ownership_verification, + ...(response.result[0].ssl?.validation_records ?? []), + ] + .map( + (record: Record) => { + if (!record) return; + + if ( + 'txt_name' in record && + 'txt_value' in record && + record.txt_name && + record.txt_value + ) { + return { + validationType: 'ssl' as const, + type: 'txt' as const, + status: response.result[0].ssl.status ?? 'pending', + key: record.txt_name, + value: record.txt_value, + }; + } + + if ( + 'type' in record && + record.type === 'txt' && + record.value && + record.name + ) { + return { + validationType: 'ownership' as const, + type: 'txt' as const, + status: response.result[0].status ?? 'pending', + key: record.name, + value: record.value, + }; + } + }, + ) + .filter(isDefined) + .concat([ + { + validationType: 'redirection' as const, + type: 'cname' as const, + status: + response.result[0].verification_errors?.[0] === + 'custom hostname does not CNAME to this zone.' + ? 'error' + : 'success', + key: response.result[0].hostname, + value: this.domainManagerService.getFrontUrl().hostname, + }, + ]), + }; + } + + // should never append. error 5xx + throw new Error('More than one custom hostname found in cloudflare'); + } + + async updateCustomDomain(fromHostname: string, toHostname: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + const fromCustomHostname = await this.getCustomDomainDetails(fromHostname); + + if (fromCustomHostname) { + await this.deleteCustomHostname(fromCustomHostname.id); + } + + return this.registerCustomDomain(toHostname); + } + + async deleteCustomHostnameByHostnameSilently(customDomain: string) { + domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); + + try { + const customHostname = await this.getCustomDomainDetails(customDomain); + + 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); + + await this.cloudflareClient.customHostnames.delete(customHostnameId, { + zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), + }); + } + + isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) { + return customDomainDetails.records.every( + ({ status }) => status === 'success', + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts index 4bcaf6577..987a45555 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts @@ -25,6 +25,7 @@ describe('DomainManagerService', () => { const result = domainManagerService.getWorkspaceUrls({ subdomain: 'subdomain', customDomain: 'custom-host.com', + isCustomDomainEnabled: true, }); expect(result).toEqual({ @@ -47,7 +48,8 @@ describe('DomainManagerService', () => { const result = domainManagerService.getWorkspaceUrls({ subdomain: 'subdomain', - customDomain: undefined, + customDomain: null, + isCustomDomainEnabled: false, }); expect(result).toEqual({ @@ -155,7 +157,8 @@ describe('DomainManagerService', () => { const result = domainManagerService.buildWorkspaceURL({ workspace: { subdomain: 'test', - customDomain: undefined, + customDomain: null, + isCustomDomainEnabled: false, }, }); @@ -177,7 +180,8 @@ describe('DomainManagerService', () => { const result = domainManagerService.buildWorkspaceURL({ workspace: { subdomain: 'test', - customDomain: undefined, + customDomain: null, + isCustomDomainEnabled: false, }, pathname: '/path/to/resource', }); @@ -200,7 +204,8 @@ describe('DomainManagerService', () => { const result = domainManagerService.buildWorkspaceURL({ workspace: { subdomain: 'test', - customDomain: undefined, + customDomain: null, + isCustomDomainEnabled: false, }, searchParams: { foo: 'bar', 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 339968b8c..f8bb68efe 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 @@ -1,37 +1,24 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import Cloudflare from 'cloudflare'; import { Repository } from 'typeorm'; import { isDefined } from 'twenty-shared'; -import { - DomainManagerException, - DomainManagerExceptionCode, -} from 'src/engine/core-modules/domain-manager/domain-manager.exception'; -import { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details'; +import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records'; import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain'; import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email'; import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name'; -import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type'; @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; @@ -78,7 +65,7 @@ export class DomainManagerService { }: { emailVerificationToken: string; email: string; - workspace: Pick; + workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType; }) { return this.buildWorkspaceURL({ workspace, @@ -92,7 +79,7 @@ export class DomainManagerService { pathname, searchParams, }: { - workspace: Pick; + workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType; pathname?: string; searchParams?: Record; }) { @@ -129,7 +116,7 @@ export class DomainManagerService { isFrontdomain && !this.isDefaultSubdomain(subdomain) ? subdomain : undefined, - customDomain: isFrontdomain ? undefined : originHostname, + customDomain: isFrontdomain ? null : originHostname, }; }; @@ -147,7 +134,7 @@ export class DomainManagerService { computeRedirectErrorUrl( errorMessage: string, - workspace: Pick, + workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType, ) { const url = this.buildWorkspaceURL({ workspace, @@ -237,149 +224,6 @@ export class DomainManagerService { return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`; } - async registerCustomDomain(customDomain: string) { - domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); - - if (await this.getCustomDomainDetails(customDomain)) { - 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: customDomain, - 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 getCustomDomainDetails( - customDomain: string, - ): Promise { - domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); - - const response = await this.cloudflareClient.customHostnames.list({ - zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'), - hostname: customDomain, - }); - - if (response.result.length === 0) { - return undefined; - } - - if (response.result.length === 1) { - return { - id: response.result[0].id, - customDomain: response.result[0].hostname, - records: [ - response.result[0].ownership_verification, - ...(response.result[0].ssl?.validation_records ?? []), - ] - .map( - (record: Record) => { - if (!record) return; - - if ( - 'txt_name' in record && - 'txt_value' in record && - record.txt_name && - record.txt_value - ) { - return { - validationType: 'ssl' as const, - type: 'txt' as const, - status: response.result[0].ssl.status ?? 'pending', - key: record.txt_name, - value: record.txt_value, - }; - } - - if ( - 'type' in record && - record.type === 'txt' && - record.value && - record.name - ) { - return { - validationType: 'ownership' as const, - type: 'txt' as const, - status: response.result[0].status ?? 'pending', - key: record.name, - value: record.value, - }; - } - }, - ) - .filter(isDefined) - .concat([ - { - validationType: 'redirection' as const, - type: 'cname' as const, - status: - response.result[0].verification_errors?.[0] === - 'custom hostname does not CNAME to this zone.' - ? 'error' - : 'success', - key: response.result[0].hostname, - value: this.getFrontUrl().hostname, - }, - ]), - }; - } - - // should never append. error 5xx - throw new Error('More than one custom hostname found in cloudflare'); - } - - async updateCustomDomain(fromHostname: string, toHostname: string) { - domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); - - const fromCustomHostname = await this.getCustomDomainDetails(fromHostname); - - if (fromCustomHostname) { - await this.deleteCustomHostname(fromCustomHostname.id); - } - - return this.registerCustomDomain(toHostname); - } - - async deleteCustomHostnameByHostnameSilently(customDomain: string) { - domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient); - - try { - const customHostname = await this.getCustomDomainDetails(customDomain); - - 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'), - }); - } - private getCustomWorkspaceUrl(customDomain: string) { const url = this.getFrontUrl(); @@ -396,14 +240,42 @@ export class DomainManagerService { return url.toString(); } + getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( + workspace?: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType | null, + ) { + if (!workspace) { + return { + subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + customDomain: null, + }; + } + + if (!workspace.isCustomDomainEnabled) { + return { + subdomain: workspace.subdomain, + customDomain: null, + }; + } + + return workspace; + } + + isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) { + return customDomainDetails.records.every( + ({ status }) => status === 'success', + ); + } + getWorkspaceUrls({ subdomain, customDomain, - }: Pick) { + isCustomDomainEnabled, + }: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType) { return { - customUrl: customDomain - ? this.getCustomWorkspaceUrl(customDomain) - : undefined, + customUrl: + isCustomDomainEnabled && customDomain + ? this.getCustomWorkspaceUrl(customDomain) + : undefined, subdomainUrl: this.getTwentyWorkspaceUrl(subdomain), }; } diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts index c3edd4aa2..cd34b84f9 100644 --- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts +++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts @@ -21,7 +21,7 @@ import { import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -39,7 +39,7 @@ export class EmailVerificationService { async sendVerificationEmail( userId: string, email: string, - workspace: Pick, + workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType, ) { if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { return { success: false }; @@ -83,7 +83,7 @@ export class EmailVerificationService { async resendEmailVerificationToken( email: string, - workspace: Pick, + workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType, ) { if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { throw new EmailVerificationException( 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 03065864c..56767abcc 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 @@ -820,6 +820,14 @@ export class EnvironmentVariables { @ValidateIf((env) => env.CLOUDFLARE_API_KEY) CLOUDFLARE_ZONE_ID: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Other, + description: 'Random string to validate queries from Cloudflare', + }) + @IsString() + @IsOptional() + CLOUDFLARE_WEBHOOK_SECRET: string; + @EnvironmentVariablesMetadata({ group: EnvironmentVariablesGroup.LLM, description: 'Driver for the LLM chat model', diff --git a/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts b/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts index f5d6c7d3e..26f9555b5 100644 --- a/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts @@ -7,7 +7,6 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm import { CustomException } from 'src/utils/custom-exception'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class GuardRedirectService { @@ -20,7 +19,12 @@ export class GuardRedirectService { dispatchErrorFromGuard( context: ExecutionContext, error: Error | CustomException, - workspace: { id?: string; subdomain: string; customDomain?: string }, + workspace: { + id?: string; + subdomain: string; + customDomain: string | null; + isCustomDomainEnabled?: boolean; + }, ) { if ('contextType' in context && context.contextType === 'graphql') { throw error; @@ -32,22 +36,7 @@ export class GuardRedirectService { .redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace)); } - getSubdomainAndCustomDomainFromWorkspace( - workspace?: Pick | null, - ) { - if (!workspace) { - return { - subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), - }; - } - - return workspace; - } - - getSubdomainAndCustomDomainFromContext(context: ExecutionContext): { - subdomain: string; - customDomain?: string; - } { + getSubdomainAndCustomDomainFromContext(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const subdomainAndCustomDomainFromReferer = request.headers.referer @@ -56,12 +45,16 @@ export class GuardRedirectService { ) : null; - return { - subdomain: - subdomainAndCustomDomainFromReferer?.subdomain ?? - this.environmentService.get('DEFAULT_SUBDOMAIN'), - customDomain: subdomainAndCustomDomainFromReferer?.customDomain, - }; + return subdomainAndCustomDomainFromReferer && + subdomainAndCustomDomainFromReferer.subdomain + ? { + subdomain: subdomainAndCustomDomainFromReferer.subdomain, + customDomain: subdomainAndCustomDomainFromReferer.customDomain, + } + : { + subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), + customDomain: null, + }; } private captureException(err: Error | CustomException, workspaceId?: string) { @@ -76,13 +69,22 @@ export class GuardRedirectService { getRedirectErrorUrlAndCaptureExceptions( err: Error | CustomException, - workspace: { id?: string; subdomain: string; customDomain?: string }, + workspace: { + id?: string; + subdomain: string; + customDomain: string | null; + isCustomDomainEnabled?: boolean; + }, ) { this.captureException(err, workspace.id); return this.domainManagerService.computeRedirectErrorUrl( err instanceof AuthException ? err.message : 'Unknown error', - workspace, + { + subdomain: workspace.subdomain, + customDomain: workspace.customDomain, + isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false, + }, ); } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts index d1d433f54..3f9cd2064 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts @@ -6,7 +6,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -17,6 +16,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service'; import { WorkspaceService } from './workspace.service'; @@ -55,6 +56,10 @@ describe('WorkspaceService', () => { provide: DomainManagerService, useValue: {}, }, + { + provide: CustomDomainService, + useValue: {}, + }, { provide: BillingSubscriptionService, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 1f0f50b45..67c42c898 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 @@ -37,6 +37,7 @@ import { import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.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 { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -59,6 +60,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly domainManagerService: DomainManagerService, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly permissionsService: PermissionsService, + private readonly customDomainService: CustomDomainService, ) { super(workspaceRepository); } @@ -111,7 +113,7 @@ export class WorkspaceService extends TypeOrmQueryService { workspace.customDomain !== customDomain && isDefined(workspace.customDomain) ) { - await this.domainManagerService.updateCustomDomain( + await this.customDomainService.updateCustomDomain( workspace.customDomain, customDomain, ); @@ -122,7 +124,7 @@ export class WorkspaceService extends TypeOrmQueryService { workspace.customDomain !== customDomain && !isDefined(workspace.customDomain) ) { - await this.domainManagerService.registerCustomDomain(customDomain); + await this.customDomainService.registerCustomDomain(customDomain); } } @@ -146,7 +148,7 @@ export class WorkspaceService extends TypeOrmQueryService { let customDomainRegistered = false; if (payload.customDomain === null && isDefined(workspace.customDomain)) { - await this.domainManagerService.deleteCustomHostnameByHostnameSilently( + await this.customDomainService.deleteCustomHostnameByHostnameSilently( workspace.customDomain, ); } @@ -179,7 +181,7 @@ export class WorkspaceService extends TypeOrmQueryService { } catch (error) { // revert custom domain registration on error if (payload.customDomain && customDomainRegistered) { - this.domainManagerService + this.customDomainService .deleteCustomHostnameByHostnameSilently(payload.customDomain) .catch((err) => { this.exceptionHandlerService.captureExceptions([err]); @@ -320,4 +322,25 @@ export class WorkspaceService extends TypeOrmQueryService { } } } + + async checkCustomDomainValidRecords(workspace: Workspace) { + if (!workspace.customDomain) return; + + const customDomainDetails = + await this.customDomainService.getCustomDomainDetails( + workspace.customDomain, + ); + + if (!customDomainDetails) return; + + const isCustomDomainWorking = + this.domainManagerService.isCustomDomainWorking(customDomainDetails); + + if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) { + workspace.isCustomDomainEnabled = isCustomDomainWorking; + await this.workspaceRepository.save(workspace); + } + + return customDomainDetails; + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 663f49d03..ff3c2f705 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -122,9 +122,9 @@ export class Workspace { @Column({ unique: true }) subdomain: string; - @Field({ nullable: true }) - @Column({ unique: true, nullable: true }) - customDomain?: string; + @Field(() => String, { nullable: true }) + @Column({ type: 'varchar', unique: true, nullable: true }) + customDomain: string | null; @Field() @Column({ default: true }) @@ -137,4 +137,8 @@ export class Workspace { @Field() @Column({ default: true }) isMicrosoftAuthEnabled: boolean; + + @Field() + @Column({ default: false }) + isCustomDomainEnabled: boolean; } 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 e031526e8..8552080d2 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 @@ -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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.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'; @@ -47,6 +46,7 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter'; import { assert } from 'src/utils/assert'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records'; import { Workspace } from './workspace.entity'; @@ -229,14 +229,12 @@ export class WorkspaceResolver { return this.domainManagerService.getWorkspaceUrls(workspace); } - @Query(() => CustomDomainDetails, { nullable: true }) + @Mutation(() => CustomDomainValidRecords, { nullable: true }) @UseGuards(WorkspaceAuthGuard) - async getCustomDomainDetails( - @AuthWorkspace() { customDomain }: Workspace, - ): Promise { - if (!customDomain) return undefined; - - return await this.domainManagerService.getCustomDomainDetails(customDomain); + async checkCustomDomainValidRecords( + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.workspaceService.checkCustomDomainValidRecords(workspace); } @Query(() => PublicWorkspaceDataOutput)