mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(console): implement active custom domain process (#3965)
This commit is contained in:
parent
417534e9e4
commit
ec6e266705
10 changed files with 361 additions and 7 deletions
|
@ -29,8 +29,12 @@
|
|||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.wrapContent {
|
||||
text-overflow: unset;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.copyToolTipAnchor {
|
||||
margin-left: _.unit(2);
|
||||
|
|
|
@ -20,6 +20,7 @@ type Props = {
|
|||
variant?: 'text' | 'contained' | 'border' | 'icon';
|
||||
hasVisibilityToggle?: boolean;
|
||||
size?: 'default' | 'small';
|
||||
isWordWrapAllowed?: boolean;
|
||||
};
|
||||
|
||||
type CopyState = TFuncKey<'translation', 'admin_console.general'>;
|
||||
|
@ -30,6 +31,7 @@ function CopyToClipboard({
|
|||
hasVisibilityToggle,
|
||||
variant = 'contained',
|
||||
size = 'default',
|
||||
isWordWrapAllowed = false,
|
||||
}: Props) {
|
||||
const copyIconReference = useRef<HTMLButtonElement>(null);
|
||||
const [copyState, setCopyState] = useState<CopyState>('copy');
|
||||
|
@ -73,7 +75,11 @@ function CopyToClipboard({
|
|||
}}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
{variant !== 'icon' && <div className={styles.content}>{displayValue}</div>}
|
||||
{variant !== 'icon' && (
|
||||
<div className={classNames(styles.content, isWordWrapAllowed && styles.wrapContent)}>
|
||||
{displayValue}
|
||||
</div>
|
||||
)}
|
||||
{hasVisibilityToggle && (
|
||||
<Tooltip content={t(showHiddenContent ? 'hide' : 'view')}>
|
||||
<IconButton
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.tip {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 12px;
|
||||
background-color: var(--color-layer-light);
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: _.unit(5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(3);
|
||||
font: var(--font-body-2);
|
||||
|
||||
.loadingIcon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
padding: _.unit(2) 0;
|
||||
|
||||
.header {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.bodyTableContainer {
|
||||
background-color: unset;
|
||||
padding-bottom: unset;
|
||||
|
||||
table > tbody > tr > td {
|
||||
border: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.column {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import { type DomainDnsRecords } from '@logto/schemas';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DynamicT from '@/components/DynamicT';
|
||||
import { Ring } from '@/components/Spinner';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
records: DomainDnsRecords;
|
||||
};
|
||||
|
||||
function DnsRecordsTable({ records }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.tip}>
|
||||
<DynamicT forKey="domain.custom.add_dns_records" />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
{records.length === 0 ? (
|
||||
<div className={styles.loading}>
|
||||
<Ring className={styles.loadingIcon} />
|
||||
<DynamicT forKey="domain.custom.generating_dns_records" />
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
isRowHoverEffectDisabled
|
||||
className={styles.table}
|
||||
headerClassName={styles.header}
|
||||
bodyClassName={styles.bodyTableContainer}
|
||||
rowGroups={[{ key: 'dnsRecords', data: records }]}
|
||||
rowIndexKey="name"
|
||||
isRowClickable={() => false}
|
||||
columns={[
|
||||
{
|
||||
title: <DynamicT forKey="domain.custom.dns_table.type_field" />,
|
||||
dataIndex: 'type',
|
||||
colSpan: 2,
|
||||
render: ({ type }) => <div className={styles.column}>{type}</div>,
|
||||
},
|
||||
{
|
||||
title: <DynamicT forKey="domain.custom.dns_table.name_field" />,
|
||||
dataIndex: 'name',
|
||||
colSpan: 7,
|
||||
render: ({ name }) => (
|
||||
<CopyToClipboard
|
||||
isWordWrapAllowed
|
||||
className={styles.column}
|
||||
value={name}
|
||||
variant="text"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <DynamicT forKey="domain.custom.dns_table.value_field" />,
|
||||
dataIndex: 'value',
|
||||
colSpan: 7,
|
||||
render: ({ value }) => (
|
||||
<CopyToClipboard
|
||||
isWordWrapAllowed
|
||||
className={styles.column}
|
||||
value={value}
|
||||
variant="text"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DnsRecordsTable;
|
|
@ -0,0 +1,65 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.step {
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-label-2);
|
||||
margin-left: _.unit(5);
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-left: _.unit(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
position: relative;
|
||||
padding: _.unit(2) 0 _.unit(6) _.unit(10);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
.contentContainer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-left: 1px dashed var(--color-divider);
|
||||
top: _.unit(1);
|
||||
bottom: _.unit(1);
|
||||
transform: translateX(_.unit(-7.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stepIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-surface-variant);
|
||||
color: var(--color-text-link);
|
||||
font: var(--font-label-3);
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
&.finished {
|
||||
background-color: var(--color-on-success-container);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import { DomainStatus } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import Success from '@/assets/images/success.svg';
|
||||
import Tip from '@/assets/images/tip.svg';
|
||||
import DynamicT from '@/components/DynamicT';
|
||||
import IconButton from '@/components/IconButton';
|
||||
import { Ring } from '@/components/Spinner';
|
||||
import ToggleTip from '@/components/Tip/ToggleTip';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
step: number;
|
||||
title: AdminConsoleKey;
|
||||
tip?: AdminConsoleKey;
|
||||
domainStatus: DomainStatus;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const domainStatusToStep: Record<DomainStatus, number> = {
|
||||
[DomainStatus.Error]: 0,
|
||||
[DomainStatus.PendingVerification]: 1,
|
||||
[DomainStatus.PendingSsl]: 2,
|
||||
[DomainStatus.Active]: 3,
|
||||
};
|
||||
|
||||
function Step({ step, title, tip, domainStatus, children }: Props) {
|
||||
const domainStatusStep = domainStatusToStep[domainStatus];
|
||||
|
||||
const isPending = step > domainStatusStep;
|
||||
const isLoading = step === domainStatusStep;
|
||||
const isFinished = step < domainStatusStep;
|
||||
|
||||
return (
|
||||
<div className={styles.step}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.stepIcon,
|
||||
isLoading && styles.loading,
|
||||
isFinished && styles.finished
|
||||
)}
|
||||
>
|
||||
{isPending && step}
|
||||
{isLoading && <Ring />}
|
||||
{isFinished && <Success className={styles.icon} />}
|
||||
</div>
|
||||
<div className={styles.title}>
|
||||
<DynamicT forKey={title} />
|
||||
</div>
|
||||
{tip && (
|
||||
<ToggleTip
|
||||
anchorClassName={styles.tip}
|
||||
content={<DynamicT forKey={tip} />}
|
||||
horizontalAlign="start"
|
||||
>
|
||||
<IconButton size="small">
|
||||
<Tip />
|
||||
</IconButton>
|
||||
</ToggleTip>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.contentContainer}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Step;
|
|
@ -0,0 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
border-top: 1px solid var(--color-divider);
|
||||
padding: _.unit(5) _.unit(6) 0;
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
DomainStatus,
|
||||
type Domain,
|
||||
type DomainDnsRecords,
|
||||
type DomainDnsRecord,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { isDomainStatus } from '../../utils';
|
||||
|
||||
import DnsRecordsTable from './components/DnsRecordsTable';
|
||||
import Step from './components/Step';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
customDomain: Domain;
|
||||
};
|
||||
|
||||
const isSetupSslDnsRecord = ({ type, name }: DomainDnsRecord) =>
|
||||
type.toUpperCase() === 'TXT' && name.includes('_acme-challenge');
|
||||
|
||||
function ActivationProcess({ customDomain }: Props) {
|
||||
const { dnsRecords, status } = customDomain;
|
||||
|
||||
// TODO @xiaoyijun Remove this type assertion when the LOG-6276 issue is done by @wangsijie
|
||||
const typedDomainStatus = isDomainStatus(status) ? status : DomainStatus.Error;
|
||||
|
||||
const { verifyDomainDnsRecord, setupSslDnsRecord } = dnsRecords.reduce<{
|
||||
verifyDomainDnsRecord: DomainDnsRecords;
|
||||
setupSslDnsRecord: DomainDnsRecords;
|
||||
}>(
|
||||
(result, record) =>
|
||||
isSetupSslDnsRecord(record)
|
||||
? {
|
||||
...result,
|
||||
setupSslDnsRecord: [...result.setupSslDnsRecord, record],
|
||||
}
|
||||
: {
|
||||
...result,
|
||||
verifyDomainDnsRecord: [...result.verifyDomainDnsRecord, record],
|
||||
},
|
||||
{
|
||||
verifyDomainDnsRecord: [],
|
||||
setupSslDnsRecord: [],
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Step
|
||||
step={1}
|
||||
title="domain.custom.verify_domain"
|
||||
tip="domain.custom.checking_dns_tip"
|
||||
domainStatus={typedDomainStatus}
|
||||
>
|
||||
<DnsRecordsTable records={verifyDomainDnsRecord} />
|
||||
</Step>
|
||||
<Step
|
||||
step={2}
|
||||
title="domain.custom.enable_ssl"
|
||||
tip="domain.custom.checking_dns_tip"
|
||||
domainStatus={typedDomainStatus}
|
||||
>
|
||||
<DnsRecordsTable records={setupSslDnsRecord} />
|
||||
</Step>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivationProcess;
|
|
@ -1,5 +1,6 @@
|
|||
import { type Domain } from '@logto/schemas';
|
||||
import { type Domain, DomainStatus } from '@logto/schemas';
|
||||
|
||||
import ActivationProcess from './components/ActivationProcess';
|
||||
import CustomDomainHeader from './components/CustomDomainHeader';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -12,7 +13,9 @@ function CustomDomain({ customDomain, onDeleteCustomDomain }: Props) {
|
|||
return (
|
||||
<div className={styles.container}>
|
||||
<CustomDomainHeader customDomain={customDomain} onDeleteCustomDomain={onDeleteCustomDomain} />
|
||||
{/* TODO @xiaoyijun add custom domain active process content */}
|
||||
{customDomain.status !== DomainStatus.Active && (
|
||||
<ActivationProcess customDomain={customDomain} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,8 @@ import useSWR from 'swr';
|
|||
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import { type RequestError } from '@/hooks/use-api';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import useSwrFetcher from '@/hooks/use-swr-fetcher';
|
||||
|
||||
import AddDomainForm from './components/AddDomainForm';
|
||||
import CustomDomain from './components/CustomDomain';
|
||||
|
@ -12,8 +13,13 @@ import DefaultDomain from './components/DefaultDomain';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
function TenantDomainSettings() {
|
||||
// Todo: @xiaoyijun setup the auto refresh interval for the domains when implementing the active domain process.
|
||||
const { data, error, mutate } = useSWR<Domain[], RequestError>('api/domains');
|
||||
const api = useApi();
|
||||
const fetcher = useSwrFetcher<Domain[]>(api);
|
||||
const { data, error, mutate } = useSWR<Domain[], RequestError>('api/domains', fetcher, {
|
||||
// Note: check the custom domain status every 10 seconds.
|
||||
refreshInterval: 10_000,
|
||||
});
|
||||
|
||||
const isLoading = !data && !error;
|
||||
/**
|
||||
* Note: we can only create a custom domain, and we don't have a default id for it, so the first element of the array is the custom domain.
|
||||
|
|
Loading…
Reference in a new issue