🚧 wip: refactor proxy resource page

This commit is contained in:
Fred KISSIE
2025-12-10 21:15:42 +01:00
parent ce6b609ca2
commit 4e842a660a
2 changed files with 286 additions and 385 deletions

View File

@@ -1,8 +1,5 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { formatAxiosError } from "@app/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Form, Form,
@@ -15,31 +12,10 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api";
import { useEffect, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { z } from "zod";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
import { UpdateResourceResponse } from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import { Checkbox } from "@app/components/ui/checkbox";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -51,26 +27,37 @@ import {
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react"; import {
import { build } from "@server/build"; SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Label } from "@app/components/ui/label";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "@app/components/DomainsTable"; import { UpdateResourceResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import { Globe } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode"; import { toASCII, toUnicode } from "punycode";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useActionState, useMemo, useState } from "react";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { useForm } from "react-hook-form";
import { useUserContext } from "@app/hooks/useUserContext";
export default function GeneralForm() { export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
const params = useParams(); const params = useParams();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const [editDomainOpen, setEditDomainOpen] = useState(false); const [editDomainOpen, setEditDomainOpen] = useState(false);
const { licenseStatus } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
const { user } = useUserContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -78,18 +65,30 @@ export default function GeneralForm() {
const api = createApiClient({ env }); const api = createApiClient({ env });
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<
ListDomainsResponse["domains"]
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [resourceFullDomain, setResourceFullDomain] = useState( const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
); );
console.log({ resource });
const [defaultSubdomain, defaultBaseDomain] = useMemo(() => {
const resourceUrl = new URL(resourceFullDomain);
const domain = resourceUrl.hostname;
const allDomainParts = domain.split(".");
let sub = undefined;
let base = domain;
if (allDomainParts.length >= 3) {
// 3 parts: [subdomain, domain, tld]
const [first, ...rest] = allDomainParts;
sub = first;
base = rest.join(".");
}
return [sub, base];
}, [resourceFullDomain]);
const [selectedDomain, setSelectedDomain] = useState<{ const [selectedDomain, setSelectedDomain] = useState<{
domainId: string; domainId: string;
subdomain?: string; subdomain?: string;
@@ -105,7 +104,6 @@ export default function GeneralForm() {
niceId: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(),
domainId: z.string().optional(), domainId: z.string().optional(),
proxyPort: z.int().min(1).max(65535).optional() proxyPort: z.int().min(1).max(65535).optional()
// enableProxy: z.boolean().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -124,8 +122,6 @@ export default function GeneralForm() {
} }
); );
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const form = useForm({ const form = useForm({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
@@ -135,58 +131,17 @@ export default function GeneralForm() {
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined, domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined proxyPort: resource.proxyPort || undefined
// enableProxy: resource.enableProxy || false
}, },
mode: "onChange" mode: "onChange"
}); });
useEffect(() => { const [, formAction, saveLoading] = useActionState(onSubmit, null);
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
const fetchDomains = async () => { async function onSubmit() {
const res = await api const isValid = await form.trigger();
.get< if (!isValid) return;
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("domainErrorFetch"),
description: formatAxiosError(
e,
t("domainErrorFetchDescription")
)
});
});
if (res?.status === 200) { const data = form.getValues();
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
setFormKey((key) => key + 1);
}
};
const load = async () => {
await fetchDomains();
await fetchSites();
setLoadingPage(false);
};
load();
}, []);
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
const res = await api const res = await api
.post<AxiosResponse<UpdateResourceResponse>>( .post<AxiosResponse<UpdateResourceResponse>>(
@@ -200,9 +155,6 @@ export default function GeneralForm() {
: undefined, : undefined,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort proxyPort: data.proxyPort
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
} }
) )
.catch((e) => { .catch((e) => {
@@ -226,9 +178,6 @@ export default function GeneralForm() {
subdomain: data.subdomain, subdomain: data.subdomain,
fullDomain: resource.fullDomain, fullDomain: resource.fullDomain,
proxyPort: data.proxyPort proxyPort: data.proxyPort
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
}); });
toast({ toast({
@@ -240,306 +189,257 @@ export default function GeneralForm() {
router.replace( router.replace(
`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general` `/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`
); );
} else {
router.refresh();
} }
setSaveLoading(false); router.refresh();
} }
setSaveLoading(false);
} }
return ( return (
!loadingPage && ( <>
<> <SettingsContainer>
<SettingsContainer> <SettingsSection>
<SettingsSection> <SettingsSectionHeader>
<SettingsSectionHeader> <SettingsSectionTitle>
<SettingsSectionTitle> {t("resourceGeneral")}
{t("resourceGeneral")} </SettingsSectionTitle>
</SettingsSectionTitle> <SettingsSectionDescription>
<SettingsSectionDescription> {t("resourceGeneralDescription")}
{t("resourceGeneralDescription")} </SettingsSectionDescription>
</SettingsSectionDescription> </SettingsSectionHeader>
</SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form} key={formKey}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} action={formAction}
className="space-y-4" className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"
render={({ field }) => ( render={() => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<FormControl> <FormControl>
<SwitchInput <SwitchInput
id="enable-resource" id="enable-resource"
defaultChecked={ defaultChecked={
resource.enabled resource.enabled
} }
label={t( label={t(
"resourceEnable" "resourceEnable"
)} )}
onCheckedChange={( onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val val
) => )
form.setValue( }
"enabled", />
val </FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!resource.http && (
<>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: undefined
) )
} }
/> />
</FormControl> </FormControl>
</div> <FormMessage />
<FormMessage /> <FormDescription>
</FormItem> {t(
)} "resourcePortNumberDescription"
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)} )}
className="flex-1" </FormDescription>
/> </FormItem>
</FormControl> )}
<FormMessage /> />
</FormItem> </>
)} )}
/>
{!resource.http && ( {resource.http && (
<> <div className="space-y-2">
<FormField <Label>{t("resourceDomain")}</Label>
control={form.control} <div className="border p-2 rounded-md flex items-center justify-between">
name="proxyPort" <span className="text-sm text-muted-foreground flex items-center gap-2">
render={({ field }) => ( <Globe size="14" />
<FormItem> {resourceFullDomain}
<FormLabel> </span>
{t( <Button
"resourcePortNumber" variant="secondary"
)} type="button"
</FormLabel> size="sm"
<FormControl> onClick={() =>
<Input setEditDomainOpen(true)
type="number" }
value={ >
field.value ?? {t("resourceEditDomain")}
"" </Button>
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{/* {build == "oss" && (
<FormField
control={form.control}
name="enableProxy"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)} */}
</>
)}
{resource.http && (
<div className="space-y-2">
<Label>
{t("resourceDomain")}
</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{resourceFullDomain}
</span>
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
>
{t(
"resourceEditDomain"
)}
</Button>
</div>
</div> </div>
)} </div>
</form> )}
</Form> </form>
</SettingsSectionForm> </Form>
</SettingsSectionBody> </SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
onClick={() => { onClick={() => {
console.log(form.getValues()); console.log(form.getValues());
}} }}
loading={saveLoading} loading={saveLoading}
disabled={saveLoading} disabled={saveLoading}
form="general-settings-form" form="general-settings-form"
> >
{t("saveSettings")} {t("saveSettings")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
<Credenza <Credenza
open={editDomainOpen} open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)} onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
> >
<CredenzaContent> <CredenzaContent>
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>Edit Domain</CredenzaTitle> <CredenzaTitle>Edit Domain</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
Select a domain for your resource Select a domain for your resource
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<DomainPicker <DomainPicker
orgId={orgId as string} orgId={orgId as string}
cols={1} cols={1}
onDomainChange={(res) => { defaultSubdomain={defaultSubdomain}
const selected = { defaultBaseDomain={defaultBaseDomain}
domainId: res.domainId, onDomainChange={(res) => {
subdomain: res.subdomain, const selected = {
fullDomain: res.fullDomain, domainId: res.domainId,
baseDomain: res.baseDomain subdomain: res.subdomain,
}; fullDomain: res.fullDomain,
setSelectedDomain(selected); baseDomain: res.baseDomain
}} };
/> setSelectedDomain(selected);
</CredenzaBody> }}
<CredenzaFooter> />
<CredenzaClose asChild> </CredenzaBody>
<Button variant="outline">{t("cancel")}</Button> <CredenzaFooter>
</CredenzaClose> <CredenzaClose asChild>
<Button <Button variant="outline">{t("cancel")}</Button>
onClick={() => { </CredenzaClose>
if (selectedDomain) { <Button
const sanitizedSubdomain = onClick={() => {
selectedDomain.subdomain if (selectedDomain) {
? finalizeSubdomainSanitize( const sanitizedSubdomain =
selectedDomain.subdomain selectedDomain.subdomain
) ? finalizeSubdomainSanitize(
: ""; selectedDomain.subdomain
)
: "";
const sanitizedFullDomain = const sanitizedFullDomain =
sanitizedSubdomain sanitizedSubdomain
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}` ? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
: selectedDomain.baseDomain; : selectedDomain.baseDomain;
setResourceFullDomain( setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}` `${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`
); );
form.setValue( form.setValue(
"domainId", "domainId",
selectedDomain.domainId selectedDomain.domainId
); );
form.setValue( form.setValue(
"subdomain", "subdomain",
sanitizedSubdomain sanitizedSubdomain
); );
setEditDomainOpen(false); setEditDomainOpen(false);
} }
}} }}
> >
Select Domain Select Domain
</Button> </Button>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
</> </>
)
); );
} }

View File

@@ -13,9 +13,10 @@ import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react"; import { cache } from "react";
import ResourceInfoBox from "@app/components/ResourceInfoBox"; import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
interface ResourceLayoutProps { interface ResourceLayoutProps {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ niceId: string; orgId: string }>; params: Promise<{ niceId: string; orgId: string }>;