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>
This commit is contained in:
Antoine Moreaux
2025-02-13 16:01:33 +01:00
committed by GitHub
parent b67e850011
commit 8a425456f2
45 changed files with 1320 additions and 352 deletions

View File

@ -384,15 +384,8 @@ export type CursorPaging = {
last?: InputMaybe<Scalars['Int']['input']>; last?: InputMaybe<Scalars['Int']['input']>;
}; };
export type CustomDomainDetails = { export type CustomDomainRecord = {
__typename?: 'CustomDomainDetails'; __typename?: 'CustomDomainRecord';
customDomain: Scalars['String']['output'];
id: Scalars['String']['output'];
records: Array<CustomDomainVerification>;
};
export type CustomDomainVerification = {
__typename?: 'CustomDomainVerification';
key: Scalars['String']['output']; key: Scalars['String']['output'];
status: Scalars['String']['output']; status: Scalars['String']['output'];
type: Scalars['String']['output']; type: Scalars['String']['output'];
@ -400,6 +393,13 @@ export type CustomDomainVerification = {
value: Scalars['String']['output']; value: Scalars['String']['output'];
}; };
export type CustomDomainValidRecords = {
__typename?: 'CustomDomainValidRecords';
customDomain: Scalars['String']['output'];
id: Scalars['String']['output'];
records: Array<CustomDomainRecord>;
};
export type DeleteOneFieldInput = { export type DeleteOneFieldInput = {
/** The id of the field to delete. */ /** The id of the field to delete. */
id: Scalars['UUID']['input']; id: Scalars['UUID']['input'];
@ -1343,6 +1343,7 @@ export type PublishServerlessFunctionInput = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
billingPortalSession: BillingSessionOutput; billingPortalSession: BillingSessionOutput;
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkUserExists: UserExistsOutput; checkUserExists: UserExistsOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
clientConfig: ClientConfig; clientConfig: ClientConfig;
@ -1359,7 +1360,6 @@ export type Query = {
findWorkspaceFromInviteHash: Workspace; findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>; findWorkspaceInvitations: Array<WorkspaceInvitation>;
getAvailablePackages: Scalars['JSON']['output']; getAvailablePackages: Scalars['JSON']['output'];
getCustomDomainDetails?: Maybe<CustomDomainDetails>;
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getPostgresCredentials?: Maybe<PostgresCredentials>; getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput; getProductPrices: BillingProductPricesOutput;
@ -2099,6 +2099,7 @@ export type Workspace = {
hasValidEnterpriseKey: Scalars['Boolean']['output']; hasValidEnterpriseKey: Scalars['Boolean']['output'];
id: Scalars['UUID']['output']; id: Scalars['UUID']['output'];
inviteHash?: Maybe<Scalars['String']['output']>; inviteHash?: Maybe<Scalars['String']['output']>;
isCustomDomainEnabled: Scalars['Boolean']['output'];
isGoogleAuthEnabled: Scalars['Boolean']['output']; isGoogleAuthEnabled: Scalars['Boolean']['output'];
isMicrosoftAuthEnabled: Scalars['Boolean']['output']; isMicrosoftAuthEnabled: Scalars['Boolean']['output'];
isPasswordAuthEnabled: Scalars['Boolean']['output']; isPasswordAuthEnabled: Scalars['Boolean']['output'];

View File

@ -321,15 +321,8 @@ export type CursorPaging = {
last?: InputMaybe<Scalars['Int']>; last?: InputMaybe<Scalars['Int']>;
}; };
export type CustomDomainDetails = { export type CustomDomainRecord = {
__typename?: 'CustomDomainDetails'; __typename?: 'CustomDomainRecord';
customDomain: Scalars['String'];
id: Scalars['String'];
records: Array<CustomDomainVerification>;
};
export type CustomDomainVerification = {
__typename?: 'CustomDomainVerification';
key: Scalars['String']; key: Scalars['String'];
status: Scalars['String']; status: Scalars['String'];
type: Scalars['String']; type: Scalars['String'];
@ -337,6 +330,13 @@ export type CustomDomainVerification = {
value: Scalars['String']; value: Scalars['String'];
}; };
export type CustomDomainValidRecords = {
__typename?: 'CustomDomainValidRecords';
customDomain: Scalars['String'];
id: Scalars['String'];
records: Array<CustomDomainRecord>;
};
export type DeleteOneFieldInput = { export type DeleteOneFieldInput = {
/** The id of the field to delete. */ /** The id of the field to delete. */
id: Scalars['UUID']; id: Scalars['UUID'];
@ -734,6 +734,7 @@ export type Mutation = {
activateWorkspace: Workspace; activateWorkspace: Workspace;
authorizeApp: AuthorizeApp; authorizeApp: AuthorizeApp;
buildDraftServerlessFunction: ServerlessFunction; buildDraftServerlessFunction: ServerlessFunction;
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput; checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON']; computeStepOutputSchema: Scalars['JSON'];
createDraftFromWorkflowVersion: WorkflowVersion; createDraftFromWorkflowVersion: WorkflowVersion;
@ -1223,7 +1224,6 @@ export type Query = {
findWorkspaceFromInviteHash: Workspace; findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>; findWorkspaceInvitations: Array<WorkspaceInvitation>;
getAvailablePackages: Scalars['JSON']; getAvailablePackages: Scalars['JSON'];
getCustomDomainDetails?: Maybe<CustomDomainDetails>;
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getPostgresCredentials?: Maybe<PostgresCredentials>; getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput; getProductPrices: BillingProductPricesOutput;
@ -1877,6 +1877,7 @@ export type Workspace = {
hasValidEnterpriseKey: Scalars['Boolean']; hasValidEnterpriseKey: Scalars['Boolean'];
id: Scalars['UUID']; id: Scalars['UUID'];
inviteHash?: Maybe<Scalars['String']>; inviteHash?: Maybe<Scalars['String']>;
isCustomDomainEnabled: Scalars['Boolean'];
isGoogleAuthEnabled: Scalars['Boolean']; isGoogleAuthEnabled: Scalars['Boolean'];
isMicrosoftAuthEnabled: Scalars['Boolean']; isMicrosoftAuthEnabled: Scalars['Boolean'];
isPasswordAuthEnabled: Scalars['Boolean']; isPasswordAuthEnabled: Scalars['Boolean'];
@ -2421,10 +2422,10 @@ export type UploadWorkspaceLogoMutationVariables = Exact<{
export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: string }; 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<{ export type GetWorkspaceFromInviteHashQueryVariables = Exact<{
inviteHash: Scalars['String']; inviteHash: Scalars['String'];
@ -4895,9 +4896,10 @@ export function useUploadWorkspaceLogoMutation(baseOptions?: Apollo.MutationHook
export type UploadWorkspaceLogoMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoMutation>; export type UploadWorkspaceLogoMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoMutation>;
export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult<UploadWorkspaceLogoMutation>; export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult<UploadWorkspaceLogoMutation>;
export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceLogoMutation, UploadWorkspaceLogoMutationVariables>; export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceLogoMutation, UploadWorkspaceLogoMutationVariables>;
export const GetCustomDomainDetailsDocument = gql` export const CheckCustomDomainValidRecordsDocument = gql`
query GetCustomDomainDetails { mutation CheckCustomDomainValidRecords {
getCustomDomainDetails { checkCustomDomainValidRecords {
id
customDomain customDomain
records { records {
type type
@ -4909,33 +4911,31 @@ export const GetCustomDomainDetailsDocument = gql`
} }
} }
`; `;
export type CheckCustomDomainValidRecordsMutationFn = Apollo.MutationFunction<CheckCustomDomainValidRecordsMutation, CheckCustomDomainValidRecordsMutationVariables>;
/** /**
* __useGetCustomDomainDetailsQuery__ * __useCheckCustomDomainValidRecordsMutation__
* *
* To run a query within a React component, call `useGetCustomDomainDetailsQuery` and pass it any options that fit your needs. * 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, `useGetCustomDomainDetailsQuery` returns an object from Apollo Client that contains loading, error, and data properties * When your component renders, `useCheckCustomDomainValidRecordsMutation` returns a tuple that includes:
* you can use to render your UI. * - 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 * @example
* const { data, loading, error } = useGetCustomDomainDetailsQuery({ * const [checkCustomDomainValidRecordsMutation, { data, loading, error }] = useCheckCustomDomainValidRecordsMutation({
* variables: { * variables: {
* }, * },
* }); * });
*/ */
export function useGetCustomDomainDetailsQuery(baseOptions?: Apollo.QueryHookOptions<GetCustomDomainDetailsQuery, GetCustomDomainDetailsQueryVariables>) { export function useCheckCustomDomainValidRecordsMutation(baseOptions?: Apollo.MutationHookOptions<CheckCustomDomainValidRecordsMutation, CheckCustomDomainValidRecordsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetCustomDomainDetailsQuery, GetCustomDomainDetailsQueryVariables>(GetCustomDomainDetailsDocument, options); return Apollo.useMutation<CheckCustomDomainValidRecordsMutation, CheckCustomDomainValidRecordsMutationVariables>(CheckCustomDomainValidRecordsDocument, options);
} }
export function useGetCustomDomainDetailsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetCustomDomainDetailsQuery, GetCustomDomainDetailsQueryVariables>) { export type CheckCustomDomainValidRecordsMutationHookResult = ReturnType<typeof useCheckCustomDomainValidRecordsMutation>;
const options = {...defaultOptions, ...baseOptions} export type CheckCustomDomainValidRecordsMutationResult = Apollo.MutationResult<CheckCustomDomainValidRecordsMutation>;
return Apollo.useLazyQuery<GetCustomDomainDetailsQuery, GetCustomDomainDetailsQueryVariables>(GetCustomDomainDetailsDocument, options); export type CheckCustomDomainValidRecordsMutationOptions = Apollo.BaseMutationOptions<CheckCustomDomainValidRecordsMutation, CheckCustomDomainValidRecordsMutationVariables>;
}
export type GetCustomDomainDetailsQueryHookResult = ReturnType<typeof useGetCustomDomainDetailsQuery>;
export type GetCustomDomainDetailsLazyQueryHookResult = ReturnType<typeof useGetCustomDomainDetailsLazyQuery>;
export type GetCustomDomainDetailsQueryResult = Apollo.QueryResult<GetCustomDomainDetailsQuery, GetCustomDomainDetailsQueryVariables>;
export const GetWorkspaceFromInviteHashDocument = gql` export const GetWorkspaceFromInviteHashDocument = gql`
query GetWorkspaceFromInviteHash($inviteHash: String!) { query GetWorkspaceFromInviteHash($inviteHash: String!) {
findWorkspaceFromInviteHash(inviteHash: $inviteHash) { findWorkspaceFromInviteHash(inviteHash: $inviteHash) {

View File

@ -1,8 +1,9 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const GET_CUSTOM_DOMAIN_DETAILS = gql` export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS = gql`
query GetCustomDomainDetails { mutation CheckCustomDomainValidRecords {
getCustomDomainDetails { checkCustomDomainValidRecords {
id
customDomain customDomain
records { records {
type type

View File

@ -1,35 +1,48 @@
/* @license Enterprise */
import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, Section } from 'twenty-ui'; import { H2Title, Section } from 'twenty-ui';
import { useGetCustomDomainDetailsQuery } from '~/generated/graphql';
import { SettingsCustomDomainRecords } from '~/pages/settings/workspace/SettingsCustomDomainRecords'; 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` const StyledDomainFormWrapper = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
`; `;
const StyledRecordsWrapper = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsCustomDomain = () => { export const SettingsCustomDomain = () => {
const { data: getCustomDomainDetailsData } = useGetCustomDomainDetailsQuery(); const customDomainRecords = useRecoilValue(customDomainRecordsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { t } = useLingui(); const { t } = useLingui();
const { control, getValues } = useFormContext<{ const { control } = useFormContext<{
customDomain: string; customDomain: string;
}>(); }>();
return ( return (
<Section> <Section>
<H2Title title={t`Domain`} description={t`Set the name of your domain`} /> <H2Title
title={t`Custom Domain`}
description={t`Set the name of your custom domain and configure your DNS records.`}
/>
<StyledDomainFormWrapper> <StyledDomainFormWrapper>
<Controller <Controller
name="customDomain" name="customDomain"
control={control} control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => ( render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextInputV2 <TextInputV2
value={value ?? undefined} value={value}
type="text" type="text"
onChange={onChange} onChange={onChange}
error={error?.message} error={error?.message}
@ -38,12 +51,17 @@ export const SettingsCustomDomain = () => {
)} )}
/> />
</StyledDomainFormWrapper> </StyledDomainFormWrapper>
{getCustomDomainDetailsData?.getCustomDomainDetails && {customDomainRecords &&
getValues('customDomain') === currentWorkspace?.customDomain &&
getCustomDomainDetailsData?.getCustomDomainDetails?.customDomain && ( currentWorkspace.customDomain === customDomainRecords?.customDomain && (
<SettingsCustomDomainRecords <StyledRecordsWrapper>
records={getCustomDomainDetailsData.getCustomDomainDetails.records} <SettingsCustomDomainRecordsStatus
/> records={customDomainRecords.records}
/>
<SettingsCustomDomainRecords
records={customDomainRecords.records}
/>
</StyledRecordsWrapper>
)} )}
</Section> </Section>
); );

View File

@ -1,20 +1,37 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useEffect } from 'react'; import { useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil'; import { useSetRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared'; 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 = () => { export const SettingsCustomDomainEffect = () => {
const { refetch } = useGetCustomDomainDetailsQuery(); const [checkCustomDomainValidRecords, { data: customDomainRecords }] =
useCheckCustomDomainValidRecordsMutation();
const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspace = useRecoilValue(currentWorkspaceState);
const initInterval = useCallback(() => {
return setInterval(async () => {
await checkCustomDomainValidRecords();
if (isDefined(customDomainRecords?.checkCustomDomainValidRecords)) {
setCustomDomainRecords(
customDomainRecords.checkCustomDomainValidRecords,
);
}
}, 3000);
}, [
checkCustomDomainValidRecords,
customDomainRecords,
setCustomDomainRecords,
]);
useEffect(() => { useEffect(() => {
let pollIntervalFn: null | ReturnType<typeof setInterval> = null; let pollIntervalFn: null | ReturnType<typeof setInterval> = null;
if (isDefined(currentWorkspace?.customDomain)) { if (isDefined(currentWorkspace?.customDomain)) {
pollIntervalFn = setInterval(async () => { pollIntervalFn = initInterval();
refetch();
}, 3000);
} }
return () => { return () => {
@ -22,7 +39,7 @@ export const SettingsCustomDomainEffect = () => {
clearInterval(pollIntervalFn); clearInterval(pollIntervalFn);
} }
}; };
}, [currentWorkspace?.customDomain, refetch]); }, [currentWorkspace?.customDomain, initInterval]);
return <></>; return <></>;
}; };

View File

@ -1,75 +1,90 @@
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { Separator } from '@/settings/components/Separator';
import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell'; 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 { 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 = ({ export const SettingsCustomDomainRecords = ({
records, 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 ( return (
<Table> <StyledTable>
<TableRow> <TableRow gridAutoColumns="35% 16% auto">
<TableHeader>Name</TableHeader> <TableHeader>Name</TableHeader>
<TableHeader>Record Type</TableHeader> <TableHeader>Type</TableHeader>
<TableHeader>Value</TableHeader> <TableHeader>Value</TableHeader>
<TableHeader>Validation Type</TableHeader>
<TableHeader>Status</TableHeader>
</TableRow> </TableRow>
<Separator></Separator>
<TableBody> <TableBody>
{records.map((record) => { {records.map((record) => {
return ( return (
<TableRow> <TableRow gridAutoColumns="30% 16% auto" key={record.key}>
<TableCell> <StyledTableCell>
<TextInputV2 <StyledButton
value={record.key} title={record.key}
type="text" onClick={() => copyToClipboardDebounced(record.key)}
disabled
sizeVariant="md"
/> />
</TableCell> </StyledTableCell>
<TableCell> <StyledTableCell>
<TextInputV2 <StyledButton
value={record.type.toUpperCase()} title={record.type.toUpperCase()}
type="text" onClick={() =>
disabled copyToClipboardDebounced(record.type.toUpperCase())
sizeVariant="md" }
/> />
</TableCell> </StyledTableCell>
<TableCell> <StyledTableCell>
<TextInputV2 <StyledButton
value={record.value} title={record.value}
type="text" onClick={() => copyToClipboardDebounced(record.value)}
disabled
sizeVariant="md"
/> />
</TableCell> </StyledTableCell>
<TableCell>
<TextInputV2
value={record.validationType}
type="text"
disabled
sizeVariant="md"
/>
</TableCell>
<TableCell>
<TextInputV2
value={record.status}
type="text"
disabled
sizeVariant="md"
/>
</TableCell>
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </StyledTable>
); );
}; };

View File

@ -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<string, { name: string; status: string; color: ThemeColor }>,
);
return (
<StyledTable>
{Object.values(rows).map((row) => {
return (
<StyledTableRow>
<TableCell>{row.name}</TableCell>
<TableCell>
<Status color={row.color} text={row.status} />
</TableCell>
</StyledTableRow>
);
})}
</StyledTable>
);
};

View File

@ -74,7 +74,7 @@ export const SettingsDomain = () => {
delayError: 500, delayError: 500,
defaultValues: { defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '', subdomain: currentWorkspace?.subdomain ?? '',
customDomain: currentWorkspace?.customDomain ?? null, customDomain: currentWorkspace?.customDomain ?? '',
}, },
resolver: zodResolver(validationSchema), resolver: zodResolver(validationSchema),
}); });
@ -83,7 +83,7 @@ export const SettingsDomain = () => {
const customDomainValue = form.watch('customDomain'); const customDomainValue = form.watch('customDomain');
const updateCustomDomain = ( const updateCustomDomain = (
customDomain: string | null | undefined, customDomain: string | null,
currentWorkspace: CurrentWorkspace, currentWorkspace: CurrentWorkspace,
) => { ) => {
updateWorkspace({ updateWorkspace({
@ -98,7 +98,8 @@ export const SettingsDomain = () => {
onCompleted: () => { onCompleted: () => {
setCurrentWorkspace({ setCurrentWorkspace({
...currentWorkspace, ...currentWorkspace,
customDomain, customDomain:
customDomain && customDomain.length > 0 ? customDomain : null,
}); });
}, },
onError: (error) => { onError: (error) => {
@ -209,9 +210,7 @@ export const SettingsDomain = () => {
<SettingsPageContainer> <SettingsPageContainer>
{/* eslint-disable-next-line react/jsx-props-no-spreading */} {/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}> <FormProvider {...form}>
{(!currentWorkspace?.customDomain || !isCustomDomainEnabled) && ( <SettingsSubdomain />
<SettingsSubdomain />
)}
{isCustomDomainEnabled && ( {isCustomDomainEnabled && (
<> <>
<SettingsCustomDomainEffect /> <SettingsCustomDomainEffect />

View File

@ -7,6 +7,7 @@ import { H2Title, Section } from 'twenty-ui';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
const StyledDomainFormWrapper = styled.div` const StyledDomainFormWrapper = styled.div`
align-items: center; align-items: center;
@ -26,6 +27,8 @@ export const SettingsSubdomain = () => {
const domainConfiguration = useRecoilValue(domainConfigurationState); const domainConfiguration = useRecoilValue(domainConfigurationState);
const { t } = useLingui(); const { t } = useLingui();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { control } = useFormContext<{ const { control } = useFormContext<{
subdomain: string; subdomain: string;
}>(); }>();
@ -47,6 +50,7 @@ export const SettingsSubdomain = () => {
type="text" type="text"
onChange={onChange} onChange={onChange}
error={error?.message} error={error?.message}
disabled={!!currentWorkspace?.customDomain}
fullWidth fullWidth
/> />
{isDefined(domainConfiguration.frontDomain) && ( {isDefined(domainConfiguration.frontDomain) && (

View File

@ -0,0 +1,8 @@
import { createState } from '@ui/utilities/state/utils/createState';
import { CustomDomainValidRecords } from '~/generated/graphql';
export const customDomainRecordsState =
createState<CustomDomainValidRecords | null>({
key: 'customDomainRecordsState',
defaultValue: null,
});

View File

@ -48,6 +48,7 @@ export const mockCurrentWorkspace: Workspace = {
hasValidEnterpriseKey: false, hasValidEnterpriseKey: false,
isGoogleAuthEnabled: true, isGoogleAuthEnabled: true,
isPasswordAuthEnabled: true, isPasswordAuthEnabled: true,
isCustomDomainEnabled: false,
workspaceUrls: { workspaceUrls: {
customUrl: undefined, customUrl: undefined,
subdomainUrl: 'twenty.twenty.com', subdomainUrl: 'twenty.twenty.com',

View File

@ -75,3 +75,4 @@ FRONT_PORT=3001
# SSL_CERT_PATH="./certs/your-cert.crt" # SSL_CERT_PATH="./certs/your-cert.crt"
# CLOUDFLARE_API_KEY= # CLOUDFLARE_API_KEY=
# CLOUDFLARE_ZONE_ID= # CLOUDFLARE_ZONE_ID=
# CLOUDFLARE_WEBHOOK_SECRET=

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsCustomDomainEnable1739203087254
implements MigrationInterface
{
name = 'AddIsCustomDomainEnable1739203087254';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "isCustomDomainEnabled" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "isCustomDomainEnabled"`,
);
}
}

View File

@ -124,6 +124,7 @@ export class GoogleAPIsAuthController {
err, err,
workspace ?? { workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
}, },
), ),
); );

View File

@ -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 { 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 { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { User } from 'src/engine/core-modules/user/user.entity'; 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') @Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter) @UseFilters(AuthRestApiExceptionFilter)
@ -28,6 +29,7 @@ export class GoogleAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
) {} ) {}
@ -118,7 +120,7 @@ export class GoogleAuthController {
return res.redirect( return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace, currentWorkspace,
), ),
), ),

View File

@ -131,6 +131,7 @@ export class MicrosoftAPIsAuthController {
err, err,
workspace ?? { workspace ?? {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
}, },
), ),
); );

View File

@ -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 { 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 { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { User } from 'src/engine/core-modules/user/user.entity'; 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') @Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter) @UseFilters(AuthRestApiExceptionFilter)
@ -29,6 +30,7 @@ export class MicrosoftAuthController {
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
private readonly domainManagerService: DomainManagerService,
) {} ) {}
@Get() @Get()
@ -119,7 +121,7 @@ export class MicrosoftAuthController {
return res.redirect( return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
currentWorkspace, currentWorkspace,
), ),
), ),

View File

@ -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 { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Controller('auth') @Controller('auth')
export class SSOAuthController { export class SSOAuthController {
@ -44,6 +45,8 @@ export class SSOAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly domainManagerService: DomainManagerService,
private readonly sSOService: SSOService, private readonly sSOService: SSOService,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@ -157,7 +160,7 @@ export class SSOAuthController {
return res.redirect( return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions( this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspaceIdentityProvider?.workspace, workspaceIdentityProvider?.workspace,
), ),
), ),

View File

@ -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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
@ -23,6 +24,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly domainManagerService: DomainManagerService,
) { ) {
super({ super({
prompt: 'select_account', prompt: 'select_account',
@ -71,7 +73,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspace, workspace,
), ),
); );

View File

@ -11,6 +11,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class GoogleOauthGuard extends AuthGuard('google') { export class GoogleOauthGuard extends AuthGuard('google') {
@ -18,6 +19,7 @@ export class GoogleOauthGuard extends AuthGuard('google') {
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly domainManagerService: DomainManagerService,
) { ) {
super({ super({
prompt: 'select_account', prompt: 'select_account',
@ -51,7 +53,7 @@ export class GoogleOauthGuard extends AuthGuard('google') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspace, workspace,
), ),
); );

View File

@ -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 { 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 { 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 { 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 { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard( export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
@ -22,11 +22,11 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
) { ) {
constructor( constructor(
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly featureFlagService: FeatureFlagService,
private readonly transientTokenService: TransientTokenService, private readonly transientTokenService: TransientTokenService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly domainManagerService: DomainManagerService,
) { ) {
super({ super({
prompt: 'select_account', prompt: 'select_account',
@ -72,7 +72,7 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspace, workspace,
), ),
); );

View File

@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
@ -13,6 +14,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly domainManagerService: DomainManagerService,
) { ) {
super({ super({
prompt: 'select_account', prompt: 'select_account',
@ -39,7 +41,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
workspace, workspace,
), ),
); );

View File

@ -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 { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class OIDCAuthGuard extends AuthGuard('openidconnect') { export class OIDCAuthGuard extends AuthGuard('openidconnect') {
constructor( constructor(
private readonly sSOService: SSOService, private readonly sSOService: SSOService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly domainManagerService: DomainManagerService,
) { ) {
super(); super();
} }
@ -88,7 +90,7 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
identityProvider?.workspace, identityProvider?.workspace,
), ),
); );

View File

@ -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 { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
const createMockExecutionContext = (mockedRequest: any): ExecutionContext => { const createMockExecutionContext = (mockedRequest: any): ExecutionContext => {
return { return {
@ -58,6 +59,13 @@ describe('OIDCAuthGuard', () => {
getSubdomainAndCustomDomainFromWorkspace: jest.fn(), getSubdomainAndCustomDomainFromWorkspace: jest.fn(),
}, },
}, },
{
provide: DomainManagerService,
useValue: {
getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain:
jest.fn(),
},
},
], ],
}).compile(); }).compile();

View File

@ -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 { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable() @Injectable()
export class SAMLAuthGuard extends AuthGuard('saml') { export class SAMLAuthGuard extends AuthGuard('saml') {
constructor( constructor(
private readonly sSOService: SSOService, private readonly sSOService: SSOService,
private readonly guardRedirectService: GuardRedirectService, private readonly guardRedirectService: GuardRedirectService,
private readonly domainManagerService: DomainManagerService,
) { ) {
super(); super();
} }
@ -49,7 +51,7 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
this.guardRedirectService.dispatchErrorFromGuard( this.guardRedirectService.dispatchErrorFromGuard(
context, context,
err, err,
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace( this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
identityProvider?.workspace, identityProvider?.workspace,
), ),
); );

View File

@ -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 { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -459,7 +460,7 @@ export class AuthService {
billingCheckoutSessionState, billingCheckoutSessionState,
}: { }: {
loginToken: string; loginToken: string;
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>; workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
billingCheckoutSessionState?: string; billingCheckoutSessionState?: string;
}) { }) {
const url = this.domainManagerService.buildWorkspaceURL({ const url = this.domainManagerService.buildWorkspaceURL({

View File

@ -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<Workspace>,
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<Workspace> = {
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();
}
}

View File

@ -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<Workspace>;
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>(CloudflareController);
WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core'));
environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
customDomainService = module.get<CustomDomainService>(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();
});
});

View File

@ -10,4 +10,5 @@ export enum DomainManagerExceptionCode {
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED', CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED', HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED', SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
INVALID_INPUT_DATA = 'INVALID_INPUT_DATA',
} }

View File

@ -3,10 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; 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({ @Module({
imports: [TypeOrmModule.forFeature([Workspace], 'core')], imports: [TypeOrmModule.forFeature([Workspace], 'core')],
providers: [DomainManagerService], providers: [DomainManagerService, CustomDomainService],
exports: [DomainManagerService], exports: [DomainManagerService, CustomDomainService],
controllers: [CloudflareController],
}) })
export class DomainManagerModule {} export class DomainManagerModule {}

View File

@ -0,0 +1,6 @@
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType = Pick<
Workspace,
'subdomain' | 'customDomain' | 'isCustomDomainEnabled'
>;

View File

@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType() @ObjectType()
class CustomDomainVerification { class CustomDomainRecord {
@Field(() => String) @Field(() => String)
validationType: 'ownership' | 'ssl' | 'redirection'; validationType: 'ownership' | 'ssl' | 'redirection';
@ -19,13 +19,13 @@ class CustomDomainVerification {
} }
@ObjectType() @ObjectType()
export class CustomDomainDetails { export class CustomDomainValidRecords {
@Field(() => String) @Field(() => String)
id: string; id: string;
@Field(() => String) @Field(() => String)
customDomain: string; customDomain: string;
@Field(() => [CustomDomainVerification]) @Field(() => [CustomDomainRecord])
records: Array<CustomDomainVerification>; records: Array<CustomDomainRecord>;
} }

View File

@ -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<Request>();
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;
}
}
}

View File

@ -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);
});
});

View File

@ -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>(CustomDomainService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService =
module.get<DomainManagerService>(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);
});
});
});

View File

@ -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<CustomDomainValidRecords | undefined> {
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<CustomDomainValidRecords['records'][0] | undefined>(
(record: Record<string, string>) => {
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',
);
}
}

View File

@ -25,6 +25,7 @@ describe('DomainManagerService', () => {
const result = domainManagerService.getWorkspaceUrls({ const result = domainManagerService.getWorkspaceUrls({
subdomain: 'subdomain', subdomain: 'subdomain',
customDomain: 'custom-host.com', customDomain: 'custom-host.com',
isCustomDomainEnabled: true,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -47,7 +48,8 @@ describe('DomainManagerService', () => {
const result = domainManagerService.getWorkspaceUrls({ const result = domainManagerService.getWorkspaceUrls({
subdomain: 'subdomain', subdomain: 'subdomain',
customDomain: undefined, customDomain: null,
isCustomDomainEnabled: false,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -155,7 +157,8 @@ describe('DomainManagerService', () => {
const result = domainManagerService.buildWorkspaceURL({ const result = domainManagerService.buildWorkspaceURL({
workspace: { workspace: {
subdomain: 'test', subdomain: 'test',
customDomain: undefined, customDomain: null,
isCustomDomainEnabled: false,
}, },
}); });
@ -177,7 +180,8 @@ describe('DomainManagerService', () => {
const result = domainManagerService.buildWorkspaceURL({ const result = domainManagerService.buildWorkspaceURL({
workspace: { workspace: {
subdomain: 'test', subdomain: 'test',
customDomain: undefined, customDomain: null,
isCustomDomainEnabled: false,
}, },
pathname: '/path/to/resource', pathname: '/path/to/resource',
}); });
@ -200,7 +204,8 @@ describe('DomainManagerService', () => {
const result = domainManagerService.buildWorkspaceURL({ const result = domainManagerService.buildWorkspaceURL({
workspace: { workspace: {
subdomain: 'test', subdomain: 'test',
customDomain: undefined, customDomain: null,
isCustomDomainEnabled: false,
}, },
searchParams: { searchParams: {
foo: 'bar', foo: 'bar',

View File

@ -1,37 +1,24 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import Cloudflare from 'cloudflare';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
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 { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain'; 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 { 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 { 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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
@Injectable() @Injectable()
export class DomainManagerService { export class DomainManagerService {
cloudflareClient?: Cloudflare;
constructor( constructor(
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) { ) {}
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
this.cloudflareClient = new Cloudflare({
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
});
}
}
getFrontUrl() { getFrontUrl() {
let baseUrl: URL; let baseUrl: URL;
@ -78,7 +65,7 @@ export class DomainManagerService {
}: { }: {
emailVerificationToken: string; emailVerificationToken: string;
email: string; email: string;
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>; workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
}) { }) {
return this.buildWorkspaceURL({ return this.buildWorkspaceURL({
workspace, workspace,
@ -92,7 +79,7 @@ export class DomainManagerService {
pathname, pathname,
searchParams, searchParams,
}: { }: {
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>; workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
pathname?: string; pathname?: string;
searchParams?: Record<string, string | number>; searchParams?: Record<string, string | number>;
}) { }) {
@ -129,7 +116,7 @@ export class DomainManagerService {
isFrontdomain && !this.isDefaultSubdomain(subdomain) isFrontdomain && !this.isDefaultSubdomain(subdomain)
? subdomain ? subdomain
: undefined, : undefined,
customDomain: isFrontdomain ? undefined : originHostname, customDomain: isFrontdomain ? null : originHostname,
}; };
}; };
@ -147,7 +134,7 @@ export class DomainManagerService {
computeRedirectErrorUrl( computeRedirectErrorUrl(
errorMessage: string, errorMessage: string,
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>, workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
) { ) {
const url = this.buildWorkspaceURL({ const url = this.buildWorkspaceURL({
workspace, workspace,
@ -237,149 +224,6 @@ export class DomainManagerService {
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`; 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<CustomDomainDetails | undefined> {
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<CustomDomainDetails['records'][0] | undefined>(
(record: Record<string, string>) => {
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) { private getCustomWorkspaceUrl(customDomain: string) {
const url = this.getFrontUrl(); const url = this.getFrontUrl();
@ -396,14 +240,42 @@ export class DomainManagerService {
return url.toString(); 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({ getWorkspaceUrls({
subdomain, subdomain,
customDomain, customDomain,
}: Pick<Workspace, 'subdomain' | 'customDomain'>) { isCustomDomainEnabled,
}: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType) {
return { return {
customUrl: customDomain customUrl:
? this.getCustomWorkspaceUrl(customDomain) isCustomDomainEnabled && customDomain
: undefined, ? this.getCustomWorkspaceUrl(customDomain)
: undefined,
subdomainUrl: this.getTwentyWorkspaceUrl(subdomain), subdomainUrl: this.getTwentyWorkspaceUrl(subdomain),
}; };
} }

View File

@ -21,7 +21,7 @@ import {
import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -39,7 +39,7 @@ export class EmailVerificationService {
async sendVerificationEmail( async sendVerificationEmail(
userId: string, userId: string,
email: string, email: string,
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>, workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
) { ) {
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
return { success: false }; return { success: false };
@ -83,7 +83,7 @@ export class EmailVerificationService {
async resendEmailVerificationToken( async resendEmailVerificationToken(
email: string, email: string,
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>, workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
) { ) {
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
throw new EmailVerificationException( throw new EmailVerificationException(

View File

@ -820,6 +820,14 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.CLOUDFLARE_API_KEY) @ValidateIf((env) => env.CLOUDFLARE_API_KEY)
CLOUDFLARE_ZONE_ID: string; CLOUDFLARE_ZONE_ID: string;
@EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other,
description: 'Random string to validate queries from Cloudflare',
})
@IsString()
@IsOptional()
CLOUDFLARE_WEBHOOK_SECRET: string;
@EnvironmentVariablesMetadata({ @EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.LLM, group: EnvironmentVariablesGroup.LLM,
description: 'Driver for the LLM chat model', description: 'Driver for the LLM chat model',

View File

@ -7,7 +7,6 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { CustomException } from 'src/utils/custom-exception'; import { CustomException } from 'src/utils/custom-exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable() @Injectable()
export class GuardRedirectService { export class GuardRedirectService {
@ -20,7 +19,12 @@ export class GuardRedirectService {
dispatchErrorFromGuard( dispatchErrorFromGuard(
context: ExecutionContext, context: ExecutionContext,
error: Error | CustomException, 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') { if ('contextType' in context && context.contextType === 'graphql') {
throw error; throw error;
@ -32,22 +36,7 @@ export class GuardRedirectService {
.redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace)); .redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace));
} }
getSubdomainAndCustomDomainFromWorkspace( getSubdomainAndCustomDomainFromContext(context: ExecutionContext) {
workspace?: Pick<Workspace, 'subdomain' | 'customDomain'> | null,
) {
if (!workspace) {
return {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
};
}
return workspace;
}
getSubdomainAndCustomDomainFromContext(context: ExecutionContext): {
subdomain: string;
customDomain?: string;
} {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const subdomainAndCustomDomainFromReferer = request.headers.referer const subdomainAndCustomDomainFromReferer = request.headers.referer
@ -56,12 +45,16 @@ export class GuardRedirectService {
) )
: null; : null;
return { return subdomainAndCustomDomainFromReferer &&
subdomain: subdomainAndCustomDomainFromReferer.subdomain
subdomainAndCustomDomainFromReferer?.subdomain ?? ? {
this.environmentService.get('DEFAULT_SUBDOMAIN'), subdomain: subdomainAndCustomDomainFromReferer.subdomain,
customDomain: subdomainAndCustomDomainFromReferer?.customDomain, customDomain: subdomainAndCustomDomainFromReferer.customDomain,
}; }
: {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
};
} }
private captureException(err: Error | CustomException, workspaceId?: string) { private captureException(err: Error | CustomException, workspaceId?: string) {
@ -76,13 +69,22 @@ export class GuardRedirectService {
getRedirectErrorUrlAndCaptureExceptions( getRedirectErrorUrlAndCaptureExceptions(
err: Error | CustomException, 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); this.captureException(err, workspace.id);
return this.domainManagerService.computeRedirectErrorUrl( return this.domainManagerService.computeRedirectErrorUrl(
err instanceof AuthException ? err.message : 'Unknown error', err instanceof AuthException ? err.message : 'Unknown error',
workspace, {
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false,
},
); );
} }
} }

View File

@ -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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { WorkspaceService } from './workspace.service'; import { WorkspaceService } from './workspace.service';
@ -55,6 +56,10 @@ describe('WorkspaceService', () => {
provide: DomainManagerService, provide: DomainManagerService,
useValue: {}, useValue: {},
}, },
{
provide: CustomDomainService,
useValue: {},
},
{ {
provide: BillingSubscriptionService, provide: BillingSubscriptionService,
useValue: {}, useValue: {},

View File

@ -37,6 +37,7 @@ import {
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags'; import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -59,6 +60,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly exceptionHandlerService: ExceptionHandlerService, private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly permissionsService: PermissionsService, private readonly permissionsService: PermissionsService,
private readonly customDomainService: CustomDomainService,
) { ) {
super(workspaceRepository); super(workspaceRepository);
} }
@ -111,7 +113,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
workspace.customDomain !== customDomain && workspace.customDomain !== customDomain &&
isDefined(workspace.customDomain) isDefined(workspace.customDomain)
) { ) {
await this.domainManagerService.updateCustomDomain( await this.customDomainService.updateCustomDomain(
workspace.customDomain, workspace.customDomain,
customDomain, customDomain,
); );
@ -122,7 +124,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
workspace.customDomain !== customDomain && workspace.customDomain !== customDomain &&
!isDefined(workspace.customDomain) !isDefined(workspace.customDomain)
) { ) {
await this.domainManagerService.registerCustomDomain(customDomain); await this.customDomainService.registerCustomDomain(customDomain);
} }
} }
@ -146,7 +148,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
let customDomainRegistered = false; let customDomainRegistered = false;
if (payload.customDomain === null && isDefined(workspace.customDomain)) { if (payload.customDomain === null && isDefined(workspace.customDomain)) {
await this.domainManagerService.deleteCustomHostnameByHostnameSilently( await this.customDomainService.deleteCustomHostnameByHostnameSilently(
workspace.customDomain, workspace.customDomain,
); );
} }
@ -179,7 +181,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
} catch (error) { } catch (error) {
// revert custom domain registration on error // revert custom domain registration on error
if (payload.customDomain && customDomainRegistered) { if (payload.customDomain && customDomainRegistered) {
this.domainManagerService this.customDomainService
.deleteCustomHostnameByHostnameSilently(payload.customDomain) .deleteCustomHostnameByHostnameSilently(payload.customDomain)
.catch((err) => { .catch((err) => {
this.exceptionHandlerService.captureExceptions([err]); this.exceptionHandlerService.captureExceptions([err]);
@ -320,4 +322,25 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
} }
} }
} }
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;
}
} }

View File

@ -122,9 +122,9 @@ export class Workspace {
@Column({ unique: true }) @Column({ unique: true })
subdomain: string; subdomain: string;
@Field({ nullable: true }) @Field(() => String, { nullable: true })
@Column({ unique: true, nullable: true }) @Column({ type: 'varchar', unique: true, nullable: true })
customDomain?: string; customDomain: string | null;
@Field() @Field()
@Column({ default: true }) @Column({ default: true })
@ -137,4 +137,8 @@ export class Workspace {
@Field() @Field()
@Column({ default: true }) @Column({ default: true })
isMicrosoftAuthEnabled: boolean; isMicrosoftAuthEnabled: boolean;
@Field()
@Column({ default: false })
isCustomDomainEnabled: boolean;
} }

View File

@ -17,7 +17,6 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { 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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -47,6 +46,7 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter'; import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { Workspace } from './workspace.entity'; import { Workspace } from './workspace.entity';
@ -229,14 +229,12 @@ export class WorkspaceResolver {
return this.domainManagerService.getWorkspaceUrls(workspace); return this.domainManagerService.getWorkspaceUrls(workspace);
} }
@Query(() => CustomDomainDetails, { nullable: true }) @Mutation(() => CustomDomainValidRecords, { nullable: true })
@UseGuards(WorkspaceAuthGuard) @UseGuards(WorkspaceAuthGuard)
async getCustomDomainDetails( async checkCustomDomainValidRecords(
@AuthWorkspace() { customDomain }: Workspace, @AuthWorkspace() workspace: Workspace,
): Promise<CustomDomainDetails | undefined> { ): Promise<CustomDomainValidRecords | undefined> {
if (!customDomain) return undefined; return this.workspaceService.checkCustomDomainValidRecords(workspace);
return await this.domainManagerService.getCustomDomainDetails(customDomain);
} }
@Query(() => PublicWorkspaceDataOutput) @Query(() => PublicWorkspaceDataOutput)