Skip to content

Commit 05d6faf

Browse files
committed
feat: Allow bringing your own domain to managed clusters
https://harperdb.atlassian.net/browse/STUDIO-613
1 parent 45e3c73 commit 05d6faf

File tree

20 files changed

+807
-95
lines changed

20 files changed

+807
-95
lines changed

src/components/NestedContentWithSubNavMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function NestedContentWithSubNavMenu({ children, className }: { children:
77
<>
88
<SubNavMenu />
99
<div
10-
className={cx('mt-32 px-4 pt-4 md:px-12 min-h-[calc(100vh-theme(spacing.32))] flex justify-center', className)}
10+
className={cx('mt-32 px-4 py-4 md:px-12 min-h-[calc(100vh-(--spacing(32)))] flex justify-center', className)}
1111
>
1212
{children}
1313
</div>

src/components/SimpleBrowseDataTable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export function SimpleBrowseDataTable<TData, TValue>({
8282
<TableCell
8383
key={cell.id}
8484
className="py-2 px-2 overflow-x-hidden max-w-32 text-ellipsis whitespace-nowrap"
85+
style={{ width: `${cell.column.getSize()}px` }}
8586
>
8687
{flexRender(cell.column.columnDef.cell, cell.getContext())}
8788
</TableCell>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
3+
import { extractDomainFromTLD } from '@/lib/string/extractDomainFromTLD';
4+
import { Save } from 'lucide-react';
5+
6+
export function AssociateDomainWithCluster({ cluster: { fqdn }, disabled, domain: { domain, id }, onClick }: {
7+
cluster: Cluster;
8+
domain: SchemaOrganizationDomain;
9+
onClick: () => any;
10+
disabled: any;
11+
}) {
12+
const recordName = extractDomainFromTLD(domain);
13+
return (
14+
<div className="grid gap-2 grid-cols-1 md:grid-cols-[80px_1fr] pb-6">
15+
<div className="text-muted-foreground md:col-span-2 text-wrap">
16+
This domain has been verified! Now to associate it with this cluster, add the following to your DNS registrar:
17+
</div>
18+
19+
<div className="col-span-1">Type:</div>
20+
<div className="col-span-1">CNAME or ALIAS</div>
21+
22+
<div className="col-span-1">Name:</div>
23+
<div className="col-span-1">
24+
<input
25+
className="py-1 px-3 bg-gray-700 rounded-md w-full"
26+
type="text"
27+
readOnly={true}
28+
name="recordName"
29+
value={recordName}
30+
/>
31+
</div>
32+
33+
<div className="col-span-1">TTL:</div>
34+
<div className="col-span-1">Auto</div>
35+
36+
<div className="col-span-1">Target:</div>
37+
<div className="col-span-1">
38+
<input
39+
className="py-1 px-3 bg-gray-700 rounded-md w-full"
40+
type="text"
41+
readOnly={true}
42+
name="recordTarget"
43+
value={fqdn}
44+
/>
45+
</div>
46+
47+
<div className="text-muted-foreground md:col-span-2 text-wrap">
48+
Once you've completed the above steps, you are ready to bind the domain to this cluster. A domain can only be
49+
bound to a single cluster.
50+
</div>
51+
52+
<div className="col-span-1"></div>
53+
<div className="col-span-1">
54+
<Button
55+
type="button"
56+
variant="submit"
57+
data-id={id}
58+
onClick={onClick}
59+
disabled={disabled}
60+
>
61+
<Save /> Bind Domain to This Cluster
62+
</Button>
63+
</div>
64+
</div>
65+
);
66+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { SimpleBrowseDataTable } from '@/components/SimpleBrowseDataTable';
2+
import { Button } from '@/components/ui/button';
3+
import { Form } from '@/components/ui/form/Form';
4+
import { FormControl } from '@/components/ui/form/FormControl';
5+
import { FormField } from '@/components/ui/form/FormField';
6+
import { FormItem } from '@/components/ui/form/FormItem';
7+
import { FormLabel } from '@/components/ui/form/FormLabel';
8+
import { FormMessage } from '@/components/ui/form/FormMessage';
9+
import { Input } from '@/components/ui/input';
10+
import { useDataTableColumns } from '@/features/cluster/domains/constants/tableDefinition';
11+
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
12+
import {
13+
AddOrganizationDomainSchema,
14+
useAddDomainToOrganization,
15+
} from '@/features/organization/mutations/addDomainToOrganization';
16+
import { validateDomainInOrganization } from '@/features/organization/mutations/validateDomainInOrganization';
17+
import { getOrganizationDomainsQueryOptions } from '@/features/organization/queries/getOrganizationDomains';
18+
import { useOrganizationRolePermissions } from '@/hooks/usePermissions';
19+
import { useRefreshClick } from '@/hooks/useRefreshClick';
20+
import { SchemaOrganizationDomain } from '@/integrations/api/api.patch';
21+
import { pluralize } from '@/lib/pluralize';
22+
import { zodResolver } from '@hookform/resolvers/zod';
23+
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
24+
import { useParams } from '@tanstack/react-router';
25+
import { ListTodoIcon, PlusIcon, RefreshCwIcon } from 'lucide-react';
26+
import { useCallback, useMemo, useState } from 'react';
27+
import { useForm } from 'react-hook-form';
28+
import { toast } from 'sonner';
29+
import z from 'zod';
30+
31+
export function DomainsManagement() {
32+
const { organizationId, clusterId }: { organizationId: string; clusterId: string } = useParams({ strict: false });
33+
const { data: cluster } = useSuspenseQuery(
34+
getClusterInfoQueryOptions(clusterId, true),
35+
);
36+
37+
const { update } = useOrganizationRolePermissions(organizationId);
38+
const {
39+
data: organizationDomains,
40+
refetch,
41+
isFetching,
42+
isRefetching,
43+
} = useQuery(getOrganizationDomainsQueryOptions(organizationId));
44+
45+
const pendingDomains = useMemo(
46+
() => organizationDomains?.filter(o => o.status === 'PENDING_VALIDATION') || [],
47+
[organizationDomains],
48+
);
49+
50+
const [sortTableDataParams] = useState({
51+
attribute: 'domain',
52+
descending: false,
53+
});
54+
const sortingState = useMemo(
55+
() => [
56+
{
57+
desc: sortTableDataParams.descending,
58+
id: sortTableDataParams.attribute,
59+
},
60+
],
61+
[sortTableDataParams],
62+
);
63+
64+
const onRefreshClick = useRefreshClick(refetch);
65+
66+
const { mutate: addDomain, isPending: isAddPending } = useAddDomainToOrganization();
67+
const form = useForm({
68+
resolver: zodResolver(AddOrganizationDomainSchema),
69+
defaultValues: {
70+
domain: '',
71+
organizationId,
72+
},
73+
});
74+
75+
const onSubmitClick = useCallback(
76+
async (formData: z.infer<typeof AddOrganizationDomainSchema>) => {
77+
if (formData) {
78+
addDomain(formData, {
79+
onSuccess: () => {
80+
form.reset();
81+
refetch();
82+
toast.success('Domain added! Please add the txt record above to your domain registrar.');
83+
},
84+
});
85+
}
86+
},
87+
[addDomain, form, refetch],
88+
);
89+
90+
const onValidateClick = useCallback(async () => {
91+
const message = `Validating ${pluralize(pendingDomains.length, 'domain', 'domains')}...`;
92+
const id = 'validatingDomains';
93+
let checked = 0;
94+
let failed = 0;
95+
for (const pendingDomain of pendingDomains) {
96+
try {
97+
toast.loading(message, {
98+
description: `${checked++} of ${pendingDomains.length} checked`,
99+
id,
100+
});
101+
await validateDomainInOrganization(pendingDomain.id);
102+
} catch {
103+
failed += 1;
104+
}
105+
}
106+
if (failed > 0) {
107+
toast.error('Validation failed!', {
108+
description:
109+
`Please make sure the TXT record has been put in place. You may need to wait a bit for the DNS change to propagate.`,
110+
id,
111+
});
112+
} else {
113+
await refetch();
114+
toast.success('Validation succeeded!', {
115+
description: `Please take a look at the next steps for newly verified domains.`,
116+
id,
117+
});
118+
}
119+
}, [pendingDomains]);
120+
121+
const dataTableColumns = useDataTableColumns(cluster);
122+
123+
return (
124+
<SimpleBrowseDataTable<SchemaOrganizationDomain, unknown>
125+
data={organizationDomains || []}
126+
isFetching={isFetching || isRefetching}
127+
columns={dataTableColumns}
128+
sortingState={sortingState}
129+
>
130+
<div className="w-full flex flex-col md:flex-row justify-between md:items-center md:space-x-2 space-y-2 md:space-y-0">
131+
{update && (
132+
<Form {...form}>
133+
<form
134+
onSubmit={form.handleSubmit(onSubmitClick)}
135+
className="flex gap-1 flex-col md:flex-row"
136+
>
137+
<FormField
138+
control={form.control}
139+
name="domain"
140+
render={({ field }) => (
141+
<FormItem className="flex-1">
142+
<FormLabel className="pb-1">New Domain Name</FormLabel>
143+
<FormControl>
144+
<Input type="text" enterKeyHint="done" autoComplete="off" {...field} />
145+
</FormControl>
146+
<FormMessage>
147+
<span className="text-muted-foreground italic">
148+
Type in a domain like example.com or your.example.com, and you'll be guided through validating
149+
and binding your cluster to it.
150+
</span>
151+
</FormMessage>
152+
</FormItem>
153+
)}
154+
/>
155+
<div className="flex-0 self-start md:pt-6.5">
156+
<Button
157+
type="submit"
158+
variant="submit"
159+
disabled={isAddPending}
160+
>
161+
<PlusIcon /> Add
162+
</Button>
163+
</div>
164+
</form>
165+
</Form>
166+
)}
167+
168+
{pendingDomains.length > 0 && (
169+
<Button
170+
variant="positiveOutline"
171+
onClick={onValidateClick}
172+
accessKey="r"
173+
disabled={isFetching || isRefetching}
174+
>
175+
<ListTodoIcon />{' '}
176+
<span>
177+
<u>V</u>alidate
178+
</span>
179+
</Button>
180+
)}
181+
<Button
182+
variant="defaultOutline"
183+
onClick={onRefreshClick}
184+
accessKey="r"
185+
disabled={isFetching || isRefetching}
186+
>
187+
<RefreshCwIcon />{' '}
188+
<span className="hidden lg:inline-block">
189+
<u>R</u>efresh
190+
</span>
191+
</Button>
192+
</div>
193+
</SimpleBrowseDataTable>
194+
);
195+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { NestedContentWithSubNavMenu } from '@/components/NestedContentWithSubNavMenu';
2+
import { DomainsManagement } from './Management';
3+
4+
export function DomainsPage() {
5+
return (
6+
<NestedContentWithSubNavMenu className="flex flex-col justify-start max-w-4xl">
7+
<DomainsManagement />
8+
</NestedContentWithSubNavMenu>
9+
);
10+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { SchemaOrganizationDomain } from '@/integrations/api/api.patch';
2+
3+
export function VerifyDomainOwnership(
4+
{ domain: { challengeToken, challengeTxtRecord } }: { domain: SchemaOrganizationDomain },
5+
) {
6+
return (
7+
<div className="grid gap-2 grid-cols-1 md:grid-cols-[80px_1fr] pb-6">
8+
<div className="text-muted-foreground md:col-span-2 text-wrap">
9+
Prove that you own this domain by adding the following to your DNS registrar:
10+
</div>
11+
12+
<div className="col-span-1">Type:</div>
13+
<div className="col-span-1">TXT</div>
14+
15+
<div className="col-span-1">Name:</div>
16+
<div className="col-span-1">
17+
<input
18+
className="py-1 px-3 bg-gray-700 rounded-md w-full"
19+
type="text"
20+
readOnly={true}
21+
name="challengeName"
22+
value={challengeTxtRecord}
23+
/>
24+
</div>
25+
26+
<div className="col-span-1">TTL:</div>
27+
<div className="col-span-1">Auto</div>
28+
29+
<div className="col-span-1">Content:</div>
30+
<div className="col-span-1">
31+
<input
32+
className="py-1 px-3 bg-gray-700 rounded-md w-full"
33+
type="text"
34+
readOnly={true}
35+
name="challengeToken"
36+
value={challengeToken}
37+
/>
38+
</div>
39+
40+
<div className="text-muted-foreground md:col-span-2 text-wrap">
41+
Then after your DNS TTL elapses, click the "Validate" button above.
42+
</div>
43+
</div>
44+
);
45+
}

0 commit comments

Comments
 (0)