add block client

This commit is contained in:
miloschwartz
2026-01-12 20:37:53 -08:00
parent b941b5571f
commit 673cd0fcd1
15 changed files with 438 additions and 15 deletions

View File

@@ -1120,6 +1120,8 @@
"actionDeleteClient": "Delete Client", "actionDeleteClient": "Delete Client",
"actionArchiveClient": "Archive Client", "actionArchiveClient": "Archive Client",
"actionUnarchiveClient": "Unarchive Client", "actionUnarchiveClient": "Unarchive Client",
"actionBlockClient": "Block Client",
"actionUnblockClient": "Unblock Client",
"actionUpdateClient": "Update Client", "actionUpdateClient": "Update Client",
"actionListClients": "List Clients", "actionListClients": "List Clients",
"actionGetClient": "Get Client", "actionGetClient": "Get Client",
@@ -2418,5 +2420,9 @@
"archiveClientQuestion": "Are you sure you want to archive this client?", "archiveClientQuestion": "Are you sure you want to archive this client?",
"archiveClientMessage": "The client will be archived and removed from your active clients list.", "archiveClientMessage": "The client will be archived and removed from your active clients list.",
"archiveClientConfirm": "Archive Client", "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" "active": "Active"
} }

View File

@@ -80,6 +80,8 @@ export enum ActionsEnum {
deleteClient = "deleteClient", deleteClient = "deleteClient",
archiveClient = "archiveClient", archiveClient = "archiveClient",
unarchiveClient = "unarchiveClient", unarchiveClient = "unarchiveClient",
blockClient = "blockClient",
unblockClient = "unblockClient",
updateClient = "updateClient", updateClient = "updateClient",
listClients = "listClients", listClients = "listClients",
getClient = "getClient", getClient = "getClient",

View File

@@ -689,7 +689,8 @@ export const clients = pgTable("clients", {
// endpoint: varchar("endpoint"), // endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch"), lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections"), 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( export const clientSitesAssociationsCache = pgTable(

View File

@@ -384,7 +384,8 @@ export const clients = sqliteTable("clients", {
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"), // endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"), 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( export const clientSitesAssociationsCache = sqliteTable(

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -3,6 +3,8 @@ export * from "./createClient";
export * from "./deleteClient"; export * from "./deleteClient";
export * from "./archiveClient"; export * from "./archiveClient";
export * from "./unarchiveClient"; export * from "./unarchiveClient";
export * from "./blockClient";
export * from "./unblockClient";
export * from "./listClients"; export * from "./listClients";
export * from "./updateClient"; export * from "./updateClient";
export * from "./getClient"; export * from "./getClient";

View File

@@ -138,7 +138,8 @@ function queryClients(
niceId: clients.niceId, niceId: clients.niceId,
agent: olms.agent, agent: olms.agent,
olmArchived: olms.archived, olmArchived: olms.archived,
archived: clients.archived archived: clients.archived,
blocked: clients.blocked
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -190,6 +190,22 @@ authenticated.post(
client.unarchiveClient 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( authenticated.post(
"/client/:clientId", "/client/:clientId",
verifyClientAccess, // this will check if the user has access to the client verifyClientAccess, // this will check if the user has access to the client

View File

@@ -859,6 +859,22 @@ authenticated.post(
client.unarchiveClient 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( authenticated.post(
"/client/:clientId", "/client/:clientId",
verifyApiKeyClientAccess, verifyApiKeyClientAccess,

View File

@@ -60,7 +60,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
userEmail: client.userEmail, userEmail: client.userEmail,
niceId: client.niceId, niceId: client.niceId,
agent: client.agent, agent: client.agent,
archived: client.archived || false archived: client.archived || false,
blocked: client.blocked || false
}; };
}; };

View File

@@ -56,7 +56,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
userEmail: client.userEmail, userEmail: client.userEmail,
niceId: client.niceId, niceId: client.niceId,
agent: client.agent, agent: client.agent,
archived: client.archived || false archived: client.archived || false,
blocked: client.blocked || false
}; };
}; };

View File

@@ -17,7 +17,8 @@ import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
ArrowUpRight, ArrowUpRight,
MoreHorizontal MoreHorizontal,
CircleSlash
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
@@ -43,6 +44,7 @@ export type ClientRow = {
niceId: string; niceId: string;
agent: string | null; agent: string | null;
archived?: boolean; archived?: boolean;
blocked?: boolean;
}; };
type ClientTableProps = { type ClientTableProps = {
@@ -59,6 +61,7 @@ export default function MachineClientsTable({
const t = useTranslations(); const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>( const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null 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 // Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => { const hasRowsWithoutUserId = useMemo(() => {
return machineClients.some((client) => !client.userId) ?? false; return machineClients.some((client) => !client.userId) ?? false;
@@ -174,6 +213,12 @@ export default function MachineClientsTable({
{t("archived")} {t("archived")}
</Badge> </Badge>
)} )}
{r.blocked && (
<Badge variant="destructive" className="flex items-center gap-1">
<CircleSlash className="h-3 w-3" />
{t("blocked")}
</Badge>
)}
</div> </div>
); );
} }
@@ -368,6 +413,20 @@ export default function MachineClientsTable({
{clientRow.archived ? "Unarchive" : "Archive"} {clientRow.archived ? "Unarchive" : "Archive"}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (clientRow.blocked) {
unblockClient(clientRow.id);
} else {
setSelectedClient(clientRow);
setIsBlockModalOpen(true);
}
}}
>
<span>
{clientRow.blocked ? "Unblock" : "Block"}
</span>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedClient(clientRow); setSelectedClient(clientRow);
@@ -418,6 +477,27 @@ export default function MachineClientsTable({
title="Delete Client" title="Delete Client"
/> />
)} )}
{selectedClient && (
<ConfirmDeleteDialog
open={isBlockModalOpen}
setOpen={(val) => {
setIsBlockModalOpen(val);
if (!val) {
setSelectedClient(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("blockClientQuestion")}</p>
<p>{t("blockClientMessage")}</p>
</div>
}
buttonText={t("blockClientConfirm")}
onConfirm={async () => blockClient(selectedClient!.id)}
string={selectedClient.name}
title={t("blockClient")}
/>
)}
<DataTable <DataTable
columns={columns} columns={columns}
@@ -446,20 +526,31 @@ export default function MachineClientsTable({
{ {
id: "active", id: "active",
label: t("active") || "Active", label: t("active") || "Active",
value: false value: "active"
}, },
{ {
id: "archived", id: "archived",
label: t("archived") || "Archived", label: t("archived") || "Archived",
value: true value: "archived"
},
{
id: "blocked",
label: t("blocked") || "Blocked",
value: "blocked"
} }
], ],
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => { filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
if (selectedValues.length === 0) return true; if (selectedValues.length === 0) return true;
const rowArchived = row.archived || false; 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
} }
]} ]}
/> />

