From 8a425456f25395a5e68182d911386e9c7a17fea9 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 13 Feb 2025 16:01:33 +0100 Subject: [PATCH] feat(workspace): add support for custom domain status toggle (#10114) Introduce isCustomDomainEnabled field in Workspace entity to manage custom domain activation. Update related services, types, and logic to validate and toggle the custom domain's status dynamically based on its current state. This ensures accurate domain configurations are reflected across the system. --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/generated-metadata/graphql.ts | 21 +- .../twenty-front/src/generated/graphql.tsx | 60 ++-- ...ls.ts => checkCustomDomainValidRecords.ts} | 7 +- .../workspace/SettingsCustomDomain.tsx | 40 ++- .../workspace/SettingsCustomDomainEffect.tsx | 33 ++- .../workspace/SettingsCustomDomainRecords.tsx | 113 ++++---- .../SettingsCustomDomainRecordsStatus.tsx | 76 +++++ .../settings/workspace/SettingsDomain.tsx | 11 +- .../settings/workspace/SettingsSubdomain.tsx | 4 + .../states/customDomainRecordsState.ts | 8 + .../src/testing/mock-data/users.ts | 1 + packages/twenty-server/.env.example | 3 +- ...39203087254-add-is-custom-domain-enable.ts | 19 ++ .../google-apis-auth.controller.ts | 1 + .../controllers/google-auth.controller.ts | 4 +- .../microsoft-apis-auth.controller.ts | 1 + .../controllers/microsoft-auth.controller.ts | 4 +- .../auth/controllers/sso-auth.controller.ts | 5 +- .../google-apis-oauth-request-code.guard.ts | 4 +- .../auth/guards/google-oauth.guard.ts | 4 +- ...microsoft-apis-oauth-request-code.guard.ts | 6 +- .../auth/guards/microsoft-oauth.guard.ts | 4 +- .../auth/guards/oidc-auth.guard.ts | 4 +- .../auth/guards/oidc-auth.spec.ts | 8 + .../auth/guards/saml-auth.guard.ts | 4 +- .../auth/services/auth.service.ts | 3 +- .../controllers/cloudflare.controller.ts | 90 ++++++ .../controllers/cloudflare.spec.ts | 210 ++++++++++++++ .../domain-manager.exception.ts | 1 + .../domain-manager/domain-manager.module.ts | 7 +- .../domain-manager/domain-manager.type.ts | 6 + ...ails.ts => custom-domain-valid-records.ts} | 8 +- .../guards/cloudflare-secret.guard.ts | 38 +++ .../guards/cloudflare-secret.spec.ts | 65 +++++ .../services/custom-domain.service.spec.ts | 271 ++++++++++++++++++ .../services/custom-domain.service.ts | 179 ++++++++++++ .../services/domain-manager.service.spec.ts | 13 +- .../services/domain-manager.service.ts | 206 +++---------- .../services/email-verification.service.ts | 6 +- .../environment/environment-variables.ts | 8 + .../services/guard-redirect.service.ts | 54 ++-- .../services/workspace.service.spec.ts | 7 +- .../workspace/services/workspace.service.ts | 31 +- .../workspace/workspace.entity.ts | 10 +- .../workspace/workspace.resolver.ts | 14 +- 45 files changed, 1320 insertions(+), 352 deletions(-) rename packages/twenty-front/src/modules/workspace/graphql/queries/{getCustomDomainDetails.ts => checkCustomDomainValidRecords.ts} (54%) create mode 100644 packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx create mode 100644 packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739203087254-add-is-custom-domain-enable.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.type.ts rename packages/twenty-server/src/engine/core-modules/domain-manager/dtos/{custom-domain-details.ts => custom-domain-valid-records.ts} (73%) create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts 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)