"use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { ArrowRight, ArrowUpDown, ArrowUpRight, MoreHorizontal, CircleSlash } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import ClientDownloadBanner from "./ClientDownloadBanner"; import { Badge } from "./ui/badge"; import { build } from "@server/build"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { InfoPopup } from "@app/components/ui/info-popup"; export type ClientRow = { id: number; name: string; subnet: string; // siteIds: string; mbIn: string; mbOut: string; orgId: string; online: boolean; olmVersion?: string; olmUpdateAvailable: boolean; userId: string | null; username: string | null; userEmail: string | null; niceId: string; agent: string | null; approvalState: "approved" | "pending" | "denied" | null; archived?: boolean; blocked?: boolean; fingerprint?: { platform: string | null; osVersion: string | null; kernelVersion: string | null; arch: string | null; deviceModel: string | null; serialNumber: string | null; username: string | null; hostname: string | null; } | null; }; type ClientTableProps = { userClients: ClientRow[]; orgId: string; }; function formatPlatform(platform: string | null | undefined): string { if (!platform) return "-"; const platformMap: Record = { macos: "macOS", windows: "Windows", linux: "Linux", ios: "iOS", android: "Android", unknown: "Unknown" }; return platformMap[platform.toLowerCase()] || platform; } export default function UserDevicesTable({ userClients }: ClientTableProps) { const router = useRouter(); const t = useTranslations(); const formatFingerprintInfo = ( fingerprint: ClientRow["fingerprint"] ): string => { if (!fingerprint) return ""; const parts: string[] = []; if (fingerprint.platform) { parts.push( `${t("platform")}: ${formatPlatform(fingerprint.platform)}` ); } if (fingerprint.deviceModel) { parts.push(`${t("deviceModel")}: ${fingerprint.deviceModel}`); } if (fingerprint.osVersion) { parts.push(`${t("osVersion")}: ${fingerprint.osVersion}`); } if (fingerprint.arch) { parts.push(`${t("architecture")}: ${fingerprint.arch}`); } if (fingerprint.hostname) { parts.push(`${t("hostname")}: ${fingerprint.hostname}`); } if (fingerprint.username) { parts.push(`${t("username")}: ${fingerprint.username}`); } if (fingerprint.serialNumber) { parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`); } return parts.join("\n"); }; const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); const api = createApiClient(useEnvContext()); const [isRefreshing, startTransition] = useTransition(); const defaultUserColumnVisibility = { subnet: false, niceId: false }; const refreshData = () => { startTransition(() => { try { router.refresh(); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } }); }; const deleteClient = (clientId: number) => { api.delete(`/client/${clientId}`) .catch((e) => { console.error("Error deleting client", e); toast({ variant: "destructive", title: "Error deleting client", description: formatAxiosError(e, "Error deleting client") }); }) .then(() => { startTransition(() => { router.refresh(); setIsDeleteModalOpen(false); }); }); }; 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(); }); }); }; const blockClient = (clientId: number) => { api.post(`/client/${clientId}/block`) .catch((e) => { console.error("Error blocking client", e); toast({ variant: "destructive", title: "Error blocking client", description: formatAxiosError(e, "Error blocking client") }); }) .then(() => { startTransition(() => { router.refresh(); }); }); }; const unblockClient = (clientId: number) => { api.post(`/client/${clientId}/unblock`) .catch((e) => { console.error("Error unblocking client", e); toast({ variant: "destructive", title: "Error unblocking client", description: formatAxiosError(e, "Error unblocking client") }); }) .then(() => { startTransition(() => { router.refresh(); }); }); }; // Check if there are any rows without userIds in the current view's data const hasRowsWithoutUserId = useMemo(() => { return userClients.some((client) => !client.userId); }, [userClients]); const columns: ExtendedColumnDef[] = useMemo(() => { const baseColumns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, friendlyName: t("name"), header: ({ column }) => { return ( ); }, cell: ({ row }) => { const r = row.original; const fingerprintInfo = r.fingerprint ? formatFingerprintInfo(r.fingerprint) : null; return (
{r.name} {fingerprintInfo && (
{t("deviceInformation")}
{fingerprintInfo}
)} {r.archived && ( {t("archived")} )} {r.blocked && ( {t("blocked")} )} {r.approvalState === "pending" && ( {t("pendingApproval")} )}
); } }, { accessorKey: "niceId", friendlyName: t("identifier"), header: ({ column }) => { return ( ); } }, { accessorKey: "userEmail", friendlyName: t("users"), header: ({ column }) => { return ( ); }, cell: ({ row }) => { const r = row.original; return r.userId ? ( ) : ( "-" ); } }, { accessorKey: "online", friendlyName: t("online"), header: ({ column }) => { return ( ); }, cell: ({ row }) => { const originalRow = row.original; if (originalRow.online) { return (
{t("online")}
); } else { return (
{t("offline")}
); } } }, { accessorKey: "mbIn", friendlyName: t("dataIn"), header: ({ column }) => { return ( ); } }, { accessorKey: "mbOut", friendlyName: t("dataOut"), header: ({ column }) => { return ( ); } }, { accessorKey: "client", friendlyName: t("agent"), header: ({ column }) => { return ( ); }, cell: ({ row }) => { const originalRow = row.original; return (
{originalRow.agent && originalRow.olmVersion ? ( {originalRow.agent + " v" + originalRow.olmVersion} ) : ( "-" )} {/*originalRow.olmUpdateAvailable && ( )*/}
); } }, { accessorKey: "subnet", friendlyName: t("address"), header: ({ column }) => { return ( ); } } ]; baseColumns.push({ id: "actions", enableHiding: false, header: () => , cell: ({ row }) => { const clientRow = row.original; return (
{ if (clientRow.archived) { unarchiveClient(clientRow.id); } else { archiveClient(clientRow.id); } }} > {clientRow.archived ? t("actionUnarchiveClient") : t("actionArchiveClient")} { if (clientRow.blocked) { unblockClient(clientRow.id); } else { blockClient(clientRow.id); } }} > {clientRow.blocked ? t("actionUnblockClient") : t("actionBlockClient")} {!clientRow.userId && ( // Machine client - also show delete option { setSelectedClient(clientRow); setIsDeleteModalOpen(true); }} > {t("delete")} )}
); } }); return baseColumns; }, [hasRowsWithoutUserId, t]); const statusFilterOptions = useMemo(() => { const allOptions = [ { id: "active", label: t("active"), value: "active" }, { id: "pending", label: t("pendingApproval"), value: "pending" }, { id: "denied", label: t("deniedApproval"), value: "denied" }, { id: "archived", label: t("archived"), value: "archived" }, { id: "blocked", label: t("blocked"), value: "blocked" } ]; if (build === "oss") { return allOptions.filter((option) => option.value !== "pending"); } return allOptions; }, [t]); const statusFilterDefaultValues = useMemo(() => { if (build === "oss") { return ["active"]; } return ["active", "pending"]; }, []); return ( <> {selectedClient && !selectedClient.userId && ( { setIsDeleteModalOpen(val); setSelectedClient(null); }} dialog={

{t("deleteClientQuestion")}

{t("clientMessageRemove")}

} buttonText={t("actionDeleteClient")} onConfirm={async () => deleteClient(selectedClient!.id)} string={selectedClient.name} title={t("actionDeleteClient")} /> )} { if (selectedValues.length === 0) return true; const rowArchived = row.archived; const rowBlocked = row.blocked; const approvalState = row.approvalState; const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied"; if (selectedValues.includes("active") && isActive) return true; if ( selectedValues.includes("pending") && approvalState === "pending" ) return true; if ( selectedValues.includes("denied") && approvalState === "denied" ) return true; if ( selectedValues.includes("archived") && rowArchived ) return true; if ( selectedValues.includes("blocked") && rowBlocked ) return true; return false; }, defaultValues: statusFilterDefaultValues } ]} /> ); }