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