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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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) {
|
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.`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
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
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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,
|
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,34 +406,40 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{/* <Link */}
|
|
||||||
{/* className="block w-full" */}
|
|
||||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
|
||||||
{/* > */}
|
|
||||||
{/* <DropdownMenuItem> */}
|
|
||||||
{/* View settings */}
|
|
||||||
{/* </DropdownMenuItem> */}
|
|
||||||
{/* </Link> */}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedClient(clientRow);
|
if (clientRow.archived) {
|
||||||
setIsDeleteModalOpen(true);
|
unarchiveClient(clientRow.id);
|
||||||
|
} else {
|
||||||
|
archiveClient(clientRow.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-red-500">Delete</span>
|
<span>{clientRow.archived ? "Unarchive" : "Archive"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{!clientRow.userId && (
|
||||||
|
// Machine client - also show delete option
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedClient(clientRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</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
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -186,29 +214,29 @@ export default function ViewDevicesDialog({
|
|||||||
<TabsContent value="available" className="mt-4">
|
<TabsContent value="available" className="mt-4">
|
||||||
{devices.filter((d) => !d.archived)
|
{devices.filter((d) => !d.archived)
|
||||||
.length === 0 ? (
|
.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
{t("noDevices") ||
|
{t("noDevices") ||
|
||||||
"No devices found"}
|
"No devices found"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="pl-3">
|
<TableHead className="pl-3">
|
||||||
{t("name") || "Name"}
|
{t("name") || "Name"}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("dateCreated") ||
|
{t("dateCreated") ||
|
||||||
"Date Created"}
|
"Date Created"}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("actions") ||
|
{t("actions") ||
|
||||||
"Actions"}
|
"Actions"}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{devices
|
{devices
|
||||||
.filter(
|
.filter(
|
||||||
(d) => !d.archived
|
(d) => !d.archived
|
||||||
@@ -217,43 +245,43 @@ export default function ViewDevicesDialog({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={device.olmId}
|
key={device.olmId}
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{device.name ||
|
{device.name ||
|
||||||
t(
|
t(
|
||||||
"unnamedDevice"
|
"unnamedDevice"
|
||||||
) ||
|
) ||
|
||||||
"Unnamed Device"}
|
"Unnamed Device"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{moment(
|
{moment(
|
||||||
device.dateCreated
|
device.dateCreated
|
||||||
).format(
|
).format(
|
||||||
"lll"
|
"lll"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDevice(
|
setSelectedDevice(
|
||||||
device
|
device
|
||||||
);
|
);
|
||||||
setIsArchiveModalOpen(
|
setIsArchiveModalOpen(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
"archive"
|
"archive"
|
||||||
) ||
|
) ||
|
||||||
"Archive"}
|
"Archive"}
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="archived" className="mt-4">
|
<TabsContent value="archived" className="mt-4">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
if (activeTabFilter) {
|
||||||
|
result = result.filter(activeTabFilter.filterFn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
|
// Apply custom filters
|
||||||
if (!activeTabFilter) {
|
if (filters && filters.length > 0) {
|
||||||
return data;
|
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);
|
return result;
|
||||||
}, [data, tabs, activeTab]);
|
}, [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}
|
||||||
|
|||||||
Reference in New Issue
Block a user