0
Fork 0
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:
Xiao Yijun 2023-06-07 17:31:21 +08:00 committed by GitHub
parent 417534e9e4
commit ec6e266705
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 361 additions and 7 deletions

View file

@ -29,8 +29,12 @@
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
&.wrapContent {
text-overflow: unset;
word-break: break-all;
}
}
.copyToolTipAnchor {
margin-left: _.unit(2);

View file

@ -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

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;
.container {
border-top: 1px solid var(--color-divider);
padding: _.unit(5) _.unit(6) 0;
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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.