Files
pangolin/src/components/UserDevicesTable.tsx
2026-02-07 04:51:37 +01:00

843 lines
31 KiB
TypeScript

"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { build } from "@server/build";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon,
CircleSlash,
MoreHorizontal
} 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 { useDebouncedCallback } from "use-debounce";
import ClientDownloadBanner from "./ClientDownloadBanner";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
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;
pagination: PaginationState;
rowCount: number;
};
export default function UserDevicesTable({
userClients,
pagination,
rowCount
}: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
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();
});
});
};
const approveDevice = async (clientRow: ClientRow) => {
try {
// Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{
data: {
approvals: Array<{ approvalId: number; clientId: number }>;
};
}>(
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
);
const approval = approvalsRes.data.data.approvals[0];
if (!approval) {
toast({
variant: "destructive",
title: t("error"),
description: t("accessApprovalErrorUpdateDescription")
});
return;
}
await api.put(
`/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
{
decision: "approved"
}
);
toast({
title: t("accessApprovalUpdated"),
description: t("accessApprovalApprovedDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessApprovalErrorUpdate"),
description: formatAxiosError(
e,
t("accessApprovalErrorUpdateDescription")
)
});
}
};
const denyDevice = async (clientRow: ClientRow) => {
try {
// Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{
data: {
approvals: Array<{ approvalId: number; clientId: number }>;
};
}>(
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
);
const approval = approvalsRes.data.data.approvals[0];
if (!approval) {
toast({
variant: "destructive",
title: t("error"),
description: t("accessApprovalErrorUpdateDescription")
});
return;
}
await api.put(
`/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
{
decision: "denied"
}
);
toast({
title: t("accessApprovalUpdated"),
description: t("accessApprovalDeniedDescription")
});
startTransition(() => {
router.refresh();
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessApprovalErrorUpdate"),
description: formatAxiosError(
e,
t("accessApprovalErrorUpdateDescription")
)
});
}
};
// 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<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="px-3">{t("name")}</span>,
cell: ({ row }) => {
const r = row.original;
const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint, t)
: null;
return (
<div className="flex items-center gap-2">
<span>{r.name}</span>
{fingerprintInfo && (
<InfoPopup>
<div className="space-y-1 text-sm">
<div className="font-semibold mb-2">
{t("deviceInformation")}
</div>
<div className="text-muted-foreground whitespace-pre-line">
{fingerprintInfo}
</div>
</div>
</InfoPopup>
)}
{r.archived && (
<Badge variant="secondary">
{t("archived")}
</Badge>
)}
{r.blocked && (
<Badge
variant="destructive"
className="flex items-center gap-1"
>
<CircleSlash className="h-3 w-3" />
{t("blocked")}
</Badge>
)}
{r.approvalState === "pending" && (
<Badge
variant="outlinePrimary"
className="flex items-center gap-1"
>
{t("pendingApproval")}
</Badge>
)}
</div>
);
}
},
{
accessorKey: "niceId",
friendlyName: t("identifier"),
header: () => <span className="px-3">{t("identifier")}</span>
},
{
accessorKey: "userEmail",
friendlyName: t("users"),
header: () => <span className="px-3">{t("users")}</span>,
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
{
accessorKey: "online",
friendlyName: t("online"),
header: () => {
return (
<ColumnFilterButton
options={[
{
value: "true",
label: t("connected")
},
{
value: "false",
label: t("disconnected")
}
]}
selectedValue={
searchParams.get("online") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("online", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("connected")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("disconnected")}</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: t("dataIn"),
header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
console.log({
dataInOrder,
searchParams: Object.fromEntries(searchParams.entries())
});
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("megabytesIn")}
>
{t("dataIn")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: t("dataOut"),
header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("megabytesOut")}
>
{t("dataOut")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("agent"),
header: () => (
<ColumnFilterButton
options={[
{
value: "macos",
label: "Pangolin macOS"
},
{
value: "ios",
label: "Pangolin iOS"
},
{
value: "ipados",
label: "Pangolin iPadOS"
},
{
value: "android",
label: "Pangolin Android"
},
{
value: "windows",
label: "Pangolin Windows"
},
{
value: "cli",
label: "Pangolin CLI"
},
{
value: "unknown",
label: t("unknown")
}
]}
selectedValue={searchParams.get("agent") ?? undefined}
onValueChange={(value) =>
handleFilterChange("agent", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("agent")}
className="p-3"
/>
),
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
{originalRow.agent && originalRow.olmVersion ? (
<Badge variant="secondary">
{originalRow.agent +
" v" +
originalRow.olmVersion}
</Badge>
) : (
"-"
)}
{/*originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)*/}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: t("address"),
header: () => <span className="px-3">{t("address")}</span>
}
];
baseColumns.push({
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const clientRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{clientRow.approvalState === "pending" &&
build !== "oss" && (
<>
<DropdownMenuItem
onClick={() =>
approveDevice(clientRow)
}
>
<span>{t("approve")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
denyDevice(clientRow)
}
>
<span>{t("deny")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
onClick={() => {
if (clientRow.archived) {
unarchiveClient(clientRow.id);
} else {
archiveClient(clientRow.id);
}
}}
>
<span>
{clientRow.archived
? t("actionUnarchiveClient")
: t("actionArchiveClient")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (clientRow.blocked) {
unblockClient(clientRow.id);
} else {
blockClient(clientRow.id);
}
}}
>
<span>
{clientRow.blocked
? t("actionUnblockClient")
: t("actionBlockClient")}
</span>
</DropdownMenuItem>
{!clientRow.userId && (
// Machine client - also show delete option
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`}
>
<Button variant={"outline"}>
{t("viewDetails")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
});
return baseColumns;
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
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" && option.value !== "denied"
);
}
return allOptions;
}, [t]);
const statusFilterDefaultValues = useMemo(() => {
if (build === "oss") {
return ["active"];
}
return ["active", "pending"];
}, []);
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
for (const val of value) {
searchParams.append(column, val);
}
}
filter({
searchParams
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
{selectedClient && !selectedClient.userId && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div className="space-y-2">
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText={t("actionDeleteClient")}
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title={t("actionDeleteClient")}
/>
)}
<ClientDownloadBanner />
<ControlledDataTable
columns={columns}
rows={userClients || []}
tableId="user-clients"
searchPlaceholder={t("resourcesSearch")}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility
columnVisibility={defaultUserColumnVisibility}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
pagination={pagination}
rowCount={rowCount}
stickyLeftColumn="name"
stickyRightColumn="actions"
filters={[
{
id: "status",
label: t("status") || "Status",
multiSelect: true,
displayMode: "calculated",
options: statusFilterOptions,
onFilter: (
selectedValues: (string | number | boolean)[]
) => {
console.log({ selectedValues });
// 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;
},
values: statusFilterDefaultValues
}
]}
/>
</>
);
}