From e49fb646b0a000842c51e6d5ba9cd2f2e7103d6f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 18 Feb 2025 22:56:46 -0500 Subject: [PATCH] refactor subdomain inputs --- server/routers/domain/listDomains.ts | 5 +- server/routers/resource/createResource.ts | 15 +- server/routers/resource/updateResource.ts | 11 +- server/setup/copyInConfig.ts | 2 +- .../settings/resources/CreateResourceForm.tsx | 196 ++++++++++----- .../[resourceId]/CustomDomainInput.tsx | 79 +++++- .../[resourceId]/ResourceInfoBox.tsx | 12 +- .../resources/[resourceId]/general/page.tsx | 228 +++++++++++++----- 8 files changed, 404 insertions(+), 144 deletions(-) diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index a5140a31..c44274ed 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -33,16 +33,17 @@ const listDomainsSchema = z .strict(); async function queryDomains(orgId: string, limit: number, offset: number) { - return await db + const res = await db .select({ domainId: domains.domainId, baseDomain: domains.baseDomain }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) - .leftJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) .limit(limit) .offset(offset); + return res; } export type ListDomainsResponse = { diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 2cf8052e..3bc7c05c 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -31,7 +31,7 @@ const createResourceParamsSchema = z const createHttpResourceSchema = z .object({ name: z.string().min(1).max(255), - subdomain: subdomainSchema.optional(), + subdomain: z.string().optional(), isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), @@ -39,6 +39,15 @@ const createHttpResourceSchema = z domainId: z.string() }) .strict() + .refine( + (data) => { + if (data.subdomain) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { message: "Invalid subdomain" } + ) .refine( (data) => { if (!config.getRawConfig().flags?.allow_base_domain_resources) { @@ -199,6 +208,8 @@ async function createHttpResource( fullDomain = `${subdomain}.${domain.baseDomain}`; } + logger.debug(`Full domain: ${fullDomain}`); + // make sure the full domain is unique const existingResource = await db .select() @@ -221,7 +232,7 @@ async function createHttpResource( .insert(resources) .values({ siteId, - fullDomain: http ? fullDomain : null, + fullDomain, orgId, name, subdomain, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 8d737541..46c36542 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -43,6 +43,15 @@ const updateHttpResourceBodySchema = z .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update" }) + .refine( + (data) => { + if (data.subdomain) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { message: "Invalid subdomain" } + ) .refine( (data) => { if (!config.getRawConfig().flags?.allow_base_domain_resources) { @@ -206,7 +215,7 @@ async function updateHttpResource( if (updateData.isBaseDomain) { fullDomain = domain.baseDomain; } else if (subdomain && domain) { - fullDomain = `${subdomain}.${domain}`; + fullDomain = `${subdomain}.${domain.baseDomain}`; } if (fullDomain) { diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 88d7bcdc..d1860677 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -69,7 +69,7 @@ export async function copyInConfig() { if (resource.isBaseDomain) { fullDomain = domain.baseDomain; } else { - fullDomain = `${resource.subdomain}.${domain}`; + fullDomain = `${resource.subdomain}.${domain.baseDomain}`; } await trx diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index d27f8831..6adf8003 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -65,10 +65,12 @@ import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { ListDomainsResponse } from "@server/routers/domain"; const createResourceFormSchema = z .object({ subdomain: z.string().optional(), + domainId: z.string().min(1).optional(), name: z.string().min(1).max(255), siteId: z.number(), http: z.boolean(), @@ -129,7 +131,9 @@ export default function CreateResourceForm({ const { env } = useEnvContext(); const [sites, setSites] = useState([]); - const [domainSuffix, setDomainSuffix] = useState(org.org.domain); + const [baseDomains, setBaseDomains] = useState< + { domainId: string; baseDomain: string }[] + >([]); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( @@ -140,6 +144,7 @@ export default function CreateResourceForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", + domainId: "", name: "", http: true, protocol: "tcp" @@ -161,17 +166,55 @@ export default function CreateResourceForm({ reset(); const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); + const res = await api + .get>(`/org/${orgId}/sites/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching sites", + description: formatAxiosError( + e, + "An error occurred when fetching the sites" + ) + }); + }); - if (res.data.data.sites.length > 0) { - form.setValue("siteId", res.data.data.sites[0].siteId); + if (res?.status === 200) { + setSites(res.data.data.sites); + + if (res.data.data.sites.length > 0) { + form.setValue("siteId", res.data.data.sites[0].siteId); + } + } + }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching domains", + description: formatAxiosError( + e, + "An error occurred when fetching the domains" + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + if (domains.length) { + form.setValue("domainId", domains[0].domainId); + } } }; fetchSites(); + fetchDomains(); }, [open]); async function onSubmit(data: CreateResourceFormValues) { @@ -181,6 +224,7 @@ export default function CreateResourceForm({ { name: data.name, subdomain: data.http ? data.subdomain : undefined, + domainId: data.http ? data.domainId : undefined, http: data.http, protocol: data.protocol, proxyPort: data.http ? undefined : data.proxyPort, @@ -278,7 +322,7 @@ export default function CreateResourceForm({ Toggle if this is an HTTP resource or a - raw TCP/UDP resource + raw TCP/UDP resource. @@ -335,60 +379,98 @@ export default function CreateResourceForm({ )} {form.watch("http") && ( - ( - - {!env.flags - .allowBaseDomainResources && ( - - Subdomain - + <> + {domainType === "subdomain" ? ( + ( + + {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} + {domainType === + "subdomain" && ( + + { + form.setValue( + "subdomain", + value + ); + form.setValue( + "domainId", + selectedDomainId + ); + }} + /> + + )} + + )} - {domainType === - "subdomain" ? ( - - + ) : ( + ( + + - + > + + + + + + + {baseDomains.map( + ( + option + ) => ( + + { + option.baseDomain + } + + ) + )} + + + + )} - - This is the fully - qualified domain name - that will be used to - access the resource. - - - + /> )} - /> + )} {!form.watch("http") && ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx index fd754dde..0764d740 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx @@ -2,27 +2,68 @@ import * as React from "react"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; + +interface DomainOption { + baseDomain: string; + domainId: string; +} interface CustomDomainInputProps { - domainSuffix: string; + domainOptions: DomainOption[]; + selectedDomainId?: string; placeholder?: string; value: string; - onChange?: (value: string) => void; + onChange?: (value: string, selectedDomainId: string) => void; } export default function CustomDomainInput({ - domainSuffix, - placeholder = "Enter subdomain", + domainOptions, + selectedDomainId, + placeholder = "Subdomain", value: defaultValue, onChange }: CustomDomainInputProps) { const [value, setValue] = React.useState(defaultValue); + const [selectedDomain, setSelectedDomain] = React.useState(); - const handleChange = (event: React.ChangeEvent) => { + React.useEffect(() => { + if (domainOptions.length) { + if (selectedDomainId) { + const selectedDomainOption = domainOptions.find( + (option) => option.domainId === selectedDomainId + ); + setSelectedDomain(selectedDomainOption || domainOptions[0]); + } else { + setSelectedDomain(domainOptions[0]); + } + } + }, [domainOptions]); + + const handleInputChange = (event: React.ChangeEvent) => { + if (!selectedDomain) { + return; + } const newValue = event.target.value; setValue(newValue); if (onChange) { - onChange(newValue); + onChange(newValue, selectedDomain.domainId); + } + }; + + const handleDomainChange = (domainId: string) => { + const newSelectedDomain = + domainOptions.find((option) => option.domainId === domainId) || + domainOptions[0]; + setSelectedDomain(newSelectedDomain); + if (onChange) { + onChange(value, newSelectedDomain.domainId); } }; @@ -33,12 +74,28 @@ export default function CustomDomainInput({ type="text" placeholder={placeholder} value={value} - onChange={handleChange} - className="rounded-r-none w-full" + onChange={handleInputChange} + className="w-1/2 mr-1 text-right" /> -
- .{domainSuffix} -
+ ); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 38f3c84d..ab135db7 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -1,9 +1,7 @@ "use client"; -import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { Separator } from "@app/components/ui/separator"; import CopyToClipboard from "@app/components/CopyToClipboard"; @@ -17,17 +15,9 @@ import { type ResourceInfoBoxType = {}; export default function ResourceInfoBox({}: ResourceInfoBoxType) { - const [copied, setCopied] = useState(false); - - const { org } = useOrgContext(); const { resource, authInfo } = useResourceContext(); - let fullUrl = `${resource.ssl ? "https" : "http"}://`; - if (resource.isBaseDomain) { - fullUrl = fullUrl + org.org.domain; - } else { - fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`; - } + let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; return ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 301354a3..6bcc3fde 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -33,7 +33,6 @@ import { useEffect, useState } from "react"; import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; -import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { toast } from "@app/hooks/useToast"; import { SettingsContainer, @@ -53,6 +52,14 @@ import { subdomainSchema } from "@server/lib/schemas"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { ListDomainsResponse } from "@server/routers/domain"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; const GeneralFormSchema = z .object({ @@ -60,7 +67,8 @@ const GeneralFormSchema = z name: z.string().min(1).max(255), proxyPort: z.number().optional(), http: z.boolean(), - isBaseDomain: z.boolean().optional() + isBaseDomain: z.boolean().optional(), + domainId: z.string().optional() }) .refine( (data) => { @@ -113,9 +121,11 @@ export default function GeneralForm() { const [sites, setSites] = useState([]); const [saveLoading, setSaveLoading] = useState(false); - const [domainSuffix, setDomainSuffix] = useState(org.org.domain); const [transferLoading, setTransferLoading] = useState(false); const [open, setOpen] = useState(false); + const [baseDomains, setBaseDomains] = useState< + ListDomainsResponse["domains"] + >([]); const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( resource.isBaseDomain ? "basedomain" : "subdomain" @@ -128,7 +138,8 @@ export default function GeneralForm() { subdomain: resource.subdomain ? resource.subdomain : undefined, proxyPort: resource.proxyPort ? resource.proxyPort : undefined, http: resource.http, - isBaseDomain: resource.isBaseDomain ? true : false + isBaseDomain: resource.isBaseDomain ? true : false, + domainId: resource.domainId || undefined }, mode: "onChange" }); @@ -147,6 +158,30 @@ export default function GeneralForm() { ); setSites(res.data.data.sites); }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching domains", + description: formatAxiosError( + e, + "An error occurred when fetching the domains" + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + } + }; + + fetchDomains(); fetchSites(); }, []); @@ -158,7 +193,8 @@ export default function GeneralForm() { name: data.name, subdomain: data.subdomain, proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain + isBaseDomain: data.isBaseDomain, + domainId: data.domainId }) .catch((e) => { toast({ @@ -292,60 +328,134 @@ export default function GeneralForm() { )} - ( - - {!env.flags - .allowBaseDomainResources && ( - - Subdomain - - )} - - {domainType === - "subdomain" ? ( - - - form.setValue( - "subdomain", - value + {domainType === "subdomain" ? ( +
+ {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} +
+
+ ( + + + + )} + /> +
+
+ ( + + + + + )} + /> +
+
+
+ ) : ( + ( + + -
- )} - - This is the subdomain - that will be used to - access the resource. - - -
- )} - /> + )} + + + + + )} + /> + )} )} @@ -427,7 +537,7 @@ export default function GeneralForm() { control={transferForm.control} name="siteId" render={({ field }) => ( - + Destination Site