Skip to content

Commit d553fd7

Browse files
authored
feat: add multiple custom domains (#7799)
1 parent 97d8e1b commit d553fd7

File tree

46 files changed

+289
-60
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+289
-60
lines changed

packages/console/src/consts/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@ export const consoleEmbeddedPricingUrl =
1818
normalizeEnv(import.meta.env.CONSOLE_EMBEDDED_PRICING_URL) ??
1919
'https://logto.io/console-embedded-pricing';
2020

21+
export const isMultipleCustomDomainsEnabled = yes(
22+
normalizeEnv(import.meta.env.MULTIPLE_CUSTOM_DOMAINS_ENABLED)
23+
);
24+
2125
export const inkeepApiKey = normalizeEnv(import.meta.env.INKEEP_API_KEY);

packages/console/src/hooks/use-custom-domain.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import useSWR from 'swr';
55

66
import { customDomainSyncInterval } from '@/consts/custom-domain';
77
import { isCloud } from '@/consts/env';
8-
import { isAbsoluteUrl } from '@/utils/url';
98

109
import { type RequestError } from './use-api';
1110

@@ -28,19 +27,27 @@ const useCustomDomain = (autoSync = false) => {
2827

2928
const mutateDomain = useCallback(
3029
(domain?: Domain) => {
31-
void mutate(domain ? [domain] : undefined);
32-
},
33-
[mutate]
34-
);
30+
if (!domain) {
31+
void mutate();
32+
return;
33+
}
3534

36-
const applyDomain = useCallback(
37-
(url: string) => {
38-
if (customDomain?.status !== DomainStatus.Active || !isAbsoluteUrl(url)) {
39-
return url;
35+
if (!data || data.length === 0) {
36+
void mutate([domain]);
37+
return;
4038
}
41-
return url.replace(new URL(url).host, customDomain.domain);
39+
40+
const exists = data.some(({ id }) => id === domain.id);
41+
42+
if (exists) {
43+
void mutate(data.map((item) => (item.id === domain.id ? domain : item)));
44+
return;
45+
}
46+
47+
// New domain added
48+
void mutate([...data, domain]);
4249
},
43-
[customDomain]
50+
[mutate, data]
4451
);
4552

4653
const activeCustomDomains = useMemo(
@@ -51,10 +58,22 @@ const useCustomDomain = (autoSync = false) => {
5158
);
5259

5360
return {
61+
/**
62+
* Legacy single custom domain.
63+
* - Represents the first (and historically only) custom domain returned by the API.
64+
* - Retained temporarily for backward compatibility while the multiple custom domains feature
65+
* is being rolled out (currently only enabled for selected enterprise customers).
66+
* - Will be removed once multi-domain support is generally available.
67+
*/
5468
data: customDomain,
69+
/**
70+
* Multiple custom domains support.
71+
* - Full array of custom domains returned from the API.
72+
* - Will eventually replace the legacy `data` field.
73+
*/
74+
allDomains: data,
5575
isLoading,
5676
mutate: mutateDomain,
57-
applyDomain,
5877
activeCustomDomains,
5978
};
6079
};

packages/console/src/pages/TenantSettings/TenantDomainSettings/AddDomainForm/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function AddDomainForm({ className, isReadonly, onSubmitCustomDomain }: Props) {
2727
watch,
2828
formState: { errors, isSubmitting },
2929
handleSubmit,
30+
reset,
3031
} = useForm<FormData>({
3132
defaultValues: {
3233
domain: '',
@@ -38,6 +39,7 @@ function AddDomainForm({ className, isReadonly, onSubmitCustomDomain }: Props) {
3839
const onSubmit = handleSubmit(
3940
trySubmitSafe(async (formData) => {
4041
await onSubmitCustomDomain(formData);
42+
reset();
4143
})
4244
);
4345

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.domainItem {
4+
margin-bottom: _.unit(2);
5+
}
6+
7+
.configDescription {
8+
font: var(--font-body-2);
9+
color: var(--color-text-secondary);
10+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { DomainStatus, type Domain } from '@logto/schemas';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import LearnMore from '@/components/LearnMore';
5+
import { customDomain as customDomainDocumentationLink } from '@/consts/external-links';
6+
import FormField from '@/ds-components/FormField';
7+
import useApi from '@/hooks/use-api';
8+
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
9+
import useCustomDomain from '@/hooks/use-custom-domain';
10+
11+
import AddDomainForm from '../AddDomainForm';
12+
import CustomDomain from '../CustomDomain';
13+
14+
import styles from './index.module.scss';
15+
16+
function MultipleCustomDomainsFormField() {
17+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
18+
const { allDomains, mutate } = useCustomDomain(true);
19+
const api = useApi();
20+
const {
21+
access: { canManageTenant },
22+
} = useCurrentTenantScopes();
23+
24+
return (
25+
<>
26+
<FormField title="domain.custom.add_custom_domain_field">
27+
<AddDomainForm
28+
isReadonly={!canManageTenant}
29+
onSubmitCustomDomain={async (json) => {
30+
const createdDomain = await api.post('api/domains', { json }).json<Domain>();
31+
mutate(createdDomain);
32+
}}
33+
/>
34+
</FormField>
35+
{allDomains && (
36+
<FormField title="domain.custom.custom_domain_field">
37+
{allDomains.map((domain) => (
38+
<CustomDomain
39+
key={domain.id}
40+
hasExtraTipsOnDelete
41+
className={styles.domainItem}
42+
isReadonly={!canManageTenant}
43+
customDomain={domain}
44+
onDeleteCustomDomain={async () => {
45+
await api.delete(`api/domains/${domain.id}`);
46+
mutate();
47+
}}
48+
/>
49+
))}
50+
{allDomains.some(({ status }) => status === DomainStatus.Active) && (
51+
<div className={styles.configDescription}>
52+
{t('domain.custom.config_custom_domain_description')}
53+
<LearnMore href={customDomainDocumentationLink} />{' '}
54+
</div>
55+
)}
56+
</FormField>
57+
)}
58+
</>
59+
);
60+
}
61+
62+
export default MultipleCustomDomainsFormField;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.notes {
4+
font: var(--font-body-2);
5+
color: var(--color-text-secondary);
6+
margin-top: _.unit(3);
7+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { DomainStatus, type Domain } from '@logto/schemas';
2+
3+
import LearnMore from '@/components/LearnMore';
4+
import { customDomain as customDomainDocumentationLink } from '@/consts/external-links';
5+
import FormField from '@/ds-components/FormField';
6+
import useApi from '@/hooks/use-api';
7+
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
8+
import useCustomDomain from '@/hooks/use-custom-domain';
9+
10+
import AddDomainForm from '../AddDomainForm';
11+
import CustomDomain from '../CustomDomain';
12+
13+
import styles from './index.module.scss';
14+
15+
function SingleCustomDomainFormField() {
16+
const { data: customDomain, mutate } = useCustomDomain(true);
17+
const api = useApi();
18+
const {
19+
access: { canManageTenant },
20+
} = useCurrentTenantScopes();
21+
22+
return (
23+
<FormField title="domain.custom.custom_domain_field">
24+
{!customDomain && (
25+
<AddDomainForm
26+
isReadonly={!canManageTenant}
27+
onSubmitCustomDomain={async (json) => {
28+
const createdDomain = await api.post('api/domains', { json }).json<Domain>();
29+
mutate(createdDomain);
30+
}}
31+
/>
32+
)}
33+
{customDomain && (
34+
<>
35+
<CustomDomain
36+
hasExtraTipsOnDelete
37+
isReadonly={!canManageTenant}
38+
customDomain={customDomain}
39+
onDeleteCustomDomain={async () => {
40+
await api.delete(`api/domains/${customDomain.id}`);
41+
mutate();
42+
}}
43+
/>
44+
{customDomain.status === DomainStatus.Active && (
45+
<div className={styles.notes}>
46+
<LearnMore href={customDomainDocumentationLink} />
47+
</div>
48+
)}
49+
</>
50+
)}
51+
</FormField>
52+
);
53+
}
54+
55+
export default SingleCustomDomainFormField;

packages/console/src/pages/TenantSettings/TenantDomainSettings/index.module.scss

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@
66
}
77
}
88

9-
.notes {
10-
font: var(--font-body-2);
11-
color: var(--color-text-secondary);
12-
margin-top: _.unit(3);
13-
}
14-
159
.upsellNotification {
1610
margin-top: _.unit(4);
1711
}

packages/console/src/pages/TenantSettings/TenantDomainSettings/index.tsx

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,20 @@
1-
import { type Domain, DomainStatus } from '@logto/schemas';
2-
import { useTranslation } from 'react-i18next';
3-
41
import FormCard from '@/components/FormCard';
5-
import LearnMore from '@/components/LearnMore';
62
import PageMeta from '@/components/PageMeta';
7-
import { customDomain as customDomainDocumentationLink } from '@/consts/external-links';
3+
import { isMultipleCustomDomainsEnabled } from '@/consts/env';
84
import FormField from '@/ds-components/FormField';
9-
import useApi from '@/hooks/use-api';
10-
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
115
import useCustomDomain from '@/hooks/use-custom-domain';
126
import useDocumentationUrl from '@/hooks/use-documentation-url';
137

148
import Skeleton from '../components/Skeleton';
159

16-
import AddDomainForm from './AddDomainForm';
17-
import CustomDomain from './CustomDomain';
1810
import DefaultDomain from './DefaultDomain';
11+
import MultipleCustomDomainsFormField from './MultipleCustomDomainsFormField';
12+
import SingleCustomDomainFormField from './SingleCustomDomainFormField';
1913
import styles from './index.module.scss';
2014

2115
function TenantDomainSettings() {
22-
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
23-
const { data: customDomain, isLoading: isLoadingCustomDomain, mutate } = useCustomDomain(true);
16+
const { isLoading: isLoadingCustomDomain } = useCustomDomain(true);
2417
const { getDocumentationUrl } = useDocumentationUrl();
25-
const api = useApi();
26-
const {
27-
access: { canManageTenant },
28-
} = useCurrentTenantScopes();
2918

3019
if (isLoadingCustomDomain) {
3120
return <Skeleton />;
@@ -42,32 +31,11 @@ function TenantDomainSettings() {
4231
targetBlank: 'noopener',
4332
}}
4433
>
45-
<FormField title="domain.custom.custom_domain_field">
46-
{customDomain ? (
47-
<CustomDomain
48-
hasExtraTipsOnDelete
49-
isReadonly={!canManageTenant}
50-
customDomain={customDomain}
51-
onDeleteCustomDomain={async () => {
52-
await api.delete(`api/domains/${customDomain.id}`);
53-
mutate();
54-
}}
55-
/>
56-
) : (
57-
<AddDomainForm
58-
isReadonly={!canManageTenant}
59-
onSubmitCustomDomain={async (json) => {
60-
const createdDomain = await api.post('api/domains', { json }).json<Domain>();
61-
mutate(createdDomain);
62-
}}
63-
/>
64-
)}
65-
{customDomain?.status === DomainStatus.Active && (
66-
<div className={styles.notes}>
67-
<LearnMore href={customDomainDocumentationLink} />
68-
</div>
69-
)}
70-
</FormField>
34+
{isMultipleCustomDomainsEnabled ? (
35+
<MultipleCustomDomainsFormField />
36+
) : (
37+
<SingleCustomDomainFormField />
38+
)}
7139
</FormCard>
7240
<FormCard
7341
title="domain.default.default_domain"

packages/console/vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ const buildConfig = (mode: string): UserConfig => ({
5151
'import.meta.env.CONSOLE_EMBEDDED_PRICING_URL': JSON.stringify(
5252
process.env.CONSOLE_EMBEDDED_PRICING_URL
5353
),
54+
'import.meta.env.MULTIPLE_CUSTOM_DOMAINS_ENABLED': JSON.stringify(
55+
process.env.MULTIPLE_CUSTOM_DOMAINS_ENABLED
56+
),
5457
'import.meta.env.INKEEP_API_KEY': JSON.stringify(process.env.INKEEP_API_KEY),
5558
// `@withtyped/client` needs this to be defined. We can optimize this later.
5659
'process.env': {},

0 commit comments

Comments
 (0)