"use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { SettingsContainer, SettingsFormCell, SettingsFormGrid, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { isValidDomain } from "@server/lib/validators"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { zodResolver } from "@hookform/resolvers/zod"; import type { CreateRedirectResponse, GetRedirectResponse } from "@server/routers/redirect"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useActionState, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { Plus } from "lucide-react"; import DomainPicker from "@app/components/DomainPicker"; import Link from "next/link"; const DEFAULT_MATCH_PATH = ".*"; const DEFAULT_PATH_MATCH_TYPE = "regex" as const; export type ExistingRedirect = GetRedirectResponse["redirect"]; type RedirectFormProps = { orgId: string; /** Omit to create a new redirect. */ redirect?: ExistingRedirect; /** Name/domain of the resource the redirect is attached to, when there is one. */ initialResource?: SelectedResource | null; }; export default function RedirectForm({ orgId, redirect, initialResource = null }: RedirectFormProps) { const isEditing = Boolean(redirect); const { env } = useEnvContext(); const api = createApiClient({ env }); const router = useRouter(); const t = useTranslations(); const [, formAction, saveLoading] = useActionState(onSubmit, null); const [deleteLoading, setDeleteLoading] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(initialResource); // DomainPicker only hands back the composed host through its callback, so // keep it locally; seed from the saved redirect for the edit case. const [domainFullDomain, setDomainFullDomain] = useState( redirect?.baseDomain ? [redirect.subdomain, redirect.baseDomain] .filter(Boolean) .join(".") : null ); const formSchema = useMemo( () => z .object({ name: z .string() .trim() .min(1, { message: t("nameRequired") }), attachTo: z.enum(["domain", "resource"]), domainId: z.string().nullable(), subdomain: z.string().nullable(), resourceId: z.number().int().positive().nullable(), destinationDomain: z .string() .trim() .min(1, { message: t("redirectDestinationDomainRequired") }) .refine(isValidDomain, { message: t("redirectDestinationDomainInvalid") }), pathMatchType: z.enum(["exact", "prefix", "regex"]), matchPath: z.string().trim().min(1), rewritePath: z.string().nullable(), rewritePathType: z .enum(["exact", "prefix", "regex", "stripPrefix"]) .nullable(), permanent: z.boolean(), enabled: z.boolean() }) .superRefine((data, ctx) => { if (data.attachTo === "domain" && !data.domainId) { ctx.addIssue({ code: "custom", message: t("redirectDomainRequired"), path: ["domainId"] }); } if (data.attachTo === "resource" && !data.resourceId) { ctx.addIssue({ code: "custom", message: t("redirectResourceRequired"), path: ["resourceId"] }); } // stripPrefix drops the matched prefix outright, so it is // the one rewrite type that needs no replacement value. if ( data.rewritePathType && data.rewritePathType !== "stripPrefix" && !data.rewritePath ) { ctx.addIssue({ code: "custom", message: t("redirectRewritePathRequired"), path: ["rewritePath"] }); } }), [t] ); type RedirectFormValues = z.infer; const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: redirect?.name ?? "", attachTo: redirect?.resourceId ? "resource" : "domain", domainId: redirect?.domainId ?? null, subdomain: redirect?.subdomain ?? null, resourceId: redirect?.resourceId ?? null, destinationDomain: redirect?.destinationDomain ?? "", pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, matchPath: redirect?.matchPath ?? DEFAULT_MATCH_PATH, rewritePath: redirect?.rewritePath ?? null, rewritePathType: redirect?.rewritePathType ?? null, permanent: redirect?.permanent ?? false, enabled: redirect?.enabled ?? true } }); const attachTo = form.watch("attachTo"); const sourceFullDomain = attachTo === "domain" ? domainFullDomain : (selectedResource?.fullDomain ?? null); // Mirror is UI-only state; on edit, infer it from whether the saved // destination already equals the source host. const [sameDomainAsSource, setSameDomainAsSource] = useState( Boolean( redirect && sourceFullDomain && redirect.destinationDomain === sourceFullDomain ) ); useEffect(() => { if (sameDomainAsSource && sourceFullDomain) { form.setValue("destinationDomain", sourceFullDomain, { shouldValidate: true }); } }, [sameDomainAsSource, sourceFullDomain, form]); const pathMatchType = form.watch("pathMatchType"); const rewritePath = form.watch("rewritePath"); const rewritePathType = form.watch("rewritePathType"); // stripPrefix is a valid rewrite with no path value, so it counts as set. const hasRewrite = Boolean(rewritePath) || rewritePathType === "stripPrefix"; async function onSubmit() { if (!(await form.trigger())) return; const values = form.getValues(); // Only one of the two attachment points is ever persisted; clear the // other so switching between them doesn't leave a stale reference. const body = { name: values.name.trim(), domainId: values.attachTo === "domain" ? values.domainId : null, subdomain: values.attachTo === "domain" ? values.subdomain || null : null, resourceId: values.attachTo === "resource" ? values.resourceId : null, destinationDomain: values.destinationDomain.trim(), pathMatchType: values.pathMatchType, matchPath: values.matchPath.trim(), rewritePath: values.rewritePath?.trim() || null, rewritePathType: values.rewritePathType, permanent: values.permanent, enabled: values.enabled }; try { if (isEditing) { await api.post( `/org/${orgId}/redirects/${redirect!.redirectId}`, body ); toast({ title: t("success"), description: t("redirectUpdated") }); router.refresh(); } else { const res = await api.put< AxiosResponse >(`/org/${orgId}/redirect`, body); toast({ title: t("success"), description: t("redirectCreated") }); router.push(`/${orgId}/settings/redirects/`); } } catch (e) { toast({ variant: "destructive", title: isEditing ? t("redirectErrorUpdate") : t("redirectErrorCreate"), description: formatAxiosError( e, isEditing ? t("redirectErrorUpdate") : t("redirectErrorCreate") ) }); } } async function onDelete() { setDeleteLoading(true); try { await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`); toast({ title: t("success"), description: t("redirectDeleted") }); router.push(`/${orgId}/settings/redirects`); } catch (e) { toast({ variant: "destructive", title: t("redirectErrorDelete"), description: formatAxiosError(e, t("redirectErrorDelete")) }); } finally { setDeleteLoading(false); setIsDeleteModalOpen(false); } } return ( <> {isEditing && (

