diff --git a/messages/en-US.json b/messages/en-US.json index 3470b334..3dd1b926 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -145,7 +145,7 @@ "never": "Never", "shareErrorSelectResource": "Please select a resource", "resourceTitle": "Manage Resources", - "resourceDescription": "Create secure proxies to your private applications", + "resourceDescription": "Access resources on sites publically or privately", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -1862,6 +1862,10 @@ "beta": "Beta", "manageClients": "Manage Clients", "manageClientsDescription": "Clients are devices that can connect to your sites", + "clientsTableUserClients": "User", + "clientsTableUserClientsDescription": "User clients are user devices in your organization.", + "clientsTableMachineClients": "Machine", + "clientsTableMachineClientsDescription": "Machine clients are clients created with credentials for machine connectivity not associated with a user.", "licenseTableValidUntil": "Valid Until", "saasLicenseKeysSettingsTitle": "Enterprise Licenses", "saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances", diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 729b59b1..84fed251 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -10,7 +10,7 @@ import { import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { and, count, eq, inArray, or, sql } from "drizzle-orm"; +import { and, count, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -96,10 +96,25 @@ const listClientsSchema = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.number().int().nonnegative()) + .pipe(z.number().int().nonnegative()), + filter: z + .enum(["user", "machine"]) + .optional() }); -function queryClients(orgId: string, accessibleClientIds: number[]) { +function queryClients(orgId: string, accessibleClientIds: number[], filter?: "user" | "machine") { + const conditions = [ + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ]; + + // Add filter condition based on filter type + if (filter === "user") { + conditions.push(isNotNull(clients.userId)); + } else if (filter === "machine") { + conditions.push(isNull(clients.userId)); + } + return db .select({ clientId: clients.clientId, @@ -121,12 +136,7 @@ function queryClients(orgId: string, accessibleClientIds: number[]) { .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(users, eq(clients.userId, users.userId)) - .where( - and( - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) - ) - ); + .where(and(...conditions)); } async function getSiteAssociations(clientIds: number[]) { @@ -188,7 +198,7 @@ export async function listClients( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, filter } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -237,18 +247,24 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds); + const baseQuery = queryClients(orgId, accessibleClientIds, filter); + + // Get client count with filter + const countConditions = [ + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ]; + + if (filter === "user") { + countConditions.push(isNotNull(clients.userId)); + } else if (filter === "machine") { + countConditions.push(isNull(clients.userId)); + } - // Get client count const countQuery = db .select({ count: count() }) .from(clients) - .where( - and( - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) - ) - ); + .where(and(...countConditions)); const clientsList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 8d8bff8f..7049306e 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -9,6 +9,7 @@ import { getTranslations } from "next-intl/server"; type ClientsPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; }; export const dynamic = "force-dynamic"; @@ -17,13 +18,28 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; - let clients: ListClientsResponse["clients"] = []; + const searchParams = await props.searchParams; + + // Default to 'user' view, or use the query param if provided + let defaultView: "user" | "machine" = "user"; + defaultView = searchParams.view === "machine" ? "machine" : "user"; + + let userClients: ListClientsResponse["clients"] = []; + let machineClients: ListClientsResponse["clients"] = []; + try { - const res = await internal.get>( - `/org/${params.orgId}/clients`, - await authCookieHeader() - ); - clients = res.data.data.clients; + const [userRes, machineRes] = await Promise.all([ + internal.get>( + `/org/${params.orgId}/clients?filter=user`, + await authCookieHeader() + ), + internal.get>( + `/org/${params.orgId}/clients?filter=machine`, + await authCookieHeader() + ) + ]); + userClients = userRes.data.data.clients; + machineClients = machineRes.data.data.clients; } catch (e) {} function formatSize(mb: number): string { @@ -36,7 +52,7 @@ export default async function ClientsPage(props: ClientsPageProps) { } } - const clientRows: ClientRow[] = clients.map((client) => { + const mapClientToRow = (client: ListClientsResponse["clients"][0]): ClientRow => { return { name: client.name, id: client.clientId, @@ -51,7 +67,10 @@ export default async function ClientsPage(props: ClientsPageProps) { username: client.username, userEmail: client.userEmail }; - }); + }; + + const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); return ( <> @@ -60,7 +79,12 @@ export default async function ClientsPage(props: ClientsPageProps) { description={t("manageClientsDescription")} /> - + ); } diff --git a/src/components/ClientsDataTable.tsx b/src/components/ClientsDataTable.tsx index ce085bdd..cf42e7bc 100644 --- a/src/components/ClientsDataTable.tsx +++ b/src/components/ClientsDataTable.tsx @@ -5,6 +5,12 @@ import { } from "@tanstack/react-table"; import { DataTable } from "@app/components/ui/data-table"; +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + interface DataTableProps { columns: ColumnDef[]; data: TData[]; @@ -13,6 +19,9 @@ interface DataTableProps { addClient?: () => void; columnVisibility?: Record; enableColumnVisibility?: boolean; + hideHeader?: boolean; + tabs?: TabFilter[]; + defaultTab?: string; } export function ClientsDataTable({ @@ -22,22 +31,27 @@ export function ClientsDataTable({ onRefresh, isRefreshing, columnVisibility, - enableColumnVisibility + enableColumnVisibility, + hideHeader = false, + tabs, + defaultTab }: DataTableProps) { return ( ); } diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx index da00c933..0a4c769f 100644 --- a/src/components/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -1,12 +1,25 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ClientsDataTable } from "@app/components/ClientsDataTable"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel, + VisibilityState +} from "@tanstack/react-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger + DropdownMenuTrigger, + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { @@ -15,11 +28,15 @@ import { ArrowUpRight, Check, MoreHorizontal, - X + X, + RefreshCw, + Columns, + Search, + Plus } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; @@ -28,6 +45,24 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import { Badge } from "./ui/badge"; import { InfoPopup } from "./ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@app/components/ui/tabs"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; export type ClientRow = { id: number; @@ -46,23 +81,93 @@ export type ClientRow = { }; type ClientTableProps = { - clients: ClientRow[]; + userClients: ClientRow[]; + machineClients: ClientRow[]; orgId: string; + defaultView?: "user" | "machine"; }; -export default function ClientsTable({ clients, orgId }: ClientTableProps) { - const router = useRouter(); +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + getTablePageSize: (tableId?: string) => + tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE +}; + +const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { + if (typeof window === "undefined") return defaultSize; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = parseInt(stored, 10); + if (parsed > 0 && parsed <= 1000) { + return parsed; + } + } + } catch (error) { + console.warn("Failed to read page size from localStorage:", error); + } + return defaultSize; +}; + +const setStoredPageSize = (pageSize: number, tableId?: string): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + localStorage.setItem(key, pageSize.toString()); + } catch (error) { + console.warn("Failed to save page size to localStorage:", error); + } +}; + +export default function ClientsTable({ + userClients, + machineClients, + orgId, + defaultView = "user" +}: ClientTableProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const t = useTranslations(); + + const [userPageSize, setUserPageSize] = useState(() => + getStoredPageSize("user-clients", 20) + ); + const [machinePageSize, setMachinePageSize] = useState(() => + getStoredPageSize("machine-clients", 20) + ); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); - const [rows, setRows] = useState(clients); const api = createApiClient(useEnvContext()); const [isRefreshing, setIsRefreshing] = useState(false); - const t = useTranslations(); + + const [userSorting, setUserSorting] = useState([]); + const [userColumnFilters, setUserColumnFilters] = + useState([]); + const [userGlobalFilter, setUserGlobalFilter] = useState([]); + + const [machineSorting, setMachineSorting] = useState([]); + const [machineColumnFilters, setMachineColumnFilters] = + useState([]); + const [machineGlobalFilter, setMachineGlobalFilter] = useState([]); + + const [userColumnVisibility, setUserColumnVisibility] = useState({ + client: false, + subnet: false + }); + const [machineColumnVisibility, setMachineColumnVisibility] = useState({ + client: false, + subnet: false, + userId: false + }); + + const currentView = searchParams.get("view") || defaultView; const refreshData = async () => { console.log("Data refreshed"); @@ -81,6 +186,18 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { } }; + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams); + if (value === "machine") { + params.set("view", "machine"); + } else { + params.delete("view"); + } + + const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`; + router.replace(newUrl, { scroll: false }); + }; + const deleteClient = (clientId: number) => { api.delete(`/client/${clientId}`) .catch((e) => { @@ -94,13 +211,60 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { .then(() => { router.refresh(); setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== clientId); - - setRows(newRows); }); }; + const getSearchInput = () => { + if (currentView === "machine") { + return ( +
+ + machineTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+ ); + } + return ( +
+ + userTable.setGlobalFilter(String(e.target.value)) + } + className="w-full pl-8" + /> + +
+ ); + }; + + const getActionButton = () => { + // Only show create button on machine clients tab + if (currentView === "machine") { + return ( + + ); + } + return null; + }; + + const columns: ColumnDef[] = [ { accessorKey: "name", @@ -342,6 +506,75 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { } ]; + const userTable = useReactTable({ + data: userClients || [], + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setUserSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setUserColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setUserGlobalFilter, + onColumnVisibilityChange: setUserColumnVisibility, + initialState: { + pagination: { + pageSize: userPageSize, + pageIndex: 0 + }, + columnVisibility: { + client: false, + subnet: false + } + }, + state: { + sorting: userSorting, + columnFilters: userColumnFilters, + globalFilter: userGlobalFilter, + columnVisibility: userColumnVisibility + } + }); + + const machineTable = useReactTable({ + data: machineClients || [], + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setMachineSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setMachineColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setMachineGlobalFilter, + onColumnVisibilityChange: setMachineColumnVisibility, + initialState: { + pagination: { + pageSize: machinePageSize, + pageIndex: 0 + }, + columnVisibility: { + client: false, + subnet: false, + userId: false + } + }, + state: { + sorting: machineSorting, + columnFilters: machineColumnFilters, + globalFilter: machineGlobalFilter, + columnVisibility: machineColumnVisibility + } + }); + + const handleUserPageSizeChange = (newPageSize: number) => { + setUserPageSize(newPageSize); + setStoredPageSize(newPageSize, "user-clients"); + }; + + const handleMachinePageSizeChange = (newPageSize: number) => { + setMachinePageSize(newPageSize); + setStoredPageSize(newPageSize, "machine-clients"); + }; + return ( <> {selectedClient && ( @@ -364,20 +597,305 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { /> )} - { - router.push(`/${orgId}/settings/clients/create`); - }} - onRefresh={refreshData} - isRefreshing={isRefreshing} - columnVisibility={{ - client: false, - subnet: false - }} - enableColumnVisibility={true} - /> +
+ + + +
+ {getSearchInput()} + + + + {t("clientsTableUserClients")} + + + {t("clientsTableMachineClients")} + + +
+
+ {currentView === "user" && userTable.getAllColumns().some((column) => column.getCanHide()) && ( + + + + + + + {t("toggleColumns") || "Toggle columns"} + + + {userTable + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {typeof column.columnDef.header === "string" + ? column.columnDef.header + : column.id} + + ); + })} + + + )} + {currentView === "machine" && machineTable.getAllColumns().some((column) => column.getCanHide()) && ( + + + + + + + {t("toggleColumns") || "Toggle columns"} + + + {machineTable + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {typeof column.columnDef.header === "string" + ? column.columnDef.header + : column.id} + + ); + })} + + + )} +
+ +
+
{getActionButton()}
+
+
+ + +
+ + + {t("clientsTableUserClientsDescription")} + + +
+ + + {userTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => header.column.getIsVisible()) + .map( + (header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ))} + + + {userTable.getRowModel().rows + ?.length ? ( + userTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+ +
+
+ +
+ + + {t("clientsTableMachineClientsDescription")} + + +
+ + + {machineTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => header.column.getIsVisible()) + .map( + (header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ))} + + + {machineTable.getRowModel().rows + ?.length ? ( + machineTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+ +
+
+
+
+
+
); }