From ed3ee64e4b78ad1b7f2895bd31aeb93a4069d14e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 28 Jan 2026 03:04:12 +0100 Subject: [PATCH 01/47] =?UTF-8?q?=E2=9C=A8=20support=20pathname=20in=20log?= =?UTF-8?q?o=20URL=20in=20branding=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- messages/en-US.json | 3 + .../loginPage/upsertLoginPageBranding.ts | 31 ++++++-- src/components/AuthPageBrandingForm.tsx | 70 ++++++++++++++----- .../resource-target-address-item.tsx | 1 + src/lib/validateLocalPath.ts | 16 +++++ 6 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 src/lib/validateLocalPath.ts diff --git a/.gitignore b/.gitignore index df9179a43..d2cdfa690 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ dynamic/ scratch/ tsconfig.json hydrateSaas.ts -CLAUDE.md \ No newline at end of file +CLAUDE.md +zaneops.* \ No newline at end of file diff --git a/messages/en-US.json b/messages/en-US.json index f2affe11a..46f9092a2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1877,6 +1877,9 @@ "authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?", "authPageBrandingDeleteConfirm": "Confirm Delete Branding", "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logo URL or Path", + "brandingLogoPathDescription": "Enter a URL (https://...) or a local path (/logo.png) from the public/ directory on your Pangolin installation.", + "brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.", "brandingPrimaryColor": "Primary Color", "brandingLogoWidth": "Width (px)", "brandingLogoHeight": "Height (px)", diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index e6e365be7..17f5fbbc5 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -29,6 +29,7 @@ import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { build } from "@server/build"; import config from "@server/private/lib/config"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; const paramsSchema = z.strictObject({ orgId: z.string() @@ -39,14 +40,36 @@ const bodySchema = z.strictObject({ .union([ z.literal(""), z - .url("Must be a valid URL") - .superRefine(async (url, ctx) => { + .string() + .superRefine(async (urlOrPath, ctx) => { + const parseResult = z.url().safeParse(urlOrPath); + if (!parseResult.success) { + if (build !== "enterprise") { + ctx.addIssue({ + code: "custom", + message: "Must be a valid URL" + }); + return; + } else { + try { + validateLocalPath(urlOrPath); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + }); + } finally { + return; + } + } + } + try { - const response = await fetch(url, { + const response = await fetch(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET - return fetch(url, { method: "GET" }); + return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index 7bf563f40..1246244b2 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -1,9 +1,5 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { startTransition, useActionState, useState } from "react"; -import { useForm } from "react-hook-form"; -import z from "zod"; import { Form, FormControl, @@ -13,6 +9,11 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; import { SettingsSection, SettingsSectionBody, @@ -21,20 +22,19 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; -import { useTranslations } from "next-intl"; -import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; -import { Input } from "./ui/input"; -import { ExternalLink, InfoIcon, XIcon } from "lucide-react"; -import { Button } from "./ui/button"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useRouter } from "next/navigation"; -import { toast } from "@app/hooks/useToast"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { build } from "@server/build"; +import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { XIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; export type AuthPageCustomizationProps = { orgId: string; @@ -44,13 +44,36 @@ export type AuthPageCustomizationProps = { const AuthPageFormSchema = z.object({ logoUrl: z.union([ z.literal(""), - z.url("Must be a valid URL").superRefine(async (url, ctx) => { + z.string().superRefine(async (urlOrPath, ctx) => { + const parseResult = z.url().safeParse(urlOrPath); + if (!parseResult.success) { + if (build !== "enterprise") { + ctx.addIssue({ + code: "custom", + message: "Must be a valid URL" + }); + return; + } else { + try { + validateLocalPath(urlOrPath); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: + "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + }); + } finally { + return; + } + } + } + try { - const response = await fetch(url, { + const response = await fetch(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET - return fetch(url, { method: "GET" }); + return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { @@ -270,12 +293,25 @@ export default function AuthPageBrandingForm({ render={({ field }) => ( - {t("brandingLogoURL")} + {build === "enterprise" + ? t( + "brandingLogoURLOrPath" + ) + : t("brandingLogoURL")} + + {build === "enterprise" + ? t( + "brandingLogoPathDescription" + ) + : t( + "brandingLogoURLDescription" + )} + )} /> diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 3c4cb9279..6479ede76 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -20,6 +20,7 @@ import { import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; +import { useEffect } from "react"; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; diff --git a/src/lib/validateLocalPath.ts b/src/lib/validateLocalPath.ts new file mode 100644 index 000000000..7f87eb440 --- /dev/null +++ b/src/lib/validateLocalPath.ts @@ -0,0 +1,16 @@ +export function validateLocalPath(value: string) { + try { + const url = new URL("https://pangoling.net" + value); + if ( + url.pathname !== value || + value.includes("..") || + value.includes("*") + ) { + throw new Error("Invalid Path"); + } + } catch { + throw new Error( + "should be a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + ); + } +} \ No newline at end of file From 38ac4c59805342a9b139d2bf0e9b93ede0897cc4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 28 Jan 2026 04:46:54 +0100 Subject: [PATCH 02/47] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20paginated=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 4 +-- src/app/[orgId]/settings/sites/page.tsx | 2 -- src/components/SitesTable.tsx | 47 +++++++++---------------- src/lib/queries.ts | 40 ++++++++++----------- 4 files changed, 36 insertions(+), 57 deletions(-) diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 4fe05c265..68fa05b13 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -77,7 +77,7 @@ const listSitesSchema = z.object({ limit: z .string() .optional() - .default("1000") + .default("1") .transform(Number) .pipe(z.int().positive()), offset: z @@ -130,7 +130,7 @@ type SiteWithUpdateAvailable = Awaited>[0] & { export type ListSitesResponse = { sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number }; + pagination: { total: number; limit: number; offset: number; }; }; registry.registerPath({ diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 85f0e2b1a..877eb594c 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -60,8 +60,6 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> - {/* */} - (null); const [rows, setRows] = useState(sites); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, startTransition] = useTransition(); const api = createApiClient(useEnvContext()); const t = useTranslations(); - const { env } = useEnvContext(); - - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(sites); - }, [sites]); const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); try { - await new Promise((resolve) => setTimeout(resolve, 200)); router.refresh(); } catch (error) { toast({ @@ -84,8 +71,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { description: t("refreshError"), variant: "destructive" }); - } finally { - setIsRefreshing(false); } }; @@ -456,7 +441,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { createSite={() => router.push(`/${orgId}/settings/sites/create`) } - onRefresh={refreshData} + onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} columnVisibility={{ niceId: false, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f0dfa811a..45746ed3a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -113,7 +113,7 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string }) => + users: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -124,7 +124,7 @@ export const orgQueries = { return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string }) => + roles: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -136,7 +136,7 @@ export const orgQueries = { } }), - sites: ({ orgId }: { orgId: string }) => + sites: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "SITES"] as const, queryFn: async ({ signal, meta }) => { @@ -147,7 +147,7 @@ export const orgQueries = { } }), - domains: ({ orgId }: { orgId: string }) => + domains: ({ orgId }: { orgId: string; }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, queryFn: async ({ signal, meta }) => { @@ -169,7 +169,7 @@ export const orgQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ - idps: { idpId: number; name: string }[]; + idps: { idpId: number; name: string; }[]; }> >( build === "saas" || useOrgOnlyIdp @@ -188,23 +188,19 @@ export const logAnalyticsFiltersSchema = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .optional(), + .optional().catch(undefined), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .optional(), - resourceId: z - .string() - .optional() - .transform(Number) - .pipe(z.int().positive()) - .optional() + .optional().catch(undefined), + resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; + export const logQueries = { requestAnalytics: ({ orgId, @@ -234,7 +230,7 @@ export const logQueries = { }; export const resourceQueries = { - resourceUsers: ({ resourceId }: { resourceId: number }) => + resourceUsers: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -244,7 +240,7 @@ export const resourceQueries = { return res.data.data.users; } }), - resourceRoles: ({ resourceId }: { resourceId: number }) => + resourceRoles: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -255,7 +251,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceUsers: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -265,7 +261,7 @@ export const resourceQueries = { return res.data.data.users; } }), - siteResourceRoles: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceRoles: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -276,7 +272,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceClients: ({ siteResourceId }: { siteResourceId: number }) => + siteResourceClients: ({ siteResourceId }: { siteResourceId: number; }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "CLIENTS"] as const, queryFn: async ({ signal, meta }) => { @@ -287,7 +283,7 @@ export const resourceQueries = { return res.data.data.clients; } }), - resourceTargets: ({ resourceId }: { resourceId: number }) => + resourceTargets: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryFn: async ({ signal, meta }) => { @@ -298,7 +294,7 @@ export const resourceQueries = { return res.data.data.targets; } }), - resourceWhitelist: ({ resourceId }: { resourceId: number }) => + resourceWhitelist: ({ resourceId }: { resourceId: number; }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, queryFn: async ({ signal, meta }) => { @@ -371,7 +367,7 @@ export const approvalQueries = { } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[] }> + AxiosResponse<{ approvals: ApprovalItem[]; }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); @@ -383,7 +379,7 @@ export const approvalQueries = { queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse<{ count: number }> + AxiosResponse<{ count: number; }> >(`/org/${orgId}/approvals/count?approvalState=pending`, { signal }); From c89c1a03da759aa675b2c15f968e2e74d39afa9d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:05:34 +0100 Subject: [PATCH 03/47] =?UTF-8?q?=F0=9F=8E=A8=20use=20prettier=20for=20for?= =?UTF-8?q?matting=20typescript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 767e57b5e..5092cb6c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" From 01a2820390847edec61898203687092ab6d6c5bf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:07:27 +0100 Subject: [PATCH 04/47] =?UTF-8?q?=F0=9F=9A=A7=20POC:=20pagination=20in=20s?= =?UTF-8?q?ites=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 34 ++--- src/app/[orgId]/settings/sites/page.tsx | 27 +++- src/components/SitesDataTable.tsx | 50 -------- src/components/SitesTable.tsx | 66 +++++++--- src/components/ui/data-table.tsx | 158 ++++++++++++++++-------- src/lib/queries.ts | 35 +++--- 6 files changed, 217 insertions(+), 153 deletions(-) delete mode 100644 src/components/SitesDataTable.tsx diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 68fa05b13..dab79c8d9 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -74,18 +74,20 @@ const listSitesParamsSchema = z.strictObject({ }); const listSitesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) }); function querySites(orgId: string, accessibleSiteIds: number[]) { @@ -130,7 +132,7 @@ type SiteWithUpdateAvailable = Awaited>[0] & { export type ListSitesResponse = { sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number; }; + pagination: { total: number; pageSize: number; page: number }; }; registry.registerPath({ @@ -160,7 +162,7 @@ export async function listSites( ) ); } - const { limit, offset } = parsedQuery.data; + const { pageSize, page } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -216,7 +218,9 @@ export async function listSites( ) ); - const sitesList = await baseQuery.limit(limit).offset(offset); + const sitesList = await baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; @@ -267,8 +271,8 @@ export async function listSites( sites: sitesWithUpdates, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 877eb594c..69bb599c7 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server"; type SitesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function SitesPage(props: SitesPageProps) { const params = await props.params; + + const searchParams = new URLSearchParams(await props.searchParams); + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/sites`, + `/org/${params.orgId}/sites?${searchParams.toString()}`, await authCookieHeader() ); - sites = res.data.data.sites; + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; } catch (e) {} const t = await getTranslations(); @@ -67,7 +78,17 @@ export default async function SitesPage(props: SitesPageProps) { - + ); } diff --git a/src/components/SitesDataTable.tsx b/src/components/SitesDataTable.tsx deleted file mode 100644 index 125f4d59a..000000000 --- a/src/components/SitesDataTable.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - createSite?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; - columnVisibility?: Record; - enableColumnVisibility?: boolean; -} - -export function SitesDataTable({ - columns, - data, - createSite, - onRefresh, - isRefreshing, - columnVisibility, - enableColumnVisibility -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 58c2366b3..497715b19 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,10 +1,14 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { SitesDataTable } from "@app/components/SitesDataTable"; + import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DataTable, + ExtendedColumnDef, + type DataTablePaginationState +} from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -26,7 +30,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; export type SiteRow = { @@ -48,15 +52,21 @@ export type SiteRow = { type SitesTableProps = { sites: SiteRow[]; + pagination: DataTablePaginationState; orgId: string; }; -export default function SitesTable({ sites, orgId }: SitesTableProps) { +export default function SitesTable({ + sites, + orgId, + pagination +}: SitesTableProps) { const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); - const [rows, setRows] = useState(sites); const [isRefreshing, startTransition] = useTransition(); const api = createApiClient(useEnvContext()); @@ -87,10 +97,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { .then(() => { router.refresh(); setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== siteId); - - setRows(newRows); }); }; @@ -413,6 +419,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } ]; + console.log({ + sites, + pagination + }); + return ( <> {selectedSite && ( @@ -429,27 +440,50 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } buttonText={t("siteConfirmDelete")} - onConfirm={async () => deleteSite(selectedSite!.id)} + onConfirm={async () => + startTransition(() => deleteSite(selectedSite!.id)) + } string={selectedSite.name} title={t("siteDelete")} /> )} - - router.push(`/${orgId}/settings/sites/create`) - } + data={sites} + persistPageSize="sites-table" + title={t("sites")} + searchPlaceholder={t("searchSitesProgress")} + manualFiltering + pagination={pagination} + onPaginationChange={(newPage) => { + console.log({ + newPage + }); + const sp = new URLSearchParams(searchParams); + sp.set("page", (newPage.pageIndex + 1).toString()); + sp.set("pageSize", newPage.pageSize.toString()); + startTransition(() => + router.push(`${pathname}?${sp.toString()}`) + ); + }} + onAdd={() => router.push(`/${orgId}/settings/sites/create`)} + addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} + defaultSort={{ + id: "name", + desc: false + }} columnVisibility={{ niceId: false, nice: false, exitNode: false, address: false }} - enableColumnVisibility={true} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index af61bb53d..bb350577d 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -151,11 +151,20 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; + filterFn: ( + row: any, + selectedValues: (string | number | boolean)[] + ) => boolean; defaultValues?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; +export type DataTablePaginationState = PaginationState & { + pageCount: number; +}; + +export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; @@ -178,6 +187,11 @@ type DataTableProps = { defaultPageSize?: number; columnVisibility?: Record; enableColumnVisibility?: boolean; + manualFiltering?: boolean; + onSearch?: (input: string) => void; + searchValue?: string; + pagination?: DataTablePaginationState; + onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") @@ -203,7 +217,12 @@ export function DataTable({ columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, persistColumnVisibility = false, + manualFiltering = false, + pagination: paginationState, stickyLeftColumn, + onSearch, + searchValue, + onPaginationChange, stickyRightColumn }: DataTableProps) { const t = useTranslations(); @@ -248,22 +267,25 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ); - const [pagination, setPagination] = useState({ + const [_pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize }); + + const pagination = paginationState ?? _pagination; + const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); - const [activeFilters, setActiveFilters] = useState>( - () => { - const initial: Record = {}; - filters?.forEach((filter) => { - initial[filter.id] = filter.defaultValues || []; - }); - return initial; - } - ); + const [activeFilters, setActiveFilters] = useState< + Record + >(() => { + const initial: Record = {}; + filters?.forEach((filter) => { + initial[filter.id] = filter.defaultValues || []; + }); + return initial; + }); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); @@ -309,7 +331,16 @@ export function DataTable({ getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, onColumnVisibilityChange: setColumnVisibility, - onPaginationChange: setPagination, + onPaginationChange: onPaginationChange + ? (state) => { + const newState = + typeof state === "function" ? state(pagination) : state; + onPaginationChange(newState); + } + : setPagination, + manualFiltering, + manualPagination: !!paginationState, + pageCount: paginationState?.pageCount, initialState: { pagination: { pageSize: pageSize, @@ -368,11 +399,11 @@ export function DataTable({ setActiveFilters((prev) => { const currentValues = prev[filterId] || []; const filter = filters?.find((f) => f.id === filterId); - + if (!filter) return prev; let newValues: (string | number | boolean)[]; - + if (filter.multiSelect) { // Multi-select: add or remove the value if (checked) { @@ -397,7 +428,7 @@ export function DataTable({ // Calculate display text for a filter based on selected values const getFilterDisplayText = (filter: DataTableFilter): string => { const selectedValues = activeFilters[filter.id] || []; - + if (selectedValues.length === 0) { return filter.label; } @@ -477,12 +508,14 @@ export function DataTable({
- table.setGlobalFilter( - String(e.target.value) - ) - } + value={searchValue ?? globalFilter ?? ""} + onChange={(e) => { + onSearch + ? onSearch(e.currentTarget.value) + : table.setGlobalFilter( + String(e.target.value) + ); + }} className="w-full pl-8" /> @@ -490,13 +523,17 @@ export function DataTable({ {filters && filters.length > 0 && (
{filters.map((filter) => { - const selectedValues = activeFilters[filter.id] || []; - const hasActiveFilters = selectedValues.length > 0; - const displayMode = filter.displayMode || filterDisplayMode; - const displayText = displayMode === "calculated" - ? getFilterDisplayText(filter) - : filter.label; - + const selectedValues = + activeFilters[filter.id] || []; + const hasActiveFilters = + selectedValues.length > 0; + const displayMode = + filter.displayMode || filterDisplayMode; + const displayText = + displayMode === "calculated" + ? getFilterDisplayText(filter) + : filter.label; + return ( @@ -507,37 +544,54 @@ export function DataTable({ > {displayText} - {displayMode === "label" && hasActiveFilters && ( - - {selectedValues.length} - - )} + {displayMode === "label" && + hasActiveFilters && ( + + { + selectedValues.length + } + + )} - + {filter.label} - {filter.options.map((option) => { - const isChecked = selectedValues.includes(option.value); - return ( - - handleFilterChange( - filter.id, - option.value, + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + e.preventDefault()} - > - {option.label} - - ); - })} + ) => + handleFilterChange( + filter.id, + option.value, + checked + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} ); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 45746ed3a..6c8e67c0b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -113,7 +113,7 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string; }) => + users: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -124,7 +124,7 @@ export const orgQueries = { return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string; }) => + roles: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -136,7 +136,7 @@ export const orgQueries = { } }), - sites: ({ orgId }: { orgId: string; }) => + sites: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "SITES"] as const, queryFn: async ({ signal, meta }) => { @@ -147,7 +147,7 @@ export const orgQueries = { } }), - domains: ({ orgId }: { orgId: string; }) => + domains: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, queryFn: async ({ signal, meta }) => { @@ -169,7 +169,7 @@ export const orgQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ - idps: { idpId: number; name: string; }[]; + idps: { idpId: number; name: string }[]; }> >( build === "saas" || useOrgOnlyIdp @@ -188,19 +188,20 @@ export const logAnalyticsFiltersSchema = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .optional().catch(undefined), + .optional() + .catch(undefined), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .optional().catch(undefined), + .optional() + .catch(undefined), resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; - export const logQueries = { requestAnalytics: ({ orgId, @@ -230,7 +231,7 @@ export const logQueries = { }; export const resourceQueries = { - resourceUsers: ({ resourceId }: { resourceId: number; }) => + resourceUsers: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -240,7 +241,7 @@ export const resourceQueries = { return res.data.data.users; } }), - resourceRoles: ({ resourceId }: { resourceId: number; }) => + resourceRoles: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -251,7 +252,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceUsers: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { @@ -261,7 +262,7 @@ export const resourceQueries = { return res.data.data.users; } }), - siteResourceRoles: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceRoles: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { @@ -272,7 +273,7 @@ export const resourceQueries = { return res.data.data.roles; } }), - siteResourceClients: ({ siteResourceId }: { siteResourceId: number; }) => + siteResourceClients: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "CLIENTS"] as const, queryFn: async ({ signal, meta }) => { @@ -283,7 +284,7 @@ export const resourceQueries = { return res.data.data.clients; } }), - resourceTargets: ({ resourceId }: { resourceId: number; }) => + resourceTargets: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryFn: async ({ signal, meta }) => { @@ -294,7 +295,7 @@ export const resourceQueries = { return res.data.data.targets; } }), - resourceWhitelist: ({ resourceId }: { resourceId: number; }) => + resourceWhitelist: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "WHITELISTS"] as const, queryFn: async ({ signal, meta }) => { @@ -367,7 +368,7 @@ export const approvalQueries = { } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[]; }> + AxiosResponse<{ approvals: ApprovalItem[] }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); @@ -379,7 +380,7 @@ export const approvalQueries = { queryKey: ["APPROVALS", orgId, "COUNT", "pending"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse<{ count: number; }> + AxiosResponse<{ count: number }> >(`/org/${orgId}/approvals/count?approvalState=pending`, { signal }); From d374ea6ea655371493e92bb07c6b885df8559ad9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:07:41 +0100 Subject: [PATCH 05/47] =?UTF-8?q?=F0=9F=9A=A7wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 497715b19..77698a2e8 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -457,9 +457,6 @@ export default function SitesTable({ manualFiltering pagination={pagination} onPaginationChange={(newPage) => { - console.log({ - newPage - }); const sp = new URLSearchParams(searchParams); sp.set("page", (newPage.pageIndex + 1).toString()); sp.set("pageSize", newPage.pageSize.toString()); From b04385a3404267ae6b2cbc30b87623a0a0cb13b8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 29 Jan 2026 05:48:41 +0100 Subject: [PATCH 06/47] =?UTF-8?q?=F0=9F=9A=A7=20search=20on=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 14 ++++++++++++- package.json | 3 ++- server/routers/site/listSites.ts | 36 ++++++++++++++++++++++---------- src/components/SitesTable.tsx | 34 +++++++++++++++++------------- src/components/ui/data-table.tsx | 7 ++++--- 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a01c8c52..af864a576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,7 @@ "tailwind-merge": "3.4.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", @@ -13944,7 +13945,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -23240,6 +23240,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", + "integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-intl": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.7.0.tgz", diff --git a/package.json b/package.json index 25d94c4d4..5de7629d7 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "tailwind-merge": "3.4.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", @@ -152,6 +153,7 @@ "@types/express": "5.0.6", "@types/express-session": "1.18.2", "@types/jmespath": "0.15.2", + "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "9.0.10", "@types/node": "24.10.2", "@types/nodemailer": "7.0.4", @@ -164,7 +166,6 @@ "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.35", - "@types/js-yaml": "4.0.9", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.8", "esbuild": "0.27.2", diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index dab79c8d9..cefaeaf3c 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -4,7 +4,7 @@ import { remoteExitNodes } from "@server/db"; 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, ilike, inArray, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -87,10 +87,29 @@ const listSitesSchema = z.object({ .min(0) .optional() .catch(1) - .default(1) + .default(1), + query: z.string().optional() }); -function querySites(orgId: string, accessibleSiteIds: number[]) { +function querySites( + orgId: string, + accessibleSiteIds: number[], + query: string = "" +) { + let conditions = and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ); + + if (query) { + conditions = and( + conditions, + or( + ilike(sites.name, "%" + query + "%"), + ilike(sites.niceId, "%" + query + "%") + ) + ); + } return db .select({ siteId: sites.siteId, @@ -118,12 +137,7 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) ) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) - ); + .where(conditions); } type SiteWithUpdateAvailable = Awaited>[0] & { @@ -162,7 +176,7 @@ export async function listSites( ) ); } - const { pageSize, page } = parsedQuery.data; + const { pageSize, page, query } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -206,7 +220,7 @@ export async function listSites( } const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds); + const baseQuery = querySites(orgId, accessibleSiteIds, query); const countQuery = db .select({ count: count() }) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 77698a2e8..5cbc92f69 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -21,7 +21,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; -import { Column } from "@tanstack/react-table"; +import { Column, type PaginationState } from "@tanstack/react-table"; import { ArrowRight, ArrowUpDown, @@ -31,7 +31,8 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; export type SiteRow = { id: number; @@ -419,10 +420,20 @@ export default function SitesTable({ } ]; - console.log({ - sites, - pagination - }); + const handlePaginationChange = (newPage: PaginationState) => { + const sp = new URLSearchParams(searchParams); + sp.set("page", (newPage.pageIndex + 1).toString()); + sp.set("pageSize", newPage.pageSize.toString()); + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + }; + + // const = useDebouncedCallback() + + const handleSearchChange = useDebouncedCallback((query: string) => { + const sp = new URLSearchParams(searchParams); + sp.set("query", query); + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + }, 300); return ( <> @@ -456,15 +467,10 @@ export default function SitesTable({ searchPlaceholder={t("searchSitesProgress")} manualFiltering pagination={pagination} - onPaginationChange={(newPage) => { - const sp = new URLSearchParams(searchParams); - sp.set("page", (newPage.pageIndex + 1).toString()); - sp.set("pageSize", newPage.pageSize.toString()); - startTransition(() => - router.push(`${pathname}?${sp.toString()}`) - ); - }} + onPaginationChange={handlePaginationChange} onAdd={() => router.push(`/${orgId}/settings/sites/create`)} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index bb350577d..63e75f933 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -189,7 +189,7 @@ type DataTableProps = { enableColumnVisibility?: boolean; manualFiltering?: boolean; onSearch?: (input: string) => void; - searchValue?: string; + searchQuery?: string; pagination?: DataTablePaginationState; onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; @@ -221,7 +221,7 @@ export function DataTable({ pagination: paginationState, stickyLeftColumn, onSearch, - searchValue, + searchQuery, onPaginationChange, stickyRightColumn }: DataTableProps) { @@ -508,7 +508,8 @@ export function DataTable({
{ onSearch ? onSearch(e.currentTarget.value) From 89695df0129f5e72c2bc2d410dce2066012b938c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 30 Jan 2026 05:39:01 +0100 Subject: [PATCH 07/47] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20pagination=20and=20?= =?UTF-8?q?search=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 21 +- src/app/[orgId]/settings/sites/page.tsx | 4 +- src/components/SitesTable.tsx | 36 +- src/components/UserDevicesTable.tsx | 89 ++-- src/components/ui/data-table.tsx | 12 +- src/components/ui/manual-data-table.tsx | 567 ++++++++++++++++++++++++ 6 files changed, 667 insertions(+), 62 deletions(-) create mode 100644 src/components/ui/manual-data-table.tsx diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index cefaeaf3c..e77f63330 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -222,15 +222,24 @@ export async function listSites( const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const baseQuery = querySites(orgId, accessibleSiteIds, query); + let conditions = and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ); + if (query) { + conditions = and( + conditions, + or( + ilike(sites.name, "%" + query + "%"), + ilike(sites.niceId, "%" + query + "%") + ) + ); + } + const countQuery = db .select({ count: count() }) .from(sites) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) - ); + .where(conditions); const sitesList = await baseQuery .limit(pageSize) diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 69bb599c7..161c757f6 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -81,10 +81,8 @@ export default async function SitesPage(props: SitesPageProps) { router.push(`${pathname}?${sp.toString()}`)); }; - // const = useDebouncedCallback() - const handleSearchChange = useDebouncedCallback((query: string) => { const sp = new URLSearchParams(searchParams); sp.set("query", query); + sp.delete("page"); startTransition(() => router.push(`${pathname}?${sp.toString()}`)); }, 300); + console.log({ + pagination, + rowCount + }); + return ( <> {selectedSite && ( @@ -459,13 +464,11 @@ export default function SitesTable({ /> )} - router.push(`/${orgId}/settings/sites/create`)} @@ -474,10 +477,7 @@ export default function SitesTable({ addButtonText={t("siteAdd")} onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} - defaultSort={{ - id: "name", - desc: false - }} + rowCount={rowCount} columnVisibility={{ niceId: false, nice: false, diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 9d1469f1d..edc840882 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -13,7 +13,10 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint"; +import { + formatFingerprintInfo, + formatPlatform +} from "@app/lib/formatDeviceFingerprint"; import { ArrowRight, ArrowUpDown, @@ -188,9 +191,13 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { try { // Fetch approvalId for this client using clientId query parameter const approvalsRes = await api.get<{ - data: { approvals: Array<{ approvalId: number; clientId: number }> }; - }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); - + data: { + approvals: Array<{ approvalId: number; clientId: number }>; + }; + }>( + `/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}` + ); + const approval = approvalsRes.data.data.approvals[0]; if (!approval) { @@ -202,9 +209,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return; } - await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { - decision: "approved" - }); + await api.put( + `/org/${clientRow.orgId}/approvals/${approval.approvalId}`, + { + decision: "approved" + } + ); toast({ title: t("accessApprovalUpdated"), @@ -230,9 +240,13 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { try { // Fetch approvalId for this client using clientId query parameter const approvalsRes = await api.get<{ - data: { approvals: Array<{ approvalId: number; clientId: number }> }; - }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); - + data: { + approvals: Array<{ approvalId: number; clientId: number }>; + }; + }>( + `/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}` + ); + const approval = approvalsRes.data.data.approvals[0]; if (!approval) { @@ -244,9 +258,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return; } - await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { - decision: "denied" - }); + await api.put( + `/org/${clientRow.orgId}/approvals/${approval.approvalId}`, + { + decision: "denied" + } + ); toast({ title: t("accessApprovalUpdated"), @@ -398,7 +415,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "online", - friendlyName: t("connectivity"), + friendlyName: t("online"), header: ({ column }) => { return ( + + + + {filter.label} + + + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + { + // handleFilterChange( + // filter.id, + // option.value, + // checked + // ) + }} + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} + + + ); + })} +
+ )} +
+
+ {onRefresh && ( +
+ +
+ )} + {onAdd && addButtonText && ( +
+ +
+ )} +
+ + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnId = header.column.id; + const accessorKey = ( + header.column.columnDef as any + ).accessorKey as string | undefined; + const stickyClasses = + getStickyClasses( + columnId, + accessorKey + ); + const isRightSticky = + isStickyColumn( + columnId, + accessorKey, + "right" + ); + const hasHideableColumns = + enableColumnVisibility && + table + .getAllColumns() + .some((col) => + col.getCanHide() + ); + + return ( + + {header.isPlaceholder ? null : isRightSticky && + hasHideableColumns ? ( +
+ + + + + + + {t( + "toggleColumns" + ) || + "Toggle columns"} + + + {table + .getAllColumns() + .filter( + ( + column + ) => + column.getCanHide() + ) + .map( + ( + column + ) => { + const columnDef = + column.columnDef as any; + const friendlyName = + columnDef.friendlyName; + const displayName = + friendlyName || + (typeof columnDef.header === + "string" + ? columnDef.header + : column.id); + return ( + + column.toggleVisibility( + !!value + ) + } + onSelect={( + e + ) => + e.preventDefault() + } + > + { + displayName + } + + ); + } + )} + + +
+ {flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} +
+
+ ) : ( + flexRender( + header.column + .columnDef + .header, + header.getContext() + ) + )} +
+ ); + })} +
+ ))} +
+ + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => { + const columnId = + cell.column.id; + const accessorKey = ( + cell.column + .columnDef as any + ).accessorKey as + | string + | undefined; + const stickyClasses = + getStickyClasses( + columnId, + accessorKey + ); + const isRightSticky = + isStickyColumn( + columnId, + accessorKey, + "right" + ); + return ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results found. + + + )} + +
+
+
+ {rowCount > 0 && ( + + onPaginationChange({ + ...pagination, + pageSize + }) + } + onPageChange={(pageIndex) => { + onPaginationChange({ + ...pagination, + pageIndex + }); + }} + isServerPagination + pageSize={pagination.pageSize} + pageIndex={pagination.pageIndex} + /> + )} +
+
+ +
+ ); +} From 066305b09558aee3ed7bace863976afe5324deae Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 31 Jan 2026 00:45:14 +0100 Subject: [PATCH 08/47] =?UTF-8?q?=E2=9C=A8=20toggle=20column=20sorting=20&?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 73 ++++++++--------- src/components/SitesTable.tsx | 129 ++++++++++--------------------- src/hooks/useSortColumn.ts | 56 ++++++++++++++ src/lib/types/sort.ts | 1 + 4 files changed, 135 insertions(+), 124 deletions(-) create mode 100644 src/hooks/useSortColumn.ts create mode 100644 src/lib/types/sort.ts diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e77f63330..9c25897e8 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -4,7 +4,17 @@ import { remoteExitNodes } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { and, count, eq, ilike, inArray, or, sql } from "drizzle-orm"; +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + or, + sql +} from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -88,28 +98,15 @@ const listSitesSchema = z.object({ .optional() .catch(1) .default(1), - query: z.string().optional() + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc") }); -function querySites( - orgId: string, - accessibleSiteIds: number[], - query: string = "" -) { - let conditions = and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ); - - if (query) { - conditions = and( - conditions, - or( - ilike(sites.name, "%" + query + "%"), - ilike(sites.niceId, "%" + query + "%") - ) - ); - } +function querySitesBase() { return db .select({ siteId: sites.siteId, @@ -136,11 +133,10 @@ function querySites( .leftJoin( remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) - ) - .where(conditions); + ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; @@ -176,7 +172,7 @@ export async function listSites( ) ); } - const { pageSize, page, query } = parsedQuery.data; + const { pageSize, page, query, sort_by, order } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -220,7 +216,7 @@ export async function listSites( } const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds, query); + const baseQuery = querySitesBase(); let conditions = and( inArray(sites.siteId, accessibleSiteIds), @@ -241,23 +237,30 @@ export async function listSites( .from(sites) .where(conditions); - const sitesList = await baseQuery + const siteListQuery = baseQuery + .where(conditions) .limit(pageSize) .offset(pageSize * (page - 1)); + + if (sort_by) { + siteListQuery.orderBy( + order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by]) + ); + } const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); - const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( - (site) => { - const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; - // Initially set to false, will be updated if version check succeeds - siteWithUpdate.newtUpdateAvailable = false; - return siteWithUpdate; - } - ); + const sitesWithUpdates: SiteWithUpdateAvailable[] = ( + await siteListQuery + ).map((site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + // Initially set to false, will be updated if version check succeeds + siteWithUpdate.newtUpdateAvailable = false; + return siteWithUpdate; + }); // Try to get the latest version, but don't block if it fails try { diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 68a7fc374..f99da8895 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -12,15 +12,18 @@ import { } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useSortColumn } from "@app/hooks/useSortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; -import { Column, type PaginationState } from "@tanstack/react-table"; +import { type PaginationState } from "@tanstack/react-table"; import { + ArrowDown01Icon, ArrowRight, - ArrowUpDown, + ArrowUp10Icon, ArrowUpRight, + ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -71,6 +74,8 @@ export default function SitesTable({ const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); + const [getSortDirection, toggleSorting] = useSortColumn(); + const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -102,22 +107,15 @@ export default function SitesTable({ }); }; + const dataInOrder = getSortDirection("megabytesIn"); + const dataOutOrder = getSortDirection("megabytesOut"); + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("name")}; } }, { @@ -125,18 +123,8 @@ export default function SitesTable({ accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("identifier")}; }, cell: ({ row }) => { return {row.original.nice || "-"}; @@ -145,18 +133,8 @@ export default function SitesTable({ { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("online")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -187,16 +165,20 @@ export default function SitesTable({ { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); }, @@ -207,16 +189,20 @@ export default function SitesTable({ { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); }, @@ -227,18 +213,8 @@ export default function SitesTable({ { accessorKey: "type", friendlyName: t("type"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("type")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -283,18 +259,8 @@ export default function SitesTable({ { accessorKey: "exitNode", friendlyName: t("exitNode"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("exitNode")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -347,18 +313,8 @@ export default function SitesTable({ }, { accessorKey: "address", - header: ({ column }: { column: Column }) => { - return ( - - ); + header: () => { + return {t("address")}; }, cell: ({ row }: { row: any }) => { const originalRow = row.original; @@ -435,11 +391,6 @@ export default function SitesTable({ startTransition(() => router.push(`${pathname}?${sp.toString()}`)); }, 300); - console.log({ - pagination, - rowCount - }); - return ( <> {selectedSite && ( diff --git a/src/hooks/useSortColumn.ts b/src/hooks/useSortColumn.ts new file mode 100644 index 000000000..95fb673eb --- /dev/null +++ b/src/hooks/useSortColumn.ts @@ -0,0 +1,56 @@ +import type { SortOrder } from "@app/lib/types/sort"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { startTransition } from "react"; + +export function useSortColumn() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const toggleSorting = (column: string) => { + const sp = new URLSearchParams(searchParams); + + let nextDirection: SortOrder = "indeterminate"; + + if (sp.get("sort_by") === column) { + nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate"; + } + + switch (nextDirection) { + case "indeterminate": { + nextDirection = "asc"; + break; + } + case "asc": { + nextDirection = "desc"; + break; + } + default: { + nextDirection = "indeterminate"; + break; + } + } + + sp.delete("sort_by"); + sp.delete("order"); + + if (nextDirection !== "indeterminate") { + sp.set("sort_by", column); + sp.set("order", nextDirection); + } + + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + }; + + function getSortDirection(column: string) { + let currentDirection: SortOrder = "indeterminate"; + + if (searchParams.get("sort_by") === column) { + currentDirection = + (searchParams.get("order") as SortOrder) ?? "indeterminate"; + } + return currentDirection; + } + + return [getSortDirection, toggleSorting] as const; +} diff --git a/src/lib/types/sort.ts b/src/lib/types/sort.ts new file mode 100644 index 000000000..69161f5af --- /dev/null +++ b/src/lib/types/sort.ts @@ -0,0 +1 @@ +export type SortOrder = "asc" | "desc" | "indeterminate"; From cda6b67befb30b2bb0b501af9814359333c14279 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 31 Jan 2026 03:02:39 +0100 Subject: [PATCH 09/47] =?UTF-8?q?=E2=9C=A8=20search,=20filter=20&=20pagina?= =?UTF-8?q?te=20sites=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +- server/routers/site/listSites.ts | 14 ++- src/components/ColumnFilter.tsx | 16 ++-- src/components/ColumnFilterButton.tsx | 126 ++++++++++++++++++++++++++ src/components/OrgSelector.tsx | 2 +- src/components/SitesTable.tsx | 99 ++++++++++++++------ 6 files changed, 223 insertions(+), 37 deletions(-) create mode 100644 src/components/ColumnFilterButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index f2affe11a..8fffe02da 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1164,7 +1164,8 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", - "searchProgress": "Search...", + "searchPlaceholder": "Search...", + "emptySearchOptions": "No options found", "create": "Create", "orgs": "Organizations", "loginError": "An unexpected error occurred. Please try again.", diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 9c25897e8..1cc54fabd 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -103,7 +103,12 @@ const listSitesSchema = z.object({ .enum(["megabytesIn", "megabytesOut"]) .optional() .catch(undefined), - order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc") + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) }); function querySitesBase() { @@ -172,7 +177,6 @@ export async function listSites( ) ); } - const { pageSize, page, query, sort_by, order } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -215,6 +219,9 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } + const { pageSize, page, query, sort_by, order, online } = + parsedQuery.data; + const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const baseQuery = querySitesBase(); @@ -231,6 +238,9 @@ export async function listSites( ) ); } + if (typeof online !== "undefined") { + conditions = and(conditions, eq(sites.online, online)); + } const countQuery = db .select({ count: count() }) diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index a856984eb..3e7b585b8 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; interface FilterOption { value: string; @@ -61,16 +62,19 @@ export function ColumnFilter({ >
- - {selectedOption - ? selectedOption.label - : placeholder} - + + {selectedOption && ( + + {selectedOption + ? selectedOption.label + : placeholder} + + )}
- + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx new file mode 100644 index 000000000..7d17066cb --- /dev/null +++ b/src/components/ColumnFilterButton.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; + +interface FilterOption { + value: string; + label: string; +} + +interface ColumnFilterButtonProps { + options: FilterOption[]; + selectedValue?: string; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + label: string; +} + +export function ColumnFilterButton({ + options, + selectedValue, + onValueChange, + placeholder, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className, + label +}: ColumnFilterButtonProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find( + (option) => option.value === selectedValue + ); + + return ( + + + + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value + ? undefined + : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index b2939a90c..e139e43af 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -83,7 +83,7 @@ export function OrgSelector({ diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index f99da8895..5076149f9 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -24,6 +24,7 @@ import { ArrowUp10Icon, ArrowUpRight, ChevronsUpDownIcon, + Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -35,6 +36,9 @@ import { ManualDataTable, type ExtendedColumnDef } from "./ui/manual-data-table"; +import { ColumnFilter } from "./ColumnFilter"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import z from "zod"; export type SiteRow = { id: number; @@ -79,33 +83,57 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - const refreshData = async () => { - try { - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } - }; + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); - const deleteSite = (siteId: number) => { - api.delete(`/site/${siteId}`) - .catch((e) => { - console.error(t("siteErrorDelete"), e); - toast({ - variant: "destructive", - title: t("siteErrorDelete"), - description: formatAxiosError(e, t("siteErrorDelete")) - }); - }) - .then(() => { + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function refreshData() { + startTransition(async () => { + try { router.refresh(); - setIsDeleteModalOpen(false); - }); - }; + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + function deleteSite(siteId: number) { + startTransition(async () => { + await api + .delete(`/site/${siteId}`) + .catch((e) => { + console.error(t("siteErrorDelete"), e); + toast({ + variant: "destructive", + title: t("siteErrorDelete"), + description: formatAxiosError(e, t("siteErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }); + } const dataInOrder = getSortDirection("megabytesIn"); const dataOutOrder = getSortDirection("megabytesOut"); @@ -134,7 +162,24 @@ export default function SitesTable({ accessorKey: "online", friendlyName: t("online"), header: () => { - return {t("online")}; + return ( + + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> + ); }, cell: ({ row }) => { const originalRow = row.original; @@ -426,7 +471,7 @@ export default function SitesTable({ searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} addButtonText={t("siteAdd")} - onRefresh={() => startTransition(refreshData)} + onRefresh={refreshData} isRefreshing={isRefreshing} rowCount={rowCount} columnVisibility={{ From bb1a375484a1a4eccd117d97eb25eded67a2ab5a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 02:20:28 +0100 Subject: [PATCH 10/47] =?UTF-8?q?=E2=9C=A8=20paginate,=20search=20&=20filt?= =?UTF-8?q?er=20resources=20by=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 80 +++++++++----- .../[orgId]/settings/resources/proxy/page.tsx | 21 +++- src/components/ProxyResourcesTable.tsx | 104 +++++++++++++++--- src/components/SitesTable.tsx | 14 ++- ...ta-table.tsx => controlled-data-table.tsx} | 42 ++++--- src/hooks/useNavigationContext.ts | 36 ++++++ 6 files changed, 227 insertions(+), 70 deletions(-) rename src/components/ui/{manual-data-table.tsx => controlled-data-table.tsx} (96%) create mode 100644 src/hooks/useNavigationContext.ts diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c17e65a40..a60d27e6f 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -17,7 +17,7 @@ import { import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import { sql, eq, or, inArray, and, count, ilike } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -27,19 +27,30 @@ const listResourcesParamsSchema = z.strictObject({ }); const listResourcesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1), + query: z.string().optional(), + enabled: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined), + authState: z + .enum(["protected", "not_protected"]) + .optional() + .catch(undefined) }); // (resource fields + a single joined target) @@ -95,7 +106,7 @@ export type ResourceWithTargets = { }>; }; -function queryResources(accessibleResourceIds: number[], orgId: string) { +function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, @@ -147,18 +158,12 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) - ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) ); } export type ListResourcesResponse = { resources: ResourceWithTargets[]; - pagination: { total: number; limit: number; offset: number }; + pagination: { total: number; pageSize: number; page: number }; }; registry.registerPath({ @@ -190,7 +195,7 @@ export async function listResources( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, authState, enabled, query } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -252,14 +257,37 @@ export async function listResources( (resource) => resource.resourceId ); + let conditions = and( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId) + ) + ); + + if (query) { + conditions = and( + conditions, + or( + ilike(resources.name, "%" + query + "%"), + ilike(resources.fullDomain, "%" + query + "%") + ) + ); + } + if (typeof enabled !== "undefined") { + conditions = and(conditions, eq(resources.enabled, enabled)); + } + const countQuery: any = db .select({ count: count() }) .from(resources) - .where(inArray(resources.resourceId, accessibleResourceIds)); + .where(conditions); - const baseQuery = queryResources(accessibleResourceIds, orgId); + const baseQuery = queryResourcesBase(); - const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); + const rows: JoinedRow[] = await baseQuery + .where(conditions) + .limit(pageSize) + .offset(pageSize * (page - 1)); // avoids TS issues with reduce/never[] const map = new Map(); @@ -324,8 +352,8 @@ export async function listResources( resources: resourcesList, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 408a9352c..57505c53c 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -16,7 +16,7 @@ import { cache } from "react"; export interface ProxyResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ProxyResourcesPage( @@ -24,14 +24,22 @@ export default async function ProxyResourcesPage( ) { const params = await props.params; const t = await getTranslations(); + const searchParams = new URLSearchParams(await props.searchParams); let resources: ListResourcesResponse["resources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/resources`, + `/org/${params.orgId}/resources?${searchParams.toString()}`, await authCookieHeader() ); - resources = res.data.data.resources; + const responseData = res.data.data; + resources = responseData.resources; + pagination = responseData.pagination; } catch (e) {} let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; @@ -104,9 +112,10 @@ export default async function ProxyResourcesPage( diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 69b180c47..20eabc4d2 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -31,8 +31,14 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import type { PaginationState } from "@tanstack/react-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; export type TargetHealth = { targetId: number; @@ -117,18 +123,22 @@ function StatusIcon({ type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ProxyResourcesTable({ resources, orgId, - defaultSort + pagination, + rowCount }: ProxyResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -140,6 +150,7 @@ export default function ProxyResourcesTable({ useState(); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const refreshData = () => { startTransition(() => { @@ -236,7 +247,7 @@ export default function ProxyResourcesTable({ - + {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( @@ -456,7 +467,24 @@ export default function ProxyResourcesTable({ { accessorKey: "enabled", friendlyName: t("enabled"), - header: () => {t("enabled")}, + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), cell: ({ row }) => ( { + 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); + return ( <> {selectedResource && ( @@ -547,21 +611,27 @@ export default function ProxyResourcesTable({ /> )} - - router.push(`/${orgId}/settings/resources/proxy/create`) + startNavigation(() => { + router.push( + `/${orgId}/settings/resources/proxy/create` + ); + }) } addButtonText={t("resourceAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="proxy-resources" + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={{ niceId: false }} stickyLeftColumn="name" stickyRightColumn="actions" diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5076149f9..761177762 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -33,9 +33,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { - ManualDataTable, + ControlledDataTable, type ExtendedColumnDef -} from "./ui/manual-data-table"; +} from "./ui/controlled-data-table"; import { ColumnFilter } from "./ColumnFilter"; import { ColumnFilterButton } from "./ColumnFilterButton"; import z from "zod"; @@ -77,6 +77,7 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const [getSortDirection, toggleSorting] = useSortColumn(); @@ -460,14 +461,19 @@ export default function SitesTable({ /> )} - router.push(`/${orgId}/settings/sites/create`)} + onAdd={() => + startNavigation(() => + router.push(`/${orgId}/settings/sites/create`) + ) + } + isNavigatingToAddPage={isNavigatingToAddPage} searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} addButtonText={t("siteAdd")} diff --git a/src/components/ui/manual-data-table.tsx b/src/components/ui/controlled-data-table.tsx similarity index 96% rename from src/components/ui/manual-data-table.tsx rename to src/components/ui/controlled-data-table.tsx index 8653d465b..c6fb505cf 100644 --- a/src/components/ui/manual-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -64,7 +64,7 @@ type DataTableFilter = { export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; -type ManualDataTableProps = { +type ControlledDataTableProps = { columns: ExtendedColumnDef[]; rows: TData[]; tableId: string; @@ -72,12 +72,13 @@ type ManualDataTableProps = { onAdd?: () => void; onRefresh?: () => void; isRefreshing?: boolean; + isNavigatingToAddPage?: boolean; searchPlaceholder?: string; filters?: DataTableFilter[]; filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) columnVisibility?: Record; enableColumnVisibility?: boolean; - onSearch: (input: string) => void; + onSearch?: (input: string) => void; searchQuery?: string; onPaginationChange: DataTablePaginationUpdateFn; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column @@ -86,7 +87,7 @@ type ManualDataTableProps = { pagination: PaginationState; }; -export function ManualDataTable({ +export function ControlledDataTable({ columns, rows, addButtonText, @@ -105,8 +106,9 @@ export function ManualDataTable({ searchQuery, onPaginationChange, stickyRightColumn, - rowCount -}: ManualDataTableProps) { + rowCount, + isNavigatingToAddPage +}: ControlledDataTableProps) { const t = useTranslations(); const [columnFilters, setColumnFilters] = useState([]); @@ -217,17 +219,20 @@ export function ManualDataTable({
-
- - onSearch(e.currentTarget.value) - } - className="w-full pl-8" - /> - -
+ {onSearch && ( +
+ + onSearch(e.currentTarget.value) + } + className="w-full pl-8" + /> + +
+ )} + {filters && filters.length > 0 && (
{filters.map((filter) => { @@ -326,7 +331,10 @@ export function ManualDataTable({ )} {onAdd && addButtonText && (
- diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts new file mode 100644 index 000000000..71b7c5523 --- /dev/null +++ b/src/hooks/useNavigationContext.ts @@ -0,0 +1,36 @@ +import { useSearchParams, usePathname, useRouter } from "next/navigation"; +import { useTransition } from "react"; + +export function useNavigationContext() { + const router = useRouter(); + const searchParams = useSearchParams(); + const path = usePathname(); + const [isNavigating, startTransition] = useTransition(); + + function navigate({ + searchParams: params, + pathname = path, + replace = false + }: { + pathname?: string; + searchParams?: URLSearchParams; + replace?: boolean; + }) { + startTransition(() => { + const fullPath = pathname + (params ? `?${params.toString()}` : ""); + + if (replace) { + router.replace(fullPath); + } else { + router.push(fullPath); + } + }); + } + + return { + pathname: path, + searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable + navigate, + isNavigating + }; +} From 1fc40b301743d54967cdc8acdffdf9ae2dc0856a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 03:42:05 +0100 Subject: [PATCH 11/47] =?UTF-8?q?=E2=9C=A8=20filter=20by=20auth=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 72 ++++++++++++++- src/components/ProxyResourcesTable.tsx | 107 ++++++++--------------- 2 files changed, 107 insertions(+), 72 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a60d27e6f..a9c4d88cd 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -17,7 +17,18 @@ import { import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count, ilike } from "drizzle-orm"; +import { + sql, + eq, + or, + inArray, + and, + count, + ilike, + asc, + not, + isNull +} from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -48,7 +59,7 @@ const listResourcesSchema = z.object({ .optional() .catch(undefined), authState: z - .enum(["protected", "not_protected"]) + .enum(["protected", "not_protected", "none"]) .optional() .catch(undefined) }); @@ -277,9 +288,63 @@ export async function listResources( conditions = and(conditions, eq(resources.enabled, enabled)); } + if (typeof authState !== "undefined") { + switch (authState) { + case "none": + conditions = and(conditions, eq(resources.http, false)); + break; + case "protected": + conditions = and( + conditions, + or( + eq(resources.sso, true), + eq(resources.emailWhitelistEnabled, true), + not(isNull(resourceHeaderAuth.headerAuthId)), + not(isNull(resourcePincode.pincodeId)), + not(isNull(resourcePassword.passwordId)) + ) + ); + break; + case "not_protected": + conditions = and( + conditions, + not(eq(resources.sso, true)), + not(eq(resources.emailWhitelistEnabled, true)), + isNull(resourceHeaderAuth.headerAuthId), + isNull(resourcePincode.pincodeId), + isNull(resourcePassword.passwordId) + ); + break; + } + } + const countQuery: any = db .select({ count: count() }) .from(resources) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuth, + eq(resourceHeaderAuth.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) .where(conditions); const baseQuery = queryResourcesBase(); @@ -287,7 +352,8 @@ export async function listResources( const rows: JoinedRow[] = await baseQuery .where(conditions) .limit(pageSize) - .offset(pageSize * (page - 1)); + .offset(pageSize * (page - 1)) + .orderBy(asc(resources.resourceId)); // avoids TS issues with reduce/never[] const map = new Map(); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 20eabc4d2..f57601b05 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -185,23 +185,24 @@ export default function ProxyResourcesTable({ }; async function toggleResourceEnabled(val: boolean, resourceId: number) { - await api - .post>( + try { + await api.post>( `resource/${resourceId}`, { enabled: val } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourcesErrorUpdate"), - description: formatAxiosError( - e, - t("resourcesErrorUpdateDescription") - ) - }); + ); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) }); + } } function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { @@ -313,38 +314,14 @@ export default function ProxyResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("identifier")}, cell: ({ row }) => { return {row.original.nice || "-"}; } @@ -370,19 +347,7 @@ export default function ProxyResourcesTable({ id: "status", accessorKey: "status", friendlyName: t("status"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("status")}, cell: ({ row }) => { const resourceRow = row.original; return ; @@ -430,19 +395,23 @@ export default function ProxyResourcesTable({ { accessorKey: "authState", friendlyName: t("authentication"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("authState", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("authentication")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ( @@ -487,16 +456,16 @@ export default function ProxyResourcesTable({ ), cell: ({ row }) => ( - toggleResourceEnabled(val, row.original.id) + startTransition(() => + toggleResourceEnabled(val, row.original.id) + ) } /> ) From 67949b4968c8fb3c05f9c38ae75ed159f6fdc052 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 4 Feb 2026 04:10:08 +0100 Subject: [PATCH 12/47] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20healthStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a9c4d88cd..dc19cf550 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -61,6 +61,10 @@ const listResourcesSchema = z.object({ authState: z .enum(["protected", "not_protected", "none"]) .optional() + .catch(undefined), + healthStatus: z + .enum(["online", "degraded", "offline", "unknown"]) + .optional() .catch(undefined) }); @@ -206,7 +210,8 @@ export async function listResources( ) ); } - const { page, pageSize, authState, enabled, query } = parsedQuery.data; + const { page, pageSize, authState, enabled, query, healthStatus } = + parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -318,6 +323,15 @@ export async function listResources( } } + if (typeof healthStatus !== "undefined") { + switch (healthStatus) { + case "online": + break; + default: + break; + } + } + const countQuery: any = db .select({ count: count() }) .from(resources) From d309ec249e42605684bc79f971daf0bf81bb9ae6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Feb 2026 03:15:18 +0100 Subject: [PATCH 13/47] =?UTF-8?q?=E2=9C=A8=20filter=20resources=20by=20sta?= =?UTF-8?q?tus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/routers/resource/listResources.ts | 215 +++++++++++++++-------- server/routers/site/listSites.ts | 8 +- src/components/ProxyResourcesTable.tsx | 34 +++- 5 files changed, 185 insertions(+), 80 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3c9574704..98c134798 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -187,7 +187,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4137db3cb..f26ecc088 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -213,7 +213,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index dc19cf550..16b83e0ed 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -27,7 +27,8 @@ import { ilike, asc, not, - isNull + isNull, + type SQL } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; @@ -63,7 +64,7 @@ const listResourcesSchema = z.object({ .optional() .catch(undefined), healthStatus: z - .enum(["online", "degraded", "offline", "unknown"]) + .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) .optional() .catch(undefined) }); @@ -86,13 +87,18 @@ type JoinedRow = { domainId: string | null; headerAuthId: number | null; - targetId: number | null; - targetIp: string | null; - targetPort: number | null; - targetEnabled: boolean | null; + // total_targets: number; + // healthy_targets: number; + // unhealthy_targets: number; + // unknown_targets: number; - hcHealth: string | null; - hcEnabled: boolean | null; + // targetId: number | null; + // targetIp: string | null; + // targetPort: number | null; + // targetEnabled: boolean | null; + + // hcHealth: string | null; + // hcEnabled: boolean | null; }; // grouped by resource with targets[]) @@ -117,10 +123,68 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }>; }; +// Aggregate filters +const total_targets = count(targets.targetId); +const healthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 + ELSE 0 + END + ) `; +const unknown_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 + ELSE 0 + END + ) `; +const unhealthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 + ELSE 0 + END + ) `; + +function countResourcesBase() { + return db + .select({ count: count() }) + .from(resources) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuth, + eq(resourceHeaderAuth.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + ); +} + function queryResourcesBase() { return db .select({ @@ -140,14 +204,7 @@ function queryResourcesBase() { niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, - targetId: targets.targetId, - targetIp: targets.ip, - targetPort: targets.port, - targetEnabled: targets.enabled, - - hcHealth: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId }) .from(resources) .leftJoin( @@ -173,6 +230,13 @@ function queryResourcesBase() { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) + ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); } @@ -323,45 +387,52 @@ export async function listResources( } } + let aggregateFilters: SQL | null | undefined = null; + if (typeof healthStatus !== "undefined") { switch (healthStatus) { - case "online": + case "healthy": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = ${total_targets}` + ); break; - default: + case "degraded": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${unhealthy_targets} > 0` + ); + break; + case "no_targets": + aggregateFilters = sql`${total_targets} = 0`; + break; + case "offline": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = 0`, + sql`${unhealthy_targets} = ${total_targets}` + ); + break; + case "unknown": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${unknown_targets} = ${total_targets}` + ); break; } } - const countQuery: any = db - .select({ count: count() }) - .from(resources) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .where(conditions); + let baseQuery = queryResourcesBase(); + let countQuery = countResourcesBase().where(conditions); - const baseQuery = queryResourcesBase(); + if (aggregateFilters) { + // @ts-expect-error idk why this is causing a type error + baseQuery = baseQuery.having(aggregateFilters); + } + if (aggregateFilters) { + // @ts-expect-error idk why this is causing a type error + countQuery = countQuery.having(aggregateFilters); + } const rows: JoinedRow[] = await baseQuery .where(conditions) @@ -369,6 +440,27 @@ export async function listResources( .offset(pageSize * (page - 1)) .orderBy(asc(resources.resourceId)); + const resourceIdList = rows.map((row) => row.resourceId); + const allResourceTargets = + resourceIdList.length === 0 + ? [] + : await db + .select({ + targetId: targets.targetId, + resourceId: targets.resourceId, + ip: targets.ip, + port: targets.port, + enabled: targets.enabled, + healthStatus: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled + }) + .from(targets) + .where(sql`${targets.resourceId} in ${resourceIdList}`) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ); + // avoids TS issues with reduce/never[] const map = new Map(); @@ -396,30 +488,9 @@ export async function listResources( map.set(row.resourceId, entry); } - if ( - row.targetId != null && - row.targetIp && - row.targetPort != null && - row.targetEnabled != null - ) { - let healthStatus: "healthy" | "unhealthy" | "unknown" = - "unknown"; - - if (row.hcEnabled && row.hcHealth) { - healthStatus = row.hcHealth as - | "healthy" - | "unhealthy" - | "unknown"; - } - - entry.targets.push({ - targetId: row.targetId, - ip: row.targetIp, - port: row.targetPort, - enabled: row.targetEnabled, - healthStatus: healthStatus - }); - } + entry.targets = allResourceTargets.filter( + (t) => t.resourceId === entry.resourceId + ); } const resourcesList: ResourceWithTargets[] = Array.from(map.values()); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 1cc54fabd..8a0a85abd 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -111,6 +111,9 @@ const listSitesSchema = z.object({ .catch(undefined) }); +function countSitesBase() { + return db.select({ count: count() }).from(sites); +} function querySitesBase() { return db .select({ @@ -242,10 +245,7 @@ export async function listSites( conditions = and(conditions, eq(sites.online, online)); } - const countQuery = db - .select({ count: count() }) - .from(sites) - .where(conditions); + const countQuery = countSitesBase().where(conditions); const siteListQuery = baseQuery .where(conditions) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index f57601b05..ca8f0443e 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -45,7 +45,7 @@ export type TargetHealth = { ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }; export type ResourceRow = { @@ -347,7 +347,33 @@ export default function ProxyResourcesTable({ id: "status", accessorKey: "status", friendlyName: t("status"), - header: () => {t("status")}, + header: () => ( + + handleFilterChange("healthStatus", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("status")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ; @@ -558,6 +584,10 @@ export default function ProxyResourcesTable({ }); }, 300); + console.log({ + rowCount + }); + return ( <> {selectedResource && ( From 748af1d8cb57789726a5e5519244640b448c5904 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Feb 2026 05:21:25 +0100 Subject: [PATCH 14/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20cleanup=20code=20for?= =?UTF-8?q?=20searching=20&=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 69 +++++------------------- server/routers/site/listSites.ts | 31 ++++++----- 2 files changed, 30 insertions(+), 70 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 16b83e0ed..add3b2b5c 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -148,43 +148,6 @@ const unhealthy_targets = sql`SUM( END ) `; -function countResourcesBase() { - return db - .select({ count: count() }) - .from(resources) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .groupBy( - resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId - ); -} - function queryResourcesBase() { return db .select({ @@ -422,23 +385,20 @@ export async function listResources( } } - let baseQuery = queryResourcesBase(); - let countQuery = countResourcesBase().where(conditions); - - if (aggregateFilters) { - // @ts-expect-error idk why this is causing a type error - baseQuery = baseQuery.having(aggregateFilters); - } - if (aggregateFilters) { - // @ts-expect-error idk why this is causing a type error - countQuery = countQuery.having(aggregateFilters); - } - - const rows: JoinedRow[] = await baseQuery + const baseQuery = queryResourcesBase() .where(conditions) - .limit(pageSize) - .offset(pageSize * (page - 1)) - .orderBy(asc(resources.resourceId)); + .having(aggregateFilters ?? sql`1 = 1`); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count(baseQuery.as("filtered_resources")); + + const [rows, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(resources.resourceId)), + countQuery + ]); const resourceIdList = rows.map((row) => row.resourceId); const allResourceTargets = @@ -495,9 +455,6 @@ export async function listResources( const resourcesList: ResourceWithTargets[] = Array.from(map.values()); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0]?.count ?? 0; - return response(res, { data: { resources: resourcesList, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 8a0a85abd..e27a328a7 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -226,7 +226,6 @@ export async function listSites( parsedQuery.data; const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySitesBase(); let conditions = and( inArray(sites.siteId, accessibleSiteIds), @@ -245,27 +244,31 @@ export async function listSites( conditions = and(conditions, eq(sites.online, online)); } - const countQuery = countSitesBase().where(conditions); + const baseQuery = querySitesBase().where(conditions); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count(baseQuery.as("filtered_sites")); const siteListQuery = baseQuery - .where(conditions) .limit(pageSize) - .offset(pageSize * (page - 1)); - - if (sort_by) { - siteListQuery.orderBy( - order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by]) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(sites[sort_by]) + : desc(sites[sort_by]) + : asc(sites.siteId) ); - } - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + + const [totalCount, rows] = await Promise.all([ + countQuery, + siteListQuery + ]); // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); - const sitesWithUpdates: SiteWithUpdateAvailable[] = ( - await siteListQuery - ).map((site) => { + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; // Initially set to false, will be updated if version check succeeds siteWithUpdate.newtUpdateAvailable = false; From 609ffccd67641f6b642edc1fd92d781517923fc4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 5 Feb 2026 05:35:59 +0100 Subject: [PATCH 15/47] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20typescript?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/target/handleHealthcheckStatusMessage.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 2bfcff190..01cbdea81 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db .update(targetHealthCheck) .set({ - hcHealth: healthStatus.status + hcHealth: healthStatus.status as + | "unknown" + | "healthy" + | "unhealthy" }) .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); From 6c85171091245380313f8b0964fc839f8444c40b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 02:42:15 +0100 Subject: [PATCH 16/47] =?UTF-8?q?=E2=9C=A8serverside=20filter+paginate=20c?= =?UTF-8?q?lient=20resources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/routers/resource/listResources.ts | 32 ----- server/routers/site/listSites.ts | 2 +- .../siteResource/listAllSiteResourcesByOrg.ts | 127 ++++++++++++------ .../settings/resources/client/page.tsx | 33 ++--- src/components/ClientResourcesTable.tsx | 86 +++++++++--- src/components/ProxyResourcesTable.tsx | 16 +-- src/components/ui/controlled-data-table.tsx | 3 +- 8 files changed, 183 insertions(+), 118 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 98c134798..82bd80e00 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -219,7 +219,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index add3b2b5c..26a0d613d 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,38 +69,6 @@ const listResourcesSchema = z.object({ .catch(undefined) }); -// (resource fields + a single joined target) -type JoinedRow = { - resourceId: number; - niceId: string; - name: string; - ssl: boolean; - fullDomain: string | null; - passwordId: number | null; - sso: boolean; - pincodeId: number | null; - whitelist: boolean; - http: boolean; - protocol: string; - proxyPort: number | null; - enabled: boolean; - domainId: string | null; - headerAuthId: number | null; - - // total_targets: number; - // healthy_targets: number; - // unhealthy_targets: number; - // unknown_targets: number; - - // targetId: number | null; - // targetIp: string | null; - // targetPort: number | null; - // targetEnabled: boolean | null; - - // hcHealth: string | null; - // hcEnabled: boolean | null; -}; - // grouped by resource with targets[]) export type ResourceWithTargets = { resourceId: number; diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e27a328a7..c65f8d100 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -247,7 +247,7 @@ export async function listSites( const baseQuery = querySitesBase().where(conditions); // we need to add `as` so that drizzle filters the result as a subquery - const countQuery = db.$count(baseQuery.as("filtered_sites")); + const countQuery = db.$count(querySitesBase().where(conditions)); const siteListQuery = baseQuery .limit(pageSize) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index dee1eebc9..2392eb767 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq, and, asc, ilike, or } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -15,18 +15,22 @@ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ }); const listAllSiteResourcesByOrgQuerySchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1), + query: z.string().optional(), + mode: z.enum(["host", "cidr"]).optional().catch(undefined) }); export type ListAllSiteResourcesByOrgResponse = { @@ -35,8 +39,36 @@ export type ListAllSiteResourcesByOrgResponse = { siteNiceId: string; siteAddress: string | null; })[]; + pagination: { total: number; pageSize: number; page: number }; }; +function querySiteResourcesBase() { + return db + .select({ + siteResourceId: siteResources.siteResourceId, + siteId: siteResources.siteId, + orgId: siteResources.orgId, + niceId: siteResources.niceId, + name: siteResources.name, + mode: siteResources.mode, + protocol: siteResources.protocol, + proxyPort: siteResources.proxyPort, + destinationPort: siteResources.destinationPort, + destination: siteResources.destination, + enabled: siteResources.enabled, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress, + tcpPortRangeString: siteResources.tcpPortRangeString, + udpPortRangeString: siteResources.udpPortRangeString, + disableIcmp: siteResources.disableIcmp, + siteName: sites.name, + siteNiceId: sites.niceId, + siteAddress: sites.address + }) + .from(siteResources) + .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); +} + registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", @@ -80,39 +112,50 @@ export async function listAllSiteResourcesByOrg( } const { orgId } = parsedParams.data; - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, mode } = parsedQuery.data; + + let conditions = and(eq(siteResources.orgId, orgId)); + if (query) { + conditions = and( + conditions, + or( + ilike(siteResources.name, "%" + query + "%"), + ilike(siteResources.destination, "%" + query + "%"), + ilike(siteResources.alias, "%" + query + "%"), + ilike(siteResources.aliasAddress, "%" + query + "%"), + ilike(sites.name, "%" + query + "%") + ) + ); + } + + if (mode) { + conditions = and(conditions, eq(siteResources.mode, mode)); + } + + const baseQuery = querySiteResourcesBase().where(conditions); + + const countQuery = db.$count( + querySiteResourcesBase().where(conditions) + ); // Get all site resources for the org with site names - const siteResourcesList = await db - .select({ - siteResourceId: siteResources.siteResourceId, - siteId: siteResources.siteId, - orgId: siteResources.orgId, - niceId: siteResources.niceId, - name: siteResources.name, - mode: siteResources.mode, - protocol: siteResources.protocol, - proxyPort: siteResources.proxyPort, - destinationPort: siteResources.destinationPort, - destination: siteResources.destination, - enabled: siteResources.enabled, - alias: siteResources.alias, - aliasAddress: siteResources.aliasAddress, - tcpPortRangeString: siteResources.tcpPortRangeString, - udpPortRangeString: siteResources.udpPortRangeString, - disableIcmp: siteResources.disableIcmp, - siteName: sites.name, - siteNiceId: sites.niceId, - siteAddress: sites.address - }) - .from(siteResources) - .innerJoin(sites, eq(siteResources.siteId, sites.siteId)) - .where(eq(siteResources.orgId, orgId)) - .limit(limit) - .offset(offset); + const [siteResourcesList, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(siteResources.siteResourceId)), + countQuery + ]); - return response(res, { - data: { siteResources: siteResourcesList }, + return response(res, { + data: { + siteResources: siteResourcesList, + pagination: { + total: totalCount, + pageSize, + page + } + }, success: true, error: false, message: "Site resources retrieved successfully", diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index ac85520e9..f5e1a701d 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -14,7 +14,7 @@ import { redirect } from "next/navigation"; export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ClientResourcesPage( @@ -22,22 +22,24 @@ export default async function ClientResourcesPage( ) { const params = await props.params; const t = await getTranslations(); - - let resources: ListResourcesResponse["resources"] = []; - try { - const res = await internal.get>( - `/org/${params.orgId}/resources`, - await authCookieHeader() - ); - resources = res.data.data.resources; - } catch (e) {} + const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get< AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; + >( + `/org/${params.orgId}/site-resources?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + siteResources = responseData.siteResources; + pagination = responseData.pagination; } catch (e) {} let org = null; @@ -89,9 +91,10 @@ export default async function ClientResourcesPage( diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c49cde8d2..5dd9ae87a 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import { ColumnFilterButton } from "./ColumnFilterButton"; export type InternalResourceRow = { id: number; @@ -51,18 +56,22 @@ export type InternalResourceRow = { type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ClientResourcesTable({ internalResources, orgId, - defaultSort + pagination, + rowCount }: ClientResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -180,9 +189,24 @@ export default function ClientResourcesTable({ accessorKey: "mode", friendlyName: t("editInternalResourceDialogMode"), header: () => ( - - {t("editInternalResourceDialogMode")} - + handleFilterChange("mode", value)} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("editInternalResourceDialogMode")} + className="p-3" + /> ), cell: ({ row }) => { const resourceRow = row.original; @@ -300,6 +324,37 @@ export default function ClientResourcesTable({ } ]; + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (value) { + searchParams.set(column, value); + } + filter({ + searchParams + }); + } + + 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); + return ( <> {selectedInternalResource && ( @@ -327,19 +382,20 @@ export default function ClientResourcesTable({ /> )} - setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} + onSearch={handleSearchChange} onRefresh={refreshData} + onPaginationChange={handlePaginationChange} + pagination={pagination} + rowCount={rowCount} isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="internal-resources" + enableColumnVisibility columnVisibility={{ niceId: false, aliasAddress: false diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index ca8f0443e..a22d96b67 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -2,9 +2,8 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { DataTable } from "@app/components/ui/data-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -14,13 +13,14 @@ import { import { InfoPopup } from "@app/components/ui/info-popup"; import { Switch } from "@app/components/ui/switch"; 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 { UpdateResourceResponse } from "@server/routers/resource"; +import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { ArrowRight, - ArrowUpDown, CheckCircle2, ChevronDown, Clock, @@ -31,14 +31,12 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; -import { ControlledDataTable } from "./ui/controlled-data-table"; -import type { PaginationState } from "@tanstack/react-table"; -import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type TargetHealth = { targetId: number; @@ -584,10 +582,6 @@ export default function ProxyResourcesTable({ }); }, 300); - console.log({ - rowCount - }); - return ( <> {selectedResource && ( diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index c6fb505cf..88f033849 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -130,7 +130,8 @@ export function ControlledDataTable({ }); console.log({ - pagination + pagination, + rowCount }); const table = useReactTable({ From 0547396213cc89814dec60ece0b2691e2f0ddde9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 02:44:23 +0100 Subject: [PATCH 17/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20do=20not=20sort=20cl?= =?UTF-8?q?ient=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ClientResourcesTable.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5dd9ae87a..e3fcd11af 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -131,19 +131,7 @@ export default function ClientResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", From ccddb9244d71c8cddcd0becae04a39c57a558b60 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:14:03 +0100 Subject: [PATCH 18/47] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20add=20types=20on?= =?UTF-8?q?=20`mode`=20in=20sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/sqlite/schema/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f26ecc088..858563693 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -247,7 +247,7 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").notNull(), // "host" | "cidr" | "port" + mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: text("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode From d521e79662cecd9c6cec6ef08f94c537bba2054a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:21:00 +0100 Subject: [PATCH 19/47] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/siteResource/createSiteResource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b6140c275..48c298d32 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -284,7 +284,7 @@ export async function createSiteResource( niceId, orgId, name, - mode, + mode: mode as "host" | "cidr", // protocol: mode === "port" ? protocol : null, // proxyPort: mode === "port" ? proxyPort : null, // destinationPort: mode === "port" ? destinationPort : null, From 588f064c2585e291fff9c9555477a78a10d34a8d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:53:14 +0100 Subject: [PATCH 20/47] =?UTF-8?q?=F0=9F=9A=B8=20make=20resource=20enabled?= =?UTF-8?q?=20switch=20optimistic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProxyResourcesTable.tsx | 63 ++++++++++++++++++++------ 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index a22d96b67..ba69dec4a 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -32,7 +32,13 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { + useOptimistic, + useRef, + useState, + useTransition, + type ComponentRef +} from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -479,18 +485,9 @@ export default function ProxyResourcesTable({ /> ), cell: ({ row }) => ( - - startTransition(() => - toggleResourceEnabled(val, row.original.id) - ) - } + ) }, @@ -632,3 +629,43 @@ export default function ProxyResourcesTable({ ); } + +type ResourceEnabledFormProps = { + resource: ResourceRow; + onToggleResourceEnabled: ( + val: boolean, + resourceId: number + ) => Promise; +}; + +function ResourceEnabledForm({ + resource, + onToggleResourceEnabled +}: ResourceEnabledFormProps) { + const enabled = resource.http + ? !!resource.domainId && resource.enabled + : resource.enabled; + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleResourceEnabled(newEnabled, resource.id); + } + + return ( +
+ formRef.current?.requestSubmit()} + /> + + ); +} From 4a31a7b84b539046cc82fb1ac7312f2b19401725 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 03:55:11 +0100 Subject: [PATCH 21/47] =?UTF-8?q?=F0=9F=9A=A8=20fix=20lint=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generateNewEnterpriseLicense.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 7cffb9d7a..94ee311b1 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense( next: NextFunction ): Promise { try { - - const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params); + const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense( const licenseData = req.body; - if (licenseData.tier != "big_license" && licenseData.tier != "small_license") { + if ( + licenseData.tier != "big_license" && + licenseData.tier != "small_license" + ) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense( return next( createHttpError( apiResponse.status || HttpCode.BAD_REQUEST, - apiResponse.message || "Failed to create license from Fossorial API" + apiResponse.message || + "Failed to create license from Fossorial API" ) ); } @@ -112,8 +117,11 @@ export async function generateNewEnterpriseLicense( ); } - const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; - const tierPrice = getLicensePriceSet()[tier] + const tier = + licenseData.tier === "big_license" + ? LicenseId.BIG_LICENSE + : LicenseId.SMALL_LICENSE; + const tierPrice = getLicensePriceSet()[tier]; const session = await stripe!.checkout.sessions.create({ client_reference_id: keyId.toString(), @@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense( { price: tierPrice, // Use the standard tier quantity: 1 - }, + } ], // Start with the standard feature set that matches the free limits customer: customer.customerId, mode: "subscription", From 67b63d3084b5937cc2faf21afbe3f32c0de5d473 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 04:52:21 +0100 Subject: [PATCH 22/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20make=20code=20cleanr?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 87 +++++++++---------- server/routers/resource/listResources.ts | 29 +++---- server/routers/site/listSites.ts | 30 +++---- .../siteResource/listAllSiteResourcesByOrg.ts | 18 ++-- server/types/Pagination.ts | 5 ++ 5 files changed, 81 insertions(+), 88 deletions(-) create mode 100644 server/types/Pagination.ts diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index b4e2eb56a..bb59755cd 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -29,6 +29,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; import semver from "semver"; import { getUserDeviceName } from "@server/db/names"; +import type { PaginatedResponse } from "@server/types/Pagination"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); @@ -89,38 +90,29 @@ const listClientsParamsSchema = z.strictObject({ }); const listClientsSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), + .catch(1) + .default(1), + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), filter: z.enum(["user", "machine"]).optional() }); -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)); - } - +function queryClientsBase() { return db .select({ clientId: clients.clientId, @@ -156,8 +148,7 @@ function queryClients( .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(users, eq(clients.userId, users.userId)) - .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) - .where(and(...conditions)); + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } async function getSiteAssociations(clientIds: number[]) { @@ -175,7 +166,7 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSitesAssociationsCache.clientId, clientIds)); } -type ClientWithSites = Awaited>[0] & { +type ClientWithSites = Awaited>[0] & { sites: Array<{ siteId: number; siteName: string | null; @@ -186,10 +177,9 @@ type ClientWithSites = Awaited>[0] & { type OlmWithUpdateAvailable = ClientWithSites; -export type ListClientsResponse = { +export type ListClientsResponse = PaginatedResponse<{ clients: Array; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", @@ -218,7 +208,7 @@ export async function listClients( ) ); } - const { limit, offset, filter } = parsedQuery.data; + const { page, pageSize, query, filter } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -267,28 +257,31 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds, filter); + const baseQuery = queryClientsBase(); // Get client count with filter - const countConditions = [ + const conditions = [ inArray(clients.clientId, accessibleClientIds), eq(clients.orgId, orgId) ]; if (filter === "user") { - countConditions.push(isNotNull(clients.userId)); + conditions.push(isNotNull(clients.userId)); } else if (filter === "machine") { - countConditions.push(isNull(clients.userId)); + conditions.push(isNull(clients.userId)); } - const countQuery = db - .select({ count: count() }) - .from(clients) - .where(and(...countConditions)); + const countQuery = db.$count( + queryClientsBase().where(and(...conditions)) + ); - const clientsList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + const [clientsList, totalCount] = await Promise.all([ + baseQuery + .where(and(...conditions)) + .limit(page) + .offset(pageSize * (page - 1)), + countQuery + ]); // Get associated sites for all clients const clientIds = clientsList.map((client) => client.clientId); @@ -368,8 +361,8 @@ export async function listClients( clients: olmsWithUpdates, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 26a0d613d..cf0769ca8 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -33,6 +33,7 @@ import { import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listResourcesParamsSchema = z.strictObject({ orgId: z.string() @@ -171,10 +172,9 @@ function queryResourcesBase() { ); } -export type ListResourcesResponse = { +export type ListResourcesResponse = PaginatedResponse<{ resources: ResourceWithTargets[]; - pagination: { total: number; pageSize: number; page: number }; -}; +}>; registry.registerPath({ method: "get", @@ -268,16 +268,15 @@ export async function listResources( (resource) => resource.resourceId ); - let conditions = and( + const conditions = [ and( inArray(resources.resourceId, accessibleResourceIds), eq(resources.orgId, orgId) ) - ); + ]; if (query) { - conditions = and( - conditions, + conditions.push( or( ilike(resources.name, "%" + query + "%"), ilike(resources.fullDomain, "%" + query + "%") @@ -285,17 +284,16 @@ export async function listResources( ); } if (typeof enabled !== "undefined") { - conditions = and(conditions, eq(resources.enabled, enabled)); + conditions.push(eq(resources.enabled, enabled)); } if (typeof authState !== "undefined") { switch (authState) { case "none": - conditions = and(conditions, eq(resources.http, false)); + conditions.push(eq(resources.http, false)); break; case "protected": - conditions = and( - conditions, + conditions.push( or( eq(resources.sso, true), eq(resources.emailWhitelistEnabled, true), @@ -306,8 +304,7 @@ export async function listResources( ); break; case "not_protected": - conditions = and( - conditions, + conditions.push( not(eq(resources.sso, true)), not(eq(resources.emailWhitelistEnabled, true)), isNull(resourceHeaderAuth.headerAuthId), @@ -318,7 +315,7 @@ export async function listResources( } } - let aggregateFilters: SQL | null | undefined = null; + let aggregateFilters: SQL | undefined = sql`1 = 1`; if (typeof healthStatus !== "undefined") { switch (healthStatus) { @@ -354,8 +351,8 @@ export async function listResources( } const baseQuery = queryResourcesBase() - .where(conditions) - .having(aggregateFilters ?? sql`1 = 1`); + .where(and(...conditions)) + .having(aggregateFilters); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c65f8d100..cc2924995 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import semver from "semver"; import cache from "@server/lib/cache"; +import type { PaginatedResponse } from "@server/types/Pagination"; async function getLatestNewtVersion(): Promise { try { @@ -111,9 +112,6 @@ const listSitesSchema = z.object({ .catch(undefined) }); -function countSitesBase() { - return db.select({ count: count() }).from(sites); -} function querySitesBase() { return db .select({ @@ -148,10 +146,9 @@ type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; -export type ListSitesResponse = { +export type ListSitesResponse = PaginatedResponse<{ sites: SiteWithUpdateAvailable[]; - pagination: { total: number; pageSize: number; page: number }; -}; +}>; registry.registerPath({ method: "get", @@ -227,13 +224,14 @@ export async function listSites( const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - let conditions = and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ); + const conditions = [ + and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ) + ]; if (query) { - conditions = and( - conditions, + conditions.push( or( ilike(sites.name, "%" + query + "%"), ilike(sites.niceId, "%" + query + "%") @@ -241,13 +239,15 @@ export async function listSites( ); } if (typeof online !== "undefined") { - conditions = and(conditions, eq(sites.online, online)); + conditions.push(eq(sites.online, online)); } - const baseQuery = querySitesBase().where(conditions); + const baseQuery = querySitesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery - const countQuery = db.$count(querySitesBase().where(conditions)); + const countQuery = db.$count( + querySitesBase().where(and(...conditions)) + ); const siteListQuery = baseQuery .limit(pageSize) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 2392eb767..f15d2eccb 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -9,6 +9,7 @@ import { eq, and, asc, ilike, or } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() @@ -33,14 +34,13 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ mode: z.enum(["host", "cidr"]).optional().catch(undefined) }); -export type ListAllSiteResourcesByOrgResponse = { +export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { siteName: string; siteNiceId: string; siteAddress: string | null; })[]; - pagination: { total: number; pageSize: number; page: number }; -}; +}>; function querySiteResourcesBase() { return db @@ -114,10 +114,9 @@ export async function listAllSiteResourcesByOrg( const { orgId } = parsedParams.data; const { page, pageSize, query, mode } = parsedQuery.data; - let conditions = and(eq(siteResources.orgId, orgId)); + const conditions = [and(eq(siteResources.orgId, orgId))]; if (query) { - conditions = and( - conditions, + conditions.push( or( ilike(siteResources.name, "%" + query + "%"), ilike(siteResources.destination, "%" + query + "%"), @@ -129,16 +128,15 @@ export async function listAllSiteResourcesByOrg( } if (mode) { - conditions = and(conditions, eq(siteResources.mode, mode)); + conditions.push(eq(siteResources.mode, mode)); } - const baseQuery = querySiteResourcesBase().where(conditions); + const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( - querySiteResourcesBase().where(conditions) + querySiteResourcesBase().where(and(...conditions)) ); - // Get all site resources for the org with site names const [siteResourcesList, totalCount] = await Promise.all([ baseQuery .limit(pageSize) diff --git a/server/types/Pagination.ts b/server/types/Pagination.ts new file mode 100644 index 000000000..b0f5edfe2 --- /dev/null +++ b/server/types/Pagination.ts @@ -0,0 +1,5 @@ +export type Pagination = { total: number; pageSize: number; page: number }; + +export type PaginatedResponse = T & { + pagination: Pagination; +}; From 9f2fd34e995b8dbf32fe06aa7c60a9df9d014a8a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 6 Feb 2026 05:37:44 +0100 Subject: [PATCH 23/47] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20user=20devices=20en?= =?UTF-8?q?dpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/index.ts | 1 + server/routers/client/listClients.ts | 30 +- server/routers/client/listUserDevices.ts | 302 ++++++++++++++++++ server/routers/external.ts | 7 + server/routers/integration.ts | 7 + server/routers/resource/listResources.ts | 2 +- .../[orgId]/settings/clients/user/page.tsx | 81 +++-- .../CreateInternalResourceDialog.tsx | 5 +- src/components/EditInternalResourceDialog.tsx | 5 +- src/lib/queries.ts | 8 +- 10 files changed, 374 insertions(+), 74 deletions(-) create mode 100644 server/routers/client/listUserDevices.ts diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 34614cc8f..e195d1c52 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -6,6 +6,7 @@ export * from "./unarchiveClient"; export * from "./blockClient"; export * from "./unblockClient"; export * from "./listClients"; +export * from "./listUserDevices"; export * from "./updateClient"; export * from "./getClient"; export * from "./createUserClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index bb59755cd..5c3702dd6 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -105,11 +105,7 @@ const listClientsSchema = z.object({ .catch(1) .default(1), query: z.string().optional(), - sort_by: z - .enum(["megabytesIn", "megabytesOut"]) - .optional() - .catch(undefined), - filter: z.enum(["user", "machine"]).optional() + sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) }); function queryClientsBase() { @@ -134,15 +130,7 @@ function queryClientsBase() { approvalState: clients.approvalState, olmArchived: olms.archived, archived: clients.archived, - blocked: clients.blocked, - deviceModel: currentFingerprint.deviceModel, - fingerprintPlatform: currentFingerprint.platform, - fingerprintOsVersion: currentFingerprint.osVersion, - fingerprintKernelVersion: currentFingerprint.kernelVersion, - fingerprintArch: currentFingerprint.arch, - fingerprintSerialNumber: currentFingerprint.serialNumber, - fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + blocked: clients.blocked }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) @@ -208,7 +196,7 @@ export async function listClients( ) ); } - const { page, pageSize, query, filter } = parsedQuery.data; + const { page, pageSize, query } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -262,15 +250,10 @@ export async function listClients( // Get client count with filter const conditions = [ inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) + eq(clients.orgId, orgId), + isNull(clients.userId) ]; - if (filter === "user") { - conditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - conditions.push(isNull(clients.userId)); - } - const countQuery = db.$count( queryClientsBase().where(and(...conditions)) ); @@ -312,11 +295,8 @@ export async function listClients( // Merge clients with their site associations and replace name with device name const clientsWithSites = clientsList.map((client) => { - const model = client.deviceModel || null; - const newName = getUserDeviceName(model, client.name); return { ...client, - name: newName, sites: sitesByClient[client.clientId] || [] }; }); diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts new file mode 100644 index 000000000..95a5b6cab --- /dev/null +++ b/server/routers/client/listUserDevices.ts @@ -0,0 +1,302 @@ +import { + clients, + currentFingerprint, + db, + olms, + orgs, + roleClients, + userClients, + users +} from "@server/db"; +import { getUserDeviceName } from "@server/db/names"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import NodeCache from "node-cache"; +import semver from "semver"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + try { + const cachedVersion = olmVersionCache.get("latestOlmVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const response = await fetch( + "https://api.github.com/repos/fosrl/olm/tags", + { + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn( + `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` + ); + return null; + } + + let tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Olm repository"); + return null; + } + tags = tags.filter((version) => !version.name.includes("rc")); + const latestVersion = tags[0].name; + + olmVersionCache.set("latestOlmVersion", latestVersion); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn("Request to fetch latest Olm version timed out (1.5s)"); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn("Connection timeout while fetching latest Olm version"); + } else { + logger.warn( + "Error fetching latest Olm version:", + error.message || error + ); + } + return null; + } +} + +const listUserDevicesParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const listUserDevicesSchema = z.object({ + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1), + query: z.string().optional(), + sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) +}); + +function queryUserDevicesBase() { + return db + .select({ + clientId: clients.clientId, + orgId: clients.orgId, + name: clients.name, + pubKey: clients.pubKey, + subnet: clients.subnet, + megabytesIn: clients.megabytesIn, + megabytesOut: clients.megabytesOut, + orgName: orgs.name, + type: clients.type, + online: clients.online, + olmVersion: olms.version, + userId: clients.userId, + username: users.username, + userEmail: users.email, + niceId: clients.niceId, + agent: olms.agent, + approvalState: clients.approvalState, + olmArchived: olms.archived, + archived: clients.archived, + blocked: clients.blocked, + deviceModel: currentFingerprint.deviceModel, + fingerprintPlatform: currentFingerprint.platform, + fingerprintOsVersion: currentFingerprint.osVersion, + fingerprintKernelVersion: currentFingerprint.kernelVersion, + fingerprintArch: currentFingerprint.arch, + fingerprintSerialNumber: currentFingerprint.serialNumber, + fingerprintUsername: currentFingerprint.username, + fingerprintHostname: currentFingerprint.hostname + }) + .from(clients) + .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) + .leftJoin(users, eq(clients.userId, users.userId)) + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); +} + +type OlmWithUpdateAvailable = Awaited< + ReturnType +>[0] & { + olmUpdateAvailable?: boolean; +}; + +export type ListUserDevicesResponse = PaginatedResponse<{ + devices: Array; +}>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user-devices", + description: "List all user devices for an organization.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + query: listUserDevicesSchema, + params: listUserDevicesParamsSchema + }, + responses: {} +}); + +export async function listUserDevices( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listUserDevicesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { page, pageSize, query } = parsedQuery.data; + + const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + let accessibleClients; + if (req.user) { + accessibleClients = await db + .select({ + clientId: sql`COALESCE(${userClients.clientId}, ${roleClients.clientId})` + }) + .from(userClients) + .fullJoin( + roleClients, + eq(userClients.clientId, roleClients.clientId) + ) + .where( + or( + eq(userClients.userId, req.user!.userId), + eq(roleClients.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleClients = await db + .select({ clientId: clients.clientId }) + .from(clients) + .where(eq(clients.orgId, orgId)); + } + + const accessibleClientIds = accessibleClients.map( + (client) => client.clientId + ); + // Get client count with filter + const conditions = [ + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNotNull(clients.userId) + ]; + + const baseQuery = queryUserDevicesBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const [clientsList, totalCount] = await Promise.all([ + baseQuery.limit(pageSize).offset(pageSize * (page - 1)), + countQuery + ]); + + // Merge clients with their site associations and replace name with device name + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map( + (client) => { + const model = client.deviceModel || null; + const newName = getUserDeviceName(model, client.name); + const OlmWithUpdate: OlmWithUpdateAvailable = { + ...client, + name: newName + }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlmVersion = await getLatestOlmVersion(); + + if (latestOlmVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlmVersion + ); + } catch (error) { + client.olmUpdateAvailable = false; + } + }); + } + } catch (error) { + // Log the error but don't let it block the response + logger.warn( + "Failed to check for OLM updates, continuing without update info:", + error + ); + } + + return response(res, { + data: { + devices: olmsWithUpdates, + pagination: { + total: totalCount, + page, + pageSize + } + }, + success: true, + error: false, + message: "Clients retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index aff01bfa8..bfffeaca2 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -143,6 +143,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyClientAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 9bb263987..85c9009de 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -818,6 +818,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index cf0769ca8..090ea9713 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -380,7 +380,7 @@ export async function listResources( hcEnabled: targetHealthCheck.hcEnabled }) .from(targets) - .where(sql`${targets.resourceId} in ${resourceIdList}`) + .where(inArray(targets.resourceId, resourceIdList)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 35a2b2e31..d047a60f2 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -1,11 +1,12 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { ListClientsResponse } from "@server/routers/client"; -import { getTranslations } from "next-intl/server"; import type { ClientRow } from "@app/components/UserDevicesTable"; import UserDevicesTable from "@app/components/UserDevicesTable"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { type ListUserDevicesResponse } from "@server/routers/client"; +import type { Pagination } from "@server/types/Pagination"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; type ClientsPageProps = { params: Promise<{ orgId: string }>; @@ -18,14 +19,21 @@ export default async function ClientsPage(props: ClientsPageProps) { const params = await props.params; - let userClients: ListClientsResponse["clients"] = []; + let userClients: ListUserDevicesResponse["devices"] = []; + + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { - const userRes = await internal.get>( - `/org/${params.orgId}/clients?filter=user`, - await authCookieHeader() - ); - userClients = userRes.data.data.clients; + const userRes = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/user-devices`, await authCookieHeader()); + const responseData = userRes.data.data; + userClients = responseData.devices; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -39,31 +47,29 @@ export default async function ClientsPage(props: ClientsPageProps) { } const mapClientToRow = ( - client: ListClientsResponse["clients"][0] + client: ListUserDevicesResponse["devices"][number] ): ClientRow => { // Build fingerprint object if any fingerprint data exists const hasFingerprintData = - (client as any).fingerprintPlatform || - (client as any).fingerprintOsVersion || - (client as any).fingerprintKernelVersion || - (client as any).fingerprintArch || - (client as any).fingerprintSerialNumber || - (client as any).fingerprintUsername || - (client as any).fingerprintHostname || - (client as any).deviceModel; + client.fingerprintPlatform || + client.fingerprintOsVersion || + client.fingerprintKernelVersion || + client.fingerprintArch || + client.fingerprintSerialNumber || + client.fingerprintUsername || + client.fingerprintHostname || + client.deviceModel; const fingerprint = hasFingerprintData ? { - platform: (client as any).fingerprintPlatform || null, - osVersion: (client as any).fingerprintOsVersion || null, - kernelVersion: - (client as any).fingerprintKernelVersion || null, - arch: (client as any).fingerprintArch || null, - deviceModel: (client as any).deviceModel || null, - serialNumber: - (client as any).fingerprintSerialNumber || null, - username: (client as any).fingerprintUsername || null, - hostname: (client as any).fingerprintHostname || null + platform: client.fingerprintPlatform, + osVersion: client.fingerprintOsVersion, + kernelVersion: client.fingerprintKernelVersion, + arch: client.fingerprintArch, + deviceModel: client.deviceModel, + serialNumber: client.fingerprintSerialNumber, + username: client.fingerprintUsername, + hostname: client.fingerprintHostname } : null; @@ -71,19 +77,19 @@ export default async function ClientsPage(props: ClientsPageProps) { name: client.name, id: client.clientId, subnet: client.subnet.split("/")[0], - mbIn: formatSize(client.megabytesIn || 0), - mbOut: formatSize(client.megabytesOut || 0), + mbIn: formatSize(client.megabytesIn ?? 0), + mbOut: formatSize(client.megabytesOut ?? 0), orgId: params.orgId, online: client.online, olmVersion: client.olmVersion || undefined, - olmUpdateAvailable: client.olmUpdateAvailable || false, + olmUpdateAvailable: Boolean(client.olmUpdateAvailable), userId: client.userId, username: client.username, userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, - archived: client.archived || false, - blocked: client.blocked || false, + archived: Boolean(client.archived), + blocked: Boolean(client.blocked), approvalState: client.approvalState, fingerprint }; @@ -91,6 +97,11 @@ export default async function ClientsPage(props: ClientsPageProps) { const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + console.log({ + userClientRows, + pagination + }); + return ( <> ; + filters?: z.infer; }) => queryOptions({ queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - ...filters, - limit: (filters.limit ?? 1000).toString() + pageSize: (filters?.pageSize ?? 1000).toString() }); const res = await meta!.api.get< From 49435398a8af8c6b1fee9010fe567bb342a172e1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 02:50:59 +0100 Subject: [PATCH 24/47] =?UTF-8?q?=F0=9F=94=A5=20cleanup=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 32 +++++++++++----------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 5c3702dd6..588f1edd6 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,35 +1,27 @@ -import { db, olms, users } from "@server/db"; import { clients, + clientSitesAssociationsCache, + currentFingerprint, + db, + olms, orgs, roleClients, sites, userClients, - clientSitesAssociationsCache, - currentFingerprint + users } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { - and, - count, - eq, - inArray, - isNotNull, - isNull, - or, - sql -} from "drizzle-orm"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; import semver from "semver"; -import { getUserDeviceName } from "@server/db/names"; -import type { PaginatedResponse } from "@server/types/Pagination"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); From fd7f6b2b998a5ed5a92256688774e254eb21a085 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 02:51:28 +0100 Subject: [PATCH 25/47] =?UTF-8?q?=E2=9C=A8=20filter=20user=20devices=20API?= =?UTF-8?q?=20finished?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 158 ++++++++++++++++++++++- 1 file changed, 151 insertions(+), 7 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 95a5b6cab..7ef5f784e 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -5,6 +5,7 @@ import { olms, orgs, roleClients, + sites, userClients, users } from "@server/db"; @@ -14,7 +15,20 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + ilike, + inArray, + isNotNull, + isNull, + not, + or, + sql, + type SQL +} from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import NodeCache from "node-cache"; @@ -96,7 +110,40 @@ const listUserDevicesSchema = z.object({ .catch(1) .default(1), query: z.string().optional(), - sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined), + agent: z + .enum([ + "windows", + "android", + "cli", + "macos", + "ios", + "ipados", + "unknown" + ]) + .optional() + .catch(undefined), + filters: z.preprocess( + (val: string) => { + return val.split(","); // the search query array is an array joined by a comma + }, + z + .array( + z.enum(["active", "pending", "denied", "blocked", "archived"]) + ) + .optional() + .default(["active", "pending"]) + .catch(["active", "pending"]) + ) }); function queryUserDevicesBase() { @@ -175,7 +222,28 @@ export async function listUserDevices( ) ); } - const { page, pageSize, query } = parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + online, + filters, + agent, + order + } = parsedQuery.data; + + console.log({ query: req.query }); + console.log({ + page, + pageSize, + query, + sort_by, + online, + filters, + agent, + order + }); const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -226,17 +294,93 @@ export async function listUserDevices( ); // Get client count with filter const conditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId), - isNotNull(clients.userId) + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNotNull(clients.userId) + ) ]; + if (query) { + conditions.push( + or( + ilike(clients.name, "%" + query + "%"), + ilike(users.name, "%" + query + "%") + ) + ); + } + + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); + } + + const agentValueMap = { + windows: "Pangolin Windows", + android: "Pangolin Android", + ios: "Pangolin iOS", + ipados: "Pangolin iPadOS", + macos: "Pangolin macOS", + cli: "Pangolin CLI" + } satisfies Record< + Exclude, + string + >; + if (typeof agent !== "undefined") { + if (agent === "unknown") { + conditions.push(isNull(olms.agent)); + } else { + conditions.push(eq(olms.agent, agentValueMap[agent])); + } + } + + if (filters.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; + + if (filters.includes("active")) { + filterAggregates.push( + and( + eq(clients.archived, false), + eq(clients.blocked, false), + or( + eq(clients.approvalState, "approved"), + isNull(clients.approvalState) // approval state of `NULL` means approved by default + ) + ) + ); + } + if (filters.includes("pending")) { + filterAggregates.push(eq(clients.approvalState, "pending")); + } + if (filters.includes("denied")) { + filterAggregates.push(eq(clients.approvalState, "denied")); + } + if (filters.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (filters.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + conditions.push(or(...filterAggregates)); + } + const baseQuery = queryUserDevicesBase().where(and(...conditions)); const countQuery = db.$count(baseQuery.as("filtered_clients")); + const listDevicesQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(clients[sort_by]) + : desc(clients[sort_by]) + : asc(clients.clientId) + ); + const [clientsList, totalCount] = await Promise.all([ - baseQuery.limit(pageSize).offset(pageSize * (page - 1)), + listDevicesQuery, countQuery ]); From db6327c4ff1f60f59d564e0bd6b08d514bbf6a1e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 02:52:23 +0100 Subject: [PATCH 26/47] =?UTF-8?q?=F0=9F=94=87=20remove=20console.logs=20in?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 7ef5f784e..0c07e40c7 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -233,18 +233,6 @@ export async function listUserDevices( order } = parsedQuery.data; - console.log({ query: req.query }); - console.log({ - page, - pageSize, - query, - sort_by, - online, - filters, - agent, - order - }); - const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( From 5d7f082ebf2afdb8edc0914763b78fbc4cf4fa4c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 04:41:42 +0100 Subject: [PATCH 27/47] =?UTF-8?q?=E2=9C=A8=20sort=20user=20device=20table?= =?UTF-8?q?=20&=20refactor=20sort=20into=20common=20functino?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 58 +-- .../[orgId]/settings/clients/user/page.tsx | 17 +- src/components/ClientResourcesTable.tsx | 2 +- src/components/SitesTable.tsx | 74 ++-- src/components/UserDevicesTable.tsx | 396 ++++++++++-------- src/hooks/useSortColumn.ts | 56 --- src/lib/sortColumn.ts | 52 +++ 7 files changed, 366 insertions(+), 289 deletions(-) delete mode 100644 src/hooks/useSortColumn.ts create mode 100644 src/lib/sortColumn.ts diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 0c07e40c7..479d16a0a 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -1,3 +1,4 @@ +import { build } from "@server/build"; import { clients, currentFingerprint, @@ -132,9 +133,12 @@ const listUserDevicesSchema = z.object({ ]) .optional() .catch(undefined), - filters: z.preprocess( - (val: string) => { - return val.split(","); // the search query array is an array joined by a comma + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; }, z .array( @@ -222,16 +226,8 @@ export async function listUserDevices( ) ); } - const { - page, - pageSize, - query, - sort_by, - online, - filters, - agent, - order - } = parsedQuery.data; + const { page, pageSize, query, sort_by, online, status, agent, order } = + parsedQuery.data; const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -293,7 +289,7 @@ export async function listUserDevices( conditions.push( or( ilike(clients.name, "%" + query + "%"), - ilike(users.name, "%" + query + "%") + ilike(users.email, "%" + query + "%") ) ); } @@ -321,34 +317,40 @@ export async function listUserDevices( } } - if (filters.length > 0) { + if (status.length > 0) { const filterAggregates: (SQL | undefined)[] = []; - if (filters.includes("active")) { + if (status.includes("active")) { filterAggregates.push( and( eq(clients.archived, false), eq(clients.blocked, false), - or( - eq(clients.approvalState, "approved"), - isNull(clients.approvalState) // approval state of `NULL` means approved by default - ) + build !== "oss" + ? or( + eq(clients.approvalState, "approved"), + isNull(clients.approvalState) // approval state of `NULL` means approved by default + ) + : undefined // undefined are automatically ignored by `drizzle-orm` ) ); } - if (filters.includes("pending")) { - filterAggregates.push(eq(clients.approvalState, "pending")); - } - if (filters.includes("denied")) { - filterAggregates.push(eq(clients.approvalState, "denied")); - } - if (filters.includes("archived")) { + + if (status.includes("archived")) { filterAggregates.push(eq(clients.archived, true)); } - if (filters.includes("blocked")) { + if (status.includes("blocked")) { filterAggregates.push(eq(clients.blocked, true)); } + if (build !== "oss") { + if (status.includes("pending")) { + filterAggregates.push(eq(clients.approvalState, "pending")); + } + if (status.includes("denied")) { + filterAggregates.push(eq(clients.approvalState, "denied")); + } + } + conditions.push(or(...filterAggregates)); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index d047a60f2..fcb24e4e3 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server"; type ClientsPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -18,6 +19,7 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let userClients: ListUserDevicesResponse["devices"] = []; @@ -30,7 +32,10 @@ export default async function ClientsPage(props: ClientsPageProps) { try { const userRes = await internal.get< AxiosResponse - >(`/org/${params.orgId}/user-devices`, await authCookieHeader()); + >( + `/org/${params.orgId}/user-devices?${searchParams.toString()}`, + await authCookieHeader() + ); const responseData = userRes.data.data; userClients = responseData.devices; pagination = responseData.pagination; @@ -97,11 +102,6 @@ export default async function ClientsPage(props: ClientsPageProps) { const userClientRows: ClientRow[] = userClients.map(mapClientToRow); - console.log({ - userClientRows, - pagination - }); - return ( <> ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index e3fcd11af..126eb2421 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -382,7 +382,7 @@ export default function ClientResourcesTable({ onPaginationChange={handlePaginationChange} pagination={pagination} rowCount={rowCount} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} enableColumnVisibility columnVisibility={{ niceId: false, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 761177762..c78577731 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -12,10 +12,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useSortColumn } from "@app/hooks/useSortColumn"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { parseDataSize } from "@app/lib/dataSize"; import { build } from "@server/build"; import { type PaginationState } from "@tanstack/react-table"; import { @@ -24,21 +24,19 @@ import { ArrowUp10Icon, ArrowUpRight, ChevronsUpDownIcon, - Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; -import { ColumnFilter } from "./ColumnFilter"; -import { ColumnFilterButton } from "./ColumnFilterButton"; -import z from "zod"; export type SiteRow = { id: number; @@ -71,16 +69,18 @@ export default function SitesTable({ rowCount }: SitesTableProps) { const router = useRouter(); - const searchParams = useSearchParams(); const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const [getSortDirection, toggleSorting] = useSortColumn(); - const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -136,9 +136,6 @@ export default function SitesTable({ }); } - const dataInOrder = getSortDirection("megabytesIn"); - const dataOutOrder = getSortDirection("megabytesOut"); - const columns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -212,6 +209,10 @@ export default function SitesTable({ accessorKey: "mbIn", friendlyName: t("dataIn"), header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); const Icon = dataInOrder === "asc" ? ArrowDown01Icon @@ -221,21 +222,23 @@ export default function SitesTable({ return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - - parseDataSize(rowB.original.mbIn) + } }, { accessorKey: "mbOut", friendlyName: t("dataOut"), header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + const Icon = dataOutOrder === "asc" ? ArrowDown01Icon @@ -245,16 +248,13 @@ export default function SitesTable({ return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - - parseDataSize(rowB.original.mbOut) + } }, { accessorKey: "type", @@ -423,18 +423,28 @@ export default function SitesTable({ } ]; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + const handlePaginationChange = (newPage: PaginationState) => { - const sp = new URLSearchParams(searchParams); - sp.set("page", (newPage.pageIndex + 1).toString()); - sp.set("pageSize", newPage.pageSize.toString()); - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); }; const handleSearchChange = useDebouncedCallback((query: string) => { - const sp = new URLSearchParams(searchParams); - sp.set("query", query); - sp.delete("page"); - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); }, 300); return ( @@ -478,7 +488,7 @@ export default function SitesTable({ onSearch={handleSearchChange} addButtonText={t("siteAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ niceId: false, diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index edc840882..7e441547e 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -2,37 +2,41 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; +import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { build } from "@server/build"; +import type { PaginationState } from "@tanstack/react-table"; import { - formatFingerprintInfo, - formatPlatform -} from "@app/lib/formatDeviceFingerprint"; -import { + ArrowDown01Icon, ArrowRight, - ArrowUpDown, + ArrowUp10Icon, ArrowUpRight, - MoreHorizontal, - CircleSlash + ChevronsUpDownIcon, + CircleSlash, + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { Badge } from "./ui/badge"; -import { build } from "@server/build"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { InfoPopup } from "@app/components/ui/info-popup"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type ClientRow = { id: number; @@ -68,9 +72,15 @@ export type ClientRow = { type ClientTableProps = { userClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; -export default function UserDevicesTable({ userClients }: ClientTableProps) { +export default function UserDevicesTable({ + userClients, + pagination, + rowCount +}: ClientTableProps) { const router = useRouter(); const t = useTranslations(); @@ -80,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { ); const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); const defaultUserColumnVisibility = { @@ -296,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; const fingerprintInfo = r.fingerprint @@ -360,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "niceId", friendlyName: t("identifier"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "userEmail", friendlyName: t("users"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("users")}, cell: ({ row }) => { const r = row.original; return r.userId ? ( @@ -416,19 +389,30 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -453,18 +437,29 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + console.log({ + dataInOrder, + searchParams: Object.fromEntries(searchParams.entries()) + }); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -472,18 +467,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -491,21 +493,48 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + ]} + selectedValue={searchParams.get("agent") ?? undefined} + onValueChange={(value) => + handleFilterChange("agent", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("agent")} + className="p-3" + /> + ), cell: ({ row }) => { const originalRow = row.original; @@ -531,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "subnet", friendlyName: t("address"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("address")} } ]; @@ -643,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); const statusFilterOptions = useMemo(() => { const allOptions = [ @@ -691,6 +706,53 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return ["active", "pending"]; }, []); + const [, setCount] = useState(0); + + function handleFilterChange( + column: string, + value: string | undefined | null | string[] + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (typeof value === "string") { + searchParams.set(column, value); + } else if (value) { + for (const val of value) { + searchParams.append(column, val); + } + } + + filter({ + searchParams + }); + setCount((c) => c + 1); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + 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); + return ( <> {selectedClient && !selectedClient.userId && ( @@ -714,67 +776,69 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { )} - { - if (selectedValues.length === 0) return true; - const rowArchived = row.archived; - const rowBlocked = row.blocked; - const approvalState = row.approvalState; - const isActive = - !rowArchived && - !rowBlocked && - approvalState !== "pending" && - approvalState !== "denied"; + // filters={[ + // { + // id: "status", + // label: t("status") || "Status", + // multiSelect: true, + // displayMode: "calculated", + // options: statusFilterOptions, + // filterFn: ( + // row: ClientRow, + // selectedValues: (string | number | boolean)[] + // ) => { + // if (selectedValues.length === 0) return true; + // const rowArchived = row.archived; + // const rowBlocked = row.blocked; + // const approvalState = row.approvalState; + // const isActive = + // !rowArchived && + // !rowBlocked && + // approvalState !== "pending" && + // approvalState !== "denied"; - if (selectedValues.includes("active") && isActive) - return true; - if ( - selectedValues.includes("pending") && - approvalState === "pending" - ) - return true; - if ( - selectedValues.includes("denied") && - approvalState === "denied" - ) - return true; - if ( - selectedValues.includes("archived") && - rowArchived - ) - return true; - if ( - selectedValues.includes("blocked") && - rowBlocked - ) - return true; - return false; - }, - defaultValues: statusFilterDefaultValues - } - ]} + // if (selectedValues.includes("active") && isActive) + // return true; + // if ( + // selectedValues.includes("pending") && + // approvalState === "pending" + // ) + // return true; + // if ( + // selectedValues.includes("denied") && + // approvalState === "denied" + // ) + // return true; + // if ( + // selectedValues.includes("archived") && + // rowArchived + // ) + // return true; + // if ( + // selectedValues.includes("blocked") && + // rowBlocked + // ) + // return true; + // return false; + // }, + // defaultValues: statusFilterDefaultValues + // } + // ]} /> ); diff --git a/src/hooks/useSortColumn.ts b/src/hooks/useSortColumn.ts deleted file mode 100644 index 95fb673eb..000000000 --- a/src/hooks/useSortColumn.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SortOrder } from "@app/lib/types/sort"; -import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import { startTransition } from "react"; - -export function useSortColumn() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const toggleSorting = (column: string) => { - const sp = new URLSearchParams(searchParams); - - let nextDirection: SortOrder = "indeterminate"; - - if (sp.get("sort_by") === column) { - nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate"; - } - - switch (nextDirection) { - case "indeterminate": { - nextDirection = "asc"; - break; - } - case "asc": { - nextDirection = "desc"; - break; - } - default: { - nextDirection = "indeterminate"; - break; - } - } - - sp.delete("sort_by"); - sp.delete("order"); - - if (nextDirection !== "indeterminate") { - sp.set("sort_by", column); - sp.set("order", nextDirection); - } - - startTransition(() => router.push(`${pathname}?${sp.toString()}`)); - }; - - function getSortDirection(column: string) { - let currentDirection: SortOrder = "indeterminate"; - - if (searchParams.get("sort_by") === column) { - currentDirection = - (searchParams.get("order") as SortOrder) ?? "indeterminate"; - } - return currentDirection; - } - - return [getSortDirection, toggleSorting] as const; -} diff --git a/src/lib/sortColumn.ts b/src/lib/sortColumn.ts new file mode 100644 index 000000000..fcb4cc98f --- /dev/null +++ b/src/lib/sortColumn.ts @@ -0,0 +1,52 @@ +import type { SortOrder } from "@app/lib/types/sort"; + +export function getNextSortOrder( + column: string, + searchParams: URLSearchParams +) { + const sp = new URLSearchParams(searchParams); + + let nextDirection: SortOrder = "indeterminate"; + + if (sp.get("sort_by") === column) { + nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate"; + } + + switch (nextDirection) { + case "indeterminate": { + nextDirection = "asc"; + break; + } + case "asc": { + nextDirection = "desc"; + break; + } + default: { + nextDirection = "indeterminate"; + break; + } + } + + sp.delete("sort_by"); + sp.delete("order"); + + if (nextDirection !== "indeterminate") { + sp.set("sort_by", column); + sp.set("order", nextDirection); + } + + return sp; +} + +export function getSortDirection( + column: string, + searchParams: URLSearchParams +) { + let currentDirection: SortOrder = "indeterminate"; + + if (searchParams.get("sort_by") === column) { + currentDirection = + (searchParams.get("order") as SortOrder) ?? "indeterminate"; + } + return currentDirection; +} From 1889386f647add7b16621c2da781f616bd32c8b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 04:51:37 +0100 Subject: [PATCH 28/47] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20table=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserDevicesTable.tsx | 97 ++++++++++----------- src/components/ui/controlled-data-table.tsx | 20 ++--- 2 files changed, 54 insertions(+), 63 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 7e441547e..e2fe6669c 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -706,8 +706,6 @@ export default function UserDevicesTable({ return ["active", "pending"]; }, []); - const [, setCount] = useState(0); - function handleFilterChange( column: string, value: string | undefined | null | string[] @@ -726,7 +724,6 @@ export default function UserDevicesTable({ filter({ searchParams }); - setCount((c) => c + 1); } function toggleSort(column: string) { @@ -791,54 +788,54 @@ export default function UserDevicesTable({ rowCount={rowCount} stickyLeftColumn="name" stickyRightColumn="actions" - // filters={[ - // { - // id: "status", - // label: t("status") || "Status", - // multiSelect: true, - // displayMode: "calculated", - // options: statusFilterOptions, - // filterFn: ( - // row: ClientRow, - // selectedValues: (string | number | boolean)[] - // ) => { - // if (selectedValues.length === 0) return true; - // const rowArchived = row.archived; - // const rowBlocked = row.blocked; - // const approvalState = row.approvalState; - // const isActive = - // !rowArchived && - // !rowBlocked && - // approvalState !== "pending" && - // approvalState !== "denied"; + filters={[ + { + id: "status", + label: t("status") || "Status", + multiSelect: true, + displayMode: "calculated", + options: statusFilterOptions, + onFilter: ( + selectedValues: (string | number | boolean)[] + ) => { + console.log({ selectedValues }); + // if (selectedValues.length === 0) return true; + // const rowArchived = row.archived; + // const rowBlocked = row.blocked; + // const approvalState = row.approvalState; + // const isActive = + // !rowArchived && + // !rowBlocked && + // approvalState !== "pending" && + // approvalState !== "denied"; - // if (selectedValues.includes("active") && isActive) - // return true; - // if ( - // selectedValues.includes("pending") && - // approvalState === "pending" - // ) - // return true; - // if ( - // selectedValues.includes("denied") && - // approvalState === "denied" - // ) - // return true; - // if ( - // selectedValues.includes("archived") && - // rowArchived - // ) - // return true; - // if ( - // selectedValues.includes("blocked") && - // rowBlocked - // ) - // return true; - // return false; - // }, - // defaultValues: statusFilterDefaultValues - // } - // ]} + // if (selectedValues.includes("active") && isActive) + // return true; + // if ( + // selectedValues.includes("pending") && + // approvalState === "pending" + // ) + // return true; + // if ( + // selectedValues.includes("denied") && + // approvalState === "denied" + // ) + // return true; + // if ( + // selectedValues.includes("archived") && + // rowArchived + // ) + // return true; + // if ( + // selectedValues.includes("blocked") && + // rowBlocked + // ) + // return true; + // return false; + }, + values: statusFilterDefaultValues + } + ]} /> ); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 88f033849..996526e4b 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -33,7 +33,7 @@ import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility" import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown export type ExtendedColumnDef = ColumnDef< @@ -54,11 +54,8 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - filterFn: ( - row: any, - selectedValues: (string | number | boolean)[] - ) => boolean; - defaultValues?: (string | number | boolean)[]; + onFilter: (selectedValues: (string | number | boolean)[]) => void; + values?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; @@ -119,15 +116,13 @@ export function ControlledDataTable({ ); // TODO: filters - const [activeFilters, setActiveFilters] = useState< - Record - >(() => { + const activeFilters = useMemo(() => { const initial: Record = {}; filters?.forEach((filter) => { - initial[filter.id] = filter.defaultValues || []; + initial[filter.id] = filter.values || []; }); return initial; - }); + }, [filters]); console.log({ pagination, @@ -147,7 +142,6 @@ export function ControlledDataTable({ }, manualFiltering: true, manualPagination: true, - // pageCount: pagination.pageCount, rowCount, state: { columnFilters, @@ -177,7 +171,7 @@ export function ControlledDataTable({ } // Multiple selections: always join with "and" - return selectedOptions.map((opt) => opt.label).join(" and "); + return selectedOptions.map((opt) => opt.label).join(" or "); }; // Helper function to check if a column should be sticky From 577cb913433a8c713f12ebacc1a9f54f595f2bd9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 05:37:01 +0100 Subject: [PATCH 29/47] =?UTF-8?q?=E2=9C=A8=20whole=20table=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserDevicesTable.tsx | 52 +++++---------------- src/components/ui/controlled-data-table.tsx | 45 ++++++++++++++---- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index e2fe6669c..7fe04ee45 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -699,16 +699,21 @@ export default function UserDevicesTable({ return allOptions; }, [t]); - const statusFilterDefaultValues = useMemo(() => { + const statusFilterValues = useMemo(() => { + const status = searchParams.getAll("status"); + if (status.length > 0) { + return status; + } + if (build === "oss") { return ["active"]; } return ["active", "pending"]; - }, []); + }, [searchParams]); function handleFilterChange( column: string, - value: string | undefined | null | string[] + value: string | null | undefined | string[] ) { searchParams.delete(column); searchParams.delete("page"); @@ -795,45 +800,10 @@ export default function UserDevicesTable({ multiSelect: true, displayMode: "calculated", options: statusFilterOptions, - onFilter: ( - selectedValues: (string | number | boolean)[] - ) => { - console.log({ selectedValues }); - // if (selectedValues.length === 0) return true; - // const rowArchived = row.archived; - // const rowBlocked = row.blocked; - // const approvalState = row.approvalState; - // const isActive = - // !rowArchived && - // !rowBlocked && - // approvalState !== "pending" && - // approvalState !== "denied"; - - // if (selectedValues.includes("active") && isActive) - // return true; - // if ( - // selectedValues.includes("pending") && - // approvalState === "pending" - // ) - // return true; - // if ( - // selectedValues.includes("denied") && - // approvalState === "denied" - // ) - // return true; - // if ( - // selectedValues.includes("archived") && - // rowArchived - // ) - // return true; - // if ( - // selectedValues.includes("blocked") && - // rowBlocked - // ) - // return true; - // return false; + onValueChange: (selectedValues: string[]) => { + handleFilterChange("status", selectedValues); }, - values: statusFilterDefaultValues + values: statusFilterValues } ]} /> diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 996526e4b..4b87a5209 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -46,7 +46,7 @@ export type ExtendedColumnDef = ColumnDef< type FilterOption = { id: string; label: string; - value: string | number | boolean; + value: string; }; type DataTableFilter = { @@ -54,8 +54,8 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - onFilter: (selectedValues: (string | number | boolean)[]) => void; - values?: (string | number | boolean)[]; + onValueChange: (selectedValues: string[]) => void; + values?: string[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; @@ -117,7 +117,7 @@ export function ControlledDataTable({ // TODO: filters const activeFilters = useMemo(() => { - const initial: Record = {}; + const initial: Record = {}; filters?.forEach((filter) => { initial[filter.id] = filter.values || []; }); @@ -174,6 +174,33 @@ export function ControlledDataTable({ return selectedOptions.map((opt) => opt.label).join(" or "); }; + const handleFilterChange = ( + filterId: string, + optionValue: string, + checked: boolean + ) => { + const currentValues = activeFilters[filterId] || []; + const filter = filters?.find((f) => f.id === filterId); + + if (!filter) return; + + let newValues: string[]; + + if (filter.multiSelect) { + // Multi-select: add or remove the value + if (checked) { + newValues = [...currentValues, optionValue]; + } else { + newValues = currentValues.filter((v) => v !== optionValue); + } + } else { + // Single-select: replace the value + newValues = checked ? [optionValue] : []; + } + + filter.onValueChange(newValues); + }; + // Helper function to check if a column should be sticky const isStickyColumn = ( columnId: string | undefined, @@ -285,11 +312,11 @@ export function ControlledDataTable({ onCheckedChange={( checked ) => { - // handleFilterChange( - // filter.id, - // option.value, - // checked - // ) + handleFilterChange( + filter.id, + option.value, + checked + ); }} onSelect={(e) => e.preventDefault() From ff61b22e7e6afaced97fb7b62d0eda7a89d24d0a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 7 Feb 2026 05:37:52 +0100 Subject: [PATCH 30/47] =?UTF-8?q?=E2=99=BB=EF=B8=8Fdo=20not=20set=20defaul?= =?UTF-8?q?t=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserDevicesTable.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 7fe04ee45..642ef1f71 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -699,18 +699,6 @@ export default function UserDevicesTable({ return allOptions; }, [t]); - const statusFilterValues = useMemo(() => { - const status = searchParams.getAll("status"); - if (status.length > 0) { - return status; - } - - if (build === "oss") { - return ["active"]; - } - return ["active", "pending"]; - }, [searchParams]); - function handleFilterChange( column: string, value: string | null | undefined | string[] @@ -803,7 +791,7 @@ export default function UserDevicesTable({ onValueChange: (selectedValues: string[]) => { handleFilterChange("status", selectedValues); }, - values: statusFilterValues + values: searchParams.getAll("status") } ]} /> From b0af0d9cd5db40e41c882cba0c8548ed2f3bcf1d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 00:31:21 +0100 Subject: [PATCH 31/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20keep=20previous=20da?= =?UTF-8?q?ta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TanstackQueryProvider.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/TanstackQueryProvider.tsx b/src/components/TanstackQueryProvider.tsx index 9a6e7dd99..ab469c2b3 100644 --- a/src/components/TanstackQueryProvider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -1,11 +1,13 @@ "use client"; -import * as React from "react"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { QueryClient } from "@tanstack/react-query"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; -import { durationToMs } from "@app/lib/durationToMs"; +import { + keepPreviousData, + QueryClient, + QueryClientProvider +} from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import * as React from "react"; export type ReactQueryProviderProps = { children: React.ReactNode; @@ -22,7 +24,8 @@ export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { staleTime: 0, meta: { api - } + }, + placeholderData: keepPreviousData }, mutations: { meta: { api } From 7f73cde7945ed5cbcaba037e05fe7908cae2862b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 00:45:20 +0100 Subject: [PATCH 32/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refetch=20approval?= =?UTF-8?q?=20count=20every=2030s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 6a3dd7ffb..1d19c1e73 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -383,6 +383,12 @@ export const approvalQueries = { signal }); return res.data.data.count; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; } }) }; From da514ef3143cd983cff9d21db95c1351ac777af0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 00:45:34 +0100 Subject: [PATCH 33/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/approvals/countApprovals.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/private/routers/approvals/countApprovals.ts b/server/private/routers/approvals/countApprovals.ts index c68e422ac..0885c7e88 100644 --- a/server/private/routers/approvals/countApprovals.ts +++ b/server/private/routers/approvals/countApprovals.ts @@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error"; import type { Request, Response, NextFunction } from "express"; import { approvals, db, type Approval } from "@server/db"; -import { eq, sql, and } from "drizzle-orm"; +import { eq, sql, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; const paramsSchema = z.strictObject({ @@ -88,7 +88,7 @@ export async function countApprovals( .where( and( eq(approvals.orgId, orgId), - sql`${approvals.decision} in ${state}` + inArray(approvals.decision, state) ) ); From 3ba2cb19a9eda825dd44629f1b4bac49195454bf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 03:20:49 +0100 Subject: [PATCH 34/47] =?UTF-8?q?=E2=9C=A8=20approval=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + .../routers/approvals/listApprovals.ts | 161 ++++++++++++------ server/routers/client/listUserDevices.ts | 2 - src/components/ApprovalFeed.tsx | 80 ++++++--- src/lib/queries.ts | 43 ++++- 5 files changed, 201 insertions(+), 87 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f9627fcc0..ac6ab691a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -459,6 +459,8 @@ "filterByApprovalState": "Filter By Approval State", "approvalListEmpty": "No approvals", "approvalState": "Approval State", + "approvalLoadMore": "Load more", + "loadingApprovals": "Loading Approvals", "approve": "Approve", "approved": "Approved", "denied": "Denied", diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 600eec871..5639b4407 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -30,7 +30,7 @@ import { currentFingerprint, type Approval } from "@server/db"; -import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; +import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm"; import response from "@server/lib/response"; import { getUserDeviceName } from "@server/db/names"; @@ -39,18 +39,26 @@ const paramsSchema = z.strictObject({ }); const querySchema = z.strictObject({ - limit: z - .string() + limit: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20), + cursorPending: z.coerce // pending cursor + .number() + .int() + .max(1) // 0 means non pending + .min(0) // 1 means pending .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), + .catch(undefined), + cursorTimestamp: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined), approvalState: z .enum(["pending", "approved", "denied", "all"]) .optional() @@ -63,13 +71,21 @@ const querySchema = z.strictObject({ .pipe(z.number().int().positive().optional()) }); -async function queryApprovals( - orgId: string, - limit: number, - offset: number, - approvalState: z.infer["approvalState"], - clientId?: number -) { +async function queryApprovals({ + orgId, + limit, + approvalState, + cursorPending, + cursorTimestamp, + clientId +}: { + orgId: string; + limit: number; + approvalState: z.infer["approvalState"]; + cursorPending?: number; + cursorTimestamp?: number; + clientId?: number; +}) { let state: Array = []; switch (approvalState) { case "pending": @@ -85,6 +101,26 @@ async function queryApprovals( state = ["approved", "denied", "pending"]; } + const conditions = [ + eq(approvals.orgId, orgId), + sql`${approvals.decision} in ${state}` + ]; + + if (clientId) { + conditions.push(eq(approvals.clientId, clientId)); + } + + const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`; + + if (cursorPending != null && cursorTimestamp != null) { + // https://stackoverflow.com/a/79720298/10322846 + // composite cursor, next data means (pending, timestamp) <= cursor + conditions.push( + lte(pendingSortKey, cursorPending), + lte(approvals.timestamp, cursorTimestamp) + ); + } + const res = await db .select({ approvalId: approvals.approvalId, @@ -107,7 +143,8 @@ async function queryApprovals( fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + fingerprintHostname: currentFingerprint.hostname, + timestamp: approvals.timestamp }) .from(approvals) .innerJoin(users, and(eq(approvals.userId, users.userId))) @@ -120,22 +157,12 @@ async function queryApprovals( ) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) - .where( - and( - eq(approvals.orgId, orgId), - sql`${approvals.decision} in ${state}`, - ...(clientId ? [eq(approvals.clientId, clientId)] : []) - ) - ) - .orderBy( - sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`, - desc(approvals.timestamp) - ) - .limit(limit) - .offset(offset); + .where(and(...conditions)) + .orderBy(desc(pendingSortKey), desc(approvals.timestamp)) + .limit(limit + 1); // the `+1` is used for the cursor // Process results to format device names and build fingerprint objects - return res.map((approval) => { + const approvalsList = res.slice(0, limit).map((approval) => { const model = approval.deviceModel || null; const deviceName = approval.clientName ? getUserDeviceName(model, approval.clientName) @@ -154,15 +181,15 @@ async function queryApprovals( const fingerprint = hasFingerprintData ? { - platform: approval.fingerprintPlatform || null, - osVersion: approval.fingerprintOsVersion || null, - kernelVersion: approval.fingerprintKernelVersion || null, - arch: approval.fingerprintArch || null, - deviceModel: approval.deviceModel || null, - serialNumber: approval.fingerprintSerialNumber || null, - username: approval.fingerprintUsername || null, - hostname: approval.fingerprintHostname || null - } + platform: approval.fingerprintPlatform ?? null, + osVersion: approval.fingerprintOsVersion ?? null, + kernelVersion: approval.fingerprintKernelVersion ?? null, + arch: approval.fingerprintArch ?? null, + deviceModel: approval.deviceModel ?? null, + serialNumber: approval.fingerprintSerialNumber ?? null, + username: approval.fingerprintUsername ?? null, + hostname: approval.fingerprintHostname ?? null + } : null; const { @@ -185,11 +212,30 @@ async function queryApprovals( niceId: approval.niceId || null }; }); + let nextCursorPending: number | null = null; + let nextCursorTimestamp: number | null = null; + if (res.length > limit) { + const lastItem = res[limit]; + nextCursorPending = lastItem.decision === "pending" ? 1 : 0; + nextCursorTimestamp = lastItem.timestamp; + } + return { + approvalsList, + nextCursorPending, + nextCursorTimestamp + }; } export type ListApprovalsResponse = { - approvals: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; + approvals: NonNullable< + Awaited> + >["approvalsList"]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; }; export async function listApprovals( @@ -217,7 +263,13 @@ export async function listApprovals( ) ); } - const { limit, offset, approvalState, clientId } = parsedQuery.data; + const { + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + } = parsedQuery.data; const { orgId } = parsedParams.data; @@ -234,13 +286,15 @@ export async function listApprovals( } } - const approvalsList = await queryApprovals( - orgId.toString(), - limit, - offset, - approvalState, - clientId - ); + const { approvalsList, nextCursorPending, nextCursorTimestamp } = + await queryApprovals({ + orgId: orgId.toString(), + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + }); const [{ count }] = await db .select({ count: sql`count(*)` }) @@ -252,7 +306,8 @@ export async function listApprovals( pagination: { total: count, limit, - offset + cursorPending: nextCursorPending, + cursorTimestamp: nextCursorTimestamp } }, success: true, diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 479d16a0a..d152250bb 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -6,7 +6,6 @@ import { olms, orgs, roleClients, - sites, userClients, users } from "@server/db"; @@ -25,7 +24,6 @@ import { inArray, isNotNull, isNull, - not, or, sql, type SQL diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 4c6122c60..9abcbeed7 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -2,23 +2,25 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { cn } from "@app/lib/cn"; import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { approvalFiltersSchema, approvalQueries, type ApprovalItem } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Ban, Check, Loader, RefreshCw } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Fragment, useActionState } from "react"; +import { ApprovalsEmptyState } from "./ApprovalsEmptyState"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Card, CardHeader } from "./ui/card"; +import { InfoPopup } from "./ui/info-popup"; import { Label } from "./ui/label"; import { Select, @@ -28,8 +30,6 @@ import { SelectValue } from "./ui/select"; import { Separator } from "./ui/separator"; -import { InfoPopup } from "./ui/info-popup"; -import { ApprovalsEmptyState } from "./ApprovalsEmptyState"; export type ApprovalFeedProps = { orgId: string; @@ -50,11 +50,17 @@ export function ApprovalFeed({ Object.fromEntries(searchParams.entries()) ); - const { data, isFetching, refetch } = useQuery( - approvalQueries.listApprovals(orgId, filters) - ); + const { + data, + isFetching, + isLoading, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage + } = useInfiniteQuery(approvalQueries.listApprovals(orgId, filters)); - const approvals = data?.approvals ?? []; + const approvals = data?.pages.flatMap((data) => data.approvals) ?? []; // Show empty state if no approvals are enabled for any role if (!hasApprovalsEnabled) { @@ -110,13 +116,13 @@ export function ApprovalFeed({ onClick={() => { refetch(); }} - disabled={isFetching} + disabled={isFetching || isLoading} className="lg:static gap-2" > {t("refresh")} @@ -140,13 +146,30 @@ export function ApprovalFeed({ ))} {approvals.length === 0 && ( -
  • - {t("approvalListEmpty")} +
  • + {isLoading + ? t("loadingApprovals") + : t("approvalListEmpty")} + + {isLoading && ( + + )}
  • )} + {hasNextPage && ( + + )}
    ); } @@ -209,19 +232,19 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {   {approval.type === "user_device" && ( - {approval.deviceName ? ( - <> - {t("requestingNewDeviceApproval")}:{" "} - {approval.niceId ? ( - - {approval.deviceName} - - ) : ( - {approval.deviceName} - )} + {approval.deviceName ? ( + <> + {t("requestingNewDeviceApproval")}:{" "} + {approval.niceId ? ( + + {approval.deviceName} + + ) : ( + {approval.deviceName} + )} {approval.fingerprint && (
    @@ -229,7 +252,10 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) { {t("deviceInformation")}
    - {formatFingerprintInfo(approval.fingerprint, t)} + {formatFingerprintInfo( + approval.fingerprint, + t + )}
    diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1d19c1e73..fe5350ff9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -16,11 +16,16 @@ import type { import type { ListTargetsResponse } from "@server/routers/target"; import type { ListUsersResponse } from "@server/routers/user"; import type ResponseT from "@server/types/Response"; -import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions +} from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; +import { wait } from "./wait"; export type ProductUpdate = { link: string | null; @@ -356,22 +361,50 @@ export const approvalQueries = { orgId: string, filters: z.infer ) => - queryOptions({ + infiniteQueryOptions({ queryKey: ["APPROVALS", orgId, filters] as const, - queryFn: async ({ signal, meta }) => { + queryFn: async ({ signal, pageParam, meta }) => { const sp = new URLSearchParams(); if (filters.approvalState) { sp.set("approvalState", filters.approvalState); } + if (pageParam) { + sp.set("cursorPending", pageParam.cursorPending.toString()); + sp.set( + "cursorTimestamp", + pageParam.cursorTimestamp.toString() + ); + } const res = await meta!.api.get< - AxiosResponse<{ approvals: ApprovalItem[] }> + AxiosResponse<{ + approvals: ApprovalItem[]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; + }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); return res.data.data; - } + }, + initialPageParam: null as { + cursorPending: number; + cursorTimestamp: number; + } | null, + placeholderData: keepPreviousData, + getNextPageParam: ({ pagination }) => + pagination.cursorPending != null && + pagination.cursorTimestamp != null + ? { + cursorPending: pagination.cursorPending, + cursorTimestamp: pagination.cursorTimestamp + } + : null }), pendingCount: (orgId: string) => queryOptions({ From 5b779ba9fe059389ab1e460dec9be258c768313a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 03:21:12 +0100 Subject: [PATCH 35/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ApprovalFeed.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 9abcbeed7..a158c1d45 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -167,7 +167,7 @@ export function ApprovalFeed({ loading={isFetchingNextPage} onClick={() => fetchNextPage()} > - {isFetchingNextPage ? t("loading") : t("approvalLoadMore")} + {t("approvalLoadMore")} )}
    From c94d246c24c8894ebfecd1e7aef8941de3b56804 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 04:00:45 +0100 Subject: [PATCH 36/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20list=20machine=20que?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 98 ++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 588f1edd6..9ba7c6843 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -15,7 +15,18 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + ilike, + inArray, + isNull, + or, + sql, + type SQL +} from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import NodeCache from "node-cache"; @@ -97,7 +108,29 @@ const listClientsSchema = z.object({ .catch(1) .default(1), query: z.string().optional(), - sort_by: z.enum(["megabytesIn", "megabytesOut"]).optional().catch(undefined) + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined), + order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined), + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; + }, + z + .array(z.enum(["active", "blocked", "archived"])) + .optional() + .default(["active"]) + .catch(["active"]) + ) }); function queryClientsBase() { @@ -188,7 +221,8 @@ export async function listClients( ) ); } - const { page, pageSize, query } = parsedQuery.data; + const { page, pageSize, online, query, status, sort_by, order } = + parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -237,24 +271,60 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClientsBase(); // Get client count with filter const conditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId), - isNull(clients.userId) + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) ]; - const countQuery = db.$count( - queryClientsBase().where(and(...conditions)) - ); + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); + } + + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; + + if (status.includes("active")) { + filterAggregates.push( + and(eq(clients.archived, false), eq(clients.blocked, false)) + ); + } + + if (status.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (status.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + conditions.push(or(...filterAggregates)); + } + + if (query) { + conditions.push(or(ilike(clients.name, "%" + query + "%"))); + } + + const baseQuery = queryClientsBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const listMachinesQuery = baseQuery + .limit(page) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(clients[sort_by]) + : desc(clients[sort_by]) + : asc(clients.clientId) + ); const [clientsList, totalCount] = await Promise.all([ - baseQuery - .where(and(...conditions)) - .limit(page) - .offset(pageSize * (page - 1)), + listMachinesQuery, countQuery ]); From d6ade102dc084f1f51dfadac1c8e20135c5582c1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Feb 2026 05:14:37 +0100 Subject: [PATCH 37/47] =?UTF-8?q?=E2=9C=A8=20filter=20&=20paginate=20on=20?= =?UTF-8?q?machine=20clients=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 4 +- .../[orgId]/settings/clients/machine/page.tsx | 20 +- src/components/MachineClientsTable.tsx | 260 ++++++++++-------- src/components/ProxyResourcesTable.tsx | 8 +- src/components/UserDevicesTable.tsx | 8 +- 5 files changed, 176 insertions(+), 124 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index d152250bb..65dba7e6c 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -124,6 +124,7 @@ const listUserDevicesSchema = z.object({ "windows", "android", "cli", + "olm", "macos", "ios", "ipados", @@ -302,7 +303,8 @@ export async function listUserDevices( ios: "Pangolin iOS", ipados: "Pangolin iPadOS", macos: "Pangolin macOS", - cli: "Pangolin CLI" + cli: "Pangolin CLI", + olm: "Olm CLI" } satisfies Record< Exclude, string diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index b3e731e85..4b40c906c 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Pagination } from "@server/types/Pagination"; type ClientsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let machineClients: ListClientsResponse["clients"] = []; + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { const machineRes = await internal.get< AxiosResponse >( - `/org/${params.orgId}/clients?filter=machine`, + `/org/${params.orgId}/clients?${searchParams.toString()}`, await authCookieHeader() ); - machineClients = machineRes.data.data.clients; + const responseData = machineRes.data.data; + machineClients = responseData.clients; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) { ); diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index ad01c40fa..5af44dd53 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -16,13 +16,23 @@ import { ArrowRight, ArrowUpDown, MoreHorizontal, - CircleSlash + CircleSlash, + ArrowDown01Icon, + ArrowUp10Icon, + ChevronsUpDownIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "./ui/badge"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { ColumnFilterButton } from "./ColumnFilterButton"; export type ClientRow = { id: number; @@ -48,14 +58,24 @@ export type ClientRow = { type ClientTableProps = { machineClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; export default function MachineClientsTable({ machineClients, - orgId + orgId, + pagination, + rowCount }: ClientTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -65,6 +85,7 @@ export default function MachineClientsTable({ const api = createApiClient(useEnvContext()); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const defaultMachineColumnVisibility = { subnet: false, @@ -182,22 +203,8 @@ export default function MachineClientsTable({ { accessorKey: "name", enableHiding: false, - friendlyName: "Name", - header: ({ column }) => { - return ( - - ); - }, + friendlyName: t("name"), + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; return ( @@ -224,38 +231,35 @@ export default function MachineClientsTable({ { accessorKey: "niceId", friendlyName: "Identifier", - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "online", - friendlyName: "Connectivity", - header: ({ column }) => { + friendlyName: t("online"), + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -279,38 +283,52 @@ export default function MachineClientsTable({ }, { accessorKey: "mbIn", - friendlyName: "Data In", - header: ({ column }) => { + friendlyName: t("dataIn"), + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } }, { accessorKey: "mbOut", - friendlyName: "Data Out", - header: ({ column }) => { + friendlyName: t("dataOut"), + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -356,22 +374,8 @@ export default function MachineClientsTable({ }, { accessorKey: "subnet", - friendlyName: "Address", - header: ({ column }) => { - return ( - - ); - } + friendlyName: t("address"), + header: () => {t("address")} } ]; @@ -455,7 +459,56 @@ export default function MachineClientsTable({ } return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); + + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | null | undefined | string[] + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (typeof value === "string") { + searchParams.set(column, value); + } else if (value) { + for (const val of value) { + searchParams.append(column, val); + } + } + + filter({ + searchParams + }); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + 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); return ( <> @@ -478,20 +531,25 @@ export default function MachineClientsTable({ title="Delete Client" /> )} - - router.push(`/${orgId}/settings/clients/machine/create`) + startNavigation(() => + router.push(`/${orgId}/settings/clients/machine/create`) + ) } + pagination={pagination} + rowCount={rowCount} addButtonText={t("createClient")} onRefresh={refreshData} - isRefreshing={isRefreshing} - enableColumnVisibility={true} - persistColumnVisibility="machine-clients" + isRefreshing={isRefreshing || isFiltering} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={defaultMachineColumnVisibility} stickyLeftColumn="name" stickyRightColumn="actions" @@ -518,30 +576,10 @@ export default function MachineClientsTable({ value: "blocked" } ], - filterFn: ( - row: ClientRow, - selectedValues: (string | number | boolean)[] - ) => { - if (selectedValues.length === 0) return true; - const rowArchived = row.archived || false; - const rowBlocked = row.blocked || false; - const isActive = !rowArchived && !rowBlocked; - - if (selectedValues.includes("active") && isActive) - return true; - if ( - selectedValues.includes("archived") && - rowArchived - ) - return true; - if ( - selectedValues.includes("blocked") && - rowBlocked - ) - return true; - return false; + onValueChange(selectedValues: string[]) { + handleFilterChange("status", selectedValues); }, - defaultValues: ["active"] // Default to showing active clients + values: searchParams.getAll("status") } ]} /> diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index ba69dec4a..490904c71 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -611,11 +611,9 @@ export default function ProxyResourcesTable({ onSearch={handleSearchChange} onPaginationChange={handlePaginationChange} onAdd={() => - startNavigation(() => { - router.push( - `/${orgId}/settings/resources/proxy/create` - ); - }) + startNavigation(() => + router.push(`/${orgId}/settings/resources/proxy/create`) + ) } addButtonText={t("resourceAdd")} onRefresh={refreshData} diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 642ef1f71..1b7b0c694 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -443,10 +443,6 @@ export default function UserDevicesTable({ searchParams ); - console.log({ - dataInOrder, - searchParams: Object.fromEntries(searchParams.entries()) - }); const Icon = dataInOrder === "asc" ? ArrowDown01Icon @@ -520,6 +516,10 @@ export default function UserDevicesTable({ value: "cli", label: "Pangolin CLI" }, + { + value: "olm", + label: "Olm CLI" + }, { value: "unknown", label: t("unknown") From 45cd4df6e5a3d71001424c0a6b036f582bd4130d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Feb 2026 00:37:42 +0100 Subject: [PATCH 38/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MachineClientsTable.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 5af44dd53..97de41130 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -336,21 +336,7 @@ export default function MachineClientsTable({ { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("agent")}, cell: ({ row }) => { const originalRow = row.original; From 6d1665004b1d40f4e655c6a3b2a40bd78a387331 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 11 Feb 2026 04:34:53 +0100 Subject: [PATCH 39/47] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20type=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/user/[niceId]/general/page.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index eef934768..6dbc40b30 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -594,7 +594,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .biometricsEnabled + .biometricsEnabled === + true ) : "-"} @@ -612,7 +613,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .diskEncrypted + .diskEncrypted === + true ) : "-"} @@ -630,7 +632,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .firewallEnabled + .firewallEnabled === + true ) : "-"} @@ -648,7 +651,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .autoUpdatesEnabled + .autoUpdatesEnabled === + true ) : "-"} @@ -666,7 +670,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .tpmAvailable + .tpmAvailable === + true ) : "-"} @@ -685,7 +690,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .windowsAntivirusEnabled + .windowsAntivirusEnabled === + true ) : "-"} @@ -703,7 +709,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .macosSipEnabled + .macosSipEnabled === + true ) : "-"} @@ -722,7 +729,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .macosGatekeeperEnabled + .macosGatekeeperEnabled === + true ) : "-"} @@ -741,7 +749,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .macosFirewallStealthMode + .macosFirewallStealthMode === + true ) : "-"} @@ -759,7 +768,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .linuxAppArmorEnabled + .linuxAppArmorEnabled === + true ) : "-"} @@ -777,7 +787,8 @@ export default function GeneralPage() { {isPaidUser ? formatPostureValue( client.posture - .linuxSELinuxEnabled + .linuxSELinuxEnabled === + true ) : "-"} From 1f8e89772d9249e49aac2c229ddcc20d61402115 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 13 Feb 2026 15:46:13 -0800 Subject: [PATCH 40/47] disable global idp routes if idp mode is org --- server/routers/idp/createIdpOrgPolicy.ts | 9 +++++++++ server/routers/idp/createOidcIdp.ts | 11 +++++++++++ server/routers/idp/updateIdpOrgPolicy.ts | 9 +++++++++ server/routers/idp/updateOidcIdp.ts | 9 +++++++++ 4 files changed, 38 insertions(+) diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts index b9a0098b5..dc7af5377 100644 --- a/server/routers/idp/createIdpOrgPolicy.ts +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -70,6 +70,15 @@ export async function createIdpOrgPolicy( const { idpId, orgId } = parsedParams.data; const { roleMapping, orgMapping } = parsedBody.data; + if (process.env.IDENTITY_PROVIDER_MODE === "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + const [existing] = await db .select() .from(idp) diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 157283623..03626bfde 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -80,6 +80,17 @@ export async function createOidcIdp( tags } = parsedBody.data; + if ( + process.env.IDENTITY_PROVIDER_MODE === "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + const key = config.getRawConfig().server.secret!; const encryptedSecret = encrypt(clientSecret, key); diff --git a/server/routers/idp/updateIdpOrgPolicy.ts b/server/routers/idp/updateIdpOrgPolicy.ts index 6432faf69..ea08de420 100644 --- a/server/routers/idp/updateIdpOrgPolicy.ts +++ b/server/routers/idp/updateIdpOrgPolicy.ts @@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy( const { idpId, orgId } = parsedParams.data; const { roleMapping, orgMapping } = parsedBody.data; + if (process.env.IDENTITY_PROVIDER_MODE === "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + // Check if IDP and policy exist const [existing] = await db .select() diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index 622d3d493..82aed75ce 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -99,6 +99,15 @@ export async function updateOidcIdp( tags } = parsedBody.data; + if (process.env.IDENTITY_PROVIDER_MODE === "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + // Check if IDP exists and is of type OIDC const [existingIdp] = await db .select() From aba586e60577bfdf2a16753b300c02cf9d6fa0e5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 13 Feb 2026 17:35:54 -0800 Subject: [PATCH 41/47] change translation --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index cc388253f..d31f8b1b1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1917,7 +1917,7 @@ "authPageBrandingDeleteConfirm": "Confirm Delete Branding", "brandingLogoURL": "Logo URL", "brandingLogoURLOrPath": "Logo URL or Path", - "brandingLogoPathDescription": "Enter a URL (https://...) or a local path (/logo.png) from the public/ directory on your Pangolin installation.", + "brandingLogoPathDescription": "Enter a URL or a local path.", "brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.", "brandingPrimaryColor": "Primary Color", "brandingLogoWidth": "Width (px)", From 1fbcad8787b282c450c69698559ce3930e947485 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 04:06:11 +0100 Subject: [PATCH 42/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 26 ++++++++++++-------------- server/db/sqlite/schema/schema.ts | 9 +-------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index cb7eef2d1..6afd463ed 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,18 +1,16 @@ -import { - pgTable, - serial, - varchar, - boolean, - integer, - bigint, - real, - text, - index, - uniqueIndex -} from "drizzle-orm/pg-core"; -import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; -import { alias } from "yargs"; +import { InferSelectModel } from "drizzle-orm"; +import { + bigint, + boolean, + index, + integer, + pgTable, + real, + serial, + text, + varchar +} from "drizzle-orm/pg-core"; export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1b9b164c0..7335f6665 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,13 +1,6 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex -} from "drizzle-orm/sqlite-core"; -import { no } from "zod/v4/locales"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), From 761a5f1d4ce64b8cab33531dbeeac4cf35f82a26 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 04:11:27 +0100 Subject: [PATCH 43/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20`like`=20&=20`?= =?UTF-8?q?LOWER(column)`=20for=20searching=20with=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 11 ++++- server/routers/client/listUserDevices.ts | 12 +++-- server/routers/resource/listResources.ts | 48 ++++++++++--------- server/routers/site/listSites.ts | 47 +++++++++--------- .../siteResource/listAllSiteResourcesByOrg.ts | 40 +++++++++++----- 5 files changed, 96 insertions(+), 62 deletions(-) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 9ba7c6843..b7eec8e47 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -20,9 +20,9 @@ import { asc, desc, eq, - ilike, inArray, isNull, + like, or, sql, type SQL @@ -305,7 +305,14 @@ export async function listClients( } if (query) { - conditions.push(or(ilike(clients.name, "%" + query + "%"))); + conditions.push( + or( + like( + sql`LOWER(${clients.name})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); } const baseQuery = queryClientsBase().where(and(...conditions)); diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 65dba7e6c..83012fa1b 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -20,10 +20,10 @@ import { asc, desc, eq, - ilike, inArray, isNotNull, isNull, + like, or, sql, type SQL @@ -287,8 +287,14 @@ export async function listUserDevices( if (query) { conditions.push( or( - ilike(clients.name, "%" + query + "%"), - ilike(users.email, "%" + query + "%") + like( + sql`LOWER(${clients.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.email})`, + "%" + query.toLowerCase() + "%" + ) ) ); } diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 090ea9713..667787233 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,39 +1,37 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import { db, resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility -} from "@server/db"; -import { - resources, - userResources, - roleResources, + resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, + resources, + roleResources, + targetHealthCheck, targets, - targetHealthCheck + userResources } from "@server/db"; import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; +import type { PaginatedResponse } from "@server/types/Pagination"; import { - sql, - eq, - or, - inArray, and, - count, - ilike, asc, - not, + count, + eq, + inArray, isNull, + like, + not, + or, + sql, type SQL } from "drizzle-orm"; -import logger from "@server/logger"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; import { fromZodError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import type { PaginatedResponse } from "@server/types/Pagination"; const listResourcesParamsSchema = z.strictObject({ orgId: z.string() @@ -278,8 +276,14 @@ export async function listResources( if (query) { conditions.push( or( - ilike(resources.name, "%" + query + "%"), - ilike(resources.fullDomain, "%" + query + "%") + like( + sql`LOWER(${resources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.fullDomain})`, + "%" + query.toLowerCase() + "%" + ) ) ); } diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index cc2924995..0bd96cac4 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,28 +1,25 @@ -import { db, exitNodes, newts } from "@server/db"; -import { orgs, roleSites, sites, userSites } from "@server/db"; -import { remoteExitNodes } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; -import response from "@server/lib/response"; import { - and, - asc, - count, - desc, - eq, - ilike, - inArray, - or, - sql -} from "drizzle-orm"; + db, + exitNodes, + newts, + orgs, + remoteExitNodes, + roleSites, + sites, + userSites +} from "@server/db"; +import cache from "@server/lib/cache"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; +import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import semver from "semver"; -import cache from "@server/lib/cache"; -import type { PaginatedResponse } from "@server/types/Pagination"; async function getLatestNewtVersion(): Promise { try { @@ -233,8 +230,14 @@ export async function listSites( if (query) { conditions.push( or( - ilike(sites.name, "%" + query + "%"), - ilike(sites.niceId, "%" + query + "%") + like( + sql`LOWER(${sites.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${sites.niceId})`, + "%" + query.toLowerCase() + "%" + ) ) ); } diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index f15d2eccb..1b3767418 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, resources } from "@server/db"; -import { siteResources, sites, SiteResource } from "@server/db"; +import { db, SiteResource, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { eq, and, asc, ilike, or } from "drizzle-orm"; -import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, asc, eq, like, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() @@ -118,11 +117,26 @@ export async function listAllSiteResourcesByOrg( if (query) { conditions.push( or( - ilike(siteResources.name, "%" + query + "%"), - ilike(siteResources.destination, "%" + query + "%"), - ilike(siteResources.alias, "%" + query + "%"), - ilike(siteResources.aliasAddress, "%" + query + "%"), - ilike(sites.name, "%" + query + "%") + like( + sql`LOWER(${siteResources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.destination})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.alias})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.aliasAddress})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${sites.name})`, + "%" + query.toLowerCase() + "%" + ) ) ); } From ddfe55e3aead0203a05c84a57c85aa2aa26403b7 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 04:19:30 +0100 Subject: [PATCH 44/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20`niceId`=20to?= =?UTF-8?q?=20query=20filtering=20on=20most=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listClients.ts | 4 ++++ server/routers/client/listUserDevices.ts | 4 ++++ server/routers/resource/listResources.ts | 4 ++++ server/routers/siteResource/listAllSiteResourcesByOrg.ts | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index b7eec8e47..c22d31b4a 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -310,6 +310,10 @@ export async function listClients( like( sql`LOWER(${clients.name})`, "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${clients.niceId})`, + "%" + query.toLowerCase() + "%" ) ) ); diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 83012fa1b..9db676d43 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -291,6 +291,10 @@ export async function listUserDevices( sql`LOWER(${clients.name})`, "%" + query.toLowerCase() + "%" ), + like( + sql`LOWER(${clients.niceId})`, + "%" + query.toLowerCase() + "%" + ), like( sql`LOWER(${users.email})`, "%" + query.toLowerCase() + "%" diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 667787233..1fa8b3166 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -280,6 +280,10 @@ export async function listResources( sql`LOWER(${resources.name})`, "%" + query.toLowerCase() + "%" ), + like( + sql`LOWER(${resources.niceId})`, + "%" + query.toLowerCase() + "%" + ), like( sql`LOWER(${resources.fullDomain})`, "%" + query.toLowerCase() + "%" diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 1b3767418..944955a50 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -121,6 +121,10 @@ export async function listAllSiteResourcesByOrg( sql`LOWER(${siteResources.name})`, "%" + query.toLowerCase() + "%" ), + like( + sql`LOWER(${siteResources.niceId})`, + "%" + query.toLowerCase() + "%" + ), like( sql`LOWER(${siteResources.destination})`, "%" + query.toLowerCase() + "%" From d4668fae99ff2626da14f90b6fa128beb75f3b56 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 14 Feb 2026 11:25:00 -0800 Subject: [PATCH 45/47] add openapi types --- server/routers/client/listClients.ts | 49 ++++++++++++-- server/routers/client/listUserDevices.ts | 66 +++++++++++++++++-- server/routers/resource/listResources.ts | 34 ++++++++-- server/routers/site/listSites.ts | 37 +++++++++-- .../siteResource/listAllSiteResourcesByOrg.ts | 24 ++++++- 5 files changed, 188 insertions(+), 22 deletions(-) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index c22d31b4a..53a66150c 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -99,25 +99,54 @@ const listClientsSchema = z.object({ .positive() .optional() .catch(20) - .default(20), + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), page: z.coerce .number() // for prettier formatting .int() .min(0) .optional() .catch(1) - .default(1), + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), query: z.string().optional(), sort_by: z .enum(["megabytesIn", "megabytesOut"]) .optional() - .catch(undefined), - order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + .catch(undefined) + .openapi({ + type: "string", + enum: ["megabytesIn", "megabytesOut"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), online: z .enum(["true", "false"]) .transform((v) => v === "true") .optional() - .catch(undefined), + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter by online status" + }), status: z.preprocess( (val: string | undefined) => { if (val) { @@ -130,6 +159,16 @@ const listClientsSchema = z.object({ .optional() .default(["active"]) .catch(["active"]) + .openapi({ + type: "array", + items: { + type: "string", + enum: ["active", "blocked", "archived"] + }, + default: ["active"], + description: + "Filter by client status. Can be a comma-separated list of values. Defaults to 'active'." + }) ) }); diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 9db676d43..54fffe43b 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -100,25 +100,54 @@ const listUserDevicesSchema = z.object({ .positive() .optional() .catch(20) - .default(20), + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), page: z.coerce .number() // for prettier formatting .int() .min(0) .optional() .catch(1) - .default(1), + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), query: z.string().optional(), sort_by: z .enum(["megabytesIn", "megabytesOut"]) .optional() - .catch(undefined), - order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + .catch(undefined) + .openapi({ + type: "string", + enum: ["megabytesIn", "megabytesOut"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), online: z .enum(["true", "false"]) .transform((v) => v === "true") .optional() - .catch(undefined), + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter by online status" + }), agent: z .enum([ "windows", @@ -131,7 +160,22 @@ const listUserDevicesSchema = z.object({ "unknown" ]) .optional() - .catch(undefined), + .catch(undefined) + .openapi({ + type: "string", + enum: [ + "windows", + "android", + "cli", + "olm", + "macos", + "ios", + "ipados", + "unknown" + ], + description: + "Filter by agent type. Use 'unknown' to filter clients with no agent detected." + }), status: z.preprocess( (val: string | undefined) => { if (val) { @@ -146,6 +190,16 @@ const listUserDevicesSchema = z.object({ .optional() .default(["active", "pending"]) .catch(["active", "pending"]) + .openapi({ + type: "array", + items: { + type: "string", + enum: ["active", "pending", "denied", "blocked", "archived"] + }, + default: ["active", "pending"], + description: + "Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled." + }) ) }); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1fa8b3166..a26a5df50 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -44,28 +44,54 @@ const listResourcesSchema = z.object({ .positive() .optional() .catch(20) - .default(20), + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), page: z.coerce .number() // for prettier formatting .int() .min(0) .optional() .catch(1) - .default(1), + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), query: z.string().optional(), enabled: z .enum(["true", "false"]) .transform((v) => v === "true") .optional() - .catch(undefined), + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter resources based on enabled status" + }), authState: z .enum(["protected", "not_protected", "none"]) .optional() - .catch(undefined), + .catch(undefined) + .openapi({ + type: "string", + enum: ["protected", "not_protected", "none"], + description: + "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." + }), healthStatus: z .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) .optional() .catch(undefined) + .openapi({ + type: "string", + enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], + description: + "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." + }) }); // grouped by resource with targets[]) diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 0bd96cac4..e4881b1ab 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -88,25 +88,54 @@ const listSitesSchema = z.object({ .positive() .optional() .catch(20) - .default(20), + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), page: z.coerce .number() // for prettier formatting .int() .min(0) .optional() .catch(1) - .default(1), + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), query: z.string().optional(), sort_by: z .enum(["megabytesIn", "megabytesOut"]) .optional() - .catch(undefined), - order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"), + .catch(undefined) + .openapi({ + type: "string", + enum: ["megabytesIn", "megabytesOut"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), online: z .enum(["true", "false"]) .transform((v) => v === "true") .optional() .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter by online status" + }) }); function querySitesBase() { diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 944955a50..ead1fc8a6 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -21,16 +21,34 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ .positive() .optional() .catch(20) - .default(20), + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), page: z.coerce .number() // for prettier formatting .int() .min(0) .optional() .catch(1) - .default(1), + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), query: z.string().optional(), - mode: z.enum(["host", "cidr"]).optional().catch(undefined) + mode: z + .enum(["host", "cidr"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["host", "cidr"], + description: "Filter site resources by mode" + }) }); export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ From 4c8edb80b3e5db16d230626463dc81b49a32f327 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 14 Feb 2026 11:40:59 -0800 Subject: [PATCH 46/47] dont show table footer in client-side data-table --- src/components/ui/controlled-data-table.tsx | 5 ----- src/components/ui/data-table.tsx | 19 ++++++++----------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 4b87a5209..a0231bb8c 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -124,11 +124,6 @@ export function ControlledDataTable({ return initial; }, [filters]); - console.log({ - pagination, - rowCount - }); - const table = useReactTable({ data: rows, columns, diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 4d79ba0d7..834c56e88 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -320,11 +320,6 @@ export function DataTable({ return result; }, [data, tabs, activeTab, filters, activeFilters]); - console.log({ - pagination, - paginationState - }); - const table = useReactTable({ data: filteredData, columns, @@ -852,12 +847,14 @@ export function DataTable({
    - + {table.getRowModel().rows?.length > 0 && ( + + )}
    From 33f0782f3a3fc24f285f9ad4923c6e48fd2fa496 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 14 Feb 2026 17:27:51 -0800 Subject: [PATCH 47/47] support delete account --- messages/en-US.json | 12 + server/lib/deleteOrg.ts | 169 +++++++ server/routers/auth/deleteMyAccount.ts | 228 ++++++++++ server/routers/auth/index.ts | 3 +- server/routers/external.ts | 1 + server/routers/org/deleteOrg.ts | 186 +------- .../delete-account/DeleteAccountClient.tsx | 74 ++++ src/app/auth/delete-account/page.tsx | 28 ++ src/components/ApplyInternalRedirect.tsx | 8 +- src/components/DeleteAccountConfirmDialog.tsx | 414 ++++++++++++++++++ src/components/ProfileIcon.tsx | 18 +- src/components/RedirectToOrg.tsx | 3 +- src/lib/internalRedirect.ts | 11 +- 13 files changed, 963 insertions(+), 192 deletions(-) create mode 100644 server/lib/deleteOrg.ts create mode 100644 server/routers/auth/deleteMyAccount.ts create mode 100644 src/app/auth/delete-account/DeleteAccountClient.tsx create mode 100644 src/app/auth/delete-account/page.tsx create mode 100644 src/components/DeleteAccountConfirmDialog.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e68d257c2..3e8257119 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -201,6 +201,7 @@ "protocolSelect": "Select a protocol", "resourcePortNumber": "Port Number", "resourcePortNumberDescription": "The external port number to proxy requests.", + "back": "Back", "cancel": "Cancel", "resourceConfig": "Configuration Snippets", "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", @@ -246,6 +247,17 @@ "orgErrorDeleteMessage": "An error occurred while deleting the organization.", "orgDeleted": "Organization deleted", "orgDeletedMessage": "The organization and its data has been deleted.", + "deleteAccount": "Delete Account", + "deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.", + "deleteAccountButton": "Delete Account", + "deleteAccountConfirmTitle": "Delete Account", + "deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.", + "deleteAccountConfirmString": "delete account", + "deleteAccountSuccess": "Account Deleted", + "deleteAccountSuccessMessage": "Your account has been deleted.", + "deleteAccountError": "Failed to delete account", + "deleteAccountPreviewAccount": "Your Account", + "deleteAccountPreviewOrgs": "Organizations you own (and all their data)", "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts new file mode 100644 index 000000000..7295555db --- /dev/null +++ b/server/lib/deleteOrg.ts @@ -0,0 +1,169 @@ +import { + clients, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + domains, + olms, + orgDomains, + orgs, + resources, + sites +} from "@server/db"; +import { newts, newtSessions } from "@server/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { sendToClient } from "#dynamic/routers/ws"; +import { deletePeer } from "@server/routers/gerbil/peers"; +import { OlmErrorCodes } from "@server/routers/olm/error"; +import { sendTerminateClient } from "@server/routers/client/terminate"; + +export type DeleteOrgByIdResult = { + deletedNewtIds: string[]; + olmsToTerminate: string[]; +}; + +/** + * Deletes one organization and its related data. Returns ids for termination + * messages; caller should call sendTerminationMessages with the result. + * Throws if org not found. + */ +export async function deleteOrgById( + orgId: string +): Promise { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + throw createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ); + } + + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, orgId)) + .limit(1); + + const orgClients = await db + .select() + .from(clients) + .where(eq(clients.orgId, orgId)); + + const deletedNewtIds: string[] = []; + const olmsToTerminate: string[] = []; + + await db.transaction(async (trx) => { + for (const site of orgSites) { + if (site.pubKey) { + if (site.type == "wireguard") { + await deletePeer(site.exitNodeId!, site.pubKey); + } else if (site.type == "newt") { + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, site.siteId)) + .returning(); + if (deletedNewt) { + deletedNewtIds.push(deletedNewt.newtId); + await trx + .delete(newtSessions) + .where( + eq(newtSessions.newtId, deletedNewt.newtId) + ); + } + } + } + logger.info(`Deleting site ${site.siteId}`); + await trx.delete(sites).where(eq(sites.siteId, site.siteId)); + } + for (const client of orgClients) { + const [olm] = await trx + .select() + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + if (olm) { + olmsToTerminate.push(olm.olmId); + } + logger.info(`Deleting client ${client.clientId}`); + await trx + .delete(clients) + .where(eq(clients.clientId, client.clientId)); + await trx + .delete(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ); + await trx + .delete(clientSitesAssociationsCache) + .where( + eq(clientSitesAssociationsCache.clientId, client.clientId) + ); + } + const allOrgDomains = await trx + .select() + .from(orgDomains) + .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(domains.configManaged, false) + ) + ); + const domainIdsToDelete: string[] = []; + for (const orgDomain of allOrgDomains) { + const domainId = orgDomain.domains.domainId; + const orgCount = await trx + .select({ count: sql`count(*)` }) + .from(orgDomains) + .where(eq(orgDomains.domainId, domainId)); + if (orgCount[0].count === 1) { + domainIdsToDelete.push(domainId); + } + } + if (domainIdsToDelete.length > 0) { + await trx + .delete(domains) + .where(inArray(domains.domainId, domainIdsToDelete)); + } + await trx.delete(resources).where(eq(resources.orgId, orgId)); + await trx.delete(orgs).where(eq(orgs.orgId, orgId)); + }); + + return { deletedNewtIds, olmsToTerminate }; +} + +export function sendTerminationMessages(result: DeleteOrgByIdResult): void { + for (const newtId of result.deletedNewtIds) { + sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch( + (error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); + } + ); + } + for (const olmId of result.olmsToTerminate) { + sendTerminateClient( + 0, + OlmErrorCodes.TERMINATED_REKEYED, + olmId + ).catch((error) => { + logger.error( + "Failed to send termination message to olm:", + error + ); + }); + } +} diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts new file mode 100644 index 000000000..2c37cd09c --- /dev/null +++ b/server/routers/auth/deleteMyAccount.ts @@ -0,0 +1,228 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, orgs, userOrgs, users } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +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 { verifySession } from "@server/auth/sessions/verifySession"; +import { + invalidateSession, + createBlankSessionTokenCookie +} from "@server/auth/sessions/app"; +import { verifyPassword } from "@server/auth/password"; +import { verifyTotpCode } from "@server/auth/totp"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { + deleteOrgById, + sendTerminationMessages +} from "@server/lib/deleteOrg"; +import { UserType } from "@server/types/UserTypes"; + +const deleteMyAccountBody = z.strictObject({ + password: z.string().optional(), + code: z.string().optional() +}); + +export type DeleteMyAccountPreviewResponse = { + preview: true; + orgs: { orgId: string; name: string }[]; + twoFactorEnabled: boolean; +}; + +export type DeleteMyAccountCodeRequestedResponse = { + codeRequested: true; +}; + +export type DeleteMyAccountSuccessResponse = { + success: true; +}; + +/** + * Self-service account deletion (saas only). Returns preview when no password; + * requires password and optional 2FA code to perform deletion. Uses shared + * deleteOrgById for each owned org (delete-my-account may delete multiple orgs). + */ +export async function deleteMyAccount( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { user, session } = await verifySession(req); + if (!user || !session) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated") + ); + } + + if (user.serverAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Server admins cannot delete their account this way" + ) + ); + } + + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Account deletion with password is only supported for internal users" + ) + ); + } + + const parsed = deleteMyAccountBody.safeParse(req.body ?? {}); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { password, code } = parsed.data; + + const userId = user.userId; + + const ownedOrgsRows = await db + .select({ + orgId: userOrgs.orgId + }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.isOwner, true) + ) + ); + + const orgIds = ownedOrgsRows.map((r) => r.orgId); + + if (!password) { + const orgsWithNames = + orgIds.length > 0 + ? await db + .select({ + orgId: orgs.orgId, + name: orgs.name + }) + .from(orgs) + .where(inArray(orgs.orgId, orgIds)) + : []; + return response(res, { + data: { + preview: true, + orgs: orgsWithNames.map((o) => ({ + orgId: o.orgId, + name: o.name ?? "" + })), + twoFactorEnabled: user.twoFactorEnabled ?? false + }, + success: true, + error: false, + message: "Preview", + status: HttpCode.OK + }); + } + + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); + if (!validPassword) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid password") + ); + } + + if (user.twoFactorEnabled) { + if (!code) { + return response(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor code required", + status: HttpCode.ACCEPTED + }); + } + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + if (!validOTP) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The two-factor code you entered is incorrect" + ) + ); + } + } + + const allDeletedNewtIds: string[] = []; + const allOlmsToTerminate: string[] = []; + + for (const row of ownedOrgsRows) { + try { + const result = await deleteOrgById(row.orgId); + allDeletedNewtIds.push(...result.deletedNewtIds); + allOlmsToTerminate.push(...result.olmsToTerminate); + } catch (err) { + logger.error( + `Failed to delete org ${row.orgId} during account deletion`, + err + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete organization" + ) + ); + } + } + + sendTerminationMessages({ + deletedNewtIds: allDeletedNewtIds, + olmsToTerminate: allOlmsToTerminate + }); + + await db.transaction(async (trx) => { + await trx.delete(users).where(eq(users.userId, userId)); + await calculateUserClientsForOrgs(userId, trx); + }); + + try { + await invalidateSession(session.sessionId); + } catch (error) { + logger.error( + "Failed to invalidate session after account deletion", + error + ); + } + + const isSecure = req.protocol === "https"; + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Account deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } +} diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index ee08d155b..7a469aa13 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -17,4 +17,5 @@ export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; export * from "./pollDeviceWebAuth"; -export * from "./lookupUser"; \ No newline at end of file +export * from "./lookupUser"; +export * from "./deleteMyAccount"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 52aaa81e9..5d25e898b 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1171,6 +1171,7 @@ authRouter.post( auth.login ); authRouter.post("/logout", auth.logout); +authRouter.post("/delete-my-account", auth.deleteMyAccount); authRouter.post( "/lookup-user", rateLimit({ diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 48d3102d2..0e5b87a2f 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,28 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - clients, - clientSiteResourcesAssociationsCache, - clientSitesAssociationsCache, - db, - domains, - olms, - orgDomains, - resources -} from "@server/db"; -import { newts, newtSessions, orgs, sites, userActions } from "@server/db"; -import { eq, and, inArray, sql } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { sendToClient } from "#dynamic/routers/ws"; -import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; -import { OlmErrorCodes } from "../olm/error"; -import { sendTerminateClient } from "../client/terminate"; +import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -56,170 +40,9 @@ export async function deleteOrg( ) ); } - const { orgId } = parsedParams.data; - - const [org] = await db - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)) - .limit(1); - - if (!org) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Organization with ID ${orgId} not found` - ) - ); - } - // we need to handle deleting each site - const orgSites = await db - .select() - .from(sites) - .where(eq(sites.orgId, orgId)) - .limit(1); - - const orgClients = await db - .select() - .from(clients) - .where(eq(clients.orgId, orgId)); - - const deletedNewtIds: string[] = []; - const olmsToTerminate: string[] = []; - - await db.transaction(async (trx) => { - for (const site of orgSites) { - if (site.pubKey) { - if (site.type == "wireguard") { - await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, site.siteId)) - .returning(); - if (deletedNewt) { - deletedNewtIds.push(deletedNewt.newtId); - - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where( - eq(newtSessions.newtId, deletedNewt.newtId) - ); - } - } - } - - logger.info(`Deleting site ${site.siteId}`); - await trx.delete(sites).where(eq(sites.siteId, site.siteId)); - } - for (const client of orgClients) { - const [olm] = await trx - .select() - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - - if (olm) { - olmsToTerminate.push(olm.olmId); - } - - logger.info(`Deleting client ${client.clientId}`); - await trx - .delete(clients) - .where(eq(clients.clientId, client.clientId)); - - // also delete the associations - await trx - .delete(clientSiteResourcesAssociationsCache) - .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) - ); - - await trx - .delete(clientSitesAssociationsCache) - .where( - eq( - clientSitesAssociationsCache.clientId, - client.clientId - ) - ); - } - - const allOrgDomains = await trx - .select() - .from(orgDomains) - .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) - .where( - and( - eq(orgDomains.orgId, orgId), - eq(domains.configManaged, false) - ) - ); - - // For each domain, check if it belongs to multiple organizations - const domainIdsToDelete: string[] = []; - for (const orgDomain of allOrgDomains) { - const domainId = orgDomain.domains.domainId; - - // Count how many organizations this domain belongs to - const orgCount = await trx - .select({ count: sql`count(*)` }) - .from(orgDomains) - .where(eq(orgDomains.domainId, domainId)); - - // Only delete the domain if it belongs to exactly 1 organization (the one being deleted) - if (orgCount[0].count === 1) { - domainIdsToDelete.push(domainId); - } - } - - // Delete domains that belong exclusively to this organization - if (domainIdsToDelete.length > 0) { - await trx - .delete(domains) - .where(inArray(domains.domainId, domainIdsToDelete)); - } - - // Delete resources - await trx.delete(resources).where(eq(resources.orgId, orgId)); - - await trx.delete(orgs).where(eq(orgs.orgId, orgId)); - }); - - // Send termination messages outside of transaction to prevent blocking - for (const newtId of deletedNewtIds) { - const payload = { - type: `newt/wg/terminate`, - data: {} - }; - // Don't await this to prevent blocking the response - sendToClient(newtId, payload).catch((error) => { - logger.error( - "Failed to send termination message to newt:", - error - ); - }); - } - - for (const olmId of olmsToTerminate) { - sendTerminateClient( - 0, // clientId not needed since we're passing olmId - OlmErrorCodes.TERMINATED_REKEYED, - olmId - ).catch((error) => { - logger.error( - "Failed to send termination message to olm:", - error - ); - }); - } - + const result = await deleteOrgById(orgId); + sendTerminationMessages(result); return response(res, { data: null, success: true, @@ -228,6 +51,9 @@ export async function deleteOrg( status: HttpCode.OK }); } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } logger.error(error); return next( createHttpError( diff --git a/src/app/auth/delete-account/DeleteAccountClient.tsx b/src/app/auth/delete-account/DeleteAccountClient.tsx new file mode 100644 index 000000000..8cd150afe --- /dev/null +++ b/src/app/auth/delete-account/DeleteAccountClient.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog"; +import UserProfileCard from "@app/components/UserProfileCard"; +import { ArrowLeft } from "lucide-react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; + +type DeleteAccountClientProps = { + displayName: string; +}; + +export default function DeleteAccountClient({ + displayName +}: DeleteAccountClientProps) { + const router = useRouter(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + function handleUseDifferentAccount() { + api.post("/auth/logout") + .catch((e) => { + console.error(t("logoutError"), e); + toast({ + title: t("logoutError"), + description: formatAxiosError(e, t("logoutError")) + }); + }) + .then(() => { + router.push( + "/auth/login?internal_redirect=/auth/delete-account" + ); + router.refresh(); + }); + } + + return ( +
    + +

    + {t("deleteAccountDescription")} +

    +
    + + +
    + +
    + ); +} diff --git a/src/app/auth/delete-account/page.tsx b/src/app/auth/delete-account/page.tsx new file mode 100644 index 000000000..5cbc8d738 --- /dev/null +++ b/src/app/auth/delete-account/page.tsx @@ -0,0 +1,28 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { build } from "@server/build"; +import { cache } from "react"; +import DeleteAccountClient from "./DeleteAccountClient"; +import { getTranslations } from "next-intl/server"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; + +export const dynamic = "force-dynamic"; + +export default async function DeleteAccountPage() { + const getUser = cache(verifySession); + const user = await getUser({ skipCheckVerifyEmail: true }); + + if (!user) { + redirect("/auth/login"); + } + + const t = await getTranslations(); + const displayName = getUserDisplayName({ user }); + + return ( +
    +

    {t("deleteAccount")}

    + +
    + ); +} diff --git a/src/components/ApplyInternalRedirect.tsx b/src/components/ApplyInternalRedirect.tsx index f2afc8cbc..24e93336a 100644 --- a/src/components/ApplyInternalRedirect.tsx +++ b/src/components/ApplyInternalRedirect.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { consumeInternalRedirectPath } from "@app/lib/internalRedirect"; +import { getInternalRedirectTarget } from "@app/lib/internalRedirect"; type ApplyInternalRedirectProps = { orgId: string; @@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({ const router = useRouter(); useEffect(() => { - const path = consumeInternalRedirectPath(); - if (path) { - router.replace(`/${orgId}${path}`); + const target = getInternalRedirectTarget(orgId); + if (target) { + router.replace(target); } }, [orgId, router]); diff --git a/src/components/DeleteAccountConfirmDialog.tsx b/src/components/DeleteAccountConfirmDialog.tsx new file mode 100644 index 000000000..7a54f9a04 --- /dev/null +++ b/src/components/DeleteAccountConfirmDialog.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import type { + DeleteMyAccountPreviewResponse, + DeleteMyAccountCodeRequestedResponse, + DeleteMyAccountSuccessResponse +} from "@server/routers/auth/deleteMyAccount"; +import { AxiosResponse } from "axios"; + +type DeleteAccountConfirmDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function DeleteAccountConfirmDialog({ + open, + setOpen +}: DeleteAccountConfirmDialogProps) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const passwordSchema = useMemo( + () => + z.object({ + password: z.string().min(1, { message: t("passwordRequired") }) + }), + [t] + ); + + const codeSchema = useMemo( + () => + z.object({ + code: z.string().length(6, { message: t("pincodeInvalid") }) + }), + [t] + ); + + const [step, setStep] = useState<0 | 1 | 2>(0); + const [loading, setLoading] = useState(false); + const [loadingPreview, setLoadingPreview] = useState(false); + const [preview, setPreview] = + useState(null); + const [passwordValue, setPasswordValue] = useState(""); + + const passwordForm = useForm>({ + resolver: zodResolver(passwordSchema), + defaultValues: { password: "" } + }); + + const codeForm = useForm>({ + resolver: zodResolver(codeSchema), + defaultValues: { code: "" } + }); + + useEffect(() => { + if (open && step === 0 && !preview) { + setLoadingPreview(true); + api.post>( + "/auth/delete-my-account", + {} + ) + .then((res) => { + if (res.data?.data?.preview) { + setPreview(res.data.data); + } + }) + .catch((err) => { + toast({ + variant: "destructive", + title: t("deleteAccountError"), + description: formatAxiosError( + err, + t("deleteAccountError") + ) + }); + setOpen(false); + }) + .finally(() => setLoadingPreview(false)); + } + }, [open, step, preview, api, setOpen, t]); + + function reset() { + setStep(0); + setPreview(null); + setPasswordValue(""); + passwordForm.reset(); + codeForm.reset(); + } + + async function handleContinueToPassword() { + setStep(1); + } + + async function handlePasswordSubmit( + values: z.infer + ) { + setLoading(true); + setPasswordValue(values.password); + try { + const res = await api.post< + | AxiosResponse + | AxiosResponse + >("/auth/delete-my-account", { password: values.password }); + + const data = res.data?.data; + + if (data && "codeRequested" in data && data.codeRequested) { + setStep(2); + } else if (data && "success" in data && data.success) { + toast({ + title: t("deleteAccountSuccess"), + description: t("deleteAccountSuccessMessage") + }); + setOpen(false); + reset(); + router.push("/auth/login"); + router.refresh(); + } + } catch (err) { + toast({ + variant: "destructive", + title: t("deleteAccountError"), + description: formatAxiosError(err, t("deleteAccountError")) + }); + } finally { + setLoading(false); + } + } + + async function handleCodeSubmit(values: z.infer) { + setLoading(true); + try { + const res = await api.post< + AxiosResponse + >("/auth/delete-my-account", { + password: passwordValue, + code: values.code + }); + + if (res.data?.data?.success) { + toast({ + title: t("deleteAccountSuccess"), + description: t("deleteAccountSuccessMessage") + }); + setOpen(false); + reset(); + router.push("/auth/login"); + router.refresh(); + } + } catch (err) { + toast({ + variant: "destructive", + title: t("deleteAccountError"), + description: formatAxiosError(err, t("deleteAccountError")) + }); + } finally { + setLoading(false); + } + } + + return ( + { + setOpen(val); + if (!val) reset(); + }} + > + + + + {t("deleteAccountConfirmTitle")} + + + +
    + {step === 0 && ( + <> + {loadingPreview ? ( +

    + {t("loading")}... +

    + ) : preview ? ( + <> +

    + {t("deleteAccountConfirmMessage")} +

    +
    +

    + {t( + "deleteAccountPreviewAccount" + )} +

    + {preview.orgs.length > 0 && ( + <> +

    + {t( + "deleteAccountPreviewOrgs" + )} +

    +
      + {preview.orgs.map( + (org) => ( +
    • + {org.name || + org.orgId} +
    • + ) + )} +
    + + )} +
    +

    + {t("cannotbeUndone")} +

    + + ) : null} + + )} + + {step === 1 && ( +
    + + ( + + + {t("password")} + + + + + + + )} + /> + + + )} + + {step === 2 && ( +
    +
    +

    + {t("otpAuthDescription")} +

    +
    +
    + + ( + + +
    + { + field.onChange( + value + ); + }} + > + + + + + + + + + +
    +
    + +
    + )} + /> + + +
    + )} +
    +
    + + + + + {step === 0 && preview && !loadingPreview && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + +
    +
    + ); +} diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index d466b707c..4c900c624 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react"; +import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { build } from "@server/build"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; @@ -187,6 +189,20 @@ export default function ProfileIcon() { + {user?.type === UserType.Internal && !user?.serverAdmin && ( + <> + + + + {t("deleteAccount")} + + + + + )} logout()}> {/* */} {t("logout")} diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx index 7ea1ea4bb..e647ee7a1 100644 --- a/src/components/RedirectToOrg.tsx +++ b/src/components/RedirectToOrg.tsx @@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) { useEffect(() => { try { - const target = getInternalRedirectTarget(targetOrgId); + const target = + getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`; router.replace(target); } catch { router.replace(`/${targetOrgId}`); diff --git a/src/lib/internalRedirect.ts b/src/lib/internalRedirect.ts index 115cea5c7..6514db66e 100644 --- a/src/lib/internalRedirect.ts +++ b/src/lib/internalRedirect.ts @@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null { } /** - * Returns the full redirect target for an org: either `/${orgId}` or - * `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the - * stored value. + * Returns the full redirect target if a valid internal_redirect was stored + * (consumes the stored value). Returns null if none was stored or expired. + * Paths starting with /auth/ are returned as-is; others are prefixed with orgId. */ -export function getInternalRedirectTarget(orgId: string): string { +export function getInternalRedirectTarget(orgId: string): string | null { const path = consumeInternalRedirectPath(); - return path ? `/${orgId}${path}` : `/${orgId}`; + if (!path) return null; + return path.startsWith("/auth/") ? path : `/${orgId}${path}`; }