mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
add archive to org clients and add unarchive
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export enum ActionsEnum {
|
||||
updateSiteResource = "updateSiteResource",
|
||||
createClient = "createClient",
|
||||
deleteClient = "deleteClient",
|
||||
archiveClient = "archiveClient",
|
||||
unarchiveClient = "unarchiveClient",
|
||||
updateClient = "updateClient",
|
||||
listClients = "listClients",
|
||||
getClient = "getClient",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
105
server/routers/client/archiveClient.ts
Normal file
105
server/routers/client/archiveClient.ts
Normal 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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))
|
||||
|
||||
93
server/routers/client/unarchiveClient.ts
Normal file
93
server/routers/client/unarchiveClient.ts
Normal 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
84
server/routers/olm/unarchiveUserOlm.ts
Normal file
84
server/routers/olm/unarchiveUserOlm.ts
Normal 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (clientRow.archived) {
|
||||
unarchiveClient(clientRow.id);
|
||||
} else {
|
||||
archiveClient(clientRow.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{clientRow.archived ? "Unarchive" : "Archive"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
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
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</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>,
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return !clientRow.userId ? (
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -358,34 +406,40 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
if (clientRow.archived) {
|
||||
unarchiveClient(clientRow.id);
|
||||
} else {
|
||||
archiveClient(clientRow.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span>{clientRow.archived ? "Unarchive" : "Archive"}</span>
|
||||
</DropdownMenuItem>
|
||||
{!clientRow.userId && (
|
||||
// Machine client - also show delete option
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
Edit
|
||||
View
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -394,7 +448,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedClient && (
|
||||
{selectedClient && !selectedClient.userId && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
@@ -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
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<TabsContent value="available" className="mt-4">
|
||||
{devices.filter((d) => !d.archived)
|
||||
.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("noDevices") ||
|
||||
"No devices found"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-3">
|
||||
{t("name") || "Name"}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("dateCreated") ||
|
||||
"Date Created"}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-3">
|
||||
{t("name") || "Name"}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("dateCreated") ||
|
||||
"Date Created"}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("actions") ||
|
||||
"Actions"}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices
|
||||
.filter(
|
||||
(d) => !d.archived
|
||||
@@ -217,43 +245,43 @@ export default function ViewDevicesDialog({
|
||||
<TableRow
|
||||
key={device.olmId}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{device.name ||
|
||||
<TableCell className="font-medium">
|
||||
{device.name ||
|
||||
t(
|
||||
"unnamedDevice"
|
||||
) ||
|
||||
"Unnamed Device"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{moment(
|
||||
device.dateCreated
|
||||
"Unnamed Device"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{moment(
|
||||
device.dateCreated
|
||||
).format(
|
||||
"lll"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedDevice(
|
||||
device
|
||||
);
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedDevice(
|
||||
device
|
||||
);
|
||||
setIsArchiveModalOpen(
|
||||
true
|
||||
);
|
||||
}}
|
||||
>
|
||||
true
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"archive"
|
||||
) ||
|
||||
"Archive"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="archived" className="mt-4">
|
||||
@@ -275,6 +303,10 @@ export default function ViewDevicesDialog({
|
||||
{t("dateCreated") ||
|
||||
"Date Created"}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("actions") ||
|
||||
"Actions"}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -300,6 +332,16 @@ export default function ViewDevicesDialog({
|
||||
"lll"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
unarchiveDevice(device.olmId);
|
||||
}}
|
||||
>
|
||||
{t("unarchive") || "Unarchive"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -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<TData, TValue> = {
|
||||
columns: ExtendedColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
@@ -156,6 +172,8 @@ type DataTableProps<TData, TValue> = {
|
||||
};
|
||||
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<string, boolean>;
|
||||
@@ -178,6 +196,8 @@ export function DataTable<TData, TValue>({
|
||||
defaultSort,
|
||||
tabs,
|
||||
defaultTab,
|
||||
filters,
|
||||
filterDisplayMode = "label",
|
||||
persistPageSize = false,
|
||||
defaultPageSize = 20,
|
||||
columnVisibility: defaultColumnVisibility,
|
||||
@@ -235,6 +255,15 @@ export function DataTable<TData, TValue>({
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
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
|
||||
const initialPageSize = useRef(pageSize);
|
||||
@@ -242,19 +271,32 @@ export function DataTable<TData, TValue>({
|
||||
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<TData, TValue>({
|
||||
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<TData, TValue>({
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</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
|
||||
value={activeTab}
|
||||
|
||||
Reference in New Issue
Block a user