Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 49 additions & 68 deletions src/features/cluster/domains/AssociateDomainWithCluster.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useCheckboxCallback } from '@/hooks/useCheckboxCallback';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
import { extractDomainFromTLD } from '@/lib/string/extractDomainFromTLD';
import { CopyIcon, Save } from 'lucide-react';
import { useCallback } from 'react';
import { CopyIcon } from 'lucide-react';

export function AssociateDomainWithCluster({ cluster: { fqdn }, disabled, domain: { domain, id }, bindDomain }: {
export function AssociateDomainWithCluster({ cluster: { fqdn }, domain: { domain, id } }: {
cluster: Cluster;
domain: SchemaOrganizationDomain;
bindDomain: (generateDomainCerts: boolean) => void;
disabled: any;
}) {
const [generateDomainCerts, onGenerateDomainCerts] = useCheckboxCallback(true);
const recordName = extractDomainFromTLD(domain);
const [onCopyName, onCopyTarget] = useCopyToClipboard(recordName, fqdn || '');
const onClick = useCallback(() => bindDomain(generateDomainCerts), [bindDomain, generateDomainCerts]);
const [onCopyName, onCopyTarget, onCopyId] = useCopyToClipboard(recordName, fqdn || '', id);

return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-[80px_1fr] pb-6">
Expand All @@ -28,74 +21,62 @@ export function AssociateDomainWithCluster({ cluster: { fqdn }, disabled, domain
<div className="col-span-1">CNAME</div>

<div className="col-span-1">Name:</div>
<div className="col-span-1 flex gap-2">
<input
className="py-1 px-3 bg-gray-700 rounded-md w-full"
type="text"
readOnly={true}
name="recordName"
value={recordName}
onClick={onCopyName}
/>
<Button type="button" size="sm" onClick={onCopyName}>
<CopyIcon className="w-4 h-4" />
</Button>
<div className="col-span-1 flex gap-2 items-center">
<div className="flex items-center gap-1 bg-gray-700 rounded-md px-3 py-1 flex-1 overflow-hidden">
<input
className="bg-transparent border-none outline-none w-full cursor-text truncate"
type="text"
readOnly={true}
name="recordName"
value={recordName}
onClick={onCopyName}
/>
<Button type="button" variant="ghost" className="h-6 w-6 p-0 shrink-0" onClick={onCopyName}>
<CopyIcon className="w-3.5 h-3.5" />
</Button>
</div>
</div>

<div className="col-span-1">TTL:</div>
<div className="col-span-1">Auto</div>

<div className="col-span-1">Target:</div>
<div className="col-span-1 flex gap-2">
<input
className="py-1 px-3 bg-gray-700 rounded-md w-full"
type="text"
readOnly={true}
name="recordTarget"
value={fqdn}
onClick={onCopyTarget}
/>
<Button type="button" size="sm" onClick={onCopyTarget}>
<CopyIcon className="w-4 h-4" />
</Button>
<div className="col-span-1 flex gap-2 items-center">
<div className="flex items-center gap-1 bg-gray-700 rounded-md px-3 py-1 flex-1 overflow-hidden">
<input
className="bg-transparent border-none outline-none w-full cursor-text truncate"
type="text"
readOnly={true}
name="recordTarget"
value={fqdn}
onClick={onCopyTarget}
/>
<Button type="button" variant="ghost" className="h-6 w-6 p-0 shrink-0" onClick={onCopyTarget}>
<CopyIcon className="w-3.5 h-3.5" />
</Button>
</div>
</div>

<div className="text-muted-foreground md:col-span-2 text-wrap">
Once you've completed the above steps, you are ready to bind the domain to this cluster. A domain can only be
bound to a single cluster.
</div>

<div className="col-span-1">
<label htmlFor="generateDomainCerts">
Certs:
</label>
</div>
<div className="col-span-1">
<label htmlFor="generateDomainCerts" className="-m-2 p-2">
Should we generate SSL certificates with LetsEncrypt?
<div className="flex mt-2">
<Switch
id="generateDomainCerts"
checked={generateDomainCerts}
className="mr-2"
onCheckedChange={onGenerateDomainCerts}
/>
{generateDomainCerts ? 'Yes' : 'No, I will add my own certificates, thank you very much.'}
</div>
</label>
<div className="col-span-1 text-xs">Domain ID:</div>
<div className="col-span-1 flex gap-2 items-center">
<div className="flex items-center gap-1 bg-gray-800 rounded-md px-3 py-0.5 text-xs text-muted-foreground italic flex-1 overflow-hidden">
<input
className="bg-transparent border-none outline-none w-full cursor-text truncate"
type="text"
readOnly={true}
name="domainId"
value={id}
onClick={onCopyId}
/>
<Button type="button" variant="ghost" className="h-5 w-5 p-0 shrink-0" onClick={onCopyId}>
<CopyIcon className="w-3 h-3" />
</Button>
</div>
</div>

<div className="col-span-1"></div>
<div className="col-span-1">
<Button
type="button"
variant="submit"
data-id={id}
onClick={onClick}
disabled={disabled}
>
<Save /> Bind to This Cluster
</Button>
<div className="text-muted-foreground md:col-span-2 text-wrap italic text-sm">
Select this domain using the checkbox to the left, then click "Bind" in the top right to finish associating it
with this cluster.
</div>
</div>
);
Expand Down
141 changes: 106 additions & 35 deletions src/features/cluster/domains/Management.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { FormItem } from '@/components/ui/form/FormItem';
import { FormLabel } from '@/components/ui/form/FormLabel';
import { FormMessage } from '@/components/ui/form/FormMessage';
import { Input } from '@/components/ui/input';
import { isRunning } from '@/components/ui/utils/badgeStatus';
import { useDataTableColumns } from '@/features/cluster/domains/constants/tableDefinition';
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
import { useSetDomainIdsOnCluster } from '@/features/clusters/mutations/setDomainIdsOnCluster';
import {
AddOrganizationDomainSchema,
useAddDomainToOrganization,
Expand All @@ -18,11 +20,13 @@ import { getOrganizationDomainsQueryOptions } from '@/features/organization/quer
import { useOrganizationRolePermissions } from '@/hooks/usePermissions';
import { useRefreshClick } from '@/hooks/useRefreshClick';
import { SchemaOrganizationDomain } from '@/integrations/api/api.patch';
import { unique } from '@/lib/arrays/unique';
import { pluralize } from '@/lib/pluralize';
import { queryClient } from '@/react-query/queryClient';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router';
import { ListTodoIcon, PlusIcon, RefreshCwIcon } from 'lucide-react';
import { ListTodoIcon, PlusIcon, RefreshCwIcon, Save } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
Expand Down Expand Up @@ -63,7 +67,35 @@ export function DomainsManagement() {

const onRefreshClick = useRefreshClick(refetch);

const { mutate: addDomain, isPending: isAddPending } = useAddDomainToOrganization();
const [selectedDomainIds, setSelectedDomainIds] = useState<string[]>([]);
const onToggleDomainSelection = useCallback((domainId: string) => {
setSelectedDomainIds((prev) =>
prev.includes(domainId) ? prev.filter((id) => id !== domainId) : [...prev, domainId]
);
}, []);

const { mutate: setDomainIds, isPending: isBindPending } = useSetDomainIdsOnCluster();

const onBindClick = useCallback(() => {
if (!isRunning(cluster.status)) {
toast.error('Cluster is currently ' + cluster.status, { description: 'To bind a domain, it must be running.' });
return;
}

setDomainIds({
clusterId: cluster.id,
domainIds: unique((cluster.domainIds?.slice() || []).concat(selectedDomainIds)),
generateDomainCerts: true,
}, {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
toast.success('Domain(s) bound! Certificates are being generated in the background now.');
setSelectedDomainIds([]);
},
});
}, [cluster, selectedDomainIds, setDomainIds]);

const { mutateAsync: addDomain, isPending: isAddPending } = useAddDomainToOrganization();
const form = useForm({
resolver: zodResolver(AddOrganizationDomainSchema),
defaultValues: {
Expand All @@ -75,13 +107,17 @@ export function DomainsManagement() {
const onSubmitClick = useCallback(
async (formData: z.infer<typeof AddOrganizationDomainSchema>) => {
if (formData) {
addDomain(formData, {
onSuccess: () => {
form.reset();
refetch();
toast.success('Domain added! Please add the txt record above to your domain registrar.');
},
});
const domains = formData.domain.split(/[,\s]+/).map((d: string) => d.trim()).filter(Boolean);
for (const domain of domains) {
await addDomain({ ...formData, domain });
}
form.reset();
await refetch();
toast.success(
`${
pluralize(domains.length, 'Domain', 'Domains')
} added! Please add the txt record above to your domain registrar.`,
);
}
},
[addDomain, form, refetch],
Expand Down Expand Up @@ -118,7 +154,20 @@ export function DomainsManagement() {
}
}, [pendingDomains]);

const dataTableColumns = useDataTableColumns(cluster);
const dataTableColumns = useDataTableColumns(cluster, selectedDomainIds, onToggleDomainSelection);

const domainValue = form.watch('domain');
const isApex = useMemo(() => {
if (typeof domainValue !== 'string') { return false; }
const parts = domainValue.trim().split('.');
return parts.length === 2;
}, [domainValue]);

const suggestWww = useCallback(() => {
if (typeof domainValue === 'string') {
form.setValue('domain', `${domainValue.trim()}, www.${domainValue.trim()}`);
}
}, [domainValue, form]);

return (
<SimpleBrowseDataTable<SchemaOrganizationDomain, unknown>
Expand All @@ -127,7 +176,7 @@ export function DomainsManagement() {
columns={dataTableColumns}
sortingState={sortingState}
>
<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">
<div className="w-full flex flex-col md:flex-row items-center md:justify-between md:space-x-2 space-y-2 md:space-y-0">
{update && (
<Form {...form}>
<form
Expand All @@ -145,6 +194,14 @@ export function DomainsManagement() {
<FormControl>
<Input type="text" enterKeyHint="done" autoComplete="off" {...field} />
</FormControl>
{isApex && (
<div className="mt-1 flex gap-4">
Adding an apex domain?
<Button variant="positiveOutline" type="button" onClick={suggestWww}>
Add www as well
</Button>
</div>
)}
<FormMessage>
<span className="text-muted-foreground italic">
Type in a domain like example.com or your.example.com, and you'll be guided through validating
Expand All @@ -154,43 +211,57 @@ export function DomainsManagement() {
</FormItem>
)}
/>
<div className="flex-0 self-start md:pt-6.5">
<div className="flex-0 self-start flex gap-1 md:pt-6.5">
<Button
type="submit"
variant="submit"
disabled={isAddPending}
>
<PlusIcon /> Add
</Button>

{pendingDomains.length > 0 && (
<Button
variant="positiveOutline"
onClick={onValidateClick}
accessKey="r"
type="button"
disabled={isFetching || isRefetching}
>
<ListTodoIcon />{' '}
<span>
<u>V</u>alidate
</span>
</Button>
)}
<Button
variant="defaultOutline"
onClick={onRefreshClick}
accessKey="r"
type="button"
disabled={isFetching || isRefetching}
>
<RefreshCwIcon />{' '}
<span className="hidden lg:inline-block">
<u>R</u>efresh
</span>
</Button>
</div>
</form>
</Form>
)}

{pendingDomains.length > 0 && (
<Button
variant="positiveOutline"
onClick={onValidateClick}
accessKey="r"
disabled={isFetching || isRefetching}
>
<ListTodoIcon />{' '}
<span>
<u>V</u>alidate
</span>
</Button>
{selectedDomainIds.length > 0 && (
<div className="flex-0 self-start md:pt-6.5">
<Button
variant="submit"
onClick={onBindClick}
disabled={isBindPending}
>
<Save /> Bind {pluralize(selectedDomainIds.length, 'Domain', 'Domains')}
</Button>
</div>
)}
<Button
variant="defaultOutline"
onClick={onRefreshClick}
accessKey="r"
disabled={isFetching || isRefetching}
>
<RefreshCwIcon />{' '}
<span className="hidden lg:inline-block">
<u>R</u>efresh
</span>
</Button>
</div>
</SimpleBrowseDataTable>
);
Expand Down
Loading
Loading