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