From 30ca3e7e97e3361db74f1fa997deea698c785e5d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 18:19:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20list=20resources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 20 +- server/routers/redirect/listRedirects.ts | 51 ++- src/app/[orgId]/settings/redirects/page.tsx | 65 +++- src/components/RedirectsTable.tsx | 367 ++++++++++++++++++++ 4 files changed, 490 insertions(+), 13 deletions(-) create mode 100644 src/components/RedirectsTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index ea3239b18..e741c9b56 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4364,5 +4364,23 @@ "rdpUnicodeKeyboardMode": "Unicode keyboard mode", "sessionToolbarShow": "Show toolbar", "sessionToolbarHide": "Hide toolbar", - "actionUpdateSiteApprovals": "Update Site Approvals" + "actionUpdateSiteApprovals": "Update Site Approvals", + "redirectsTitle": "Manage Redirects", + "redirectsDescription": "Forward requests from a path on your domains or resources to another URL", + "redirectsSearch": "Search redirects...", + "redirectAdd": "Add Redirect", + "redirectSource": "Source", + "redirectAttachedTo": "Attached To", + "redirectDestination": "Destination", + "redirectType": "Type", + "redirectTypePermanent": "Permanent (301)", + "redirectTypeTemporary": "Temporary (302)", + "redirectUpdated": "Redirect updated successfully", + "redirectErrorUpdate": "Failed to update redirect", + "redirectDeleted": "Redirect deleted successfully", + "redirectErrorDelete": "Failed to delete redirect", + "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." } diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 4310e9b10..28163d17f 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -1,18 +1,32 @@ 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, asc, eq, like, sql } from "drizzle-orm"; +import { and, asc, eq, like, or, sql } from "drizzle-orm"; import type { PaginatedResponse } from "@server/types/Pagination"; export type ListRedirectsResponse = PaginatedResponse<{ - redirects: Redirect[]; + redirects: Array<{ + redirectId: number; + orgId: string; + niceId: string; + name: string; + sourcePath: string; + destinationUrl: string | null; + permanent: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceFullDomain: string | null; + domainId: string | null; + baseDomain: string | null; + }>; }>; const paramsSchema = z.strictObject({ @@ -104,17 +118,36 @@ export async function listRedirects( const conditions = [eq(redirects.orgId, orgId)]; if (query) { + const term = "%" + query.toLowerCase() + "%"; conditions.push( - like( - sql`LOWER(${redirects.name})`, - "%" + query.toLowerCase() + "%" - ) + or( + like(sql`LOWER(${redirects.name})`, term), + like(sql`LOWER(${redirects.sourcePath})`, term), + like(sql`LOWER(${redirects.destinationUrl})`, term) + )! ); } const baseQuery = db - .select() + .select({ + redirectId: redirects.redirectId, + orgId: redirects.orgId, + niceId: redirects.niceId, + name: redirects.name, + sourcePath: redirects.sourcePath, + destinationUrl: redirects.destinationUrl, + permanent: redirects.permanent, + enabled: redirects.enabled, + resourceId: redirects.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId, + resourceFullDomain: resources.fullDomain, + domainId: redirects.domainId, + baseDomain: domains.baseDomain + }) .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) .where(and(...conditions)); const countQuery = db.$count( diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index a386e6570..684edd2ff 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -1,3 +1,9 @@ +import RedirectsTable from "@app/components/RedirectsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListRedirectsResponse } from "@server/routers/redirect"; +import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -7,12 +13,65 @@ export const metadata: Metadata = { type RedirectIndexPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; + export const dynamic = "force-dynamic"; -export default async function ApiKeysPage(props: RedirectIndexPageProps) { - const params = await props.params; +export default async function RedirectIndexPage(props: RedirectIndexPageProps) { + const { orgId } = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); const t = await getTranslations(); - return null; + let redirects: ListRedirectsResponse["redirects"] = []; + let pagination: ListRedirectsResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${orgId}/redirects?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + redirects = responseData.redirects; + pagination = responseData.pagination; + } catch { + // empty list on error + } + + return ( + <> + + + ({ + redirectId: redirect.redirectId, + niceId: redirect.niceId, + name: redirect.name, + sourcePath: redirect.sourcePath, + destinationUrl: redirect.destinationUrl, + permanent: redirect.permanent, + enabled: redirect.enabled, + resourceId: redirect.resourceId, + resourceName: redirect.resourceName, + resourceNiceId: redirect.resourceNiceId, + resourceFullDomain: redirect.resourceFullDomain, + domainId: redirect.domainId, + baseDomain: redirect.baseDomain + }))} + rowCount={pagination.total} + pagination={{ + pageIndex: pagination.page - 1, + pageSize: pagination.pageSize + }} + /> + + ); } diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx new file mode 100644 index 000000000..163e62801 --- /dev/null +++ b/src/components/RedirectsTable.tsx @@ -0,0 +1,367 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { PaginationState } from "@tanstack/react-table"; +import { ArrowRight, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export type RedirectRow = { + redirectId: number; + niceId: string; + name: string; + sourcePath: string; + destinationUrl: string | null; + permanent: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceFullDomain: string | null; + domainId: string | null; + baseDomain: string | null; +}; + +type RedirectsTableProps = { + redirects: RedirectRow[]; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export default function RedirectsTable({ + redirects, + orgId, + pagination, + rowCount +}: RedirectsTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [rows, setRows] = useState(redirects); + const [selected, setSelected] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + + useEffect(() => { + setRows(redirects); + }, [redirects]); + + function refreshData() { + startTransition(() => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + async function toggleEnabled(row: RedirectRow, enabled: boolean) { + setRows((prev) => + prev.map((r) => + r.redirectId === row.redirectId ? { ...r, enabled } : r + ) + ); + + try { + await api.post(`/org/${orgId}/redirects/${row.redirectId}`, { + enabled + }); + toast({ + title: t("success"), + description: t("redirectUpdated") + }); + router.refresh(); + } catch (e) { + setRows((prev) => + prev.map((r) => + r.redirectId === row.redirectId + ? { ...r, enabled: row.enabled } + : r + ) + ); + toast({ + variant: "destructive", + title: t("redirectErrorUpdate"), + description: formatAxiosError(e, t("redirectErrorUpdate")) + }); + } + } + + function deleteRedirect(row: RedirectRow) { + startTransition(async () => { + try { + await api.delete(`/org/${orgId}/redirects/${row.redirectId}`); + setRows((prev) => + prev.filter((r) => r.redirectId !== row.redirectId) + ); + setIsDeleteModalOpen(false); + setSelected(null); + toast({ + title: t("success"), + description: t("redirectDeleted") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("redirectErrorDelete"), + description: formatAxiosError(e, t("redirectErrorDelete")) + }); + } + }); + } + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + enableHiding: false, + header: () => {t("name")}, + cell: ({ row }) => ( + + {row.original.name} + + ) + }, + { + id: "source", + friendlyName: t("redirectSource"), + header: () => ( + {t("redirectSource")} + ), + cell: ({ row }) => { + const redirect = row.original; + const host = + redirect.resourceFullDomain ?? redirect.baseDomain; + + if (!host) { + return ( + + {redirect.sourcePath} + + ); + } + + return ( + + {host} + {redirect.sourcePath} + + ); + } + }, + { + id: "attachedTo", + friendlyName: t("redirectAttachedTo"), + header: () => ( + {t("redirectAttachedTo")} + ), + cell: ({ row }) => { + const redirect = row.original; + + if (redirect.resourceId && redirect.resourceNiceId) { + return ( + + {redirect.resourceName} + + ); + } + + if (redirect.baseDomain) { + return {redirect.baseDomain}; + } + + return -; + } + }, + { + accessorKey: "destinationUrl", + friendlyName: t("redirectDestination"), + header: () => ( + {t("redirectDestination")} + ), + cell: ({ row }) => ( + + {row.original.destinationUrl ?? "-"} + + ) + }, + { + accessorKey: "permanent", + friendlyName: t("redirectType"), + header: () => {t("redirectType")}, + cell: ({ row }) => ( + + {row.original.permanent + ? t("redirectTypePermanent") + : t("redirectTypeTemporary")} + + ) + }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => {t("enabled")}, + cell: ({ row }) => ( + + toggleEnabled(row.original, checked) + } + /> + ) + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => ( +
+ + + + + + + + {t("edit")} + + + { + setSelected(row.original); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ) + } + ], + [orgId, t] + ); + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelected(null); + } + }} + dialog={ +
+

{t("redirectQuestionRemove")}

+

{t("redirectMessageRemove")}

+
+ } + buttonText={t("redirectDeleteConfirm")} + onConfirm={async () => deleteRedirect(selected)} + string={selected.name} + title={t("redirectDelete")} + /> + )} + + + startNavigation(() => + router.push(`/${orgId}/settings/redirects/create`) + ) + } + isNavigatingToAddPage={isNavigatingToAddPage} + tableId="redirects-table" + searchPlaceholder={t("redirectsSearch")} + pagination={pagination} + onPaginationChange={handlePaginationChange} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} + columnVisibility={{ + attachedTo: false + }} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +}