add archive to org clients and add unarchive

This commit is contained in:
miloschwartz
2026-01-12 15:52:06 -08:00
parent ca026b41c0
commit b941b5571f
22 changed files with 800 additions and 100 deletions

View File

@@ -1118,6 +1118,8 @@
"actionUpdateIdpOrg": "Update IDP Org", "actionUpdateIdpOrg": "Update IDP Org",
"actionCreateClient": "Create Client", "actionCreateClient": "Create Client",
"actionDeleteClient": "Delete Client", "actionDeleteClient": "Delete Client",
"actionArchiveClient": "Archive Client",
"actionUnarchiveClient": "Unarchive Client",
"actionUpdateClient": "Update Client", "actionUpdateClient": "Update Client",
"actionListClients": "List Clients", "actionListClients": "List Clients",
"actionGetClient": "Get Client", "actionGetClient": "Get Client",
@@ -2406,5 +2408,15 @@
"deviceMessageArchive": "The device will be archived and removed from your active devices list.", "deviceMessageArchive": "The device will be archived and removed from your active devices list.",
"deviceArchiveConfirm": "Archive Device", "deviceArchiveConfirm": "Archive Device",
"archiveDevice": "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"
} }

View File

@@ -78,6 +78,8 @@ export enum ActionsEnum {
updateSiteResource = "updateSiteResource", updateSiteResource = "updateSiteResource",
createClient = "createClient", createClient = "createClient",
deleteClient = "deleteClient", deleteClient = "deleteClient",
archiveClient = "archiveClient",
unarchiveClient = "unarchiveClient",
updateClient = "updateClient", updateClient = "updateClient",
listClients = "listClients", listClients = "listClients",
getClient = "getClient", getClient = "getClient",

View File

@@ -688,7 +688,8 @@ export const clients = pgTable("clients", {
online: boolean("online").notNull().default(false), online: boolean("online").notNull().default(false),
// endpoint: varchar("endpoint"), // endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch"), lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections") maxConnections: integer("maxConnections"),
archived: boolean("archived").notNull().default(false)
}); });
export const clientSitesAssociationsCache = pgTable( export const clientSitesAssociationsCache = pgTable(

View File

@@ -383,7 +383,8 @@ export const clients = sqliteTable("clients", {
type: text("type").notNull(), // "olm" type: text("type").notNull(), // "olm"
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)
}); });
export const clientSitesAssociationsCache = sqliteTable( export const clientSitesAssociationsCache = sqliteTable(

View File

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

View File

@@ -60,11 +60,12 @@ export async function deleteClient(
); );
} }
// Only allow deletion of machine clients (clients without userId)
if (client.userId) { if (client.userId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
`Cannot delete a user client with this endpoint` `Cannot delete a user client. User clients must be archived instead.`
) )
); );
} }

View File

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

View File

@@ -136,7 +136,9 @@ function queryClients(
username: users.username, username: users.username,
userEmail: users.email, userEmail: users.email,
niceId: clients.niceId, niceId: clients.niceId,
agent: olms.agent agent: olms.agent,
olmArchived: olms.archived,
archived: clients.archived
}) })
.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 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<any> {
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"
)
);
}
}

View File

@@ -174,6 +174,22 @@ authenticated.delete(
client.deleteClient 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( 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
@@ -815,6 +831,13 @@ authenticated.post(
olm.archiveUserOlm olm.archiveUserOlm
); );
authenticated.post(
"/user/:userId/olm/:olmId/unarchive",
verifyIsLoggedInUser,
verifyOlmAccess,
olm.unarchiveUserOlm
);
authenticated.get( authenticated.get(
"/user/:userId/olm/:olmId", "/user/:userId/olm/:olmId",
verifyIsLoggedInUser, verifyIsLoggedInUser,

View File

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

View File

@@ -8,7 +8,6 @@ import response from "@server/lib/response";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate"; import { sendTerminateClient } from "../client/terminate";
@@ -19,17 +18,6 @@ const paramsSchema = z
}) })
.strict(); .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( export async function archiveUserOlm(
req: Request, req: Request,
res: Response, res: Response,

View File

@@ -1,7 +1,7 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { disconnectClient } from "#dynamic/routers/ws"; import { disconnectClient } from "#dynamic/routers/ws";
import { MessageHandler } from "@server/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 { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
@@ -108,6 +108,8 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
return; return;
} }
let client: (typeof clients.$inferSelect) | undefined;
if (olm.userId) { if (olm.userId) {
// we need to check a user token to make sure its still valid // we need to check a user token to make sure its still valid
const { session: userSession, user } = const { session: userSession, user } =
@@ -122,7 +124,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
} }
// get the client // get the client
const [client] = await db const [userClient] = await db
.select() .select()
.from(clients) .from(clients)
.where( .where(
@@ -133,11 +135,13 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
) )
.limit(1); .limit(1);
if (!client) { if (!userClient) {
logger.warn("Client not found for olm ping"); logger.warn("Client not found for olm ping");
return; return;
} }
client = userClient;
const sessionId = encodeHexLowerCase( const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(userToken)) sha256(new TextEncoder().encode(userToken))
); );
@@ -167,9 +171,12 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
.update(clients) .update(clients)
.set({ .set({
lastPing: Math.floor(Date.now() / 1000), lastPing: Math.floor(Date.now() / 1000),
online: true online: true,
archived: false
}) })
.where(eq(clients.clientId, olm.clientId)); .where(eq(clients.clientId, olm.clientId));
await db.update(olms).set({ archived: false }).where(eq(olms.olmId, olm.olmId));
} catch (error) { } catch (error) {
logger.error("Error handling ping message", { error }); logger.error("Error handling ping message", { error });
} }

