add archive device instead of delete

This commit is contained in:
miloschwartz
2026-01-09 18:00:00 -08:00
parent 4c8d2266ec
commit 2ba49e84bb
8 changed files with 300 additions and 86 deletions

View File

@@ -2394,5 +2394,17 @@
"maintenanceScreenTitle": "Service Temporarily Unavailable", "maintenanceScreenTitle": "Service Temporarily Unavailable",
"maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.", "maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.",
"maintenanceScreenEstimatedCompletion": "Estimated Completion:", "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"
} }

View File

@@ -726,7 +726,8 @@ export const olms = pgTable("olms", {
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes // optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" onDelete: "cascade"
}) }),
archived: boolean("archived").notNull().default(false)
}); });
export const olmSessions = pgTable("clientSession", { export const olmSessions = pgTable("clientSession", {

View File

@@ -423,7 +423,8 @@ export const olms = sqliteTable("olms", {
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes // optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" onDelete: "cascade"
}) }),
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
}); });
export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {

View File

@@ -808,11 +808,11 @@ authenticated.put("/user/:userId/olm", verifyIsLoggedInUser, olm.createUserOlm);
authenticated.get("/user/:userId/olms", verifyIsLoggedInUser, olm.listUserOlms); authenticated.get("/user/:userId/olms", verifyIsLoggedInUser, olm.listUserOlms);
authenticated.delete( authenticated.post(
"/user/:userId/olm/:olmId", "/user/:userId/olm/:olmId/archive",
verifyIsLoggedInUser, verifyIsLoggedInUser,
verifyOlmAccess, verifyOlmAccess,
olm.deleteUserOlm olm.archiveUserOlm
); );
authenticated.get( authenticated.get(

View File

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

View File

@@ -3,9 +3,8 @@ export * from "./getOlmToken";
export * from "./createUserOlm"; export * from "./createUserOlm";
export * from "./handleOlmRelayMessage"; export * from "./handleOlmRelayMessage";
export * from "./handleOlmPingMessage"; export * from "./handleOlmPingMessage";
export * from "./deleteUserOlm"; export * from "./archiveUserOlm";
export * from "./listUserOlms"; export * from "./listUserOlms";
export * from "./deleteUserOlm";
export * from "./getUserOlm"; export * from "./getUserOlm";
export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmServerPeerAddMessage";
export * from "./handleOlmUnRelayMessage"; export * from "./handleOlmUnRelayMessage";

View File

@@ -51,6 +51,7 @@ export type ListUserOlmsResponse = {
name: string | null; name: string | null;
clientId: number | null; clientId: number | null;
userId: string | null; userId: string | null;
archived: boolean;
}>; }>;
pagination: { pagination: {
total: number; total: number;
@@ -89,7 +90,7 @@ export async function listUserOlms(
const { userId } = parsedParams.data; const { userId } = parsedParams.data;
// Get total count // Get total count (including archived OLMs)
const [totalCountResult] = await db const [totalCountResult] = await db
.select({ count: count() }) .select({ count: count() })
.from(olms) .from(olms)
@@ -97,7 +98,7 @@ export async function listUserOlms(
const total = totalCountResult?.count || 0; const total = totalCountResult?.count || 0;
// Get OLMs for the current user // Get OLMs for the current user (including archived OLMs)
const userOlms = await db const userOlms = await db
.select({ .select({
olmId: olms.olmId, olmId: olms.olmId,
@@ -105,7 +106,8 @@ export async function listUserOlms(
version: olms.version, version: olms.version,
name: olms.name, name: olms.name,
clientId: olms.clientId, clientId: olms.clientId,
userId: olms.userId userId: olms.userId,
archived: olms.archived
}) })
.from(olms) .from(olms)
.where(eq(olms.userId, userId)) .where(eq(olms.userId, userId))

View File

@@ -27,6 +27,7 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from "@app/components/ui/table"; } from "@app/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment"; import moment from "moment";
@@ -44,6 +45,7 @@ type Device = {
name: string | null; name: string | null;
clientId: number | null; clientId: number | null;
userId: string | null; userId: string | null;
archived: boolean;
}; };
export default function ViewDevicesDialog({ export default function ViewDevicesDialog({
@@ -57,8 +59,9 @@ export default function ViewDevicesDialog({
const [devices, setDevices] = useState<Device[]>([]); const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null); const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
const fetchDevices = async () => { const fetchDevices = async () => {
setLoading(true); setLoading(true);
@@ -90,26 +93,31 @@ export default function ViewDevicesDialog({
} }
}, [open]); }, [open]);
const deleteDevice = async (olmId: string) => { const archiveDevice = async (olmId: string) => {
try { try {
await api.delete(`/user/${user?.userId}/olm/${olmId}`); await api.post(`/user/${user?.userId}/olm/${olmId}/archive`);
toast({ toast({
title: t("deviceDeleted") || "Device deleted", title: t("deviceArchived") || "Device archived",
description: description:
t("deviceDeletedDescription") || t("deviceArchivedDescription") ||
"The device has been successfully deleted." "The device has been successfully archived."
}); });
setDevices(devices.filter((d) => d.olmId !== olmId)); // Update the device's archived status in the local state
setIsDeleteModalOpen(false); setDevices(
devices.map((d) =>
d.olmId === olmId ? { ...d, archived: true } : d
)
);
setIsArchiveModalOpen(false);
setSelectedDevice(null); setSelectedDevice(null);
} catch (error: any) { } catch (error: any) {
console.error("Error deleting device:", error); console.error("Error archiving device:", error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("errorDeletingDevice") || "Error deleting device", title: t("errorArchivingDevice"),
description: formatAxiosError( description: formatAxiosError(
error, error,
t("failedToDeleteDevice") || "Failed to delete device" t("failedToArchiveDevice")
) )
}); });
} }
@@ -118,7 +126,7 @@ export default function ViewDevicesDialog({
function reset() { function reset() {
setDevices([]); setDevices([]);
setSelectedDevice(null); setSelectedDevice(null);
setIsDeleteModalOpen(false); setIsArchiveModalOpen(false);
} }
return ( return (
@@ -147,61 +155,159 @@ export default function ViewDevicesDialog({
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>
) : devices.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("noDevices") || "No devices found"}
</div>
) : ( ) : (
<div className="rounded-md border"> <Tabs
<Table> value={activeTab}
<TableHeader> onValueChange={(value) =>
<TableRow> setActiveTab(value as "available" | "archived")
<TableHead className="pl-3"> }
{t("name") || "Name"} className="w-full"
</TableHead> >
<TableHead> <TabsList className="grid w-full grid-cols-2">
{t("dateCreated") || <TabsTrigger value="available">
"Date Created"} {t("available") || "Available"} (
</TableHead> {
<TableHead> devices.filter(
{t("actions") || "Actions"} (d) => !d.archived
</TableHead> ).length
</TableRow> }
</TableHeader> )
<TableBody> </TabsTrigger>
{devices.map((device) => ( <TabsTrigger value="archived">
<TableRow key={device.olmId}> {t("archived") || "Archived"} (
<TableCell className="font-medium"> {
{device.name || devices.filter(
t("unnamedDevice") || (d) => d.archived
"Unnamed Device"} ).length
</TableCell> }
<TableCell> )
{moment( </TabsTrigger>
device.dateCreated </TabsList>
).format("lll")} <TabsContent value="available" className="mt-4">
</TableCell> {devices.filter((d) => !d.archived)
<TableCell> .length === 0 ? (
<Button <div className="text-center py-8 text-muted-foreground">
variant="outline" {t("noDevices") ||
onClick={() => { "No devices found"}
setSelectedDevice( </div>
device ) : (
); <div className="rounded-md border">
setIsDeleteModalOpen( <Table>
true <TableHeader>
); <TableRow>
}} <TableHead className="pl-3">
> {t("name") || "Name"}
{t("delete") || </TableHead>
"Delete"} <TableHead>
</Button> {t("dateCreated") ||
</TableCell> "Date Created"}
</TableRow> </TableHead>
))} <TableHead>
</TableBody> {t("actions") ||
</Table> "Actions"}
</div> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices
.filter(
(d) => !d.archived
)
.map((device) => (
<TableRow
key={device.olmId}
>
<TableCell className="font-medium">
{device.name ||
t(
"unnamedDevice"
) ||
"Unnamed Device"}
</TableCell>
<TableCell>
{moment(
device.dateCreated
).format(
"lll"
)}
</TableCell>
<TableCell>
<Button
variant="outline"
onClick={() => {
setSelectedDevice(
device
);
setIsArchiveModalOpen(
true
);
}}
>
{t(
"archive"
) ||
"Archive"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
<TabsContent value="archived" className="mt-4">
{devices.filter((d) => d.archived)
.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("noArchivedDevices") ||
"No archived 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>
</TableRow>
</TableHeader>
<TableBody>
{devices
.filter(
(d) => d.archived
)
.map((device) => (
<TableRow
key={device.olmId}
>
<TableCell className="font-medium">
{device.name ||
t(
"unnamedDevice"
) ||
"Unnamed Device"}
</TableCell>
<TableCell>
{moment(
device.dateCreated
).format(
"lll"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
)} )}
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
@@ -216,9 +322,9 @@ export default function ViewDevicesDialog({
{selectedDevice && ( {selectedDevice && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isArchiveModalOpen}
setOpen={(val) => { setOpen={(val) => {
setIsDeleteModalOpen(val); setIsArchiveModalOpen(val);
if (!val) { if (!val) {
setSelectedDevice(null); setSelectedDevice(null);
} }
@@ -226,19 +332,19 @@ export default function ViewDevicesDialog({
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p> <p>
{t("deviceQuestionRemove") || {t("deviceQuestionArchive") ||
"Are you sure you want to delete this device?"} "Are you sure you want to archive this device?"}
</p> </p>
<p> <p>
{t("deviceMessageRemove") || {t("deviceMessageArchive") ||
"This action cannot be undone."} "The device will be archived and removed from your active devices list."}
</p> </p>
</div> </div>
} }
buttonText={t("deviceDeleteConfirm") || "Delete Device"} buttonText={t("deviceArchiveConfirm") || "Archive Device"}
onConfirm={async () => deleteDevice(selectedDevice.olmId)} onConfirm={async () => archiveDevice(selectedDevice.olmId)}
string={selectedDevice.name || selectedDevice.olmId} string={selectedDevice.name || selectedDevice.olmId}
title={t("deleteDevice") || "Delete Device"} title={t("archiveDevice") || "Archive Device"}
/> />
)} )}
</> </>