diff --git a/messages/en-US.json b/messages/en-US.json index 3dd1b926..2a499866 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2128,5 +2128,20 @@ "deviceAuthorize": "Authorize {applicationName}", "deviceConnected": "Device Connected!", "deviceAuthorizedMessage": "Your device is authorized to access your account.", - "pangolinCloud": "Pangolin Cloud" + "pangolinCloud": "Pangolin Cloud", + "viewDevices": "View Devices", + "viewDevicesDescription": "Manage your connected devices", + "noDevices": "No devices found", + "dateCreated": "Date Created", + "unnamedDevice": "Unnamed Device", + "deviceQuestionRemove": "Are you sure you want to delete this device?", + "deviceMessageRemove": "This action cannot be undone.", + "deviceDeleteConfirm": "Delete Device", + "deleteDevice": "Delete Device", + "errorLoadingDevices": "Error loading devices", + "failedToLoadDevices": "Failed to load devices", + "deviceDeleted": "Device deleted", + "deviceDeletedDescription": "The device has been successfully deleted.", + "errorDeletingDevice": "Error deleting device", + "failedToDeleteDevice": "Failed to delete device" } diff --git a/server/routers/external.ts b/server/routers/external.ts index db90f212..f71f9250 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -758,11 +758,24 @@ authenticated.delete( // createNewt // ); +// only for logged in user authenticated.put( "/olm", olm.createOlm ); +// only for logged in user +authenticated.get( + "/olms", + olm.listOlms +); + +// only for logged in user +authenticated.delete( + "/olm/:olmId", + olm.deleteOlm +); + authenticated.put( "/idp/oidc", verifyUserIsServerAdmin, diff --git a/server/routers/olm/deleteOlm.ts b/server/routers/olm/deleteOlm.ts new file mode 100644 index 00000000..7ba41dc5 --- /dev/null +++ b/server/routers/olm/deleteOlm.ts @@ -0,0 +1,112 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms, clients, clientSites } 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 deleteOlmParamsSchema = z + .object({ + olmId: z.string() + }) + .strict(); + +export async function deleteOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + const parsedParams = deleteOlmParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Verify the OLM belongs to the current user + const [existingOlm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)) + .limit(1); + + if (!existingOlm) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Olm with ID ${olmId} not found` + ) + ); + } + + if (existingOlm.userId !== userId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to delete this device" + ) + ); + } + + // Delete associated clients and the OLM in a transaction + await db.transaction(async (trx) => { + // Find all clients associated with this OLM + const associatedClients = await trx + .select({ clientId: clients.clientId }) + .from(clients) + .where(eq(clients.olmId, olmId)); + + // Delete client-site associations for each associated client + for (const client of associatedClients) { + await trx + .delete(clientSites) + .where(eq(clientSites.clientId, client.clientId)); + } + + // Delete all associated clients + if (associatedClients.length > 0) { + await trx + .delete(clients) + .where(eq(clients.olmId, olmId)); + } + + // Finally, delete the OLM itself + await trx.delete(olms).where(eq(olms.olmId, olmId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete device" + ) + ); + } +} + diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 8426612e..5e28b96a 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -2,4 +2,6 @@ export * from "./handleOlmRegisterMessage"; export * from "./getOlmToken"; export * from "./createOlm"; export * from "./handleOlmRelayMessage"; -export * from "./handleOlmPingMessage"; \ No newline at end of file +export * from "./handleOlmPingMessage"; +export * from "./listOlms"; +export * from "./deleteOlm"; \ No newline at end of file diff --git a/server/routers/olm/listOlms.ts b/server/routers/olm/listOlms.ts new file mode 100644 index 00000000..c61e1d8d --- /dev/null +++ b/server/routers/olm/listOlms.ts @@ -0,0 +1,117 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq, count, desc } 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 listOlmsSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export type ListOlmsResponse = { + olms: Array<{ + olmId: string; + dateCreated: string; + version: string | null; + name: string | null; + clientId: number | null; + userId: string | null; + }>; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +export async function listOlms( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + const parsedQuery = listOlmsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { limit, offset } = parsedQuery.data; + + // Get total count + const [totalCountResult] = await db + .select({ count: count() }) + .from(olms) + .where(eq(olms.userId, userId)); + + const total = totalCountResult?.count || 0; + + // Get OLMs for the current user + const userOlms = await db + .select({ + olmId: olms.olmId, + dateCreated: olms.dateCreated, + version: olms.version, + name: olms.name, + clientId: olms.clientId, + userId: olms.userId + }) + .from(olms) + .where(eq(olms.userId, userId)) + .orderBy(desc(olms.dateCreated)) + .limit(limit) + .offset(offset); + + return response(res, { + data: { + olms: userOlms, + pagination: { + total, + limit, + offset + } + }, + success: true, + error: false, + message: "OLMs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to list OLMs" + ) + ); + } +} + diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 5789a83c..8f017626 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -14,7 +14,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; -import { Laptop, LogOut, Moon, Sun } from "lucide-react"; +import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -23,6 +23,7 @@ import Disable2FaForm from "./Disable2FaForm"; import SecurityKeyForm from "./SecurityKeyForm"; import Enable2FaDialog from "./Enable2FaDialog"; import ChangePasswordDialog from "./ChangePasswordDialog"; +import ViewDevicesDialog from "./ViewDevicesDialog"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; import LocaleSwitcher from "@app/components/LocaleSwitcher"; @@ -43,6 +44,7 @@ export default function ProfileIcon() { const [openDisable2fa, setOpenDisable2fa] = useState(false); const [openSecurityKey, setOpenSecurityKey] = useState(false); const [openChangePassword, setOpenChangePassword] = useState(false); + const [openViewDevices, setOpenViewDevices] = useState(false); const t = useTranslations(); @@ -84,6 +86,10 @@ export default function ProfileIcon() { open={openChangePassword} setOpen={setOpenChangePassword} /> + @@ -146,6 +152,13 @@ export default function ProfileIcon() { )} + setOpenViewDevices(true)} + > + + {t("viewDevices") || "View Devices"} + + {t("theme")} {(["light", "dark", "system"] as const).map( (themeOption) => ( diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx new file mode 100644 index 00000000..07f7a340 --- /dev/null +++ b/src/components/ViewDevicesDialog.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useTranslations } from "next-intl"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { ListOlmsResponse } from "@server/routers/olm"; +import { ResponseT } from "@server/types/Response"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { RefreshCw } from "lucide-react"; +import moment from "moment"; + +type ViewDevicesDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; +}; + +type Device = { + olmId: string; + dateCreated: string; + version: string | null; + name: string | null; + clientId: number | null; + userId: string | null; +}; + +export default function ViewDevicesDialog({ open, setOpen }: ViewDevicesDialogProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedDevice, setSelectedDevice] = useState(null); + + const fetchDevices = async () => { + setLoading(true); + try { + const res = await api.get>("/olms"); + if (res.data.success && res.data.data) { + setDevices(res.data.data.olms); + } + } catch (error: any) { + console.error("Error fetching devices:", error); + toast({ + variant: "destructive", + title: t("errorLoadingDevices") || "Error loading devices", + description: formatAxiosError(error, t("failedToLoadDevices") || "Failed to load devices") + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open) { + fetchDevices(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const deleteDevice = async (olmId: string) => { + try { + await api.delete(`/olm/${olmId}`); + toast({ + title: t("deviceDeleted") || "Device deleted", + description: t("deviceDeletedDescription") || "The device has been successfully deleted." + }); + setDevices(devices.filter(d => d.olmId !== olmId)); + setIsDeleteModalOpen(false); + setSelectedDevice(null); + } catch (error: any) { + console.error("Error deleting device:", error); + toast({ + variant: "destructive", + title: t("errorDeletingDevice") || "Error deleting device", + description: formatAxiosError(error, t("failedToDeleteDevice") || "Failed to delete device") + }); + } + }; + + function reset() { + setDevices([]); + setSelectedDevice(null); + setIsDeleteModalOpen(false); + } + + return ( + <> + { + setOpen(val); + if (!val) { + reset(); + } + }} + > + + + + {t("viewDevices") || "View Devices"} + + + {t("viewDevicesDescription") || "Manage your connected devices"} + + + + {loading ? ( +
+ +
+ ) : 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")} + + + + + + ))} + +
+
+ )} +
+ + + + + +
+
+ + {selectedDevice && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelectedDevice(null); + } + }} + dialog={ +
+

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

+

{t("deviceMessageRemove") || "This action cannot be undone."}

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