View File

@@ -4,6 +4,7 @@ export * from "./createUserOlm";
export * from "./handleOlmRelayMessage"; export * from "./handleOlmRelayMessage";
export * from "./handleOlmPingMessage"; export * from "./handleOlmPingMessage";
export * from "./archiveUserOlm"; export * from "./archiveUserOlm";
export * from "./unarchiveUserOlm";
export * from "./listUserOlms"; export * from "./listUserOlms";
export * from "./getUserOlm"; export * from "./getUserOlm";
export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmServerPeerAddMessage";

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ export type ClientRow = {
userEmail: string | null; userEmail: string | null;
niceId: string; niceId: string;
agent: string | null; agent: string | null;
archived?: boolean;
}; };
type ClientTableProps = { 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 // 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;
@@ -128,6 +163,19 @@ export default function MachineClientsTable({
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
},
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-2">
<span>{r.name}</span>
{r.archived && (
<Badge variant="secondary">
{t("archived")}
</Badge>
)}
</div>
);
} }
}, },
{ {
@@ -307,14 +355,19 @@ export default function MachineClientsTable({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{/* <Link */} <DropdownMenuItem
{/* className="block w-full" */} onClick={() => {
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */} if (clientRow.archived) {
{/* > */} unarchiveClient(clientRow.id);
{/* <DropdownMenuItem> */} } else {
{/* View settings */} archiveClient(clientRow.id);
{/* </DropdownMenuItem> */} }
{/* </Link> */} }}
>
<span>
{clientRow.archived ? "Unarchive" : "Archive"}
</span>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedClient(clientRow); setSelectedClient(clientRow);
@@ -383,6 +436,32 @@ export default function MachineClientsTable({
columnVisibility={defaultMachineColumnVisibility} columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" 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
}
]}
/> />
</> </>
); );

View File

