💄 Redirect table

This commit is contained in:
Fred KISSIE
2026-09-11 21:25:20 +02:00
parent 7ce2668b5b
commit 4324eebc1f
7 changed files with 131 additions and 65 deletions
+4 -3
View File
@@ -4369,11 +4369,11 @@
"redirectsDescription": "Forward requests from a path on your domains or resources to another URL",
"redirectsSearch": "Search redirects...",
"redirectAdd": "Add Redirect",
"redirectSource": "Source",
"redirectSourceDomain": "Source Domain",
"redirectAttachedTo": "Attached To",
"redirectType": "Type",
"redirectTypePermanent": "Permanent (301)",
"redirectTypeTemporary": "Temporary (302)",
"redirectTypePermanent": "Permanent (308)",
"redirectTypeTemporary": "Temporary (307)",
"redirectUpdated": "Redirect updated successfully",
"redirectErrorUpdate": "Failed to update redirect",
"redirectDeleted": "Redirect deleted successfully",
@@ -4385,6 +4385,7 @@
"redirectDestinationDomain": "Destination Domain",
"redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com",
"redirectDestinationDomainRequired": "Enter a destination domain",
"redirectDestinationDomainInvalid": "Enter a valid domain, such as example.com",
"redirectMatchPathDescription": "Which incoming paths this redirect applies to",
"redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.",
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
+2 -1
View File
@@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import {
redirectDestinationDomainSchema,
redirectMatchPathSchema,
redirectPathMatchTypeSchema,
redirectRewritePathSchema,
@@ -30,7 +31,7 @@ const bodySchema = z.strictObject({
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationDomain: z.string().nonempty(),
destinationDomain: redirectDestinationDomainSchema,
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema,
rewritePath: redirectRewritePathSchema.optional().nullable(),
+2 -1
View File
@@ -11,6 +11,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import {
redirectNiceIdSchema,
redirectDestinationDomainSchema,
redirectMatchPathSchema,
redirectPathMatchTypeSchema,
redirectRewritePathSchema,
@@ -32,7 +33,7 @@ const bodySchema = z.strictObject({
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationDomain: z.string().nonempty().optional(),
destinationDomain: redirectDestinationDomainSchema.optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional(),
rewritePath: redirectRewritePathSchema.optional().nullable(),
+8
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { isValidDomain } from "@server/lib/validators";
export const redirectNiceIdSchema = z
.string()
@@ -21,3 +22,10 @@ export const redirectRewritePathTypeSchema = z.enum([
export const redirectMatchPathSchema = z.string().nonempty().default("*");
export const redirectRewritePathSchema = z.string().nonempty();
export const redirectDestinationDomainSchema = z
.string()
.nonempty()
.refine(isValidDomain, {
message: "Invalid domain"
});
+10 -5
View File
@@ -141,6 +141,11 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(build === "saas"
? [
{
@@ -324,11 +329,6 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
@@ -467,6 +467,11 @@ export const commandBarNavSections = (
title: "commandMachineClients",
icon: <Server className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(build === "saas"
? [
{
+13 -14
View File
@@ -40,6 +40,7 @@ import {
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";
@@ -50,7 +51,7 @@ import type {
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ResourceSelector, type SelectedResource } from "./resource-selector";
@@ -88,7 +89,7 @@ export default function RedirectForm({
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
@@ -111,6 +112,9 @@ export default function RedirectForm({
.trim()
.min(1, {
message: t("redirectDestinationDomainRequired")
})
.refine(isValidDomain, {
message: t("redirectDestinationDomainInvalid")
}),
pathMatchType: z.enum(["exact", "prefix", "regex"]),
matchPath: z.string().trim().min(1),
@@ -181,8 +185,10 @@ export default function RedirectForm({
const hasRewrite =
Boolean(rewritePath) || rewritePathType === "stripPrefix";
async function onSubmit(values: RedirectFormValues) {
setSaveLoading(true);
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.
@@ -221,9 +227,7 @@ export default function RedirectForm({
title: t("success"),
description: t("redirectCreated")
});
router.push(
`/${orgId}/settings/redirects/${res.data.data.redirect.niceId}`
);
router.push(`/${orgId}/settings/redirects/`);
}
} catch (e) {
toast({
@@ -238,8 +242,6 @@ export default function RedirectForm({
: t("redirectErrorCreate")
)
});
} finally {
setSaveLoading(false);
}
}
@@ -297,10 +299,7 @@ export default function RedirectForm({
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="redirect-form"
>
<form action={formAction} id="redirect-form">
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
@@ -546,7 +545,7 @@ export default function RedirectForm({
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<form action={formAction}>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
+88 -37
View File
@@ -19,7 +19,13 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { PaginationState } from "@tanstack/react-table";
import { ArrowRight, MoreHorizontal } from "lucide-react";
import {
ArrowRight,
ArrowUpRight,
GlobeIcon,
MoreHorizontal,
WaypointsIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -194,34 +200,13 @@ export default function RedirectsTable({
)
},
{
id: "source",
friendlyName: t("redirectSource"),
header: () => (
<span className="p-3">{t("redirectSource")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
// 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">
<Badge variant="secondary" className="shrink-0">
{matchTypeLabel(redirect.pathMatchType)}
</Badge>
<code className="text-sm truncate">
{host ?? ""}
{redirect.matchPath}
</code>
</div>
);
}
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => (
<code className="text-sm">{row.original.niceId}</code>
)
},
{
id: "attachedTo",
@@ -234,28 +219,94 @@ export default function RedirectsTable({
if (redirect.resourceId && redirect.resourceNiceId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/resources/${redirect.resourceNiceId}`}
className="hover:underline"
>
<WaypointsIcon className="size-3 text-muted-foreground" />
{redirect.resourceName}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
if (redirect.baseDomain) {
console.log({
redirect
});
if (redirect.domainId) {
return (
<span>
{[redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")}
</span>
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/domains/${redirect.domainId}`}
>
<GlobeIcon className="size-3 text-muted-foreground" />
{redirect.baseDomain}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
return <span>-</span>;
}
},
{
id: "sourceDomain",
friendlyName: t("redirectSourceDomain"),
header: () => (
<span className="p-3">{t("redirectSourceDomain")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
// 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 host ? (
<code className="text-sm">{host}</code>
) : (
<span>-</span>
);
}
},
{
id: "matchPath",
accessorKey: "matchPath",
friendlyName: t("matchPath"),
header: () => <span className="p-3">{t("matchPath")}</span>,
cell: ({ row }) => {
const redirect = row.original;
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="shrink-0">
{matchTypeLabel(redirect.pathMatchType)}
</Badge>
<code className="text-sm truncate">
{redirect.matchPath}
</code>
</div>
);
}
},
{
accessorKey: "destinationDomain",
friendlyName: t("redirectDestinationDomain"),
@@ -417,7 +468,7 @@ export default function RedirectsTable({
rowCount={rowCount}
columnVisibility={{
attachedTo: false,
rewritePath: false
niceId: false
}}
enableColumnVisibility
stickyLeftColumn="name"