🚧 redirect create form

This commit is contained in:
Fred KISSIE
2026-09-10 22:05:45 +02:00
parent 9e85b39fd5
commit 7ce2668b5b
11 changed files with 192 additions and 96 deletions
+1 -1
View File
@@ -4397,13 +4397,13 @@
"redirectSettings": "Redirect Settings", "redirectSettings": "Redirect Settings",
"selectedRedirectDomain": "Selected Domain", "selectedRedirectDomain": "Selected Domain",
"selectedRedirectResource": "Selected Resource", "selectedRedirectResource": "Selected Resource",
"redirectResourceNoDomain": "This resource has no domain",
"redirectSettingsGeneralDescription": "Configure the basic redirect settings", "redirectSettingsGeneralDescription": "Configure the basic redirect settings",
"redirectSettingsDescription": "Configure where requests come from and where they are sent", "redirectSettingsDescription": "Configure where requests come from and where they are sent",
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it", "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource", "redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
"redirectAttachDomain": "Domain", "redirectAttachDomain": "Domain",
"redirectAttachResource": "Resource", "redirectAttachResource": "Resource",
"redirectDomainSelect": "Select a domain",
"redirectDomainRequired": "Select a domain to attach this redirect to", "redirectDomainRequired": "Select a domain to attach this redirect to",
"redirectResourceRequired": "Select a resource to attach this redirect to", "redirectResourceRequired": "Select a resource to attach this redirect to",
"redirectPermanent": "Permanent Redirect", "redirectPermanent": "Permanent Redirect",
+1 -1
View File
@@ -248,7 +248,7 @@ export const redirects = pgTable("redirects", {
.$type<"exact" | "prefix" | "regex">() .$type<"exact" | "prefix" | "regex">()
.notNull() .notNull()
.default("regex"), // exact, prefix, regex .default("regex"), // exact, prefix, regex
matchPath: varchar("matchPath").notNull().default("*"), matchPath: varchar("matchPath").notNull().default(".*"),
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value, rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
// else, the original path will be kept // else, the original path will be kept
rewritePathType: varchar("rewritePathType").$type< rewritePathType: varchar("rewritePathType").$type<
+1
View File
@@ -258,6 +258,7 @@ export const redirects = sqliteTable("redirects", {
}), }),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
subdomain: text("subdomain"),
destinationDomain: text("destinationDomain").notNull(), destinationDomain: text("destinationDomain").notNull(),
pathMatchType: text("pathMatchType") pathMatchType: text("pathMatchType")
.$type<"exact" | "prefix" | "regex">() .$type<"exact" | "prefix" | "regex">()
@@ -29,6 +29,7 @@ const bodySchema = z.strictObject({
name: z.string().nonempty(), name: z.string().nonempty(),
resourceId: z.number().int().positive().optional().nullable(), resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(), domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationDomain: z.string().nonempty(), destinationDomain: z.string().nonempty(),
pathMatchType: redirectPathMatchTypeSchema.optional(), pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema, matchPath: redirectMatchPathSchema,
@@ -103,6 +104,7 @@ export async function createRedirect(
name, name,
resourceId, resourceId,
domainId, domainId,
subdomain,
destinationDomain, destinationDomain,
pathMatchType, pathMatchType,
matchPath, matchPath,
@@ -170,6 +172,7 @@ export async function createRedirect(
niceId, niceId,
resourceId: resourceId ?? null, resourceId: resourceId ?? null,
domainId: domainId ?? null, domainId: domainId ?? null,
subdomain: subdomain ?? null,
destinationDomain, destinationDomain,
pathMatchType: pathMatchType ?? "regex", pathMatchType: pathMatchType ?? "regex",
matchPath, matchPath,
+2
View File
@@ -16,6 +16,7 @@ export type GetRedirectResponse = {
orgId: string; orgId: string;
niceId: string; niceId: string;
name: string; name: string;
subdomain: string | null;
destinationDomain: string; destinationDomain: string;
pathMatchType: "exact" | "prefix" | "regex"; pathMatchType: "exact" | "prefix" | "regex";
matchPath: string; matchPath: string;
@@ -39,6 +40,7 @@ const redirectColumns = {
orgId: redirects.orgId, orgId: redirects.orgId,
niceId: redirects.niceId, niceId: redirects.niceId,
name: redirects.name, name: redirects.name,
subdomain: redirects.subdomain,
destinationDomain: redirects.destinationDomain, destinationDomain: redirects.destinationDomain,
pathMatchType: redirects.pathMatchType, pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath, matchPath: redirects.matchPath,
+2
View File
@@ -16,6 +16,7 @@ export type ListRedirectsResponse = PaginatedResponse<{
orgId: string; orgId: string;
niceId: string; niceId: string;
name: string; name: string;
subdomain: string | null;
destinationDomain: string; destinationDomain: string;
pathMatchType: "exact" | "prefix" | "regex"; pathMatchType: "exact" | "prefix" | "regex";
matchPath: string; matchPath: string;
@@ -137,6 +138,7 @@ export async function listRedirects(
orgId: redirects.orgId, orgId: redirects.orgId,
niceId: redirects.niceId, niceId: redirects.niceId,
name: redirects.name, name: redirects.name,
subdomain: redirects.subdomain,
destinationDomain: redirects.destinationDomain, destinationDomain: redirects.destinationDomain,
pathMatchType: redirects.pathMatchType, pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath, matchPath: redirects.matchPath,
@@ -31,6 +31,7 @@ const bodySchema = z.strictObject({
niceId: redirectNiceIdSchema.optional(), niceId: redirectNiceIdSchema.optional(),
resourceId: z.number().int().positive().optional().nullable(), resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(), domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationDomain: z.string().nonempty().optional(), destinationDomain: z.string().nonempty().optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(), pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional(), matchPath: redirectMatchPathSchema.optional(),
@@ -196,6 +197,9 @@ export async function updateRedirect(
if (body.domainId !== undefined) { if (body.domainId !== undefined) {
updateData.domainId = body.domainId; updateData.domainId = body.domainId;
} }
if (body.subdomain !== undefined) {
updateData.subdomain = body.subdomain;
}
if (body.destinationDomain !== undefined) { if (body.destinationDomain !== undefined) {
updateData.destinationDomain = body.destinationDomain; updateData.destinationDomain = body.destinationDomain;
} }
@@ -55,6 +55,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
redirectId: redirect.redirectId, redirectId: redirect.redirectId,
niceId: redirect.niceId, niceId: redirect.niceId,
name: redirect.name, name: redirect.name,
subdomain: redirect.subdomain,
destinationDomain: redirect.destinationDomain, destinationDomain: redirect.destinationDomain,
pathMatchType: redirect.pathMatchType, pathMatchType: redirect.pathMatchType,
matchPath: redirect.matchPath, matchPath: redirect.matchPath,
+94 -76
View File
@@ -41,7 +41,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import type { import type {
@@ -49,7 +48,6 @@ import type {
GetRedirectResponse GetRedirectResponse
} from "@server/routers/redirect"; } from "@server/routers/redirect";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -63,9 +61,10 @@ import {
PathRewriteModal PathRewriteModal
} from "@app/components/PathMatchRenameModal"; } from "@app/components/PathMatchRenameModal";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import DomainPicker from "@app/components/DomainPicker";
import Link from "next/link"; import Link from "next/link";
const DEFAULT_MATCH_PATH = "*"; const DEFAULT_MATCH_PATH = ".*";
const DEFAULT_PATH_MATCH_TYPE = "regex" as const; const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
export type ExistingRedirect = GetRedirectResponse["redirect"]; export type ExistingRedirect = GetRedirectResponse["redirect"];
@@ -95,8 +94,6 @@ export default function RedirectForm({
const [selectedResource, setSelectedResource] = const [selectedResource, setSelectedResource] =
useState<SelectedResource | null>(initialResource); useState<SelectedResource | null>(initialResource);
const { data: domains = [] } = useQuery(orgQueries.domains({ orgId }));
const formSchema = useMemo( const formSchema = useMemo(
() => () =>
z z
@@ -107,6 +104,7 @@ export default function RedirectForm({
.min(1, { message: t("nameRequired") }), .min(1, { message: t("nameRequired") }),
attachTo: z.enum(["domain", "resource"]), attachTo: z.enum(["domain", "resource"]),
domainId: z.string().nullable(), domainId: z.string().nullable(),
subdomain: z.string().nullable(),
resourceId: z.number().int().positive().nullable(), resourceId: z.number().int().positive().nullable(),
destinationDomain: z destinationDomain: z
.string() .string()
@@ -163,6 +161,7 @@ export default function RedirectForm({
name: redirect?.name ?? "", name: redirect?.name ?? "",
attachTo: redirect?.resourceId ? "resource" : "domain", attachTo: redirect?.resourceId ? "resource" : "domain",
domainId: redirect?.domainId ?? null, domainId: redirect?.domainId ?? null,
subdomain: redirect?.subdomain ?? null,
resourceId: redirect?.resourceId ?? null, resourceId: redirect?.resourceId ?? null,
destinationDomain: redirect?.destinationDomain ?? "", destinationDomain: redirect?.destinationDomain ?? "",
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
@@ -190,6 +189,8 @@ export default function RedirectForm({
const body = { const body = {
name: values.name.trim(), name: values.name.trim(),
domainId: values.attachTo === "domain" ? values.domainId : null, domainId: values.attachTo === "domain" ? values.domainId : null,
subdomain:
values.attachTo === "domain" ? values.subdomain || null : null,
resourceId: resourceId:
values.attachTo === "resource" ? values.resourceId : null, values.attachTo === "resource" ? values.resourceId : null,
destinationDomain: values.destinationDomain.trim(), destinationDomain: values.destinationDomain.trim(),
@@ -283,7 +284,7 @@ export default function RedirectForm({
)} )}
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection className="pb-10">
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("general")} {t("general")}
@@ -301,6 +302,35 @@ export default function RedirectForm({
id="redirect-form" id="redirect-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-enabled"
label={t(
"enabled"
)}
description={t(
"redirectEnabledDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField <FormField
control={form.control} control={form.control}
@@ -368,56 +398,41 @@ export default function RedirectForm({
/> />
</SettingsFormCell> </SettingsFormCell>
{attachTo === "domain" ? ( {attachTo === "domain" ? (
<SettingsFormCell span="half"> <SettingsFormCell span="full">
<FormField <FormField
control={form.control} control={form.control}
name="domainId" name="domainId"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormLabel> <DomainPicker
{t( orgId={orgId}
"selectedRedirectDomain" cols={1}
)} hideFreeDomain
</FormLabel> defaultDomainId={
<Select redirect?.domainId
value={
field.value ??
undefined
} }
onValueChange={ allowWildcard
field.onChange defaultSubdomain={
redirect?.subdomain
} }
> onDomainChange={(
<FormControl> res
<SelectTrigger> ) => {
<SelectValue form.setValue(
placeholder={t( "domainId",
"redirectDomainSelect" res?.domainId ??
)} null,
/> {
</SelectTrigger> shouldValidate: true
</FormControl> }
<SelectContent> );
{domains.map( form.setValue(
( "subdomain",
domain res?.subdomain ||
) => ( null
<SelectItem );
key={ }}
domain.domainId />
}
value={
domain.domainId
}
>
{
domain.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -484,6 +499,33 @@ export default function RedirectForm({
/> />
</SettingsFormCell> </SettingsFormCell>
)} )}
{attachTo === "resource" && (
<SettingsFormCell span="full">
<FormItem>
<FormLabel>
{t("resourceDomain")}
</FormLabel>
<Input
disabled
readOnly
value={
selectedResource?.fullDomain ??
""
}
placeholder={
selectedResource
? t(
"redirectResourceNoDomain"
)
: t(
"resourceSelect"
)
}
/>
</FormItem>
</SettingsFormCell>
)}
</SettingsFormGrid> </SettingsFormGrid>
</form> </form>
</Form> </Form>
@@ -491,7 +533,7 @@ export default function RedirectForm({
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
<SettingsSection> <SettingsSection className="pb-10">
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("redirectSettings")} {t("redirectSettings")}
@@ -504,33 +546,9 @@ export default function RedirectForm({
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(onSubmit)}>
onSubmit={form.handleSubmit(onSubmit)}
id="redirect-form"
>
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}
name="destinationDomain" name="destinationDomain"
+16 -3
View File
@@ -30,6 +30,7 @@ export type RedirectRow = {
redirectId: number; redirectId: number;
niceId: string; niceId: string;
name: string; name: string;
subdomain: string | null;
destinationDomain: string; destinationDomain: string;
pathMatchType: "exact" | "prefix" | "regex"; pathMatchType: "exact" | "prefix" | "regex";
matchPath: string; matchPath: string;
@@ -200,8 +201,14 @@ export default function RedirectsTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const redirect = row.original; const redirect = row.original;
const host = // A domain-attached redirect may target a specific host
redirect.resourceFullDomain ?? redirect.baseDomain; // under the base domain, e.g. old.example.com.
const domainHost = redirect.baseDomain
? [redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")
: null;
const host = redirect.resourceFullDomain ?? domainHost;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -237,7 +244,13 @@ export default function RedirectsTable({
} }
if (redirect.baseDomain) { if (redirect.baseDomain) {
return <span>{redirect.baseDomain}</span>; return (
<span>
{[redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")}
</span>
);
} }
return <span>-</span>; return <span>-</span>;
+67 -15
View File
@@ -1,23 +1,46 @@
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
export function SettingsContainer({ children }: { children: React.ReactNode }) { export function SettingsContainer({
return <div className="space-y-6">{children}</div>; children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-6", className)}>{children}</div>;
} }
export function SettingsSection({ children }: { children: React.ReactNode }) { export function SettingsSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return ( return (
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]"> <div
className={cn(
"border rounded-lg bg-card p-5 flex flex-col min-h-[200px]",
className
)}
>
{children} {children}
</div> </div>
); );
} }
export function SettingsSectionHeader({ export function SettingsSectionHeader({
children children,
className
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) { }) {
return <div className="text-lg space-y-0.5 pb-6">{children}</div>; return (
<div className={cn("text-lg space-y-0.5 pb-6", className)}>
{children}
</div>
);
} }
export function SettingsSectionForm({ export function SettingsSectionForm({
@@ -87,23 +110,36 @@ export function SettingsFormCell({
} }
export function SettingsSectionTitle({ export function SettingsSectionTitle({
children children,
className
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) { }) {
return ( return (
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2"> <h2
className={cn(
"text-1xl font-semibold tracking-tight flex items-center gap-2",
className
)}
>
{children} {children}
</h2> </h2>
); );
} }
export function SettingsSectionDescription({ export function SettingsSectionDescription({
children children,
className
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) { }) {
return <p className="text-muted-foreground text-sm">{children}</p>; return (
<p className={cn("text-muted-foreground text-sm", className)}>
{children}
</p>
);
} }
export function SettingsSubsectionHeader({ export function SettingsSubsectionHeader({
@@ -141,11 +177,15 @@ export function SettingsSubsectionDescription({
} }
export function SettingsSectionBody({ export function SettingsSectionBody({
children children,
className
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) { }) {
return <div className="space-y-5 flex-grow">{children}</div>; return (
<div className={cn("space-y-5 flex-grow", className)}>{children}</div>
);
} }
export function SettingsSectionFooter({ export function SettingsSectionFooter({
@@ -169,10 +209,22 @@ export function SettingsSectionFooter({
export function SettingsSectionGrid({ export function SettingsSectionGrid({
children, children,
cols cols = 4,
className
}: { }: {
children: React.ReactNode; children: React.ReactNode;
cols: number; cols?: number;
className?: string;
}) { }) {
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>; return (
<div
style={{
// @ts-expect-error
"--cols": `repeat(${cols}, minmax(0, 1fr))`
}}
className={cn(`grid md:grid-cols-(--cols) gap-6`, className)}
>
{children}
</div>
);
} }