mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 21:41:41 +02:00
🚧 wip
This commit is contained in:
+28
-2
@@ -4371,7 +4371,6 @@
|
|||||||
"redirectAdd": "Add Redirect",
|
"redirectAdd": "Add Redirect",
|
||||||
"redirectSource": "Source",
|
"redirectSource": "Source",
|
||||||
"redirectAttachedTo": "Attached To",
|
"redirectAttachedTo": "Attached To",
|
||||||
"redirectDestination": "Destination",
|
|
||||||
"redirectType": "Type",
|
"redirectType": "Type",
|
||||||
"redirectTypePermanent": "Permanent (301)",
|
"redirectTypePermanent": "Permanent (301)",
|
||||||
"redirectTypeTemporary": "Temporary (302)",
|
"redirectTypeTemporary": "Temporary (302)",
|
||||||
@@ -4382,5 +4381,32 @@
|
|||||||
"redirectDelete": "Delete Redirect",
|
"redirectDelete": "Delete Redirect",
|
||||||
"redirectDeleteConfirm": "Confirm Delete Redirect",
|
"redirectDeleteConfirm": "Confirm Delete Redirect",
|
||||||
"redirectQuestionRemove": "Are you sure you want to remove this redirect?",
|
"redirectQuestionRemove": "Are you sure you want to remove this redirect?",
|
||||||
"redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded."
|
"redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.",
|
||||||
|
"redirectDestinationDomain": "Destination Domain",
|
||||||
|
"redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com",
|
||||||
|
"redirectDestinationDomainRequired": "Enter a destination domain",
|
||||||
|
"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",
|
||||||
|
"redirectCreate": "Create Redirect",
|
||||||
|
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
||||||
|
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
||||||
|
"redirectGoBack": "Back to Redirects",
|
||||||
|
"redirectCreated": "Redirect created successfully",
|
||||||
|
"redirectErrorCreate": "Failed to create redirect",
|
||||||
|
"redirectSettings": "Redirect Settings",
|
||||||
|
"selectedRedirectDomain": "Selected Domain",
|
||||||
|
"selectedRedirectResource": "Selected Resource",
|
||||||
|
"redirectSettingsGeneralDescription": "Configure the basic redirect settings",
|
||||||
|
"redirectSettingsDescription": "Configure where requests come from and where they are sent",
|
||||||
|
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
|
||||||
|
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
|
||||||
|
"redirectAttachDomain": "Domain",
|
||||||
|
"redirectAttachResource": "Resource",
|
||||||
|
"redirectDomainSelect": "Select a domain",
|
||||||
|
"redirectDomainRequired": "Select a domain to attach this redirect to",
|
||||||
|
"redirectResourceRequired": "Select a resource to attach this redirect to",
|
||||||
|
"redirectPermanent": "Permanent Redirect",
|
||||||
|
"redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.",
|
||||||
|
"redirectDangerSectionDescription": "Permanently remove this redirect. This cannot be undone."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,8 +242,19 @@ export const redirects = pgTable("redirects", {
|
|||||||
}),
|
}),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
sourcePath: varchar("sourcePath").notNull(),
|
subdomain: varchar("subdomain"),
|
||||||
destinationUrl: varchar("destinationUrl"),
|
destinationDomain: varchar("destinationDomain").notNull(),
|
||||||
|
pathMatchType: varchar("pathMatchType")
|
||||||
|
.$type<"exact" | "prefix" | "regex">()
|
||||||
|
.notNull()
|
||||||
|
.default("regex"), // exact, prefix, regex
|
||||||
|
matchPath: varchar("matchPath").notNull().default("*"),
|
||||||
|
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
|
||||||
|
// else, the original path will be kept
|
||||||
|
rewritePathType: varchar("rewritePathType").$type<
|
||||||
|
"exact" | "prefix" | "regex" | "stripPrefix"
|
||||||
|
>(), // exact, prefix, regex, stripPrefix
|
||||||
|
|
||||||
permanent: boolean("permanent").notNull().default(false),
|
permanent: boolean("permanent").notNull().default(false),
|
||||||
enabled: boolean("enabled").notNull().default(true)
|
enabled: boolean("enabled").notNull().default(true)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -258,8 +258,18 @@ export const redirects = sqliteTable("redirects", {
|
|||||||
}),
|
}),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
sourcePath: text("sourcePath").notNull(),
|
destinationDomain: text("destinationDomain").notNull(),
|
||||||
destinationUrl: text("destinationUrl"),
|
pathMatchType: text("pathMatchType")
|
||||||
|
.$type<"exact" | "prefix" | "regex">()
|
||||||
|
.notNull()
|
||||||
|
.default("regex"), // exact, prefix, regex
|
||||||
|
matchPath: text("matchPath").notNull().default("*"),
|
||||||
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value,
|
||||||
|
// else, the original path will be kept
|
||||||
|
rewritePathType: text("rewritePathType").$type<
|
||||||
|
"exact" | "prefix" | "regex" | "stripPrefix"
|
||||||
|
>(), // exact, prefix, regex, stripPrefix
|
||||||
|
|
||||||
permanent: integer("permanent", { mode: "boolean" })
|
permanent: integer("permanent", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
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 { redirectSourcePathSchema } from "@server/routers/redirect/validation";
|
import {
|
||||||
|
redirectMatchPathSchema,
|
||||||
|
redirectPathMatchTypeSchema,
|
||||||
|
redirectRewritePathSchema,
|
||||||
|
redirectRewritePathTypeSchema
|
||||||
|
} from "@server/routers/redirect/validation";
|
||||||
import { getUniqueRedirectName } from "@server/db/names";
|
import { getUniqueRedirectName } from "@server/db/names";
|
||||||
|
|
||||||
export type CreateRedirectResponse = {
|
export type CreateRedirectResponse = {
|
||||||
@@ -24,11 +29,26 @@ const bodySchema = z.strictObject({
|
|||||||
name: z.string().nonempty(),
|
name: z.string().nonempty(),
|
||||||
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(),
|
||||||
sourcePath: redirectSourcePathSchema,
|
destinationDomain: z.string().nonempty(),
|
||||||
destinationUrl: z.url().optional().nullable(),
|
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||||
|
matchPath: redirectMatchPathSchema,
|
||||||
|
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||||
|
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||||
permanent: z.boolean().optional(),
|
permanent: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
});
|
}).refine(
|
||||||
|
(data) =>
|
||||||
|
// stripPrefix removes the matched prefix and needs no replacement
|
||||||
|
// value; every other rewrite type is meaningless without one.
|
||||||
|
!data.rewritePathType ||
|
||||||
|
data.rewritePathType === "stripPrefix" ||
|
||||||
|
Boolean(data.rewritePath),
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"rewritePath is required unless rewritePathType is stripPrefix",
|
||||||
|
path: ["rewritePath"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "put",
|
method: "put",
|
||||||
@@ -83,8 +103,11 @@ export async function createRedirect(
|
|||||||
name,
|
name,
|
||||||
resourceId,
|
resourceId,
|
||||||
domainId,
|
domainId,
|
||||||
sourcePath,
|
destinationDomain,
|
||||||
destinationUrl,
|
pathMatchType,
|
||||||
|
matchPath,
|
||||||
|
rewritePath,
|
||||||
|
rewritePathType,
|
||||||
permanent,
|
permanent,
|
||||||
enabled
|
enabled
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
@@ -147,8 +170,11 @@ export async function createRedirect(
|
|||||||
niceId,
|
niceId,
|
||||||
resourceId: resourceId ?? null,
|
resourceId: resourceId ?? null,
|
||||||
domainId: domainId ?? null,
|
domainId: domainId ?? null,
|
||||||
sourcePath,
|
destinationDomain,
|
||||||
destinationUrl: destinationUrl ?? null,
|
pathMatchType: pathMatchType ?? "regex",
|
||||||
|
matchPath,
|
||||||
|
rewritePath: rewritePath ?? null,
|
||||||
|
rewritePathType: rewritePathType ?? null,
|
||||||
permanent: permanent ?? false,
|
permanent: permanent ?? false,
|
||||||
enabled: enabled ?? true
|
enabled: enabled ?? true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { redirects, db } from "@server/db";
|
import { domains, redirects, resources, db } from "@server/db";
|
||||||
import type { Redirect } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -12,7 +11,49 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
export type GetRedirectResponse = {
|
export type GetRedirectResponse = {
|
||||||
redirect: Redirect;
|
redirect: {
|
||||||
|
redirectId: number;
|
||||||
|
orgId: string;
|
||||||
|
niceId: string;
|
||||||
|
name: string;
|
||||||
|
destinationDomain: string;
|
||||||
|
pathMatchType: "exact" | "prefix" | "regex";
|
||||||
|
matchPath: string;
|
||||||
|
rewritePath: string | null;
|
||||||
|
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||||
|
permanent: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
resourceId: number | null;
|
||||||
|
resourceName: string | null;
|
||||||
|
resourceNiceId: string | null;
|
||||||
|
resourceFullDomain: string | null;
|
||||||
|
resourceSsl: boolean | null;
|
||||||
|
resourceWildcard: boolean | null;
|
||||||
|
domainId: string | null;
|
||||||
|
baseDomain: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectColumns = {
|
||||||
|
redirectId: redirects.redirectId,
|
||||||
|
orgId: redirects.orgId,
|
||||||
|
niceId: redirects.niceId,
|
||||||
|
name: redirects.name,
|
||||||
|
destinationDomain: redirects.destinationDomain,
|
||||||
|
pathMatchType: redirects.pathMatchType,
|
||||||
|
matchPath: redirects.matchPath,
|
||||||
|
rewritePath: redirects.rewritePath,
|
||||||
|
rewritePathType: redirects.rewritePathType,
|
||||||
|
permanent: redirects.permanent,
|
||||||
|
enabled: redirects.enabled,
|
||||||
|
resourceId: redirects.resourceId,
|
||||||
|
resourceName: resources.name,
|
||||||
|
resourceNiceId: resources.niceId,
|
||||||
|
resourceFullDomain: resources.fullDomain,
|
||||||
|
resourceSsl: resources.ssl,
|
||||||
|
resourceWildcard: resources.wildcard,
|
||||||
|
domainId: redirects.domainId,
|
||||||
|
baseDomain: domains.baseDomain
|
||||||
};
|
};
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -29,8 +70,10 @@ const paramsSchema = z.strictObject({
|
|||||||
async function query(orgId: string, redirectId?: number, niceId?: string) {
|
async function query(orgId: string, redirectId?: number, niceId?: string) {
|
||||||
if (redirectId) {
|
if (redirectId) {
|
||||||
const [res] = await db
|
const [res] = await db
|
||||||
.select()
|
.select(redirectColumns)
|
||||||
.from(redirects)
|
.from(redirects)
|
||||||
|
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||||
|
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(redirects.redirectId, redirectId),
|
eq(redirects.redirectId, redirectId),
|
||||||
@@ -41,8 +84,10 @@ async function query(orgId: string, redirectId?: number, niceId?: string) {
|
|||||||
return res;
|
return res;
|
||||||
} else if (niceId) {
|
} else if (niceId) {
|
||||||
const [res] = await db
|
const [res] = await db
|
||||||
.select()
|
.select(redirectColumns)
|
||||||
.from(redirects)
|
.from(redirects)
|
||||||
|
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||||
|
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||||
.where(
|
.where(
|
||||||
and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId))
|
and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ export type ListRedirectsResponse = PaginatedResponse<{
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
name: string;
|
name: string;
|
||||||
sourcePath: string;
|
destinationDomain: string;
|
||||||
destinationUrl: string | null;
|
pathMatchType: "exact" | "prefix" | "regex";
|
||||||
|
matchPath: string;
|
||||||
|
rewritePath: string | null;
|
||||||
|
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
@@ -122,8 +125,8 @@ export async function listRedirects(
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
like(sql`LOWER(${redirects.name})`, term),
|
like(sql`LOWER(${redirects.name})`, term),
|
||||||
like(sql`LOWER(${redirects.sourcePath})`, term),
|
like(sql`LOWER(${redirects.matchPath})`, term),
|
||||||
like(sql`LOWER(${redirects.destinationUrl})`, term)
|
like(sql`LOWER(${redirects.destinationDomain})`, term)
|
||||||
)!
|
)!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,8 +137,11 @@ export async function listRedirects(
|
|||||||
orgId: redirects.orgId,
|
orgId: redirects.orgId,
|
||||||
niceId: redirects.niceId,
|
niceId: redirects.niceId,
|
||||||
name: redirects.name,
|
name: redirects.name,
|
||||||
sourcePath: redirects.sourcePath,
|
destinationDomain: redirects.destinationDomain,
|
||||||
destinationUrl: redirects.destinationUrl,
|
pathMatchType: redirects.pathMatchType,
|
||||||
|
matchPath: redirects.matchPath,
|
||||||
|
rewritePath: redirects.rewritePath,
|
||||||
|
rewritePathType: redirects.rewritePathType,
|
||||||
permanent: redirects.permanent,
|
permanent: redirects.permanent,
|
||||||
enabled: redirects.enabled,
|
enabled: redirects.enabled,
|
||||||
resourceId: redirects.resourceId,
|
resourceId: redirects.resourceId,
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { and, eq, ne } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
redirectNiceIdSchema,
|
redirectNiceIdSchema,
|
||||||
redirectSourcePathSchema
|
redirectMatchPathSchema,
|
||||||
|
redirectPathMatchTypeSchema,
|
||||||
|
redirectRewritePathSchema,
|
||||||
|
redirectRewritePathTypeSchema
|
||||||
} from "@server/routers/redirect/validation";
|
} from "@server/routers/redirect/validation";
|
||||||
|
|
||||||
export type UpdateRedirectResponse = {
|
export type UpdateRedirectResponse = {
|
||||||
@@ -28,8 +31,11 @@ const bodySchema = z.strictObject({
|
|||||||
niceId: redirectNiceIdSchema.optional(),
|
niceId: redirectNiceIdSchema.optional(),
|
||||||
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(),
|
||||||
sourcePath: redirectSourcePathSchema.optional(),
|
destinationDomain: z.string().nonempty().optional(),
|
||||||
destinationUrl: z.url().optional().nullable(),
|
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||||
|
matchPath: redirectMatchPathSchema.optional(),
|
||||||
|
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||||
|
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||||
permanent: z.boolean().optional(),
|
permanent: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
@@ -190,11 +196,20 @@ export async function updateRedirect(
|
|||||||
if (body.domainId !== undefined) {
|
if (body.domainId !== undefined) {
|
||||||
updateData.domainId = body.domainId;
|
updateData.domainId = body.domainId;
|
||||||
}
|
}
|
||||||
if (body.sourcePath !== undefined) {
|
if (body.destinationDomain !== undefined) {
|
||||||
updateData.sourcePath = body.sourcePath;
|
updateData.destinationDomain = body.destinationDomain;
|
||||||
}
|
}
|
||||||
if (body.destinationUrl !== undefined) {
|
if (body.pathMatchType !== undefined) {
|
||||||
updateData.destinationUrl = body.destinationUrl;
|
updateData.pathMatchType = body.pathMatchType;
|
||||||
|
}
|
||||||
|
if (body.matchPath !== undefined) {
|
||||||
|
updateData.matchPath = body.matchPath;
|
||||||
|
}
|
||||||
|
if (body.rewritePath !== undefined) {
|
||||||
|
updateData.rewritePath = body.rewritePath;
|
||||||
|
}
|
||||||
|
if (body.rewritePathType !== undefined) {
|
||||||
|
updateData.rewritePathType = body.rewritePathType;
|
||||||
}
|
}
|
||||||
if (body.permanent !== undefined) {
|
if (body.permanent !== undefined) {
|
||||||
updateData.permanent = body.permanent;
|
updateData.permanent = body.permanent;
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ export const redirectNiceIdSchema = z
|
|||||||
"niceId can only contain letters, numbers, and dashes"
|
"niceId can only contain letters, numbers, and dashes"
|
||||||
);
|
);
|
||||||
|
|
||||||
export const redirectSourcePathSchema = z
|
export const redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]);
|
||||||
.string()
|
|
||||||
.nonempty()
|
export const redirectRewritePathTypeSchema = z.enum([
|
||||||
.regex(/^\//, "sourcePath must start with a /")
|
"exact",
|
||||||
.default("/*");
|
"prefix",
|
||||||
|
"regex",
|
||||||
|
"stripPrefix"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const redirectMatchPathSchema = z.string().nonempty().default("*");
|
||||||
|
|
||||||
|
export const redirectRewritePathSchema = z.string().nonempty();
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import RedirectForm from "@app/components/RedirectForm";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import type { GetRedirectResponse } from "@server/routers/redirect";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Edit Redirect"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type EditRedirectPageProps = {
|
||||||
|
params: Promise<{ orgId: string; niceId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function EditRedirectPage(props: EditRedirectPageProps) {
|
||||||
|
const { orgId, niceId } = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let redirect: GetRedirectResponse["redirect"];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<GetRedirectResponse>>(
|
||||||
|
`/org/${orgId}/redirect/${niceId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
redirect = res.data.data.redirect;
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resource selector needs the resource's display fields up front so the
|
||||||
|
// trigger shows a name instead of a bare id before the list query resolves.
|
||||||
|
const initialResource =
|
||||||
|
redirect.resourceId && redirect.resourceNiceId
|
||||||
|
? {
|
||||||
|
resourceId: redirect.resourceId,
|
||||||
|
niceId: redirect.resourceNiceId,
|
||||||
|
name: redirect.resourceName ?? redirect.resourceNiceId,
|
||||||
|
fullDomain: redirect.resourceFullDomain,
|
||||||
|
ssl: redirect.resourceSsl ?? false,
|
||||||
|
wildcard: redirect.resourceWildcard ?? false
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={redirect.name}
|
||||||
|
description={t("redirectEditDescription")}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/${orgId}/settings/redirects`}>
|
||||||
|
{t("redirectGoBack")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RedirectForm
|
||||||
|
orgId={orgId}
|
||||||
|
redirect={redirect}
|
||||||
|
initialResource={initialResource}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import RedirectForm from "@app/components/RedirectForm";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Redirect"
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateRedirectPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CreateRedirectPage(
|
||||||
|
props: CreateRedirectPageProps
|
||||||
|
) {
|
||||||
|
const { orgId } = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("redirectCreate")}
|
||||||
|
description={t("redirectCreateDescription")}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/${orgId}/settings/redirects`}>
|
||||||
|
{t("redirectGoBack")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RedirectForm orgId={orgId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,8 +55,11 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
|||||||
redirectId: redirect.redirectId,
|
redirectId: redirect.redirectId,
|
||||||
niceId: redirect.niceId,
|
niceId: redirect.niceId,
|
||||||
name: redirect.name,
|
name: redirect.name,
|
||||||
sourcePath: redirect.sourcePath,
|
destinationDomain: redirect.destinationDomain,
|
||||||
destinationUrl: redirect.destinationUrl,
|
pathMatchType: redirect.pathMatchType,
|
||||||
|
matchPath: redirect.matchPath,
|
||||||
|
rewritePath: redirect.rewritePath,
|
||||||
|
rewritePathType: redirect.rewritePathType,
|
||||||
permanent: redirect.permanent,
|
permanent: redirect.permanent,
|
||||||
enabled: redirect.enabled,
|
enabled: redirect.enabled,
|
||||||
resourceId: redirect.resourceId,
|
resourceId: redirect.resourceId,
|
||||||
|
|||||||
@@ -0,0 +1,777 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import type {
|
||||||
|
CreateRedirectResponse,
|
||||||
|
GetRedirectResponse
|
||||||
|
} from "@server/routers/redirect";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||||
|
import {
|
||||||
|
PathMatchDisplay,
|
||||||
|
PathMatchModal,
|
||||||
|
PathRewriteDisplay,
|
||||||
|
PathRewriteModal
|
||||||
|
} from "@app/components/PathMatchRenameModal";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const DEFAULT_MATCH_PATH = "*";
|
||||||
|
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
|
||||||
|
|
||||||
|
export type ExistingRedirect = GetRedirectResponse["redirect"];
|
||||||
|
|
||||||
|
type RedirectFormProps = {
|
||||||
|
orgId: string;
|
||||||
|
/** Omit to create a new redirect. */
|
||||||
|
redirect?: ExistingRedirect;
|
||||||
|
/** Name/domain of the resource the redirect is attached to, when there is one. */
|
||||||
|
initialResource?: SelectedResource | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RedirectForm({
|
||||||
|
orgId,
|
||||||
|
redirect,
|
||||||
|
initialResource = null
|
||||||
|
}: RedirectFormProps) {
|
||||||
|
const isEditing = Boolean(redirect);
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selectedResource, setSelectedResource] =
|
||||||
|
useState<SelectedResource | null>(initialResource);
|
||||||
|
|
||||||
|
const { data: domains = [] } = useQuery(orgQueries.domains({ orgId }));
|
||||||
|
|
||||||
|
const formSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { message: t("nameRequired") }),
|
||||||
|
attachTo: z.enum(["domain", "resource"]),
|
||||||
|
domainId: z.string().nullable(),
|
||||||
|
resourceId: z.number().int().positive().nullable(),
|
||||||
|
destinationDomain: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, {
|
||||||
|
message: t("redirectDestinationDomainRequired")
|
||||||
|
}),
|
||||||
|
pathMatchType: z.enum(["exact", "prefix", "regex"]),
|
||||||
|
matchPath: z.string().trim().min(1),
|
||||||
|
rewritePath: z.string().nullable(),
|
||||||
|
rewritePathType: z
|
||||||
|
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||||
|
.nullable(),
|
||||||
|
permanent: z.boolean(),
|
||||||
|
enabled: z.boolean()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.attachTo === "domain" && !data.domainId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("redirectDomainRequired"),
|
||||||
|
path: ["domainId"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.attachTo === "resource" && !data.resourceId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("redirectResourceRequired"),
|
||||||
|
path: ["resourceId"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// stripPrefix drops the matched prefix outright, so it is
|
||||||
|
// the one rewrite type that needs no replacement value.
|
||||||
|
if (
|
||||||
|
data.rewritePathType &&
|
||||||
|
data.rewritePathType !== "stripPrefix" &&
|
||||||
|
!data.rewritePath
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("redirectRewritePathRequired"),
|
||||||
|
path: ["rewritePath"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
type RedirectFormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<RedirectFormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: redirect?.name ?? "",
|
||||||
|
attachTo: redirect?.resourceId ? "resource" : "domain",
|
||||||
|
domainId: redirect?.domainId ?? null,
|
||||||
|
resourceId: redirect?.resourceId ?? null,
|
||||||
|
destinationDomain: redirect?.destinationDomain ?? "",
|
||||||
|
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
|
||||||
|
matchPath: redirect?.matchPath ?? DEFAULT_MATCH_PATH,
|
||||||
|
rewritePath: redirect?.rewritePath ?? null,
|
||||||
|
rewritePathType: redirect?.rewritePathType ?? null,
|
||||||
|
permanent: redirect?.permanent ?? false,
|
||||||
|
enabled: redirect?.enabled ?? true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachTo = form.watch("attachTo");
|
||||||
|
const pathMatchType = form.watch("pathMatchType");
|
||||||
|
const rewritePath = form.watch("rewritePath");
|
||||||
|
const rewritePathType = form.watch("rewritePathType");
|
||||||
|
// stripPrefix is a valid rewrite with no path value, so it counts as set.
|
||||||
|
const hasRewrite =
|
||||||
|
Boolean(rewritePath) || rewritePathType === "stripPrefix";
|
||||||
|
|
||||||
|
async function onSubmit(values: RedirectFormValues) {
|
||||||
|
setSaveLoading(true);
|
||||||
|
|
||||||
|
// Only one of the two attachment points is ever persisted; clear the
|
||||||
|
// other so switching between them doesn't leave a stale reference.
|
||||||
|
const body = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
domainId: values.attachTo === "domain" ? values.domainId : null,
|
||||||
|
resourceId:
|
||||||
|
values.attachTo === "resource" ? values.resourceId : null,
|
||||||
|
destinationDomain: values.destinationDomain.trim(),
|
||||||
|
pathMatchType: values.pathMatchType,
|
||||||
|
matchPath: values.matchPath.trim(),
|
||||||
|
rewritePath: values.rewritePath?.trim() || null,
|
||||||
|
rewritePathType: values.rewritePathType,
|
||||||
|
permanent: values.permanent,
|
||||||
|
enabled: values.enabled
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing) {
|
||||||
|
await api.post(
|
||||||
|
`/org/${orgId}/redirects/${redirect!.redirectId}`,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("redirectUpdated")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const res = await api.put<
|
||||||
|
AxiosResponse<CreateRedirectResponse>
|
||||||
|
>(`/org/${orgId}/redirect`, body);
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("redirectCreated")
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/redirects/${res.data.data.redirect.niceId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: isEditing
|
||||||
|
? t("redirectErrorUpdate")
|
||||||
|
: t("redirectErrorCreate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
isEditing
|
||||||
|
? t("redirectErrorUpdate")
|
||||||
|
: t("redirectErrorCreate")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`);
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("redirectDeleted")
|
||||||
|
});
|
||||||
|
router.push(`/${orgId}/settings/redirects`);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("redirectErrorDelete"),
|
||||||
|
description: formatAxiosError(e, t("redirectErrorDelete"))
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isEditing && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={setIsDeleteModalOpen}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{t("redirectQuestionRemove")}</p>
|
||||||
|
<p>{t("redirectMessageRemove")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText={t("redirectDeleteConfirm")}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
string={redirect!.name}
|
||||||
|
title={t("redirectDelete")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("general")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("redirectSettingsGeneralDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="redirect-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("name")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="attachTo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"redirectAttachedTo"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="domain">
|
||||||
|
{t(
|
||||||
|
"redirectAttachDomain"
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="resource">
|
||||||
|
{t(
|
||||||
|
"redirectAttachResource"
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"redirectAttachedToDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
{attachTo === "domain" ? (
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domainId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"selectedRedirectDomain"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
field.value ??
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"redirectDomainSelect"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map(
|
||||||
|
(
|
||||||
|
domain
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
domain.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
domain.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
domain.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
) : (
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="resourceId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"selectedRedirectResource"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between",
|
||||||
|
!field.value &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedResource?.name ??
|
||||||
|
t(
|
||||||
|
"resourceSelect"
|
||||||
|
)}
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0">
|
||||||
|
<ResourceSelector
|
||||||
|
orgId={
|
||||||
|
orgId
|
||||||
|
}
|
||||||
|
selectedResource={
|
||||||
|
selectedResource
|
||||||
|
}
|
||||||
|
onSelectResource={(
|
||||||
|
resource
|
||||||
|
) => {
|
||||||
|
setSelectedResource(
|
||||||
|
resource
|
||||||
|
);
|
||||||
|
field.onChange(
|
||||||
|
resource.resourceId
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("redirectSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("redirectSettingsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="redirect-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("name")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationDomain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"redirectDestinationDomain"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"redirectDestinationDomainDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="matchPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>
|
||||||
|
{t("matchPath")}
|
||||||
|
</FormLabel>
|
||||||
|
<PathMatchModal
|
||||||
|
value={{
|
||||||
|
path: field.value,
|
||||||
|
pathMatchType:
|
||||||
|
pathMatchType
|
||||||
|
}}
|
||||||
|
onChange={(
|
||||||
|
config
|
||||||
|
) => {
|
||||||
|
// matchPath and
|
||||||
|
// pathMatchType are
|
||||||
|
// NOT NULL, so a
|
||||||
|
// clear falls back
|
||||||
|
// to the defaults
|
||||||
|
// rather than null.
|
||||||
|
field.onChange(
|
||||||
|
config.path ||
|
||||||
|
DEFAULT_MATCH_PATH
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"pathMatchType",
|
||||||
|
(config.pathMatchType as
|
||||||
|
| "exact"
|
||||||
|
| "prefix"
|
||||||
|
| "regex") ||
|
||||||
|
DEFAULT_PATH_MATCH_TYPE
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
<PathMatchDisplay
|
||||||
|
value={{
|
||||||
|
path: field.value,
|
||||||
|
pathMatchType:
|
||||||
|
pathMatchType
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"redirectMatchPathDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rewritePath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>
|
||||||
|
{t("rewritePath")}
|
||||||
|
</FormLabel>
|
||||||
|
<PathRewriteModal
|
||||||
|
value={{
|
||||||
|
rewritePath:
|
||||||
|
field.value,
|
||||||
|
rewritePathType:
|
||||||
|
rewritePathType
|
||||||
|
}}
|
||||||
|
onChange={(
|
||||||
|
config
|
||||||
|
) => {
|
||||||
|
field.onChange(
|
||||||
|
config.rewritePath ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"rewritePathType",
|
||||||
|
(config.rewritePathType as
|
||||||
|
| "exact"
|
||||||
|
| "prefix"
|
||||||
|
| "regex"
|
||||||
|
| "stripPrefix"
|
||||||
|
| null) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
hasRewrite ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
<PathRewriteDisplay
|
||||||
|
value={{
|
||||||
|
rewritePath:
|
||||||
|
field.value,
|
||||||
|
rewritePathType:
|
||||||
|
rewritePathType
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t(
|
||||||
|
"rewritePath"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"redirectRewritePathDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="permanent"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="redirect-permanent"
|
||||||
|
label={t(
|
||||||
|
"redirectPermanent"
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"redirectPermanentDescription"
|
||||||
|
)}
|
||||||
|
checked={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("dangerSection")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("redirectDangerSectionDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
loading={deleteLoading}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
>
|
||||||
|
{t("redirectDelete")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link href={`/${orgId}/settings/redirects`}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="redirect-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={saveLoading}
|
||||||
|
>
|
||||||
|
{isEditing ? t("saveSettings") : t("redirectAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,8 +30,11 @@ export type RedirectRow = {
|
|||||||
redirectId: number;
|
redirectId: number;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
name: string;
|
name: string;
|
||||||
sourcePath: string;
|
destinationDomain: string;
|
||||||
destinationUrl: string | null;
|
pathMatchType: "exact" | "prefix" | "regex";
|
||||||
|
matchPath: string;
|
||||||
|
rewritePath: string | null;
|
||||||
|
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
@@ -100,6 +103,24 @@ export default function RedirectsTable({
|
|||||||
filter({ searchParams });
|
filter({ searchParams });
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
|
function matchTypeLabel(type: RedirectRow["pathMatchType"]) {
|
||||||
|
return {
|
||||||
|
prefix: t("pathMatchPrefix"),
|
||||||
|
exact: t("pathMatchExact"),
|
||||||
|
regex: t("pathMatchRegex")
|
||||||
|
}[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteTypeLabel(type: RedirectRow["rewritePathType"]) {
|
||||||
|
if (!type) return "";
|
||||||
|
return {
|
||||||
|
prefix: t("pathRewritePrefix"),
|
||||||
|
exact: t("pathRewriteExact"),
|
||||||
|
regex: t("pathRewriteRegex"),
|
||||||
|
stripPrefix: t("pathRewriteStrip")
|
||||||
|
}[type];
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleEnabled(row: RedirectRow, enabled: boolean) {
|
async function toggleEnabled(row: RedirectRow, enabled: boolean) {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r) =>
|
prev.map((r) =>
|
||||||
@@ -182,19 +203,16 @@ export default function RedirectsTable({
|
|||||||
const host =
|
const host =
|
||||||
redirect.resourceFullDomain ?? redirect.baseDomain;
|
redirect.resourceFullDomain ?? redirect.baseDomain;
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
return (
|
|
||||||
<span className="font-mono text-sm">
|
|
||||||
{redirect.sourcePath}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-sm">
|
<div className="flex items-center gap-2">
|
||||||
{host}
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{redirect.sourcePath}
|
{matchTypeLabel(redirect.pathMatchType)}
|
||||||
</span>
|
</Badge>
|
||||||
|
<code className="text-sm truncate">
|
||||||
|
{host ?? ""}
|
||||||
|
{redirect.matchPath}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -226,17 +244,46 @@ export default function RedirectsTable({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "destinationUrl",
|
accessorKey: "destinationDomain",
|
||||||
friendlyName: t("redirectDestination"),
|
friendlyName: t("redirectDestinationDomain"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3">{t("redirectDestination")}</span>
|
<span className="p-3">
|
||||||
|
{t("redirectDestinationDomain")}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-sm">
|
<code className="text-sm">
|
||||||
{row.original.destinationUrl ?? "-"}
|
{row.original.destinationDomain}
|
||||||
</span>
|
</code>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "rewritePath",
|
||||||
|
accessorKey: "rewritePath",
|
||||||
|
friendlyName: t("rewritePath"),
|
||||||
|
header: () => <span className="p-3">{t("rewritePath")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const redirect = row.original;
|
||||||
|
const hasRewrite =
|
||||||
|
Boolean(redirect.rewritePath) ||
|
||||||
|
redirect.rewritePathType === "stripPrefix";
|
||||||
|
|
||||||
|
if (!hasRewrite) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
{rewriteTypeLabel(redirect.rewritePathType)}
|
||||||
|
</Badge>
|
||||||
|
<code className="text-sm truncate">
|
||||||
|
{redirect.rewritePath ?? ""}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "permanent",
|
accessorKey: "permanent",
|
||||||
friendlyName: t("redirectType"),
|
friendlyName: t("redirectType"),
|
||||||
@@ -356,7 +403,8 @@ export default function RedirectsTable({
|
|||||||
isRefreshing={isRefreshing || isFiltering}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
attachedTo: false
|
attachedTo: false,
|
||||||
|
rewritePath: false
|
||||||
}}
|
}}
|
||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function SettingsFormCell({
|
|||||||
"min-w-0",
|
"min-w-0",
|
||||||
span === "quarter" && "md:col-span-1",
|
span === "quarter" && "md:col-span-1",
|
||||||
span === "half" && "md:col-span-2",
|
span === "half" && "md:col-span-2",
|
||||||
span === "full" && "md:col-span-4",
|
span === "full" && "col-span-full",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user