From 673cd0fcd13c1184dd34551ce1109ddd30f8e489 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 12 Jan 2026 20:37:53 -0800 Subject: [PATCH] add block client --- messages/en-US.json | 6 ++ server/auth/actions.ts | 2 + server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/routers/client/blockClient.ts | 101 ++++++++++++++++++ server/routers/client/index.ts | 2 + server/routers/client/listClients.ts | 3 +- server/routers/client/unblockClient.ts | 93 ++++++++++++++++ server/routers/external.ts | 16 +++ server/routers/integration.ts | 16 +++ .../[orgId]/settings/clients/machine/page.tsx | 3 +- .../[orgId]/settings/clients/user/page.tsx | 3 +- src/components/MachineClientsTable.tsx | 101 +++++++++++++++++- src/components/PermissionsSelectBox.tsx | 2 + src/components/UserDevicesTable.tsx | 99 ++++++++++++++++- 15 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 server/routers/client/blockClient.ts create mode 100644 server/routers/client/unblockClient.ts diff --git a/messages/en-US.json b/messages/en-US.json index 0ddd883c..12e4f63f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1120,6 +1120,8 @@ "actionDeleteClient": "Delete Client", "actionArchiveClient": "Archive Client", "actionUnarchiveClient": "Unarchive Client", + "actionBlockClient": "Block Client", + "actionUnblockClient": "Unblock Client", "actionUpdateClient": "Update Client", "actionListClients": "List Clients", "actionGetClient": "Get Client", @@ -2418,5 +2420,9 @@ "archiveClientQuestion": "Are you sure you want to archive this client?", "archiveClientMessage": "The client will be archived and removed from your active clients list.", "archiveClientConfirm": "Archive Client", + "blockClient": "Block Client", + "blockClientQuestion": "Are you sure you want to block this client?", + "blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.", + "blockClientConfirm": "Block Client", "active": "Active" } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index e0f86244..ea3ab6d1 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -80,6 +80,8 @@ export enum ActionsEnum { deleteClient = "deleteClient", archiveClient = "archiveClient", unarchiveClient = "unarchiveClient", + blockClient = "blockClient", + unblockClient = "unblockClient", updateClient = "updateClient", listClients = "listClients", getClient = "getClient", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b5ae30c6..a0b1e3be 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -689,7 +689,8 @@ export const clients = pgTable("clients", { // endpoint: varchar("endpoint"), lastHolePunch: integer("lastHolePunch"), maxConnections: integer("maxConnections"), - archived: boolean("archived").notNull().default(false) + archived: boolean("archived").notNull().default(false), + blocked: boolean("blocked").notNull().default(false) }); export const clientSitesAssociationsCache = pgTable( diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 9d84d6b5..84211a1e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -384,7 +384,8 @@ export const clients = sqliteTable("clients", { online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), lastHolePunch: integer("lastHolePunch"), - archived: integer("archived", { mode: "boolean" }).notNull().default(false) + archived: integer("archived", { mode: "boolean" }).notNull().default(false), + blocked: integer("blocked", { mode: "boolean" }).notNull().default(false) }); export const clientSitesAssociationsCache = sqliteTable( diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts new file mode 100644 index 00000000..e1a00ff6 --- /dev/null +++ b/server/routers/client/blockClient.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } 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 { OpenAPITags, registry } from "@server/openApi"; +import { sendTerminateClient } from "./terminate"; + +const blockClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/block", + description: "Block a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: blockClientSchema + }, + responses: {} +}); + +export async function blockClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = blockClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (client.blocked) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is already blocked` + ) + ); + } + + await db.transaction(async (trx) => { + // Block the client + await trx + .update(clients) + .set({ blocked: true }) + .where(eq(clients.clientId, clientId)); + + // Send terminate signal if there's an associated OLM and it's connected + if (client.olmId && client.online) { + await sendTerminateClient(client.clientId, client.olmId); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client blocked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to block client" + ) + ); + } +} diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 72d64de2..34614cc8 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -3,6 +3,8 @@ export * from "./createClient"; export * from "./deleteClient"; export * from "./archiveClient"; export * from "./unarchiveClient"; +export * from "./blockClient"; +export * from "./unblockClient"; export * from "./listClients"; export * from "./updateClient"; export * from "./getClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 41e995f4..18bc3e38 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -138,7 +138,8 @@ function queryClients( niceId: clients.niceId, agent: olms.agent, olmArchived: olms.archived, - archived: clients.archived + archived: clients.archived, + blocked: clients.blocked }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) diff --git a/server/routers/client/unblockClient.ts b/server/routers/client/unblockClient.ts new file mode 100644 index 00000000..82b608a2 --- /dev/null +++ b/server/routers/client/unblockClient.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } 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 { OpenAPITags, registry } from "@server/openApi"; + +const unblockClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/unblock", + description: "Unblock a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: unblockClientSchema + }, + responses: {} +}); + +export async function unblockClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = unblockClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.blocked) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is not blocked` + ) + ); + } + + // Unblock the client + await db + .update(clients) + .set({ blocked: false }) + .where(eq(clients.clientId, clientId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client unblocked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unblock client" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 564a7531..9b6490a5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -190,6 +190,22 @@ authenticated.post( client.unarchiveClient ); +authenticated.post( + "/client/:clientId/block", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyClientAccess, // this will check if the user has access to the client diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6de01843..3373285b 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -859,6 +859,22 @@ authenticated.post( client.unarchiveClient ); +authenticated.post( + "/client/:clientId/block", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index 88508475..6c39041c 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -60,7 +60,8 @@ export default async function ClientsPage(props: ClientsPageProps) { userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, - archived: client.archived || false + archived: client.archived || false, + blocked: client.blocked || false }; }; diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 52e5963f..79f9d800 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -56,7 +56,8 @@ export default async function ClientsPage(props: ClientsPageProps) { userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, - archived: client.archived || false + archived: client.archived || false, + blocked: client.blocked || false }; }; diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 71c4ea7b..85d09664 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -17,7 +17,8 @@ import { ArrowRight, ArrowUpDown, ArrowUpRight, - MoreHorizontal + MoreHorizontal, + CircleSlash } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -43,6 +44,7 @@ export type ClientRow = { niceId: string; agent: string | null; archived?: boolean; + blocked?: boolean; }; type ClientTableProps = { @@ -59,6 +61,7 @@ export default function MachineClientsTable({ const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); @@ -138,6 +141,42 @@ export default function MachineClientsTable({ }); }; + const blockClient = (clientId: number) => { + api.post(`/client/${clientId}/block`) + .catch((e) => { + console.error("Error blocking client", e); + toast({ + variant: "destructive", + title: "Error blocking client", + description: formatAxiosError(e, "Error blocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + setIsBlockModalOpen(false); + setSelectedClient(null); + }); + }); + }; + + const unblockClient = (clientId: number) => { + api.post(`/client/${clientId}/unblock`) + .catch((e) => { + console.error("Error unblocking client", e); + toast({ + variant: "destructive", + title: "Error unblocking client", + description: formatAxiosError(e, "Error unblocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + // Check if there are any rows without userIds in the current view's data const hasRowsWithoutUserId = useMemo(() => { return machineClients.some((client) => !client.userId) ?? false; @@ -174,6 +213,12 @@ export default function MachineClientsTable({ {t("archived")} )} + {r.blocked && ( + + + {t("blocked")} + + )} ); } @@ -368,6 +413,20 @@ export default function MachineClientsTable({ {clientRow.archived ? "Unarchive" : "Archive"} + { + if (clientRow.blocked) { + unblockClient(clientRow.id); + } else { + setSelectedClient(clientRow); + setIsBlockModalOpen(true); + } + }} + > + + {clientRow.blocked ? "Unblock" : "Block"} + + { setSelectedClient(clientRow); @@ -418,6 +477,27 @@ export default function MachineClientsTable({ title="Delete Client" /> )} + {selectedClient && ( + { + setIsBlockModalOpen(val); + if (!val) { + setSelectedClient(null); + } + }} + dialog={ +
+

{t("blockClientQuestion")}

+

{t("blockClientMessage")}

+
+ } + buttonText={t("blockClientConfirm")} + onConfirm={async () => blockClient(selectedClient!.id)} + string={selectedClient.name} + title={t("blockClient")} + /> + )} { if (selectedValues.length === 0) return true; const rowArchived = row.archived || false; - return selectedValues.includes(rowArchived); + 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; }, - defaultValues: [false] // Default to showing active clients + defaultValues: ["active"] // Default to showing active clients } ]} /> diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 1b13066d..73f8a212 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -105,6 +105,8 @@ function getActionsCategories(root: boolean) { [t("actionDeleteClient")]: "deleteClient", [t("actionArchiveClient")]: "archiveClient", [t("actionUnarchiveClient")]: "unarchiveClient", + [t("actionBlockClient")]: "blockClient", + [t("actionUnblockClient")]: "unblockClient", [t("actionUpdateClient")]: "updateClient", [t("actionListClients")]: "listClients", [t("actionGetClient")]: "getClient" diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index c68c6821..14d12373 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -17,7 +17,8 @@ import { ArrowRight, ArrowUpDown, ArrowUpRight, - MoreHorizontal + MoreHorizontal, + CircleSlash } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -44,6 +45,7 @@ export type ClientRow = { niceId: string; agent: string | null; archived?: boolean; + blocked?: boolean; }; type ClientTableProps = { @@ -56,6 +58,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); @@ -134,6 +137,42 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); }; + const blockClient = (clientId: number) => { + api.post(`/client/${clientId}/block`) + .catch((e) => { + console.error("Error blocking client", e); + toast({ + variant: "destructive", + title: "Error blocking client", + description: formatAxiosError(e, "Error blocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + setIsBlockModalOpen(false); + setSelectedClient(null); + }); + }); + }; + + const unblockClient = (clientId: number) => { + api.post(`/client/${clientId}/unblock`) + .catch((e) => { + console.error("Error unblocking client", e); + toast({ + variant: "destructive", + title: "Error unblocking client", + description: formatAxiosError(e, "Error unblocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + // Check if there are any rows without userIds in the current view's data const hasRowsWithoutUserId = useMemo(() => { return userClients.some((client) => !client.userId); @@ -170,6 +209,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { {t("archived")} )} + {r.blocked && ( + + + {t("blocked")} + + )} ); } @@ -417,6 +462,18 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { > {clientRow.archived ? "Unarchive" : "Archive"}
+ { + if (clientRow.blocked) { + unblockClient(clientRow.id); + } else { + setSelectedClient(clientRow); + setIsBlockModalOpen(true); + } + }} + > + {clientRow.blocked ? "Unblock" : "Block"} + {!clientRow.userId && ( // Machine client - also show delete option )} + {selectedClient && ( + { + setIsBlockModalOpen(val); + if (!val) { + setSelectedClient(null); + } + }} + dialog={ +
+

{t("blockClientQuestion")}

+

{t("blockClientMessage")}

+
+ } + buttonText={t("blockClientConfirm")} + onConfirm={async () => blockClient(selectedClient!.id)} + string={selectedClient.name} + title={t("blockClient")} + /> + )} @@ -493,20 +571,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { id: "active", label: t("active") || "Active", - value: false + value: "active" }, { id: "archived", label: t("archived") || "Archived", - value: true + value: "archived" + }, + { + id: "blocked", + label: t("blocked") || "Blocked", + value: "blocked" } ], filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => { if (selectedValues.length === 0) return true; const rowArchived = row.archived || false; - return selectedValues.includes(rowArchived); + 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; }, - defaultValues: [false] // Default to showing active clients + defaultValues: ["active"] // Default to showing active clients } ]} />