mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-12 14:01:18 +02:00
💄 Redirect table
This commit is contained in:
+4
-3
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user