diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index d43a4fdd..af6807b9 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -39,7 +39,6 @@ const createHttpResourceSchema = z isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), - protocol: z.string(), domainId: z.string() }) .strict() @@ -203,7 +202,7 @@ async function createHttpResource( ); } - const { name, subdomain, isBaseDomain, http, protocol, domainId } = + const { name, subdomain, isBaseDomain, http, domainId } = parsedBody.data; const [orgDomain] = await db @@ -262,7 +261,7 @@ async function createHttpResource( name, subdomain, http, - protocol, + protocol: "tcp", ssl: true, isBaseDomain }) diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index fa83a761..2428472e 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -21,10 +21,8 @@ import { } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { set } from "zod"; import { formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; @@ -58,10 +56,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const api = createApiClient(useEnvContext()); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedResource, setSelectedResource] = - useState(); + const [selectedResource, setSelectedResource] = useState(); const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) @@ -282,11 +278,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { return ( <> - - {selectedResource && ( { - setIsCreateModalOpen(true); + router.push(`/${orgId}/settings/resources/create`); }} /> diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/create/page.tsx similarity index 57% rename from src/app/[orgId]/settings/resources/CreateResourceForm.tsx rename to src/app/[orgId]/settings/resources/create/page.tsx index 9df51e92..d500a809 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -1,6 +1,14 @@ "use client"; -import { Button, buttonVariants } from "@app/components/ui/button"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; import { Form, FormControl, @@ -10,48 +18,22 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; import { useParams, useRouter } from "next/navigation"; import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api"; -import { CheckIcon } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import CustomDomainInput from "./[resourceId]/CustomDomainInput"; -import { AxiosResponse } from "axios"; -import { Resource } from "@server/db/schemas"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { cn } from "@app/lib/cn"; -import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { Resource } from "@server/db/schemas"; +import { StrategySelect } from "@app/components/StrategySelect"; import { Select, SelectContent, @@ -60,222 +42,74 @@ import { SelectValue } from "@app/components/ui/select"; import { subdomainSchema } from "@server/lib/schemas"; -import Link from "next/link"; -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"; import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; -import { StrategySelect } from "@app/components/StrategySelect"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { cn } from "@app/lib/cn"; -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(), - protocol: z.string(), - proxyPort: z.number().optional(), - isBaseDomain: z.boolean().optional() +const baseResourceFormSchema = z.object({ + name: z.string().min(1).max(255), + siteId: z.number(), + http: z.boolean() +}); + +const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [ + z.object({ + isBaseDomain: z.literal(true), + domainId: z.string().min(1) + }), + z.object({ + isBaseDomain: z.literal(false), + domainId: z.string().min(1), + subdomain: z.string().pipe(subdomainSchema) }) - .refine( - (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; - } - return true; - }, - { - message: "Invalid port number", - path: ["proxyPort"] - } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: "Invalid subdomain", - path: ["subdomain"] - } - ); +]); -type CreateResourceFormValues = z.infer; +const tcpUdpResourceFormSchema = z.object({ + protocol: z.string(), + proxyPort: z.number().int().min(1).max(65535) +}); -type CreateResourceFormProps = { - open: boolean; - setOpen: (open: boolean) => void; -}; +type BaseResourceFormValues = z.infer; +type HttpResourceFormValues = z.infer; +type TcpUdpResourceFormValues = z.infer; -export default function CreateResourceForm({ - open, - setOpen -}: CreateResourceFormProps) { - const [formKey, setFormKey] = useState(0); - const api = createApiClient(useEnvContext()); +type ResourceType = "http" | "raw"; - const [loading, setLoading] = useState(false); - const params = useParams(); +interface ResourceTypeOption { + id: ResourceType; + title: string; + description: string; + disabled?: boolean; +} - const orgId = params.orgId; +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); const router = useRouter(); - const { org } = useOrgContext(); - const { env } = useEnvContext(); - + const [loadingPage, setLoadingPage] = useState(true); const [sites, setSites] = useState([]); const [baseDomains, setBaseDomains] = useState< { domainId: string; baseDomain: string }[] >([]); - const [showSnippets, setShowSnippets] = useState(false); - const [resourceId, setResourceId] = useState(null); - const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( - "subdomain" - ); - const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); - const form = useForm({ - resolver: zodResolver(createResourceFormSchema), - defaultValues: { - subdomain: "", - domainId: "", - name: "", - http: true, - protocol: "tcp" - } - }); - - function reset() { - form.reset(); - setSites([]); - setShowSnippets(false); - setResourceId(null); - } - - useEffect(() => { - if (!open) { - return; - } - - reset(); - - const fetchSites = async () => { - 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?.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); - setFormKey((k) => k + 1); - } - } - }; - - const load = async () => { - setLoadingPage(true); - - await fetchSites(); - await fetchDomains(); - await new Promise((r) => setTimeout(r, 200)); - - setLoadingPage(false); - }; - - load(); - }, [open]); - - async function onSubmit(data: CreateResourceFormValues) { - const res = await api - .put>( - `/org/${orgId}/site/${data.siteId}/resource/`, - { - 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, - siteId: data.siteId, - isBaseDomain: data.http ? data.isBaseDomain : undefined - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error creating resource", - description: formatAxiosError( - e, - "An error occurred when creating the resource" - ) - }); - }); - - if (res && res.status === 201) { - const id = res.data.data.resourceId; - setResourceId(id); - - if (data.http) { - goToResource(id); - } else { - setShowSnippets(true); - router.refresh(); - } - } - } - - function goToResource(id?: number) { - // navigate to the resource page - router.push(`/${orgId}/settings/resources/${id || resourceId}`); - } - - const launchOptions = [ + const resourceTypes: ReadonlyArray = [ { id: "http", title: "HTTPS Resource", @@ -286,45 +120,203 @@ export default function CreateResourceForm({ id: "raw", title: "Raw TCP/UDP Resource", description: - "Proxy requests to your app over TCP/UDP using a port number." + "Proxy requests to your app over TCP/UDP using a port number.", + disabled: !env.flags.allowRawResources } ]; + const baseForm = useForm({ + resolver: zodResolver(baseResourceFormSchema), + defaultValues: { + name: "", + http: true + } + }); + + const httpForm = useForm({ + resolver: zodResolver(httpResourceFormSchema), + defaultValues: { + subdomain: "", + domainId: "", + isBaseDomain: false + } + }); + + const tcpUdpForm = useForm({ + resolver: zodResolver(tcpUdpResourceFormSchema), + defaultValues: { + protocol: "tcp", + proxyPort: undefined + } + }); + + async function onSubmit() { + setCreateLoading(true); + + const baseData = baseForm.getValues(); + const isHttp = baseData.http; + + try { + const payload = { + name: baseData.name, + siteId: baseData.siteId, + http: baseData.http + }; + + if (isHttp) { + const httpData = httpForm.getValues(); + if (httpData.isBaseDomain) { + Object.assign(payload, { + domainId: httpData.domainId, + isBaseDomain: true + }); + } else { + Object.assign(payload, { + subdomain: httpData.subdomain, + domainId: httpData.domainId, + isBaseDomain: false + }); + } + } else { + const tcpUdpData = tcpUdpForm.getValues(); + Object.assign(payload, { + protocol: tcpUdpData.protocol, + proxyPort: tcpUdpData.proxyPort + }); + } + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/site/${baseData.siteId}/resource/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating resource", + description: formatAxiosError( + e, + "An error occurred when creating the resource" + ) + }); + }); + + if (res && res.status === 201) { + const id = res.data.data.resourceId; + router.push(`/${orgId}/settings/resources/${id}`); + } + } catch (e) { + console.error("Error creating resource:", e); + toast({ + variant: "destructive", + title: "Error creating resource", + description: "An unexpected error occurred" + }); + } + + setCreateLoading(false); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(true); + + const fetchSites = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/sites/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching sites", + description: formatAxiosError( + e, + "An error occurred when fetching the sites" + ) + }); + }); + + if (res?.status === 200) { + setSites(res.data.data.sites); + + if (res.data.data.sites.length > 0) { + baseForm.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) { + httpForm.setValue("domainId", domains[0].domainId); + } + } + }; + + await fetchSites(); + await fetchDomains(); + + setLoadingPage(false); + }; + + load(); + }, []); + return ( <> - { - setOpen(val); - setLoading(false); +
+ + +
- // reset all values - form.reset(); - }} - > - - - Create Resource - - Create a new resource to proxy requests to your app - - - - {loadingPage ? ( - - ) : ( -
- {!showSnippets && ( -
+ {!loadingPage && ( +
+ + + + + Resource Information + + + + + ( @@ -335,12 +327,17 @@ export default function CreateResourceForm({ + + This is the display + name for the + resource. + )} /> ( @@ -395,7 +392,7 @@ export default function CreateResourceForm({ site.siteId } onSelect={() => { - form.setValue( + baseForm.setValue( "siteId", site.siteId ); @@ -430,35 +427,61 @@ export default function CreateResourceForm({ )} /> + + + + + - {!env.flags.allowRawResources || ( -
- - Resource Type - - - form.setValue( - "http", - value === "http" - ) - } - /> - - You cannot change the - type of resource after - creation. - -
- )} + + + + Resource Type + + + Determine how you want to access your + resource + + + + { + baseForm.setValue( + "http", + value === "http" + ); + }} + cols={2} + /> + + - {form.watch("http") && - env.flags + {baseForm.watch("http") ? ( + + + + HTTPS Settings + + + Configure how your resource will be + accessed over HTTPS + + + + +
+ + {env.flags .allowBaseDomainResources && ( ( @@ -467,20 +490,15 @@ export default function CreateResourceForm({ + + + + )} + /> +
+
+ ( + + + + + - - - )} - /> -
-
- ( - - - - - )} - /> -
+ + {baseDomains.map( + ( + option + ) => ( + + . + { + option.baseDomain + } + + ) + )} + + + + + )} + />
- ) : ( - ( - - - Base - Domain - - - - - )} - /> - )} - - )} + + The subdomain where + your resource will + be accessible. + + + )} - {!form.watch("http") && ( - <> + {httpForm.watch( + "isBaseDomain" + ) && ( ( - Protocol + Base Domain )} /> - ( - - - Port Number - + )} + + + + + + ) : ( + + + + TCP/UDP Settings + + + Configure how your resource will be + accessed over TCP/UDP + + + + +
+ + ( + + + Protocol + + - field.onChange( - e - .target - .value - ? parseInt( - e - .target - .value - ) - : null - ) - } - /> + + + - - - The external - port number - to proxy - requests. - - - )} - /> - - )} - - - )} - - {showSnippets && ( -
-
-
-

- Traefik: Add Entrypoints -

- + + TCP + + + UDP + + + + + + )} /> -
-
-
-
-

- Gerbil: Expose Ports in - Docker Compose -

- ( + + + Port Number + + + + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + The external + port number to + proxy requests. + + + )} /> -
-
+ + + + + + )} + - - - Learn how to configure TCP/UDP - resources - - - -
- )} - - )} -
- - - - - {!showSnippets && ( - - )} +
+ + - )} - - - + if (baseValid && settingsValid) { + onSubmit(); + } + }} + loading={createLoading} + > + Create Resource + +
+ + )} ); }