list resources table

This commit is contained in:
Fred KISSIE
2026-09-10 18:19:46 +02:00
parent 2bccdc33fc
commit 30ca3e7e97
4 changed files with 490 additions and 13 deletions
+19 -1
View File
@@ -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."
}
+42 -9
View File
@@ -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(
+62 -3
View File
@@ -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<Record<string, string>>;
};
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<AxiosResponse<ListRedirectsResponse>>(
`/org/${orgId}/redirects?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
redirects = responseData.redirects;
pagination = responseData.pagination;
} catch {
// empty list on error
}
return (
<>
<SettingsSectionTitle
title={t("redirectsTitle")}
description={t("redirectsDescription")}
/>
<RedirectsTable
orgId={orgId}
redirects={redirects.map((redirect) => ({
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
}}
/>
</>
);
}
+367
View File
@@ -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<RedirectRow | null>(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<ExtendedColumnDef<RedirectRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
className="hover:underline"
>
{row.original.name}
</Link>
)
},
{
id: "source",
friendlyName: t("redirectSource"),
header: () => (
<span className="p-3">{t("redirectSource")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
const host =
redirect.resourceFullDomain ?? redirect.baseDomain;
if (!host) {
return (
<span className="font-mono text-sm">
{redirect.sourcePath}
</span>
);
}
return (
<span className="font-mono text-sm">
{host}
{redirect.sourcePath}
</span>
);
}
},
{
id: "attachedTo",
friendlyName: t("redirectAttachedTo"),
header: () => (
<span className="p-3">{t("redirectAttachedTo")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
if (redirect.resourceId && redirect.resourceNiceId) {
return (
<Link
href={`/${orgId}/settings/resources/${redirect.resourceNiceId}`}
className="hover:underline"
>
{redirect.resourceName}
</Link>
);
}
if (redirect.baseDomain) {
return <span>{redirect.baseDomain}</span>;
}
return <span>-</span>;
}
},
{
accessorKey: "destinationUrl",
friendlyName: t("redirectDestination"),
header: () => (
<span className="p-3">{t("redirectDestination")}</span>
),
cell: ({ row }) => (
<span className="font-mono text-sm">
{row.original.destinationUrl ?? "-"}
</span>
)
},
{
accessorKey: "permanent",
friendlyName: t("redirectType"),
header: () => <span className="p-3">{t("redirectType")}</span>,
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.permanent
? t("redirectTypePermanent")
: t("redirectTypeTemporary")}
</Badge>
)
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={(checked) =>
toggleEnabled(row.original, checked)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
>
{t("edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
>
<Button variant="outline">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
)
}
],
[orgId, t]
);
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelected(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("redirectQuestionRemove")}</p>
<p>{t("redirectMessageRemove")}</p>
</div>
}
buttonText={t("redirectDeleteConfirm")}
onConfirm={async () => deleteRedirect(selected)}
string={selected.name}
title={t("redirectDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={rows}
addButtonText={t("redirectAdd")}
onAdd={() =>
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"
/>
</>
);
}