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

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

View File

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

View File

@ -1,75 +1,90 @@
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { Separator } from '@/settings/components/Separator';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Button } from 'twenty-ui';
import { Table } from '@/ui/layout/table/components/Table';
import { CustomDomainDetails } from '~/generated/graphql';
import { CustomDomainValidRecords } from '~/generated/graphql';
import styled from '@emotion/styled';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useDebouncedCallback } from 'use-debounce';
const StyledTable = styled(Table)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableCell = styled(TableCell)`
overflow: hidden;
`;
const StyledButton = styled(Button)`
-moz-user-select: text;
-ms-user-select: text;
-webkit-user-select: text;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: ${({ theme }) => theme.spacing(7)};
overflow: hidden;
user-select: text;
width: 100%;
`;
export const SettingsCustomDomainRecords = ({
records,
}: {
records: CustomDomainDetails['records'];
records: CustomDomainValidRecords['records'];
}) => {
const { enqueueSnackBar } = useSnackBar();
const copyToClipboard = (value: string) => {
navigator.clipboard.writeText(value);
enqueueSnackBar('Copied to clipboard!', {
variant: SnackBarVariant.Success,
});
};
const copyToClipboardDebounced = useDebouncedCallback(copyToClipboard, 200);
return (
<Table>
<TableRow>
<StyledTable>
<TableRow gridAutoColumns="35% 16% auto">
<TableHeader>Name</TableHeader>
<TableHeader>Record Type</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader>Validation Type</TableHeader>
<TableHeader>Status</TableHeader>
</TableRow>
<Separator></Separator>
<TableBody>
{records.map((record) => {
return (
<TableRow>
<TableCell>
<TextInputV2
value={record.key}
type="text"
disabled
sizeVariant="md"
<TableRow gridAutoColumns="30% 16% auto" key={record.key}>
<StyledTableCell>
<StyledButton
title={record.key}
onClick={() => copyToClipboardDebounced(record.key)}
/>
</TableCell>
<TableCell>
<TextInputV2
value={record.type.toUpperCase()}
type="text"
disabled
sizeVariant="md"
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.type.toUpperCase()}
onClick={() =>
copyToClipboardDebounced(record.type.toUpperCase())
}
/>
</TableCell>
<TableCell>
<TextInputV2
value={record.value}
type="text"
disabled
sizeVariant="md"
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.value}
onClick={() => copyToClipboardDebounced(record.value)}
/>
</TableCell>
<TableCell>
<TextInputV2
value={record.validationType}
type="text"
disabled
sizeVariant="md"
/>
</TableCell>
<TableCell>
<TextInputV2
value={record.status}
type="text"
disabled
sizeVariant="md"
/>
</TableCell>
</StyledTableCell>
</TableRow>
);
})}
</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,
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
customDomain: currentWorkspace?.customDomain ?? null,
customDomain: currentWorkspace?.customDomain ?? '',
},
resolver: zodResolver(validationSchema),
});
@ -83,7 +83,7 @@ export const SettingsDomain = () => {
const customDomainValue = form.watch('customDomain');
const updateCustomDomain = (
customDomain: string | null | undefined,
customDomain: string | null,
currentWorkspace: CurrentWorkspace,
) => {
updateWorkspace({
@ -98,7 +98,8 @@ export const SettingsDomain = () => {
onCompleted: () => {
setCurrentWorkspace({
...currentWorkspace,
customDomain,
customDomain:
customDomain && customDomain.length > 0 ? customDomain : null,
});
},
onError: (error) => {
@ -209,9 +210,7 @@ export const SettingsDomain = () => {
<SettingsPageContainer>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
{(!currentWorkspace?.customDomain || !isCustomDomainEnabled) && (
<SettingsSubdomain />
)}
<SettingsSubdomain />
{isCustomDomainEnabled && (
<>
<SettingsCustomDomainEffect />

View File

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

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