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",
|
"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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${orgId}/settings/resources/${redirect.resourceNiceId}`}
|
href={`/${orgId}/settings/resources/${redirect.resourceNiceId}`}
|
||||||
className="hover:underline"
|
|
||||||
>
|
>
|
||||||
|
<WaypointsIcon className="size-3 text-muted-foreground" />
|
||||||
{redirect.resourceName}
|
{redirect.resourceName}
|
||||||
|
<ArrowUpRight className="size-3" />
|
||||||
</Link>
|
</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"
|
||||||
|
|||||||
Reference in New Issue
Block a user