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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,74 +3,85 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { Status, ThemeColor } from 'twenty-ui'; import { Status, ThemeColor } from 'twenty-ui';
import styled from '@emotion/styled'; 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)` const StyledTable = styled(Table)`
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid ${({ theme }) => theme.border.color.light}; 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)` const StyledTableRow = styled(TableRow)`
display: flex; 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; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0;
font-size: ${({ theme }) => theme.font.size.sm};
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
`; `;
export const SettingsCustomDomainRecordsStatus = ({ const StyledTableCell = styled(TableCell)`
records, padding: 0;
}: { `;
records: CustomDomainValidRecords['records'];
}) => { const records = [
const rows = records.reduce( { name: 'CNAME', validationType: 'redirection' as const },
(acc, record) => { { name: 'TXT Validation', validationType: 'ownership' as const },
acc[record.validationType] = { { name: 'SSL Certificate Generation', validationType: 'ssl' as const },
name: acc[record.validationType].name, ];
status: record.status,
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: color:
record.status === 'error' foundRecord && foundRecord.status === 'error'
? 'red' ? 'red'
: record.status === 'pending' : foundRecord && foundRecord.status === 'pending'
? 'yellow' ? '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 ( return (
<StyledTable> <StyledTable>
{Object.values(rows).map((row) => { {rows.map((row) => (
return ( <StyledTableRow key={row.name}>
<StyledTableRow> <StyledTableCell>{row.name}</StyledTableCell>
<TableCell>{row.name}</TableCell> <StyledTableCell>
<TableCell> <Status color={row.color} text={capitalize(row.status)} />
<Status color={row.color} text={row.status} /> </StyledTableCell>
</TableCell> </StyledTableRow>
</StyledTableRow> ))}
);
})}
</StyledTable> </StyledTable>
); );
}; };

View File

@ -42,6 +42,12 @@ export const SettingsDomain = () => {
}), }),
customDomain: z customDomain: z
.string() .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( .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])$/, /^(([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; subdomain: string;
customDomain: string | null; customDomain: string | null;
}>({ }>({
mode: 'onChange', mode: 'onSubmit',
delayError: 500, delayError: 500,
defaultValues: { defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '', 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 ( if (
isDefined(values.subdomain) && isDefined(values.subdomain) &&
values.subdomain !== currentWorkspace.subdomain values.subdomain !== currentWorkspace.subdomain
@ -197,24 +212,19 @@ export const SettingsDomain = () => {
]} ]}
actionButton={ actionButton={
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={
!form.formState.isValid ||
(subdomainValue === currentWorkspace?.subdomain &&
customDomainValue === currentWorkspace?.customDomain)
}
onCancel={() => navigate(SettingsPath.Workspace)} onCancel={() => navigate(SettingsPath.Workspace)}
onSave={handleSave} onSave={form.handleSubmit(handleSave)}
/> />
} }
> >
<SettingsPageContainer> <SettingsPageContainer>
{/* eslint-disable-next-line react/jsx-props-no-spreading */} {/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}> <FormProvider {...form}>
<SettingsSubdomain /> <SettingsSubdomain handleSave={handleSave} />
{isCustomDomainEnabled && ( {isCustomDomainEnabled && (
<> <>
<SettingsCustomDomainEffect /> <SettingsCustomDomainEffect />
<SettingsCustomDomain /> <SettingsCustomDomain handleSave={handleSave} />
</> </>
)} )}
</FormProvider> </FormProvider>

View File

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

View File

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

View File

@ -81,8 +81,6 @@ export class CustomDomainService {
] ]
.map<CustomDomainValidRecords['records'][0] | undefined>( .map<CustomDomainValidRecords['records'][0] | undefined>(
(record: Record<string, string>) => { (record: Record<string, string>) => {
if (!record) return;
if ( if (
'txt_name' in record && 'txt_name' in record &&
'txt_value' in record && 'txt_value' in record &&
@ -92,7 +90,11 @@ export class CustomDomainService {
return { return {
validationType: 'ssl' as const, validationType: 'ssl' as const,
type: 'txt' 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, key: record.txt_name,
value: record.txt_value, value: record.txt_value,
}; };
@ -120,10 +122,16 @@ export class CustomDomainService {
validationType: 'redirection' as const, validationType: 'redirection' as const,
type: 'cname' as const, type: 'cname' as const,
status: status:
response.result[0].verification_errors?.[0] === // wait 10s before starting the real check
'custom hostname does not CNAME to this zone.' response.result[0].created_at &&
? 'error' new Date().getTime() -
: 'success', 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, key: response.result[0].hostname,
value: this.domainManagerService.getFrontUrl().hostname, value: this.domainManagerService.getFrontUrl().hostname,
}, },