{t("redirectQuestionRemove")}

{t("redirectMessageRemove")}

} buttonText={t("redirectDeleteConfirm")} onConfirm={onDelete} string={redirect!.name} title={t("redirectDelete")} /> )} {t("redirectSource")} {t("redirectSourceSectionDescription")}
( )} /> ( {t("name")} )} /> ( {t( "redirectAttachedTo" )} {t( "redirectAttachedToDescription" )} )} /> {attachTo === "domain" ? ( ( { form.setValue( "domainId", res?.domainId ?? null, { shouldValidate: true } ); form.setValue( "subdomain", res?.subdomain || null ); setDomainFullDomain( res?.fullDomain ?? null ); }} /> )} /> ) : ( ( {t( "selectedRedirectResource" )} { setSelectedResource( resource ); field.onChange( resource.resourceId ); }} /> )} /> )} {attachTo === "resource" && ( {t("resourceDomain")} )}
{t("redirectSettings")} {t("redirectSettingsDescription")}
( {t( "redirectDestinationDomain" )} {t( "redirectDestinationDomainDescription" )} )} /> ( {t("matchPath")} { // matchPath and // pathMatchType are // NOT NULL, so a // clear falls back // to the defaults // rather than null. field.onChange( config.path || DEFAULT_MATCH_PATH ); form.setValue( "pathMatchType", (config.pathMatchType as | "exact" | "prefix" | "regex") || DEFAULT_PATH_MATCH_TYPE ); }} trigger={ } /> {t( "redirectMatchPathDescription" )} )} /> ( {t("rewritePath")} { field.onChange( config.rewritePath || null ); form.setValue( "rewritePathType", (config.rewritePathType as | "exact" | "prefix" | "regex" | "stripPrefix" | null) ?? null ); }} trigger={ hasRewrite ? ( ) : ( ) } /> {t( "redirectRewritePathDescription" )} )} /> ( )} />
{isEditing && ( {t("dangerSection")} )}
); }