🚧 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",
"selectedRedirectDomain": "Selected Domain",
"selectedRedirectResource": "Selected Resource",
"redirectResourceNoDomain": "This resource has no domain",
"redirectSettingsGeneralDescription": "Configure the basic redirect settings",
"redirectSettingsDescription": "Configure where requests come from and where they are sent",
"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",
"redirectAttachDomain": "Domain",
"redirectAttachResource": "Resource",
"redirectDomainSelect": "Select a domain",
"redirectDomainRequired": "Select a domain to attach this redirect to",
"redirectResourceRequired": "Select a resource to attach this redirect to",
"redirectPermanent": "Permanent Redirect",
+1 -1
View File
@@ -248,7 +248,7 @@ export const redirects = pgTable("redirects", {
.$type<"exact" | "prefix" | "regex">()
.notNull()
.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,
// else, the original path will be kept
rewritePathType: varchar("rewritePathType").$type<
+1
View File
@@ -258,6 +258,7 @@ export const redirects = sqliteTable("redirects", {
}),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
subdomain: text("subdomain"),
destinationDomain: text("destinationDomain").notNull(),
pathMatchType: text("pathMatchType")
.$type<"exact" | "prefix" | "regex">()
@@ -29,6 +29,7 @@ const bodySchema = z.strictObject({
name: z.string().nonempty(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationDomain: z.string().nonempty(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema,
@@ -103,6 +104,7 @@ export async function createRedirect(
name,
resourceId,
domainId,
subdomain,
destinationDomain,
pathMatchType,
matchPath,
@@ -170,6 +172,7 @@ export async function createRedirect(
niceId,
resourceId: resourceId ?? null,
domainId: domainId ?? null,
subdomain: subdomain ?? null,
destinationDomain,
pathMatchType: pathMatchType ?? "regex",
matchPath,
+2
View File
@@ -16,6 +16,7 @@ export type GetRedirectResponse = {
orgId: string;
niceId: string;
name: string;
subdomain: string | null;
destinationDomain: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string;
@@ -39,6 +40,7 @@ const redirectColumns = {
orgId: redirects.orgId,
niceId: redirects.niceId,
name: redirects.name,
subdomain: redirects.subdomain,
destinationDomain: redirects.destinationDomain,
pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath,
+2
View File
@@ -16,6 +16,7 @@ export type ListRedirectsResponse = PaginatedResponse<{
orgId: string;
niceId: string;
name: string;
subdomain: string | null;
destinationDomain: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string;
@@ -137,6 +138,7 @@ export async function listRedirects(
orgId: redirects.orgId,
niceId: redirects.niceId,
name: redirects.name,
subdomain: redirects.subdomain,
destinationDomain: redirects.destinationDomain,
pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath,
@@ -31,6 +31,7 @@ const bodySchema = z.strictObject({
niceId: redirectNiceIdSchema.optional(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationDomain: z.string().nonempty().optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional(),
@@ -196,6 +197,9 @@ export async function updateRedirect(
if (body.domainId !== undefined) {
updateData.domainId = body.domainId;
}
if (body.subdomain !== undefined) {
updateData.subdomain = body.subdomain;
}
if (body.destinationDomain !== undefined) {
updateData.destinationDomain = body.destinationDomain;
}
@@ -55,6 +55,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
redirectId: redirect.redirectId,
niceId: redirect.niceId,
name: redirect.name,
subdomain: redirect.subdomain,
destinationDomain: redirect.destinationDomain,
pathMatchType: redirect.pathMatchType,
matchPath: redirect.matchPath,
+94 -76
View File
@@ -41,7 +41,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod";
import type {
@@ -49,7 +48,6 @@ import type {
GetRedirectResponse
} from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -63,9 +61,10 @@ import {
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_MATCH_PATH = ".*";
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
export type ExistingRedirect = GetRedirectResponse["redirect"];
@@ -95,8 +94,6 @@ export default function RedirectForm({
const [selectedResource, setSelectedResource] =
useState<SelectedResource | null>(initialResource);
const { data: domains = [] } = useQuery(orgQueries.domains({ orgId }));
const formSchema = useMemo(
() =>
z
@@ -107,6 +104,7 @@ export default function RedirectForm({
.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()
@@ -163,6 +161,7 @@ export default function RedirectForm({
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,
@@ -190,6 +189,8 @@ export default function RedirectForm({
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(),
@@ -283,7 +284,7 @@ export default function RedirectForm({
)}
<SettingsContainer>
<SettingsSection>
<SettingsSection className="pb-10">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("general")}
@@ -301,6 +302,35 @@ export default function RedirectForm({
id="redirect-form"
>
<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">
<FormField
control={form.control}
@@ -368,56 +398,41 @@ export default function RedirectForm({
/>
</SettingsFormCell>
{attachTo === "domain" ? (
<SettingsFormCell span="half">
<SettingsFormCell span="full">
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
render={() => (
<FormItem>
<FormLabel>
{t(
"selectedRedirectDomain"
)}
</FormLabel>
<Select
value={
field.value ??
undefined
<DomainPicker
orgId={orgId}
cols={1}
hideFreeDomain
defaultDomainId={
redirect?.domainId
}
onValueChange={
field.onChange
allowWildcard
defaultSubdomain={
redirect?.subdomain
}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"redirectDomainSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{domains.map(
(
domain
) => (
<SelectItem
key={
domain.domainId
}
value={
domain.domainId
}
>
{
domain.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
onDomainChange={(
res
) => {
form.setValue(
"domainId",
res?.domainId ??
null,
{
shouldValidate: true
}
);
form.setValue(
"subdomain",
res?.subdomain ||
null
);
}}
/>
<FormMessage />
</FormItem>
)}
@@ -484,6 +499,33 @@ export default function RedirectForm({
/>
</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>
</form>
</Form>
@@ -491,7 +533,7 @@ export default function RedirectForm({
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSection className="pb-10">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("redirectSettings")}
@@ -504,33 +546,9 @@ export default function RedirectForm({
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="redirect-form"
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<SettingsFormGrid>
<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
control={form.control}
name="destinationDomain"
+16 -3
View File
@@ -30,6 +30,7 @@ export type RedirectRow = {
redirectId: number;
niceId: string;
name: string;
subdomain: string | null;
destinationDomain: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string;
@@ -200,8 +201,14 @@ export default function RedirectsTable({
),
cell: ({ row }) => {
const redirect = row.original;
const host =
redirect.resourceFullDomain ?? redirect.baseDomain;
// A domain-attached redirect may target a specific host
// 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 (
<div className="flex items-center gap-2">
@@ -237,7 +244,13 @@ export default function RedirectsTable({
}
if (redirect.baseDomain) {
return <span>{redirect.baseDomain}</span>;
return (
<span>
{[redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")}
</span>
);
}
return <span>-</span>;
+67 -15
View File
@@ -1,23 +1,46 @@
import { cn } from "@app/lib/cn";
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
export function SettingsContainer({
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 (
<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}
</div>
);
}
export function SettingsSectionHeader({
children
children,
className
}: {
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({
@@ -87,23 +110,36 @@ export function SettingsFormCell({
}
export function SettingsSectionTitle({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
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}
</h2>
);
}
export function SettingsSectionDescription({
children
children,
className
}: {
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({
@@ -141,11 +177,15 @@ export function SettingsSubsectionDescription({
}
export function SettingsSectionBody({
children
children,
className
}: {
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({
@@ -169,10 +209,22 @@ export function SettingsSectionFooter({
export function SettingsSectionGrid({
children,
cols
cols = 4,
className
}: {
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>
);
}