feat(settings): review custom domain (#10393)

Introduce improved validation logic for custom domains, including regex
validation with descriptive error messages. Implement asynchronous
domain update functionality with a loading indicator and polling to
check record statuses. Refactor components to streamline functionality
and align with updated state management.

Fix https://github.com/twentyhq/core-team-issues/issues/453
This commit is contained in:
Antoine Moreaux
2025-02-24 11:31:45 +01:00
committed by GitHub
parent c5c6192434
commit 92462b3ae5
12 changed files with 232 additions and 139 deletions

View File

@ -34,7 +34,10 @@ export type ActivateWorkspaceInput = {
export type AdminPanelHealthServiceData = {
__typename?: 'AdminPanelHealthServiceData';
description: Scalars['String']['output'];
details?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
label: Scalars['String']['output'];
queues?: Maybe<Array<AdminPanelWorkerQueueHealth>>;
status: AdminPanelHealthServiceStatus;
};
@ -44,17 +47,11 @@ export enum AdminPanelHealthServiceStatus {
OUTAGE = 'OUTAGE'
}
export enum AdminPanelIndicatorHealthStatusInputEnum {
DATABASE = 'DATABASE',
MESSAGE_SYNC = 'MESSAGE_SYNC',
REDIS = 'REDIS',
WORKER = 'WORKER'
}
export type AdminPanelWorkerQueueHealth = {
__typename?: 'AdminPanelWorkerQueueHealth';
id: Scalars['String']['output'];
metrics: WorkerQueueMetrics;
name: Scalars['String']['output'];
queueName: Scalars['String']['output'];
status: AdminPanelHealthServiceStatus;
workers: Scalars['Float']['output'];
};
@ -707,6 +704,13 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String']['input'];
};
export enum HealthIndicatorId {
connectedAccount = 'connectedAccount',
database = 'database',
redis = 'redis',
worker = 'worker'
}
export enum IdentityProviderType {
OIDC = 'OIDC',
SAML = 'SAML'
@ -1506,7 +1510,7 @@ export type QueryGetAvailablePackagesArgs = {
export type QueryGetIndicatorHealthStatusArgs = {
indicatorName: AdminPanelIndicatorHealthStatusInputEnum;
indicatorId: HealthIndicatorId;
};
@ -1871,10 +1875,14 @@ export type Support = {
export type SystemHealth = {
__typename?: 'SystemHealth';
database: AdminPanelHealthServiceData;
messageSync: AdminPanelHealthServiceData;
redis: AdminPanelHealthServiceData;
worker: AdminPanelHealthServiceData;
services: Array<SystemHealthService>;
};
export type SystemHealthService = {
__typename?: 'SystemHealthService';
id: HealthIndicatorId;
label: Scalars['String']['output'];
status: AdminPanelHealthServiceStatus;
};
export type TimelineCalendarEvent = {

View File

@ -12,6 +12,7 @@ type SettingsCardProps = {
onClick?: () => void;
title: string;
className?: string;
Status?: ReactNode;
};
const StyledCard = styled(Card)<{
@ -77,6 +78,7 @@ export const SettingsCard = ({
onClick,
title,
className,
Status,
}: SettingsCardProps) => {
const theme = useTheme();
@ -94,6 +96,7 @@ export const SettingsCard = ({
{title}
{soon && <Pill label="Soon" />}
</StyledTitle>
{Status && Status}
<StyledIconChevronRight size={theme.icon.size.sm} />
</StyledHeader>
{description && <StyledDescription>{description}</StyledDescription>}

View File

@ -11,7 +11,13 @@ import {
useRef,
useState,
} from 'react';
import { AutogrowWrapper, IconComponent, IconEye, IconEyeOff } from 'twenty-ui';
import {
AutogrowWrapper,
IconComponent,
IconEye,
IconEyeOff,
Loader,
} from 'twenty-ui';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
@ -158,6 +164,7 @@ export type TextInputV2ComponentProps = Omit<
dataTestId?: string;
sizeVariant?: TextInputV2Size;
inheritFontStyles?: boolean;
loading?: boolean;
};
type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps;
@ -193,6 +200,7 @@ const TextInputV2Component = forwardRef<
inheritFontStyles = false,
dataTestId,
autoGrow = false,
loading = false,
},
ref,
) => {
@ -284,6 +292,12 @@ const TextInputV2Component = forwardRef<
<RightIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
)}
{!error && type !== INPUT_TYPE_PASSWORD && !!loading && (
<StyledTrailingIcon>
<Loader color={'gray'} />
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
<InputErrorHelper isVisible={!noErrorHelper}>{error}</InputErrorHelper>

View File

@ -1,6 +1,12 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { H2Title, IconWorld, Section, UndecoratedLink } from 'twenty-ui';
import {
H2Title,
IconWorld,
Section,
UndecoratedLink,
Status,
} from 'twenty-ui';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SettingsCard } from '@/settings/components/SettingsCard';
@ -12,11 +18,12 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
export const SettingsWorkspace = () => {
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { t } = useLingui();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
return (
<SubMenuTopBarContainer
title={t`General`}
@ -48,6 +55,11 @@ export const SettingsWorkspace = () => {
<SettingsCard
title={t`Customize Domain`}
Icon={<IconWorld />}
Status={
currentWorkspace?.customDomain ? (
<Status text={'Active'} color={'turquoise'} />
) : undefined
}
/>
</UndecoratedLink>
</Section>

View File

@ -17,16 +17,26 @@ const StyledDomainFormWrapper = styled.div`
const StyledRecordsWrapper = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
& > :not(:first-of-type) {
margin-top: ${({ theme }) => theme.spacing(4)};
}
`;
export const SettingsCustomDomain = () => {
const customDomainRecords = useRecoilValue(customDomainRecordsState);
export const SettingsCustomDomain = ({
handleSave,
}: {
handleSave: () => void;
}) => {
const { customDomainRecords, loading } = useRecoilValue(
customDomainRecordsState,
);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { t } = useLingui();
const { control } = useFormContext<{
const { control, handleSubmit } = useFormContext<{
customDomain: string;
}>();
@ -45,24 +55,29 @@ export const SettingsCustomDomain = () => {
value={value}
type="text"
onChange={onChange}
placeholder="crm.yourdomain.com"
error={error?.message}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSubmit(handleSave);
}
}}
loading={!!loading}
fullWidth
/>
)}
/>
</StyledDomainFormWrapper>
{customDomainRecords &&
currentWorkspace?.customDomain &&
currentWorkspace.customDomain === customDomainRecords?.customDomain && (
<StyledRecordsWrapper>
<SettingsCustomDomainRecordsStatus
records={customDomainRecords.records}
/>
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
<SettingsCustomDomainRecordsStatus />
{customDomainRecords && (
<SettingsCustomDomainRecords
records={customDomainRecords.records}
/>
</StyledRecordsWrapper>
)}
)}
</StyledRecordsWrapper>
)}
</Section>
);
};

View File

@ -6,32 +6,35 @@ import { useCheckCustomDomainValidRecordsMutation } from '~/generated/graphql';
import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState';
export const SettingsCustomDomainEffect = () => {
const [checkCustomDomainValidRecords, { data: customDomainRecords }] =
const [checkCustomDomainValidRecords] =
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,
]);
const checkCustomDomainValidRecordsPolling = useCallback(async () => {
setCustomDomainRecords((currentState) => ({
...currentState,
loading: true,
}));
checkCustomDomainValidRecords({
onCompleted: (data) => {
if (isDefined(data.checkCustomDomainValidRecords)) {
setCustomDomainRecords({
loading: false,
customDomainRecords: data.checkCustomDomainValidRecords,
});
}
},
});
}, [checkCustomDomainValidRecords, setCustomDomainRecords]);
useEffect(() => {
let pollIntervalFn: null | ReturnType<typeof setInterval> = null;
if (isDefined(currentWorkspace?.customDomain)) {
pollIntervalFn = initInterval();
checkCustomDomainValidRecordsPolling();
pollIntervalFn = setInterval(checkCustomDomainValidRecordsPolling, 6000);
}
return () => {
@ -39,7 +42,7 @@ export const SettingsCustomDomainEffect = () => {
clearInterval(pollIntervalFn);
}
};
}, [currentWorkspace?.customDomain, initInterval]);
}, [checkCustomDomainValidRecordsPolling, currentWorkspace?.customDomain]);
return <></>;
};

