From 2ba49e84bbbb7de29a37515eb1a36efbfc90f60f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 9 Jan 2026 18:00:00 -0800 Subject: [PATCH] add archive device instead of delete --- messages/en-US.json | 14 +- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/routers/external.ts | 6 +- server/routers/olm/archiveUserOlm.ts | 93 ++++++++++ server/routers/olm/index.ts | 3 +- server/routers/olm/listUserOlms.ts | 8 +- src/components/ViewDevicesDialog.tsx | 256 +++++++++++++++++++-------- 8 files changed, 300 insertions(+), 86 deletions(-) create mode 100644 server/routers/olm/archiveUserOlm.ts diff --git a/messages/en-US.json b/messages/en-US.json index db649cdd..35b7e925 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2394,5 +2394,17 @@ "maintenanceScreenTitle": "Service Temporarily Unavailable", "maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.", "maintenanceScreenEstimatedCompletion": "Estimated Completion:", - "createInternalResourceDialogDestinationRequired": "Destination is required" + "createInternalResourceDialogDestinationRequired": "Destination is required", + "available": "Available", + "archived": "Archived", + "noArchivedDevices": "No archived devices found", + "deviceArchived": "Device archived", + "deviceArchivedDescription": "The device has been successfully archived.", + "errorArchivingDevice": "Error archiving device", + "failedToArchiveDevice": "Failed to archive device", + "deviceQuestionArchive": "Are you sure you want to archive this device?", + "deviceMessageArchive": "The device will be archived and removed from your active devices list.", + "deviceArchiveConfirm": "Archive Device", + "archiveDevice": "Archive Device", + "archive": "Archive" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e49ed352..571877cf 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -726,7 +726,8 @@ export const olms = pgTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) + }), + archived: boolean("archived").notNull().default(false) }); export const olmSessions = pgTable("clientSession", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5f60b23e..69647229 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -423,7 +423,8 @@ export const olms = sqliteTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) + }), + archived: integer("archived", { mode: "boolean" }).notNull().default(false) }); export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { diff --git a/server/routers/external.ts b/server/routers/external.ts index cb5328ab..71ea8ef1 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -808,11 +808,11 @@ authenticated.put("/user/:userId/olm", verifyIsLoggedInUser, olm.createUserOlm); authenticated.get("/user/:userId/olms", verifyIsLoggedInUser, olm.listUserOlms); -authenticated.delete( - "/user/:userId/olm/:olmId", +authenticated.post( + "/user/:userId/olm/:olmId/archive", verifyIsLoggedInUser, verifyOlmAccess, - olm.deleteUserOlm + olm.archiveUserOlm ); authenticated.get( diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts new file mode 100644 index 00000000..9664552f --- /dev/null +++ b/server/routers/olm/archiveUserOlm.ts @@ -0,0 +1,93 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms, clients } 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"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "../client/terminate"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .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, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Archive the OLM and disconnect associated clients in a transaction + await db.transaction(async (trx) => { + // Find all clients associated with this OLM + const associatedClients = await trx + .select() + .from(clients) + .where(eq(clients.olmId, olmId)); + + // Disconnect clients from the OLM (set olmId to null) + for (const client of associatedClients) { + await trx + .update(clients) + .set({ olmId: null }) + .where(eq(clients.clientId, client.clientId)); + + await rebuildClientAssociationsFromClient(client, trx); + await sendTerminateClient(client.clientId, olmId); + } + + // Archive the OLM (set archived to true) + await trx + .update(olms) + .set({ archived: true }) + .where(eq(olms.olmId, olmId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device archived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to archive device" + ) + ); + } +} diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 594ef9cb..4b75bdfc 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -3,9 +3,8 @@ export * from "./getOlmToken"; export * from "./createUserOlm"; export * from "./handleOlmRelayMessage"; export * from "./handleOlmPingMessage"; -export * from "./deleteUserOlm"; +export * from "./archiveUserOlm"; export * from "./listUserOlms"; -export * from "./deleteUserOlm"; export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmUnRelayMessage"; diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index 2756c917..16585e9f 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -51,6 +51,7 @@ export type ListUserOlmsResponse = { name: string | null; clientId: number | null; userId: string | null; + archived: boolean; }>; pagination: { total: number; @@ -89,7 +90,7 @@ export async function listUserOlms( const { userId } = parsedParams.data; - // Get total count + // Get total count (including archived OLMs) const [totalCountResult] = await db .select({ count: count() }) .from(olms) @@ -97,7 +98,7 @@ export async function listUserOlms( const total = totalCountResult?.count || 0; - // Get OLMs for the current user + // Get OLMs for the current user (including archived OLMs) const userOlms = await db .select({ olmId: olms.olmId, @@ -105,7 +106,8 @@ export async function listUserOlms( version: olms.version, name: olms.name, clientId: olms.clientId, - userId: olms.userId + userId: olms.userId, + archived: olms.archived }) .from(olms) .where(eq(olms.userId, userId)) diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx index 70c55ded..86417694 100644 --- a/src/components/ViewDevicesDialog.tsx +++ b/src/components/ViewDevicesDialog.tsx @@ -27,6 +27,7 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Loader2, RefreshCw } from "lucide-react"; import moment from "moment"; @@ -44,6 +45,7 @@ type Device = { name: string | null; clientId: number | null; userId: string | null; + archived: boolean; }; export default function ViewDevicesDialog({ @@ -57,8 +59,9 @@ export default function ViewDevicesDialog({ const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); const [selectedDevice, setSelectedDevice] = useState(null); + const [activeTab, setActiveTab] = useState<"available" | "archived">("available"); const fetchDevices = async () => { setLoading(true); @@ -90,26 +93,31 @@ export default function ViewDevicesDialog({ } }, [open]); - const deleteDevice = async (olmId: string) => { + const archiveDevice = async (olmId: string) => { try { - await api.delete(`/user/${user?.userId}/olm/${olmId}`); + await api.post(`/user/${user?.userId}/olm/${olmId}/archive`); toast({ - title: t("deviceDeleted") || "Device deleted", + title: t("deviceArchived") || "Device archived", description: - t("deviceDeletedDescription") || - "The device has been successfully deleted." + t("deviceArchivedDescription") || + "The device has been successfully archived." }); - setDevices(devices.filter((d) => d.olmId !== olmId)); - setIsDeleteModalOpen(false); + // Update the device's archived status in the local state + setDevices( + devices.map((d) => + d.olmId === olmId ? { ...d, archived: true } : d + ) + ); + setIsArchiveModalOpen(false); setSelectedDevice(null); } catch (error: any) { - console.error("Error deleting device:", error); + console.error("Error archiving device:", error); toast({ variant: "destructive", - title: t("errorDeletingDevice") || "Error deleting device", + title: t("errorArchivingDevice"), description: formatAxiosError( error, - t("failedToDeleteDevice") || "Failed to delete device" + t("failedToArchiveDevice") ) }); } @@ -118,7 +126,7 @@ export default function ViewDevicesDialog({ function reset() { setDevices([]); setSelectedDevice(null); - setIsDeleteModalOpen(false); + setIsArchiveModalOpen(false); } return ( @@ -147,61 +155,159 @@ export default function ViewDevicesDialog({
- ) : devices.length === 0 ? ( -
- {t("noDevices") || "No devices found"} -
) : ( -
- - - - - {t("name") || "Name"} - - - {t("dateCreated") || - "Date Created"} - - - {t("actions") || "Actions"} - - - - - {devices.map((device) => ( - - - {device.name || - t("unnamedDevice") || - "Unnamed Device"} - - - {moment( - device.dateCreated - ).format("lll")} - - - - - - ))} - -
-
+ + setActiveTab(value as "available" | "archived") + } + className="w-full" + > + + + {t("available") || "Available"} ( + { + devices.filter( + (d) => !d.archived + ).length + } + ) + + + {t("archived") || "Archived"} ( + { + devices.filter( + (d) => d.archived + ).length + } + ) + + + + {devices.filter((d) => !d.archived) + .length === 0 ? ( +
+ {t("noDevices") || + "No devices found"} +
+ ) : ( +
+ + + + + {t("name") || "Name"} + + + {t("dateCreated") || + "Date Created"} + + + {t("actions") || + "Actions"} + + + + + {devices + .filter( + (d) => !d.archived + ) + .map((device) => ( + + + {device.name || + t( + "unnamedDevice" + ) || + "Unnamed Device"} + + + {moment( + device.dateCreated + ).format( + "lll" + )} + + + + + + ))} + +
+
+ )} +
+ + {devices.filter((d) => d.archived) + .length === 0 ? ( +
+ {t("noArchivedDevices") || + "No archived devices found"} +
+ ) : ( +
+ + + + + {t("name") || "Name"} + + + {t("dateCreated") || + "Date Created"} + + + + + {devices + .filter( + (d) => d.archived + ) + .map((device) => ( + + + {device.name || + t( + "unnamedDevice" + ) || + "Unnamed Device"} + + + {moment( + device.dateCreated + ).format( + "lll" + )} + + + ))} + +
+
+ )} +
+
)} @@ -216,9 +322,9 @@ export default function ViewDevicesDialog({ {selectedDevice && ( { - setIsDeleteModalOpen(val); + setIsArchiveModalOpen(val); if (!val) { setSelectedDevice(null); } @@ -226,19 +332,19 @@ export default function ViewDevicesDialog({ dialog={

- {t("deviceQuestionRemove") || - "Are you sure you want to delete this device?"} + {t("deviceQuestionArchive") || + "Are you sure you want to archive this device?"}

- {t("deviceMessageRemove") || - "This action cannot be undone."} + {t("deviceMessageArchive") || + "The device will be archived and removed from your active devices list."}

} - buttonText={t("deviceDeleteConfirm") || "Delete Device"} - onConfirm={async () => deleteDevice(selectedDevice.olmId)} + buttonText={t("deviceArchiveConfirm") || "Archive Device"} + onConfirm={async () => archiveDevice(selectedDevice.olmId)} string={selectedDevice.name || selectedDevice.olmId} - title={t("deleteDevice") || "Delete Device"} + title={t("archiveDevice") || "Archive Device"} /> )}