View File

@@ -105,6 +105,8 @@ function getActionsCategories(root: boolean) {
[t("actionDeleteClient")]: "deleteClient", [t("actionDeleteClient")]: "deleteClient",
[t("actionArchiveClient")]: "archiveClient", [t("actionArchiveClient")]: "archiveClient",
[t("actionUnarchiveClient")]: "unarchiveClient", [t("actionUnarchiveClient")]: "unarchiveClient",
[t("actionBlockClient")]: "blockClient",
[t("actionUnblockClient")]: "unblockClient",
[t("actionUpdateClient")]: "updateClient", [t("actionUpdateClient")]: "updateClient",
[t("actionListClients")]: "listClients", [t("actionListClients")]: "listClients",
[t("actionGetClient")]: "getClient" [t("actionGetClient")]: "getClient"

View File

@@ -17,7 +17,8 @@ import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
ArrowUpRight, ArrowUpRight,
MoreHorizontal MoreHorizontal,
CircleSlash
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
@@ -44,6 +45,7 @@ export type ClientRow = {
niceId: string; niceId: string;
agent: string | null; agent: string | null;
archived?: boolean; archived?: boolean;
blocked?: boolean;
}; };
type ClientTableProps = { type ClientTableProps = {
@@ -56,6 +58,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
const t = useTranslations(); const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>( const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null 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 // Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => { const hasRowsWithoutUserId = useMemo(() => {
return userClients.some((client) => !client.userId); return userClients.some((client) => !client.userId);
@@ -170,6 +209,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{t("archived")} {t("archived")}
</Badge> </Badge>
)} )}
{r.blocked && (
<Badge variant="destructive" className="flex items-center gap-1">
<CircleSlash className="h-3 w-3" />
{t("blocked")}
</Badge>
)}
</div> </div>
); );
} }
@@ -417,6 +462,18 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
> >
<span>{clientRow.archived ? "Unarchive" : "Archive"}</span> <span>{clientRow.archived ? "Unarchive" : "Archive"}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (clientRow.blocked) {
unblockClient(clientRow.id);
} else {
setSelectedClient(clientRow);
setIsBlockModalOpen(true);
}
}}
>
<span>{clientRow.blocked ? "Unblock" : "Block"}</span>
</DropdownMenuItem>
{!clientRow.userId && ( {!clientRow.userId && (
// Machine client - also show delete option // Machine client - also show delete option
<DropdownMenuItem <DropdownMenuItem
@@ -467,6 +524,27 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
title="Delete Client" title="Delete Client"
/> />
)} )}
{selectedClient && (
<ConfirmDeleteDialog
open={isBlockModalOpen}
setOpen={(val) => {
setIsBlockModalOpen(val);
if (!val) {
setSelectedClient(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("blockClientQuestion")}</p>
<p>{t("blockClientMessage")}</p>
</div>
}
buttonText={t("blockClientConfirm")}
onConfirm={async () => blockClient(selectedClient!.id)}
string={selectedClient.name}
title={t("blockClient")}
/>
)}
<ClientDownloadBanner /> <ClientDownloadBanner />
@@ -493,20 +571,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
id: "active", id: "active",
label: t("active") || "Active", label: t("active") || "Active",
value: false value: "active"
}, },
{ {
id: "archived", id: "archived",
label: t("archived") || "Archived", label: t("archived") || "Archived",
value: true value: "archived"
},
{
id: "blocked",
label: t("blocked") || "Blocked",
value: "blocked"
} }
], ],
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => { filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
if (selectedValues.length === 0) return true; if (selectedValues.length === 0) return true;
const rowArchived = row.archived || false; 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
} }
]} ]}
/> />