View File

@ -29,7 +29,7 @@ const StyledButton = styled(Button)`
color: ${({ theme }) => theme.font.color.tertiary};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: ${({ theme }) => theme.spacing(7)};
height: ${({ theme }) => theme.spacing(6)};
overflow: hidden;
user-select: text;
width: 100%;
@ -61,32 +61,30 @@ export const SettingsCustomDomainRecords = ({
<TableHeader>Value</TableHeader>
</TableRow>
<TableBody>
{records.map((record) => {
return (
<TableRow gridAutoColumns="30% 16% auto" key={record.key}>
<StyledTableCell>
<StyledButton
title={record.key}
onClick={() => copyToClipboardDebounced(record.key)}
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.type.toUpperCase()}
onClick={() =>
copyToClipboardDebounced(record.type.toUpperCase())
}
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.value}
onClick={() => copyToClipboardDebounced(record.value)}
/>
</StyledTableCell>
</TableRow>
);
})}
{records.map((record) => (
<TableRow gridAutoColumns="30% 16% auto" key={record.key}>
<StyledTableCell>
<StyledButton
title={record.key}
onClick={() => copyToClipboardDebounced(record.key)}
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.type.toUpperCase()}
onClick={() =>
copyToClipboardDebounced(record.type.toUpperCase())
}
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.value}
onClick={() => copyToClipboardDebounced(record.value)}
/>
</StyledTableCell>
</TableRow>
))}
</TableBody>
</StyledTable>
);

View File

@ -3,74 +3,85 @@ 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';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState';
import { useRecoilValue } from 'recoil';
import { capitalize } from 'twenty-shared';
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};
padding: 0 ${({ theme }) => theme.spacing(2)};
border-radius: ${({ theme }) => theme.border.radius.sm};
`;
const StyledTableRow = styled(TableRow)`
display: flex;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 0;
align-items: center;
justify-content: space-between;
padding: 0;
font-size: ${({ theme }) => theme.font.size.sm};
&: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,
const StyledTableCell = styled(TableCell)`
padding: 0;
`;
const records = [
{ name: 'CNAME', validationType: 'redirection' as const },
{ name: 'TXT Validation', validationType: 'ownership' as const },
{ name: 'SSL Certificate Generation', validationType: 'ssl' as const },
];
export const SettingsCustomDomainRecordsStatus = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { customDomainRecords } = useRecoilValue(customDomainRecordsState);
const defaultValues: { status: string; color: ThemeColor } =
currentWorkspace?.customDomain === customDomainRecords?.customDomain
? {
status: 'success',
color: 'green',
}
: {
status: 'loading',
color: 'gray',
};
const rows = records.map<{ name: string; status: string; color: ThemeColor }>(
(record) => {
const foundRecord = customDomainRecords?.records.find(
({ validationType }) => validationType === record.validationType,
);
return {
name: record.name,
status: foundRecord ? foundRecord.status : defaultValues.status,
color:
record.status === 'error'
foundRecord && foundRecord.status === 'error'
? 'red'
: record.status === 'pending'
: foundRecord && foundRecord.status === 'pending'
? 'yellow'
: 'green',
: defaultValues.color,
};
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>
);
})}
{rows.map((row) => (
<StyledTableRow key={row.name}>
<StyledTableCell>{row.name}</StyledTableCell>
<StyledTableCell>
<Status color={row.color} text={capitalize(row.status)} />
</StyledTableCell>
</StyledTableRow>
))}
</StyledTable>
);
};

View File

@ -42,6 +42,12 @@ export const SettingsDomain = () => {
}),
customDomain: z
.string()
.regex(
/^([a-zA-Z0-9][a-zA-Z0-9-]*\.)+[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}$/,
{
message: t`Invalid custom domain. Please include at least one subdomain (e.g., sub.example.com).`,
},
)
.regex(
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/,
{
@ -70,7 +76,7 @@ export const SettingsDomain = () => {
subdomain: string;
customDomain: string | null;
}>({
mode: 'onChange',
mode: 'onSubmit',
delayError: 500,
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
@ -169,6 +175,15 @@ export const SettingsDomain = () => {
});
}
if (
subdomainValue === currentWorkspace?.subdomain &&
customDomainValue === currentWorkspace?.customDomain
) {
return enqueueSnackBar(t`No change detected`, {
variant: SnackBarVariant.Error,
});
}
if (
isDefined(values.subdomain) &&
values.subdomain !== currentWorkspace.subdomain
@ -197,24 +212,19 @@ export const SettingsDomain = () => {
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={
!form.formState.isValid ||
(subdomainValue === currentWorkspace?.subdomain &&
customDomainValue === currentWorkspace?.customDomain)
}
onCancel={() => navigate(SettingsPath.Workspace)}
onSave={handleSave}
onSave={form.handleSubmit(handleSave)}
/>
}
>
<SettingsPageContainer>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<SettingsSubdomain />
<SettingsSubdomain handleSave={handleSave} />
{isCustomDomainEnabled && (
<>
<SettingsCustomDomainEffect />
<SettingsCustomDomain />
<SettingsCustomDomain handleSave={handleSave} />
</>
)}
</FormProvider>

View File

@ -23,13 +23,17 @@ const StyledDomain = styled.h2`
white-space: nowrap;
`;
export const SettingsSubdomain = () => {
export const SettingsSubdomain = ({
handleSave,
}: {
handleSave: () => void;
}) => {
const domainConfiguration = useRecoilValue(domainConfigurationState);
const { t } = useLingui();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { control } = useFormContext<{
const { control, handleSubmit } = useFormContext<{
subdomain: string;
}>();
@ -52,6 +56,11 @@ export const SettingsSubdomain = () => {
error={error?.message}
disabled={!!currentWorkspace?.customDomain}
fullWidth
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSubmit(handleSave);
}
}}
/>
{isDefined(domainConfiguration.frontDomain) && (
<StyledDomain>

View File

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

View File

@ -81,8 +81,6 @@ export class CustomDomainService {
]
.map<CustomDomainValidRecords['records'][0] | undefined>(
(record: Record<string, string>) => {
if (!record) return;
if (
'txt_name' in record &&
'txt_value' in record &&
@ -92,7 +90,11 @@ export class CustomDomainService {
return {
validationType: 'ssl' as const,
type: 'txt' as const,
status: response.result[0].ssl.status ?? 'pending',
status:
!response.result[0].ssl.status ||
response.result[0].ssl.status.startsWith('pending')
? 'pending'
: response.result[0].ssl.status,
key: record.txt_name,
value: record.txt_value,
};
@ -120,10 +122,16 @@ export class CustomDomainService {
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',
// wait 10s before starting the real check
response.result[0].created_at &&
new Date().getTime() -
new Date(response.result[0].created_at).getTime() <
1000 * 10
? 'pending'
: 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,
},