refactor subdomain inputs

This commit is contained in:
miloschwartz 2025-02-18 22:56:46 -05:00
parent 82f990eb8b
commit e49fb646b0
No known key found for this signature in database
8 changed files with 404 additions and 144 deletions

View file

@ -33,16 +33,17 @@ const listDomainsSchema = z
.strict(); .strict();
async function queryDomains(orgId: string, limit: number, offset: number) { async function queryDomains(orgId: string, limit: number, offset: number) {
return await db const res = await db
.select({ .select({
domainId: domains.domainId, domainId: domains.domainId,
baseDomain: domains.baseDomain baseDomain: domains.baseDomain
}) })
.from(orgDomains) .from(orgDomains)
.where(eq(orgDomains.orgId, orgId)) .where(eq(orgDomains.orgId, orgId))
.leftJoin(domains, eq(domains.domainId, orgDomains.domainId)) .innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return res;
} }
export type ListDomainsResponse = { export type ListDomainsResponse = {

View file

@ -31,7 +31,7 @@ const createResourceParamsSchema = z
const createHttpResourceSchema = z const createHttpResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
subdomain: subdomainSchema.optional(), subdomain: z.string().optional(),
isBaseDomain: z.boolean().optional(), isBaseDomain: z.boolean().optional(),
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
@ -39,6 +39,15 @@ const createHttpResourceSchema = z
domainId: z.string() domainId: z.string()
}) })
.strict() .strict()
.refine(
(data) => {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{ message: "Invalid subdomain" }
)
.refine( .refine(
(data) => { (data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) { if (!config.getRawConfig().flags?.allow_base_domain_resources) {
@ -199,6 +208,8 @@ async function createHttpResource(
fullDomain = `${subdomain}.${domain.baseDomain}`; fullDomain = `${subdomain}.${domain.baseDomain}`;
} }
logger.debug(`Full domain: ${fullDomain}`);
// make sure the full domain is unique // make sure the full domain is unique
const existingResource = await db const existingResource = await db
.select() .select()
@ -221,7 +232,7 @@ async function createHttpResource(
.insert(resources) .insert(resources)
.values({ .values({
siteId, siteId,
fullDomain: http ? fullDomain : null, fullDomain,
orgId, orgId,
name, name,
subdomain, subdomain,

View file

@ -43,6 +43,15 @@ const updateHttpResourceBodySchema = z
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update" 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( .refine(
(data) => { (data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) { if (!config.getRawConfig().flags?.allow_base_domain_resources) {
@ -206,7 +215,7 @@ async function updateHttpResource(
if (updateData.isBaseDomain) { if (updateData.isBaseDomain) {
fullDomain = domain.baseDomain; fullDomain = domain.baseDomain;
} else if (subdomain && domain) { } else if (subdomain && domain) {
fullDomain = `${subdomain}.${domain}`; fullDomain = `${subdomain}.${domain.baseDomain}`;
} }
if (fullDomain) { if (fullDomain) {

View file

@ -69,7 +69,7 @@ export async function copyInConfig() {
if (resource.isBaseDomain) { if (resource.isBaseDomain) {
fullDomain = domain.baseDomain; fullDomain = domain.baseDomain;
} else { } else {
fullDomain = `${resource.subdomain}.${domain}`; fullDomain = `${resource.subdomain}.${domain.baseDomain}`;
} }
await trx await trx

View file

@ -65,10 +65,12 @@ import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
const createResourceFormSchema = z const createResourceFormSchema = z
.object({ .object({
subdomain: z.string().optional(), subdomain: z.string().optional(),
domainId: z.string().min(1).optional(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
@ -129,7 +131,9 @@ export default function CreateResourceForm({
const { env } = useEnvContext(); const { env } = useEnvContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain); const [baseDomains, setBaseDomains] = useState<
{ domainId: string; baseDomain: string }[]
>([]);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null); const [resourceId, setResourceId] = useState<number | null>(null);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
@ -140,6 +144,7 @@ export default function CreateResourceForm({
resolver: zodResolver(createResourceFormSchema), resolver: zodResolver(createResourceFormSchema),
defaultValues: { defaultValues: {
subdomain: "", subdomain: "",
domainId: "",
name: "", name: "",
http: true, http: true,
protocol: "tcp" protocol: "tcp"
@ -161,17 +166,55 @@ export default function CreateResourceForm({
reset(); reset();
const fetchSites = async () => { const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>( const res = await api
`/org/${orgId}/sites/` .get<AxiosResponse<ListSitesResponse>>(`/org/${orgId}/sites/`)
); .catch((e) => {
setSites(res.data.data.sites); toast({
variant: "destructive",
title: "Error fetching sites",
description: formatAxiosError(
e,
"An error occurred when fetching the sites"
)
});
});
if (res.data.data.sites.length > 0) { if (res?.status === 200) {
form.setValue("siteId", res.data.data.sites[0].siteId); 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<ListDomainsResponse>
>(`/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(); fetchSites();
fetchDomains();
}, [open]); }, [open]);
async function onSubmit(data: CreateResourceFormValues) { async function onSubmit(data: CreateResourceFormValues) {
@ -181,6 +224,7 @@ export default function CreateResourceForm({
{ {
name: data.name, name: data.name,
subdomain: data.http ? data.subdomain : undefined, subdomain: data.http ? data.subdomain : undefined,
domainId: data.http ? data.domainId : undefined,
http: data.http, http: data.http,
protocol: data.protocol, protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort, proxyPort: data.http ? undefined : data.proxyPort,
@ -278,7 +322,7 @@ export default function CreateResourceForm({
<FormDescription> <FormDescription>
Toggle if this is an Toggle if this is an
HTTP resource or a HTTP resource or a
raw TCP/UDP resource raw TCP/UDP resource.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@ -335,60 +379,98 @@ export default function CreateResourceForm({
)} )}
{form.watch("http") && ( {form.watch("http") && (
<FormField <>
control={form.control} {domainType === "subdomain" ? (
name="subdomain" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="subdomain"
{!env.flags render={({ field }) => (
.allowBaseDomainResources && ( <FormItem>
<FormLabel> {!env.flags
Subdomain .allowBaseDomainResources && (
</FormLabel> <FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" && (
<FormControl>
<CustomDomainInput
value={
field.value ??
""
}
domainOptions={
baseDomains
}
placeholder="Subdomain"
onChange={(
value,
selectedDomainId
) => {
form.setValue(
"subdomain",
value
);
form.setValue(
"domainId",
selectedDomainId
);
}}
/>
</FormControl>
)}
<FormMessage />
</FormItem>
)} )}
{domainType === />
"subdomain" ? ( ) : (
<FormControl> <FormField
<CustomDomainInput control={form.control}
value={ name="domainId"
field.value ?? render={({ field }) => (
"" <FormItem>
<Select
onValueChange={
field.onChange
} }
domainSuffix={ defaultValue={
domainSuffix baseDomains[0]
?.domainId
} }
placeholder="Subdomain" >
onChange={( <FormControl>
value <SelectTrigger>
) => <SelectValue />
form.setValue( </SelectTrigger>
"subdomain", </FormControl>
value <SelectContent>
) {baseDomains.map(
} (
/> option
</FormControl> ) => (
) : ( <SelectItem
<FormControl> key={
<Input option.domainId
value={ }
domainSuffix value={
} option.domainId
readOnly }
disabled >
/> {
</FormControl> option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} )}
<FormDescription> />
This is the fully
qualified domain name
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)} )}
/> </>
)} )}
{!form.watch("http") && ( {!form.watch("http") && (

View file

@ -2,27 +2,68 @@
import * as React from "react"; import * as React from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface DomainOption {
baseDomain: string;
domainId: string;
}
interface CustomDomainInputProps { interface CustomDomainInputProps {
domainSuffix: string; domainOptions: DomainOption[];
selectedDomainId?: string;
placeholder?: string; placeholder?: string;
value: string; value: string;
onChange?: (value: string) => void; onChange?: (value: string, selectedDomainId: string) => void;
} }
export default function CustomDomainInput({ export default function CustomDomainInput({
domainSuffix, domainOptions,
placeholder = "Enter subdomain", selectedDomainId,
placeholder = "Subdomain",
value: defaultValue, value: defaultValue,
onChange onChange
}: CustomDomainInputProps) { }: CustomDomainInputProps) {
const [value, setValue] = React.useState(defaultValue); const [value, setValue] = React.useState(defaultValue);
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { 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<HTMLInputElement>) => {
if (!selectedDomain) {
return;
}
const newValue = event.target.value; const newValue = event.target.value;
setValue(newValue); setValue(newValue);
if (onChange) { 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" type="text"
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={handleChange} onChange={handleInputChange}
className="rounded-r-none w-full" className="w-1/2 mr-1 text-right"
/> />
<div className="max-w-1/2 flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground"> <Select
<span className="text-sm truncate">.{domainSuffix}</span> onValueChange={handleDomainChange}
</div> value={selectedDomain?.domainId}
defaultValue={selectedDomain?.domainId}
>
<SelectTrigger className="w-1/2 pr-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domainOptions.map((option) => (
<SelectItem
key={option.domainId}
value={option.domainId}
>
.{option.baseDomain}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
); );

View file

@ -1,9 +1,7 @@
"use client"; "use client";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
@ -17,17 +15,9 @@ import {
type ResourceInfoBoxType = {}; type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) { export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const [copied, setCopied] = useState(false);
const { org } = useOrgContext();
const { resource, authInfo } = useResourceContext(); const { resource, authInfo } = useResourceContext();
let fullUrl = `${resource.ssl ? "https" : "http"}://`; let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
if (resource.isBaseDomain) {
fullUrl = fullUrl + org.org.domain;
} else {
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
}
return ( return (
<Alert> <Alert>

View file

@ -33,7 +33,6 @@ import { useEffect, useState } from "react";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import {
SettingsContainer, SettingsContainer,
@ -53,6 +52,14 @@ import { subdomainSchema } from "@server/lib/schemas";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label"; 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 const GeneralFormSchema = z
.object({ .object({
@ -60,7 +67,8 @@ const GeneralFormSchema = z
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
proxyPort: z.number().optional(), proxyPort: z.number().optional(),
http: z.boolean(), http: z.boolean(),
isBaseDomain: z.boolean().optional() isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@ -113,9 +121,11 @@ export default function GeneralForm() {
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
const [transferLoading, setTransferLoading] = useState(false); const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<
ListDomainsResponse["domains"]
>([]);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain" resource.isBaseDomain ? "basedomain" : "subdomain"
@ -128,7 +138,8 @@ export default function GeneralForm() {
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined, proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http, http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
}, },
mode: "onChange" mode: "onChange"
}); });
@ -147,6 +158,30 @@ export default function GeneralForm() {
); );
setSites(res.data.data.sites); setSites(res.data.data.sites);
}; };
const fetchDomains = async () => {
const res = await api
.get<
AxiosResponse<ListDomainsResponse>
>(`/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(); fetchSites();
}, []); }, []);
@ -158,7 +193,8 @@ export default function GeneralForm() {
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain isBaseDomain: data.isBaseDomain,
domainId: data.domainId
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@ -292,60 +328,134 @@ export default function GeneralForm() {
</div> </div>
)} )}
<FormField {domainType === "subdomain" ? (
control={form.control} <div className="w-fill space-y-2">
name="subdomain" {!env.flags
render={({ field }) => ( .allowBaseDomainResources && (
<FormItem> <FormLabel>
{!env.flags Subdomain
.allowBaseDomainResources && ( </FormLabel>
<FormLabel> )}
Subdomain <div className="flex">
</FormLabel> <div className="w-1/2 mr-1">
)} <FormField
control={
{domainType === form.control
"subdomain" ? ( }
<FormControl> name="subdomain"
<CustomDomainInput render={({
value={ field
field.value || }) => (
"" <FormControl>
} <Input
domainSuffix={ {...field}
domainSuffix className="text-right"
} />
placeholder="Enter subdomain" </FormControl>
onChange={( )}
value />
) => </div>
form.setValue( <div className="w-1/2">
"subdomain", <FormField
value control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
) )
} )}
/> </SelectContent>
</FormControl> </Select>
) : ( <FormMessage />
<FormControl> </FormItem>
<Input )}
value={ />
domainSuffix )}
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
This is the subdomain
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</> </>
)} )}
@ -427,7 +537,7 @@ export default function GeneralForm() {
control={transferForm.control} control={transferForm.control}
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem>
<FormLabel> <FormLabel>
Destination Site Destination Site
</FormLabel> </FormLabel>