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:
@ -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>
|
||||
);
|
||||
|
||||
@ -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 <></>;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 />
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user