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