diff --git a/messages/en-US.json b/messages/en-US.json index e741c9b56..2e925ad14 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4371,7 +4371,6 @@ "redirectAdd": "Add Redirect", "redirectSource": "Source", "redirectAttachedTo": "Attached To", - "redirectDestination": "Destination", "redirectType": "Type", "redirectTypePermanent": "Permanent (301)", "redirectTypeTemporary": "Temporary (302)", @@ -4382,5 +4381,32 @@ "redirectDelete": "Delete Redirect", "redirectDeleteConfirm": "Confirm Delete 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." } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 38c617e0d..4811400e6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -242,8 +242,19 @@ export const redirects = pgTable("redirects", { }), niceId: text("niceId").notNull(), name: varchar("name").notNull(), - sourcePath: varchar("sourcePath").notNull(), - destinationUrl: varchar("destinationUrl"), + subdomain: varchar("subdomain"), + 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), enabled: boolean("enabled").notNull().default(true) }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5fbabdb88..f7f4884b9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -258,8 +258,18 @@ export const redirects = sqliteTable("redirects", { }), niceId: text("niceId").notNull(), name: text("name").notNull(), - sourcePath: text("sourcePath").notNull(), - destinationUrl: text("destinationUrl"), + destinationDomain: text("destinationDomain").notNull(), + 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" }) .notNull() .default(false), diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index c1c94b9ad..d8da0040d 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -9,7 +9,12 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; 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"; export type CreateRedirectResponse = { @@ -24,11 +29,26 @@ const bodySchema = z.strictObject({ name: z.string().nonempty(), resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), - sourcePath: redirectSourcePathSchema, - destinationUrl: z.url().optional().nullable(), + destinationDomain: z.string().nonempty(), + pathMatchType: redirectPathMatchTypeSchema.optional(), + matchPath: redirectMatchPathSchema, + rewritePath: redirectRewritePathSchema.optional().nullable(), + rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), permanent: 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({ method: "put", @@ -83,8 +103,11 @@ export async function createRedirect( name, resourceId, domainId, - sourcePath, - destinationUrl, + destinationDomain, + pathMatchType, + matchPath, + rewritePath, + rewritePathType, permanent, enabled } = parsedBody.data; @@ -147,8 +170,11 @@ export async function createRedirect( niceId, resourceId: resourceId ?? null, domainId: domainId ?? null, - sourcePath, - destinationUrl: destinationUrl ?? null, + destinationDomain, + pathMatchType: pathMatchType ?? "regex", + matchPath, + rewritePath: rewritePath ?? null, + rewritePathType: rewritePathType ?? null, permanent: permanent ?? false, enabled: enabled ?? true }) diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index 979bf6035..03f7aa186 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -1,7 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { redirects, db } from "@server/db"; -import type { Redirect } from "@server/db"; +import { domains, redirects, resources, db } from "@server/db"; import response from "@server/lib/response"; import stoi from "@server/lib/stoi"; import HttpCode from "@server/types/HttpCode"; @@ -12,7 +11,49 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; 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({ @@ -29,8 +70,10 @@ const paramsSchema = z.strictObject({ async function query(orgId: string, redirectId?: number, niceId?: string) { if (redirectId) { const [res] = await db - .select() + .select(redirectColumns) .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) .where( and( eq(redirects.redirectId, redirectId), @@ -41,8 +84,10 @@ async function query(orgId: string, redirectId?: number, niceId?: string) { return res; } else if (niceId) { const [res] = await db - .select() + .select(redirectColumns) .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) .where( and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId)) ) diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 28163d17f..d5984f53e 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -16,8 +16,11 @@ export type ListRedirectsResponse = PaginatedResponse<{ orgId: string; niceId: string; name: string; - sourcePath: string; - destinationUrl: string | null; + destinationDomain: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; permanent: boolean; enabled: boolean; resourceId: number | null; @@ -122,8 +125,8 @@ export async function listRedirects( conditions.push( or( like(sql`LOWER(${redirects.name})`, term), - like(sql`LOWER(${redirects.sourcePath})`, term), - like(sql`LOWER(${redirects.destinationUrl})`, term) + like(sql`LOWER(${redirects.matchPath})`, term), + like(sql`LOWER(${redirects.destinationDomain})`, term) )! ); } @@ -134,8 +137,11 @@ export async function listRedirects( orgId: redirects.orgId, niceId: redirects.niceId, name: redirects.name, - sourcePath: redirects.sourcePath, - destinationUrl: redirects.destinationUrl, + destinationDomain: redirects.destinationDomain, + pathMatchType: redirects.pathMatchType, + matchPath: redirects.matchPath, + rewritePath: redirects.rewritePath, + rewritePathType: redirects.rewritePathType, permanent: redirects.permanent, enabled: redirects.enabled, resourceId: redirects.resourceId, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 7f10ae69f..9158fd270 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -11,7 +11,10 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import { redirectNiceIdSchema, - redirectSourcePathSchema + redirectMatchPathSchema, + redirectPathMatchTypeSchema, + redirectRewritePathSchema, + redirectRewritePathTypeSchema } from "@server/routers/redirect/validation"; export type UpdateRedirectResponse = { @@ -28,8 +31,11 @@ const bodySchema = z.strictObject({ niceId: redirectNiceIdSchema.optional(), resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), - sourcePath: redirectSourcePathSchema.optional(), - destinationUrl: z.url().optional().nullable(), + destinationDomain: z.string().nonempty().optional(), + pathMatchType: redirectPathMatchTypeSchema.optional(), + matchPath: redirectMatchPathSchema.optional(), + rewritePath: redirectRewritePathSchema.optional().nullable(), + rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), permanent: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -190,11 +196,20 @@ export async function updateRedirect( if (body.domainId !== undefined) { updateData.domainId = body.domainId; } - if (body.sourcePath !== undefined) { - updateData.sourcePath = body.sourcePath; + if (body.destinationDomain !== undefined) { + updateData.destinationDomain = body.destinationDomain; } - if (body.destinationUrl !== undefined) { - updateData.destinationUrl = body.destinationUrl; + if (body.pathMatchType !== undefined) { + 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) { updateData.permanent = body.permanent; diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index 49fe256bd..aa264222d 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -9,8 +9,15 @@ export const redirectNiceIdSchema = z "niceId can only contain letters, numbers, and dashes" ); -export const redirectSourcePathSchema = z - .string() - .nonempty() - .regex(/^\//, "sourcePath must start with a /") - .default("/*"); +export const redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]); + +export const redirectRewritePathTypeSchema = z.enum([ + "exact", + "prefix", + "regex", + "stripPrefix" +]); + +export const redirectMatchPathSchema = z.string().nonempty().default("*"); + +export const redirectRewritePathSchema = z.string().nonempty(); diff --git a/src/app/[orgId]/settings/redirects/[niceId]/page.tsx b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx new file mode 100644 index 000000000..8ceb1fc9e --- /dev/null +++ b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx @@ -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>( + `/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 ( + <> +
+ + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/redirects/create/page.tsx b/src/app/[orgId]/settings/redirects/create/page.tsx new file mode 100644 index 000000000..2e6f0245d --- /dev/null +++ b/src/app/[orgId]/settings/redirects/create/page.tsx @@ -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 ( + <> +
+ + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 684edd2ff..f2113c4e1 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -55,8 +55,11 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { redirectId: redirect.redirectId, niceId: redirect.niceId, name: redirect.name, - sourcePath: redirect.sourcePath, - destinationUrl: redirect.destinationUrl, + destinationDomain: redirect.destinationDomain, + pathMatchType: redirect.pathMatchType, + matchPath: redirect.matchPath, + rewritePath: redirect.rewritePath, + rewritePathType: redirect.rewritePathType, permanent: redirect.permanent, enabled: redirect.enabled, resourceId: redirect.resourceId, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx new file mode 100644 index 000000000..fed65b713 --- /dev/null +++ b/src/components/RedirectForm.tsx @@ -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(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; + + const form = useForm({ + 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 + >(`/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 && ( + +

{t("redirectQuestionRemove")}

+

{t("redirectMessageRemove")}

+ + } + buttonText={t("redirectDeleteConfirm")} + onConfirm={onDelete} + string={redirect!.name} + title={t("redirectDelete")} + /> + )} + + + + + + {t("general")} + + + {t("redirectSettingsGeneralDescription")} + + + + + +
+ + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "redirectAttachedTo" + )} + + + + {t( + "redirectAttachedToDescription" + )} + + + + )} + /> + + {attachTo === "domain" ? ( + + ( + + + {t( + "selectedRedirectDomain" + )} + + + + + )} + /> + + ) : ( + + ( + + + {t( + "selectedRedirectResource" + )} + + + + + + + + + { + setSelectedResource( + resource + ); + field.onChange( + resource.resourceId + ); + }} + /> + + + + + )} + /> + + )} + +
+ +
+
+
+ + + + + {t("redirectSettings")} + + + {t("redirectSettingsDescription")} + + + + + +
+ + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "redirectDestinationDomain" + )} + + + + + + {t( + "redirectDestinationDomainDescription" + )} + + + + )} + /> + + + + ( + + + {t("matchPath")} + + { + // 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={ + + } + /> + + {t( + "redirectMatchPathDescription" + )} + + + + )} + /> + + + + ( + + + {t("rewritePath")} + + { + field.onChange( + config.rewritePath || + null + ); + form.setValue( + "rewritePathType", + (config.rewritePathType as + | "exact" + | "prefix" + | "regex" + | "stripPrefix" + | null) ?? + null + ); + }} + trigger={ + hasRewrite ? ( + + ) : ( + + ) + } + /> + + {t( + "redirectRewritePathDescription" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + +
+ +
+
+
+ + {isEditing && ( + + + + {t("dangerSection")} + + + {t("redirectDangerSectionDescription")} + + + + + + + )} + +
+ + +
+
+ + ); +} diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 163e62801..f4ae50c88 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -30,8 +30,11 @@ export type RedirectRow = { redirectId: number; niceId: string; name: string; - sourcePath: string; - destinationUrl: string | null; + destinationDomain: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; permanent: boolean; enabled: boolean; resourceId: number | null; @@ -100,6 +103,24 @@ export default function RedirectsTable({ filter({ searchParams }); }, 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) { setRows((prev) => prev.map((r) => @@ -182,19 +203,16 @@ export default function RedirectsTable({ const host = redirect.resourceFullDomain ?? redirect.baseDomain; - if (!host) { - return ( - - {redirect.sourcePath} - - ); - } - return ( - - {host} - {redirect.sourcePath} - +
+ + {matchTypeLabel(redirect.pathMatchType)} + + + {host ?? ""} + {redirect.matchPath} + +
); } }, @@ -226,17 +244,46 @@ export default function RedirectsTable({ } }, { - accessorKey: "destinationUrl", - friendlyName: t("redirectDestination"), + accessorKey: "destinationDomain", + friendlyName: t("redirectDestinationDomain"), header: () => ( - {t("redirectDestination")} + + {t("redirectDestinationDomain")} + ), cell: ({ row }) => ( - - {row.original.destinationUrl ?? "-"} - + + {row.original.destinationDomain} + ) }, + { + id: "rewritePath", + accessorKey: "rewritePath", + friendlyName: t("rewritePath"), + header: () => {t("rewritePath")}, + cell: ({ row }) => { + const redirect = row.original; + const hasRewrite = + Boolean(redirect.rewritePath) || + redirect.rewritePathType === "stripPrefix"; + + if (!hasRewrite) { + return -; + } + + return ( +
+ + {rewriteTypeLabel(redirect.rewritePathType)} + + + {redirect.rewritePath ?? ""} + +
+ ); + } + }, { accessorKey: "permanent", friendlyName: t("redirectType"), @@ -356,7 +403,8 @@ export default function RedirectsTable({ isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ - attachedTo: false + attachedTo: false, + rewritePath: false }} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 892766c6d..ec3c99809 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -77,7 +77,7 @@ export function SettingsFormCell({ "min-w-0", span === "quarter" && "md:col-span-1", span === "half" && "md:col-span-2", - span === "full" && "md:col-span-4", + span === "full" && "col-span-full", className )} >