@@ -103,6 +103,8 @@ function getActionsCategories(root: boolean) {
Client: { Client: {
[t("actionCreateClient")]: "createClient", [t("actionCreateClient")]: "createClient",
[t("actionDeleteClient")]: "deleteClient", [t("actionDeleteClient")]: "deleteClient",
[t("actionArchiveClient")]: "archiveClient",
[t("actionUnarchiveClient")]: "unarchiveClient",
[t("actionUpdateClient")]: "updateClient", [t("actionUpdateClient")]: "updateClient",
[t("actionListClients")]: "listClients", [t("actionListClients")]: "listClients",
[t("actionGetClient")]: "getClient" [t("actionGetClient")]: "getClient"

View File

@@ -43,6 +43,7 @@ export type ClientRow = {
userEmail: string | null; userEmail: string | null;
niceId: string; niceId: string;
agent: string | null; agent: string | null;
archived?: boolean;
}; };
type ClientTableProps = { 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 // 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);
@@ -124,6 +159,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
},
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-2">
<span>{r.name}</span>
{r.archived && (
<Badge variant="secondary">
{t("archived")}
</Badge>
)}
</div>
);
} }
}, },
{ {
@@ -348,7 +396,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
header: () => <span className="p-3"></span>, header: () => <span className="p-3"></span>,
cell: ({ row }) => { cell: ({ row }) => {
const clientRow = row.original; const clientRow = row.original;
return !clientRow.userId ? ( return (
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -358,14 +406,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{/* <Link */} <DropdownMenuItem
{/* className="block w-full" */} onClick={() => {
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */} if (clientRow.archived) {
{/* > */} unarchiveClient(clientRow.id);
{/* <DropdownMenuItem> */} } else {
{/* View settings */} archiveClient(clientRow.id);
{/* </DropdownMenuItem> */} }
{/* </Link> */} }}
>
<span>{clientRow.archived ? "Unarchive" : "Archive"}</span>
</DropdownMenuItem>
{!clientRow.userId && (
// Machine client - also show delete option
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedClient(clientRow); setSelectedClient(clientRow);
@@ -374,18 +427,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
> >
<span className="text-red-500">Delete</span> <span className="text-red-500">Delete</span>
</DropdownMenuItem> </DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link <Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`} href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
> >
<Button variant={"outline"}> <Button variant={"outline"}>
Edit View
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
</div> </div>
) : null; );
} }
}); });
@@ -394,7 +448,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return ( return (
<> <>
{selectedClient && ( {selectedClient && !selectedClient.userId && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isDeleteModalOpen}
setOpen={(val) => { setOpen={(val) => {
@@ -429,6 +483,32 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
columnVisibility={defaultUserColumnVisibility} columnVisibility={defaultUserColumnVisibility}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" 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
}
]}
/> />
</> </>
); );

View File

@@ -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() { function reset() {
setDevices([]); setDevices([]);
setSelectedDevice(null); setSelectedDevice(null);
@@ -275,6 +303,10 @@ export default function ViewDevicesDialog({
{t("dateCreated") || {t("dateCreated") ||
"Date Created"} "Date Created"}
</TableHead> </TableHead>
<TableHead>
{t("actions") ||
"Actions"}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -300,6 +332,16 @@ export default function ViewDevicesDialog({
"lll" "lll"
)} )}
</TableCell> </TableCell>
<TableCell>
<Button
variant="outline"
onClick={() => {
unarchiveDevice(device.olmId);
}}
>
{t("unarchive") || "Unarchive"}
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw, Columns } from "lucide-react"; import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -140,6 +140,22 @@ type TabFilter = {
filterFn: (row: any) => boolean; 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<TData, TValue> = { type DataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[]; columns: ExtendedColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
@@ -156,6 +172,8 @@ type DataTableProps<TData, TValue> = {
}; };
tabs?: TabFilter[]; tabs?: TabFilter[];
defaultTab?: string; defaultTab?: string;
filters?: DataTableFilter[];
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
persistPageSize?: boolean | string; persistPageSize?: boolean | string;
defaultPageSize?: number; defaultPageSize?: number;
columnVisibility?: Record<string, boolean>; columnVisibility?: Record<string, boolean>;
@@ -178,6 +196,8 @@ export function DataTable<TData, TValue>({
defaultSort, defaultSort,
tabs, tabs,
defaultTab, defaultTab,
filters,
filterDisplayMode = "label",
persistPageSize = false, persistPageSize = false,
defaultPageSize = 20, defaultPageSize = 20,
columnVisibility: defaultColumnVisibility, columnVisibility: defaultColumnVisibility,
@@ -235,6 +255,15 @@ export function DataTable<TData, TValue>({
const [activeTab, setActiveTab] = useState<string>( const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || "" defaultTab || tabs?.[0]?.id || ""
); );
const [activeFilters, setActiveFilters] = useState<Record<string, (string | number | boolean)[]>>(
() => {
const initial: Record<string, (string | number | boolean)[]> = {};
filters?.forEach((filter) => {
initial[filter.id] = filter.defaultValues || [];
});
return initial;
}
);
// Track initial values to avoid storing defaults on first render // Track initial values to avoid storing defaults on first render
const initialPageSize = useRef(pageSize); const initialPageSize = useRef(pageSize);
@@ -242,19 +271,32 @@ export function DataTable<TData, TValue>({
const hasUserChangedPageSize = useRef(false); const hasUserChangedPageSize = useRef(false);
const hasUserChangedColumnVisibility = useRef(false); const hasUserChangedColumnVisibility = useRef(false);
// Apply tab filter to data // Apply tab and custom filters to data
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!tabs || activeTab === "") { let result = data;
return data;
}
// Apply tab filter
if (tabs && activeTab !== "") {
const activeTabFilter = tabs.find((tab) => tab.id === activeTab); const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
if (!activeTabFilter) { if (activeTabFilter) {
return data; result = result.filter(activeTabFilter.filterFn);
}
} }
return data.filter(activeTabFilter.filterFn); // Apply custom filters
}, [data, tabs, activeTab]); 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 result;
}, [data, tabs, activeTab, filters, activeFilters]);
const table = useReactTable({ const table = useReactTable({
data: filteredData, data: filteredData,
@@ -318,6 +360,64 @@ export function DataTable<TData, TValue>({
setPagination((prev) => ({ ...prev, pageIndex: 0 })); 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 // Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
hasUserChangedPageSize.current = true; hasUserChangedPageSize.current = true;
@@ -387,6 +487,63 @@ export function DataTable<TData, TValue>({
/> />
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" /> <Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div> </div>
{filters && filters.length > 0 && (
<div className="flex gap-2">
{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 (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
<Button
variant={"outline"}
size="sm"
className="h-9"
>
<Filter className="h-4 w-4 mr-2" />
{displayText}
{displayMode === "label" && hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{selectedValues.length}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map((option) => {
const isChecked = selectedValues.includes(option.value);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={isChecked}
onCheckedChange={(checked) =>
handleFilterChange(
filter.id,
option.value,
checked
)
}
onSelect={(e) => e.preventDefault()}
>
{option.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
})}
</div>
)}
{tabs && tabs.length > 0 && ( {tabs && tabs.length > 0 && (
<Tabs <Tabs
value={activeTab} value={activeTab}