From b941b5571fd79bc8fed4b4fe86e9ad6dec4f504a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 12 Jan 2026 15:52:06 -0800 Subject: [PATCH] add archive to org clients and add unarchive --- messages/en-US.json | 14 +- server/auth/actions.ts | 2 + server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/routers/client/archiveClient.ts | 105 +++++++++++ server/routers/client/deleteClient.ts | 3 +- server/routers/client/index.ts | 2 + server/routers/client/listClients.ts | 4 +- server/routers/client/unarchiveClient.ts | 93 ++++++++++ server/routers/external.ts | 23 +++ server/routers/integration.ts | 16 ++ server/routers/olm/archiveUserOlm.ts | 12 -- server/routers/olm/handleOlmPingMessage.ts | 15 +- server/routers/olm/index.ts | 1 + server/routers/olm/unarchiveUserOlm.ts | 84 +++++++++ .../[orgId]/settings/clients/machine/page.tsx | 3 +- .../[orgId]/settings/clients/user/page.tsx | 3 +- src/components/MachineClientsTable.tsx | 95 +++++++++- src/components/PermissionsSelectBox.tsx | 2 + src/components/UserDevicesTable.tsx | 110 +++++++++-- src/components/ViewDevicesDialog.tsx | 132 ++++++++----- src/components/ui/data-table.tsx | 175 +++++++++++++++++- 22 files changed, 800 insertions(+), 100 deletions(-) create mode 100644 server/routers/client/archiveClient.ts create mode 100644 server/routers/client/unarchiveClient.ts create mode 100644 server/routers/olm/unarchiveUserOlm.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2aa312b7..0ddd883c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1118,6 +1118,8 @@ "actionUpdateIdpOrg": "Update IDP Org", "actionCreateClient": "Create Client", "actionDeleteClient": "Delete Client", + "actionArchiveClient": "Archive Client", + "actionUnarchiveClient": "Unarchive Client", "actionUpdateClient": "Update Client", "actionListClients": "List Clients", "actionGetClient": "Get Client", @@ -2406,5 +2408,15 @@ "deviceMessageArchive": "The device will be archived and removed from your active devices list.", "deviceArchiveConfirm": "Archive Device", "archiveDevice": "Archive Device", - "archive": "Archive" + "archive": "Archive", + "deviceUnarchived": "Device unarchived", + "deviceUnarchivedDescription": "The device has been successfully unarchived.", + "errorUnarchivingDevice": "Error unarchiving device", + "failedToUnarchiveDevice": "Failed to unarchive device", + "unarchive": "Unarchive", + "archiveClient": "Archive Client", + "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", + "active": "Active" } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71017f8d..e0f86244 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -78,6 +78,8 @@ export enum ActionsEnum { updateSiteResource = "updateSiteResource", createClient = "createClient", deleteClient = "deleteClient", + archiveClient = "archiveClient", + unarchiveClient = "unarchiveClient", updateClient = "updateClient", listClients = "listClients", getClient = "getClient", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 571877cf..b5ae30c6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -688,7 +688,8 @@ export const clients = pgTable("clients", { online: boolean("online").notNull().default(false), // endpoint: varchar("endpoint"), lastHolePunch: integer("lastHolePunch"), - maxConnections: integer("maxConnections") + maxConnections: integer("maxConnections"), + archived: boolean("archived").notNull().default(false) }); export const clientSitesAssociationsCache = pgTable( diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 69647229..9d84d6b5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -383,7 +383,8 @@ export const clients = sqliteTable("clients", { type: text("type").notNull(), // "olm" online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), - lastHolePunch: integer("lastHolePunch") + lastHolePunch: integer("lastHolePunch"), + archived: integer("archived", { mode: "boolean" }).notNull().default(false) }); export const clientSitesAssociationsCache = sqliteTable( diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts new file mode 100644 index 00000000..330f6ed8 --- /dev/null +++ b/server/routers/client/archiveClient.ts @@ -0,0 +1,105 @@ +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 { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "./terminate"; + +const archiveClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/archive", + description: "Archive a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: archiveClientSchema + }, + responses: {} +}); + +export async function archiveClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = archiveClientSchema.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.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is already archived` + ) + ); + } + + await db.transaction(async (trx) => { + // Archive the client + await trx + .update(clients) + .set({ archived: true }) + .where(eq(clients.clientId, clientId)); + + // Rebuild associations to clean up related data + await rebuildClientAssociationsFromClient(client, trx); + + // Send terminate signal if there's an associated OLM + if (client.olmId) { + await sendTerminateClient(client.clientId, client.olmId); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client archived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to archive client" + ) + ); + } +} diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 775708ce..a16a2996 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -60,11 +60,12 @@ export async function deleteClient( ); } + // Only allow deletion of machine clients (clients without userId) if (client.userId) { return next( createHttpError( HttpCode.BAD_REQUEST, - `Cannot delete a user client with this endpoint` + `Cannot delete a user client. User clients must be archived instead.` ) ); } diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 8e88c11e..72d64de2 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -1,6 +1,8 @@ export * from "./pickClientDefaults"; export * from "./createClient"; export * from "./deleteClient"; +export * from "./archiveClient"; +export * from "./unarchiveClient"; export * from "./listClients"; export * from "./updateClient"; export * from "./getClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 36e61c9d..41e995f4 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -136,7 +136,9 @@ function queryClients( username: users.username, userEmail: users.email, niceId: clients.niceId, - agent: olms.agent + agent: olms.agent, + olmArchived: olms.archived, + archived: clients.archived }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) diff --git a/server/routers/client/unarchiveClient.ts b/server/routers/client/unarchiveClient.ts new file mode 100644 index 00000000..62c5c17c --- /dev/null +++ b/server/routers/client/unarchiveClient.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 unarchiveClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/unarchive", + description: "Unarchive a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: unarchiveClientSchema + }, + responses: {} +}); + +export async function unarchiveClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = unarchiveClientSchema.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.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is not archived` + ) + ); + } + + // Unarchive the client + await db + .update(clients) + .set({ archived: false }) + .where(eq(clients.clientId, clientId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client unarchived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unarchive client" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 71ea8ef1..564a7531 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -174,6 +174,22 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + authenticated.post( "/client/:clientId", verifyClientAccess, // this will check if the user has access to the client @@ -815,6 +831,13 @@ authenticated.post( olm.archiveUserOlm ); +authenticated.post( + "/user/:userId/olm/:olmId/unarchive", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.unarchiveUserOlm +); + authenticated.get( "/user/:userId/olm/:olmId", verifyIsLoggedInUser, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 9db5fe71..6de01843 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -843,6 +843,22 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + authenticated.post( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts index 9664552f..46abd1a1 100644 --- a/server/routers/olm/archiveUserOlm.ts +++ b/server/routers/olm/archiveUserOlm.ts @@ -8,7 +8,6 @@ import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; @@ -19,17 +18,6 @@ const paramsSchema = z }) .strict(); -// registry.registerPath({ -// method: "post", -// path: "/user/{userId}/olm/{olmId}/archive", -// description: "Archive an olm for a user.", -// tags: [OpenAPITags.User, OpenAPITags.Client], -// request: { -// params: paramsSchema -// }, -// responses: {} -// }); - export async function archiveUserOlm( req: Request, res: Response, diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 0fa490c8..36ca0001 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; import { disconnectClient } from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Olm } from "@server/db"; +import { clients, olms, Olm } from "@server/db"; import { eq, lt, isNull, and, or } from "drizzle-orm"; import logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -108,6 +108,8 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { return; } + let client: (typeof clients.$inferSelect) | undefined; + if (olm.userId) { // we need to check a user token to make sure its still valid const { session: userSession, user } = @@ -122,7 +124,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { } // get the client - const [client] = await db + const [userClient] = await db .select() .from(clients) .where( @@ -133,11 +135,13 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { ) .limit(1); - if (!client) { + if (!userClient) { logger.warn("Client not found for olm ping"); return; } + client = userClient; + const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(userToken)) ); @@ -167,9 +171,12 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { .update(clients) .set({ lastPing: Math.floor(Date.now() / 1000), - online: true + online: true, + archived: false }) .where(eq(clients.clientId, olm.clientId)); + + await db.update(olms).set({ archived: false }).where(eq(olms.olmId, olm.olmId)); } catch (error) { logger.error("Error handling ping message", { error }); } diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 4b75bdfc..6957c18b 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -4,6 +4,7 @@ export * from "./createUserOlm"; export * from "./handleOlmRelayMessage"; export * from "./handleOlmPingMessage"; export * from "./archiveUserOlm"; +export * from "./unarchiveUserOlm"; export * from "./listUserOlms"; export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; diff --git a/server/routers/olm/unarchiveUserOlm.ts b/server/routers/olm/unarchiveUserOlm.ts new file mode 100644 index 00000000..28d540b8 --- /dev/null +++ b/server/routers/olm/unarchiveUserOlm.ts @@ -0,0 +1,84 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +export async function unarchiveUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Check if OLM exists and is archived + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)) + .limit(1); + + if (!olm) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `OLM with ID ${olmId} not found` + ) + ); + } + + if (!olm.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `OLM with ID ${olmId} is not archived` + ) + ); + } + + // Unarchive the OLM (set archived to false) + await db + .update(olms) + .set({ archived: false }) + .where(eq(olms.olmId, olmId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device unarchived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unarchive device" + ) + ); + } +} diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index f2618bc2..88508475 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -59,7 +59,8 @@ export default async function ClientsPage(props: ClientsPageProps) { username: client.username, userEmail: client.userEmail, niceId: client.niceId, - agent: client.agent + agent: client.agent, + archived: client.archived || false }; }; diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 28288fd2..52e5963f 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -55,7 +55,8 @@ export default async function ClientsPage(props: ClientsPageProps) { username: client.username, userEmail: client.userEmail, niceId: client.niceId, - agent: client.agent + agent: client.agent, + archived: client.archived || false }; }; diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 67ed2e08..71c4ea7b 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -42,6 +42,7 @@ export type ClientRow = { userEmail: string | null; niceId: string; agent: string | null; + archived?: boolean; }; type ClientTableProps = { @@ -103,6 +104,40 @@ export default function MachineClientsTable({ }); }; + const archiveClient = (clientId: number) => { + api.post(`/client/${clientId}/archive`) + .catch((e) => { + console.error("Error archiving client", e); + toast({ + variant: "destructive", + title: "Error archiving client", + description: formatAxiosError(e, "Error archiving client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + + const unarchiveClient = (clientId: number) => { + api.post(`/client/${clientId}/unarchive`) + .catch((e) => { + console.error("Error unarchiving client", e); + toast({ + variant: "destructive", + title: "Error unarchiving client", + description: formatAxiosError(e, "Error unarchiving 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; @@ -128,6 +163,19 @@ export default function MachineClientsTable({ ); + }, + cell: ({ row }) => { + const r = row.original; + return ( +
+ {r.name} + {r.archived && ( + + {t("archived")} + + )} +
+ ); } }, { @@ -307,14 +355,19 @@ export default function MachineClientsTable({ - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} + { + if (clientRow.archived) { + unarchiveClient(clientRow.id); + } else { + archiveClient(clientRow.id); + } + }} + > + + {clientRow.archived ? "Unarchive" : "Archive"} + + { setSelectedClient(clientRow); @@ -383,6 +436,32 @@ export default function MachineClientsTable({ columnVisibility={defaultMachineColumnVisibility} stickyLeftColumn="name" stickyRightColumn="actions" + filters={[ + { + id: "status", + label: t("status") || "Status", + multiSelect: true, + displayMode: "calculated", + options: [ + { + id: "active", + label: t("active") || "Active", + value: false + }, + { + id: "archived", + label: t("archived") || "Archived", + value: true + } + ], + filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => { + if (selectedValues.length === 0) return true; + const rowArchived = row.archived || false; + return selectedValues.includes(rowArchived); + }, + defaultValues: [false] // Default to showing active clients + } + ]} /> ); diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index a5cfad7b..1b13066d 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -103,6 +103,8 @@ function getActionsCategories(root: boolean) { Client: { [t("actionCreateClient")]: "createClient", [t("actionDeleteClient")]: "deleteClient", + [t("actionArchiveClient")]: "archiveClient", + [t("actionUnarchiveClient")]: "unarchiveClient", [t("actionUpdateClient")]: "updateClient", [t("actionListClients")]: "listClients", [t("actionGetClient")]: "getClient" diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index e413207a..c68c6821 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -43,6 +43,7 @@ export type ClientRow = { userEmail: string | null; niceId: string; agent: string | null; + archived?: boolean; }; type ClientTableProps = { @@ -99,6 +100,40 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); }; + const archiveClient = (clientId: number) => { + api.post(`/client/${clientId}/archive`) + .catch((e) => { + console.error("Error archiving client", e); + toast({ + variant: "destructive", + title: "Error archiving client", + description: formatAxiosError(e, "Error archiving client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + + const unarchiveClient = (clientId: number) => { + api.post(`/client/${clientId}/unarchive`) + .catch((e) => { + console.error("Error unarchiving client", e); + toast({ + variant: "destructive", + title: "Error unarchiving client", + description: formatAxiosError(e, "Error unarchiving 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); @@ -124,6 +159,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { ); + }, + cell: ({ row }) => { + const r = row.original; + return ( +
+ {r.name} + {r.archived && ( + + {t("archived")} + + )} +
+ ); } }, { @@ -348,7 +396,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { header: () => , cell: ({ row }) => { const clientRow = row.original; - return !clientRow.userId ? ( + return (
@@ -358,34 +406,40 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); + if (clientRow.archived) { + unarchiveClient(clientRow.id); + } else { + archiveClient(clientRow.id); + } }} > - Delete + {clientRow.archived ? "Unarchive" : "Archive"} + {!clientRow.userId && ( + // Machine client - also show delete option + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + Delete + + )}
- ) : null; + ); } }); @@ -394,7 +448,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return ( <> - {selectedClient && ( + {selectedClient && !selectedClient.userId && ( { @@ -429,6 +483,32 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { columnVisibility={defaultUserColumnVisibility} stickyLeftColumn="name" stickyRightColumn="actions" + filters={[ + { + id: "status", + label: t("status") || "Status", + multiSelect: true, + displayMode: "calculated", + options: [ + { + id: "active", + label: t("active") || "Active", + value: false + }, + { + id: "archived", + label: t("archived") || "Archived", + value: true + } + ], + filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => { + if (selectedValues.length === 0) return true; + const rowArchived = row.archived || false; + return selectedValues.includes(rowArchived); + }, + defaultValues: [false] // Default to showing active clients + } + ]} /> ); diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx index 86417694..a54a71fd 100644 --- a/src/components/ViewDevicesDialog.tsx +++ b/src/components/ViewDevicesDialog.tsx @@ -123,6 +123,34 @@ export default function ViewDevicesDialog({ } }; + const unarchiveDevice = async (olmId: string) => { + try { + await api.post(`/user/${user?.userId}/olm/${olmId}/unarchive`); + toast({ + title: t("deviceUnarchived") || "Device unarchived", + description: + t("deviceUnarchivedDescription") || + "The device has been successfully unarchived." + }); + // Update the device's archived status in the local state + setDevices( + devices.map((d) => + d.olmId === olmId ? { ...d, archived: false } : d + ) + ); + } catch (error: any) { + console.error("Error unarchiving device:", error); + toast({ + variant: "destructive", + title: t("errorUnarchivingDevice") || "Error unarchiving device", + description: formatAxiosError( + error, + t("failedToUnarchiveDevice") || "Failed to unarchive device" + ) + }); + } + }; + function reset() { setDevices([]); setSelectedDevice(null); @@ -186,29 +214,29 @@ export default function ViewDevicesDialog({ {devices.filter((d) => !d.archived) .length === 0 ? ( -
+
{t("noDevices") || "No devices found"} -
- ) : ( -
- - - - - {t("name") || "Name"} - - - {t("dateCreated") || - "Date Created"} - - + + ) : ( +
+
+ + + + {t("name") || "Name"} + + + {t("dateCreated") || + "Date Created"} + + {t("actions") || "Actions"} - - - - + + + + {devices .filter( (d) => !d.archived @@ -217,43 +245,43 @@ export default function ViewDevicesDialog({ - - {device.name || + + {device.name || t( "unnamedDevice" ) || - "Unnamed Device"} - - - {moment( - device.dateCreated + "Unnamed Device"} + + + {moment( + device.dateCreated ).format( "lll" )} - - - - - - ))} - -
-
+ + + + ))} + + +
)}
@@ -275,6 +303,10 @@ export default function ViewDevicesDialog({ {t("dateCreated") || "Date Created"} + + {t("actions") || + "Actions"} + @@ -300,6 +332,16 @@ export default function ViewDevicesDialog({ "lll" )} + + + ))} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index d6339081..af61bb53 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Columns } from "lucide-react"; +import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, CardContent, @@ -140,6 +140,22 @@ type TabFilter = { filterFn: (row: any) => boolean; }; +type FilterOption = { + id: string; + label: string; + value: string | number | boolean; +}; + +type DataTableFilter = { + id: string; + label: string; + options: FilterOption[]; + multiSelect?: boolean; + filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; + defaultValues?: (string | number | boolean)[]; + displayMode?: "label" | "calculated"; // How to display the filter button text +}; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; @@ -156,6 +172,8 @@ type DataTableProps = { }; tabs?: TabFilter[]; defaultTab?: string; + filters?: DataTableFilter[]; + filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) persistPageSize?: boolean | string; defaultPageSize?: number; columnVisibility?: Record; @@ -178,6 +196,8 @@ export function DataTable({ defaultSort, tabs, defaultTab, + filters, + filterDisplayMode = "label", persistPageSize = false, defaultPageSize = 20, columnVisibility: defaultColumnVisibility, @@ -235,6 +255,15 @@ export function DataTable({ 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; + } + ); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); @@ -242,19 +271,32 @@ export function DataTable({ const hasUserChangedPageSize = useRef(false); const hasUserChangedColumnVisibility = useRef(false); - // Apply tab filter to data + // Apply tab and custom filters to data const filteredData = useMemo(() => { - if (!tabs || activeTab === "") { - return data; + let result = data; + + // Apply tab filter + if (tabs && activeTab !== "") { + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (activeTabFilter) { + result = result.filter(activeTabFilter.filterFn); + } } - const activeTabFilter = tabs.find((tab) => tab.id === activeTab); - if (!activeTabFilter) { - return data; + // Apply custom filters + if (filters && filters.length > 0) { + filters.forEach((filter) => { + const selectedValues = activeFilters[filter.id] || []; + if (selectedValues.length > 0) { + result = result.filter((row) => + filter.filterFn(row, selectedValues) + ); + } + }); } - return data.filter(activeTabFilter.filterFn); - }, [data, tabs, activeTab]); + return result; + }, [data, tabs, activeTab, filters, activeFilters]); const table = useReactTable({ data: filteredData, @@ -318,6 +360,64 @@ export function DataTable({ setPagination((prev) => ({ ...prev, pageIndex: 0 })); }; + const handleFilterChange = ( + filterId: string, + optionValue: string | number | boolean, + checked: boolean + ) => { + 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) { + newValues = [...currentValues, optionValue]; + } else { + newValues = currentValues.filter((v) => v !== optionValue); + } + } else { + // Single-select: replace the value + newValues = checked ? [optionValue] : []; + } + + return { + ...prev, + [filterId]: newValues + }; + }); + // Reset to first page when changing filters + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }; + + // 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; + } + + const selectedOptions = filter.options.filter((option) => + selectedValues.includes(option.value) + ); + + if (selectedOptions.length === 0) { + return filter.label; + } + + if (selectedOptions.length === 1) { + return selectedOptions[0].label; + } + + // Multiple selections: always join with "and" + return selectedOptions.map((opt) => opt.label).join(" and "); + }; + // Enhanced pagination component that updates our local state const handlePageSizeChange = (newPageSize: number) => { hasUserChangedPageSize.current = true; @@ -387,6 +487,63 @@ 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; + + 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} + + ); + })} + + + ); + })} +
+ )} {tabs && tabs.length > 0 && (