💄 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
+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
+92 -41
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 (
<Link
href={`/${orgId}/settings/resources/${redirect.resourceNiceId}`}
className="hover:underline"
<Button
variant="outline"
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 (
<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"