diff --git a/messages/en-US.json b/messages/en-US.json index 8e42ca361..c6d731e90 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4369,11 +4369,11 @@ "redirectsDescription": "Forward requests from a path on your domains or resources to another URL", "redirectsSearch": "Search redirects...", "redirectAdd": "Add Redirect", - "redirectSource": "Source", + "redirectSourceDomain": "Source Domain", "redirectAttachedTo": "Attached To", "redirectType": "Type", - "redirectTypePermanent": "Permanent (301)", - "redirectTypeTemporary": "Temporary (302)", + "redirectTypePermanent": "Permanent (308)", + "redirectTypeTemporary": "Temporary (307)", "redirectUpdated": "Redirect updated successfully", "redirectErrorUpdate": "Failed to update redirect", "redirectDeleted": "Redirect deleted successfully", @@ -4385,6 +4385,7 @@ "redirectDestinationDomain": "Destination Domain", "redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com", "redirectDestinationDomainRequired": "Enter a destination domain", + "redirectDestinationDomainInvalid": "Enter a valid domain, such as example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", "redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.", "redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix", diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index a5b77a913..d1ca209a3 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { + redirectDestinationDomainSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -30,7 +31,7 @@ const bodySchema = z.strictObject({ resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: z.string().nonempty(), + destinationDomain: redirectDestinationDomainSchema, pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema, rewritePath: redirectRewritePathSchema.optional().nullable(), diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 0dfd27722..0a6b53a3c 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -11,6 +11,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import { redirectNiceIdSchema, + redirectDestinationDomainSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -32,7 +33,7 @@ const bodySchema = z.strictObject({ resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: z.string().nonempty().optional(), + destinationDomain: redirectDestinationDomainSchema.optional(), pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional(), rewritePath: redirectRewritePathSchema.optional().nullable(), diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index aa264222d..7c9122fab 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isValidDomain } from "@server/lib/validators"; export const redirectNiceIdSchema = z .string() @@ -21,3 +22,10 @@ export const redirectRewritePathTypeSchema = z.enum([ export const redirectMatchPathSchema = z.string().nonempty().default("*"); export const redirectRewritePathSchema = z.string().nonempty(); + +export const redirectDestinationDomainSchema = z + .string() + .nonempty() + .refine(isValidDomain, { + message: "Invalid domain" + }); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 820619cd8..9aba63d15 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,6 +141,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/domains", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(build === "saas" ? [ { @@ -324,11 +329,6 @@ export const orgNavSections = ( href: "/{orgId}/settings/api-keys", icon: }, - { - title: "sidebarRedirects", - href: "/{orgId}/settings/redirects", - icon: - }, ...(!env?.flags.disableEnterpriseFeatures ? [ { @@ -467,6 +467,11 @@ export const commandBarNavSections = ( title: "commandMachineClients", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(build === "saas" ? [ { diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 5a85e58f1..e9f5230d9 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -40,6 +40,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { isValidDomain } from "@server/lib/validators"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -50,7 +51,7 @@ import type { import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; @@ -88,7 +89,7 @@ export default function RedirectForm({ const router = useRouter(); const t = useTranslations(); - const [saveLoading, setSaveLoading] = useState(false); + const [, formAction, saveLoading] = useActionState(onSubmit, null); const [deleteLoading, setDeleteLoading] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = @@ -111,6 +112,9 @@ export default function RedirectForm({ .trim() .min(1, { message: t("redirectDestinationDomainRequired") + }) + .refine(isValidDomain, { + message: t("redirectDestinationDomainInvalid") }), pathMatchType: z.enum(["exact", "prefix", "regex"]), matchPath: z.string().trim().min(1), @@ -181,8 +185,10 @@ export default function RedirectForm({ const hasRewrite = Boolean(rewritePath) || rewritePathType === "stripPrefix"; - async function onSubmit(values: RedirectFormValues) { - setSaveLoading(true); + async function onSubmit() { + if (!(await form.trigger())) return; + + const values = form.getValues(); // Only one of the two attachment points is ever persisted; clear the // other so switching between them doesn't leave a stale reference. @@ -221,9 +227,7 @@ export default function RedirectForm({ title: t("success"), description: t("redirectCreated") }); - router.push( - `/${orgId}/settings/redirects/${res.data.data.redirect.niceId}` - ); + router.push(`/${orgId}/settings/redirects/`); } } catch (e) { toast({ @@ -238,8 +242,6 @@ export default function RedirectForm({ : t("redirectErrorCreate") ) }); - } finally { - setSaveLoading(false); } } @@ -297,10 +299,7 @@ export default function RedirectForm({
- + - + ( - {t("redirectSource")} - ), - cell: ({ row }) => { - const redirect = row.original; - // A domain-attached redirect may target a specific host - // under the base domain, e.g. old.example.com. - const domainHost = redirect.baseDomain - ? [redirect.subdomain, redirect.baseDomain] - .filter(Boolean) - .join(".") - : null; - const host = redirect.resourceFullDomain ?? domainHost; - - return ( -
- - {matchTypeLabel(redirect.pathMatchType)} - - - {host ?? ""} - {redirect.matchPath} - -
- ); - } + id: "niceId", + accessorKey: "niceId", + friendlyName: t("identifier"), + header: () => {t("identifier")}, + cell: ({ row }) => ( + {row.original.niceId} + ) }, { id: "attachedTo", @@ -234,28 +219,94 @@ export default function RedirectsTable({ if (redirect.resourceId && redirect.resourceNiceId) { return ( - - {redirect.resourceName} - + + + {redirect.resourceName} + + + ); } - if (redirect.baseDomain) { + console.log({ + redirect + }); + + if (redirect.domainId) { return ( - - {[redirect.subdomain, redirect.baseDomain] - .filter(Boolean) - .join(".")} - + ); } return -; } }, + + { + id: "sourceDomain", + friendlyName: t("redirectSourceDomain"), + header: () => ( + {t("redirectSourceDomain")} + ), + cell: ({ row }) => { + const redirect = row.original; + // A domain-attached redirect may target a specific host + // under the base domain, e.g. old.example.com. + const domainHost = redirect.baseDomain + ? [redirect.subdomain, redirect.baseDomain] + .filter(Boolean) + .join(".") + : null; + const host = redirect.resourceFullDomain ?? domainHost; + + return host ? ( + {host} + ) : ( + - + ); + } + }, + { + id: "matchPath", + accessorKey: "matchPath", + friendlyName: t("matchPath"), + header: () => {t("matchPath")}, + cell: ({ row }) => { + const redirect = row.original; + return ( +
+ + {matchTypeLabel(redirect.pathMatchType)} + + + {redirect.matchPath} + +
+ ); + } + }, + { accessorKey: "destinationDomain", friendlyName: t("redirectDestinationDomain"), @@ -417,7 +468,7 @@ export default function RedirectsTable({ rowCount={rowCount} columnVisibility={{ attachedTo: false, - rewritePath: false + niceId: false }} enableColumnVisibility stickyLeftColumn="name"