This commit is contained in:
Fred KISSIE
2026-09-10 20:08:47 +02:00
parent 30ca3e7e97
commit 9e85b39fd5
14 changed files with 1147 additions and 61 deletions
+28 -2
View File
@@ -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."
} }
+13 -2
View File
@@ -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)
}); });
+12 -2
View File
@@ -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),
+34 -8
View File
@@ -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
}) })
+50 -5
View File
@@ -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))
) )
+12 -6
View File
@@ -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,
+22 -7
View File
@@ -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;
+12 -5
View File
@@ -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} />
</>
);
}
+5 -2
View File
@@ -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,
+777
View File
@@ -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>
</>
);
}
+69 -21
View File
@@ -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"
+1 -1
View File
@@ -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
)} )}
> >