diff --git a/messages/en-US.json b/messages/en-US.json index 8b42a193b..8cf8de399 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -575,7 +575,7 @@ "provisioningKeysUpdated": "Provisioning key updated", "provisioningKeysUpdatedDescription": "Your changes have been saved.", "provisioningKeysBannerTitle": "Site Provisioning Keys", - "provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", + "provisioningKeysBannerDescription": "A single provisioning key handles site setup for you. When a site starts with the key for the first time, Pangolin creates it and issues a new ID and secret.", "provisioningKeysBannerButtonText": "Learn More", "pendingSitesBannerTitle": "Pending Sites", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.", diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 33ae377df..dc742f4a2 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -99,6 +99,34 @@ const listClientsSchema = z.strictObject({ type: "boolean", description: "Filter by online status" }), + agent: z + .enum([ + "windows", + "android", + "cli", + "olm", + "macos", + "ios", + "ipados", + "unknown" + ]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: [ + "windows", + "android", + "cli", + "olm", + "macos", + "ios", + "ipados", + "unknown" + ], + description: + "Filter by agent type. Use 'unknown' to filter clients with no agent detected." + }), status: z.preprocess( (val: string | undefined) => { if (val) { @@ -280,6 +308,7 @@ export async function listClients( online, query, status, + agent, sort_by, order, labels: labelFilter, @@ -347,6 +376,26 @@ export async function listClients( conditions.push(eq(clients.online, online)); } + const agentValueMap = { + windows: "Pangolin Windows", + android: "Pangolin Android", + ios: "Pangolin iOS", + ipados: "Pangolin iPadOS", + macos: "Pangolin macOS", + cli: "Pangolin CLI", + olm: "Olm CLI" + } satisfies Record< + Exclude, + string + >; + if (typeof agent !== "undefined") { + if (agent === "unknown") { + conditions.push(isNull(olms.agent)); + } else { + conditions.push(eq(olms.agent, agentValueMap[agent])); + } + } + if (status.length > 0) { const filterAggregates: (SQL | undefined)[] = []; diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 41d89a1bb..f37982dd2 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -123,6 +123,10 @@ const listUserDevicesSchema = z.strictObject({ description: "Filter by agent type. Use 'unknown' to filter clients with no agent detected." }), + userId: z.string().nonempty().optional().catch(undefined).openapi({ + type: "string", + description: "Filter by user ID" + }), status: z.preprocess( (val: string | undefined) => { if (val) { @@ -249,8 +253,17 @@ export async function listUserDevices( ) ); } - const { page, pageSize, query, sort_by, online, status, agent, order } = - parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + online, + status, + agent, + userId, + order + } = parsedQuery.data; const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -331,6 +344,10 @@ export async function listUserDevices( conditions.push(eq(clients.online, online)); } + if (typeof userId !== "undefined") { + conditions.push(eq(clients.userId, userId)); + } + const agentValueMap = { windows: "Pangolin Windows", android: "Pangolin Android", diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index ad99728a6..2805f85ee 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -1,10 +1,14 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import type { ClientRow } from "@app/components/UserDevicesTable"; import UserDevicesTable from "@app/components/UserDevicesTable"; +import type { SelectedUser } from "@app/components/user-selector"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { type ListUserDevicesResponse } from "@server/routers/client"; +import type { GetOrgUserResponse } from "@server/routers/user"; import type { Pagination } from "@server/types/Pagination"; +import type ResponseT from "@server/types/Response"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; @@ -34,12 +38,14 @@ export default async function ClientsPage(props: ClientsPageProps) { pageSize: 20 }; + const header = await authCookieHeader(); + try { const userRes = await internal.get< AxiosResponse >( `/org/${params.orgId}/user-devices?${searchParams.toString()}`, - await authCookieHeader() + header ); const responseData = userRes.data.data; userClients = responseData.devices; @@ -113,6 +119,30 @@ export default async function ClientsPage(props: ClientsPageProps) { const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + const userIdParam = searchParams.get("userId"); + let initialFilterUser: SelectedUser | null = null; + if (userIdParam) { + try { + const userRes = await internal.get( + `/org/${params.orgId}/user/${encodeURIComponent(userIdParam)}`, + header + ); + const user = (userRes.data as ResponseT).data; + if (user) { + initialFilterUser = { + id: userIdParam, + text: getUserDisplayName({ + email: user.email, + name: user.name, + username: user.username + }) + }; + } + } catch { + // leave null so the table falls back to the id + } + } + return ( <> ); diff --git a/src/app/globals.css b/src/app/globals.css index 31cab7bb7..c79d8ed6c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,7 +5,7 @@ @custom-variant dark (&:is(.dark *)); :root { - --radius: 0.6rem; + --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); diff --git a/src/components/MachineClientsBanner.tsx b/src/components/MachineClientsBanner.tsx index cd32e91fd..e5595d965 100644 --- a/src/components/MachineClientsBanner.tsx +++ b/src/components/MachineClientsBanner.tsx @@ -23,7 +23,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => { description={t("machineClientsBannerDescription")} > @@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => { diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 5ade407d2..ed35456a2 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -384,7 +384,52 @@ export default function MachineClientsTable({ { accessorKey: "client", friendlyName: t("agent"), - header: () => {t("agent")}, + header: () => ( + + handleFilterChange("agent", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("agent")} + className="p-3" + /> + ), cell: ({ row }) => { const originalRow = row.original; diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 9d0070c5d..e0178ba1f 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -37,8 +37,10 @@ import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; +import { ColumnEntityFilter } from "./ColumnEntityFilter"; import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; +import { UserSelector, type SelectedUser } from "./user-selector"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { @@ -124,12 +126,15 @@ type ClientTableProps = { orgId: string; pagination: PaginationState; rowCount: number; + initialFilterUser?: SelectedUser | null; }; export default function UserDevicesTable({ userClients, + orgId, pagination, - rowCount + rowCount, + initialFilterUser = null }: ClientTableProps) { const router = useRouter(); const t = useTranslations(); @@ -138,6 +143,7 @@ export default function UserDevicesTable({ const [selectedClient, setSelectedClient] = useState( null ); + const [userFilterOpen, setUserFilterOpen] = useState(false); const api = createApiClient(useEnvContext()); const { @@ -150,6 +156,17 @@ export default function UserDevicesTable({ const latestPlatformVersions = data.data?.data; + const userIdQ = searchParams.get("userId"); + const selectedUser: SelectedUser | null = useMemo(() => { + if (!userIdQ) { + return null; + } + if (initialFilterUser && initialFilterUser.id === userIdQ) { + return initialFilterUser; + } + return { id: userIdQ, text: userIdQ }; + }, [initialFilterUser, userIdQ]); + const defaultUserColumnVisibility = { subnet: false, niceId: false, @@ -360,6 +377,45 @@ export default function UserDevicesTable({ return userClients.some((client) => !client.userId); }, [userClients]); + function handleFilterChange( + column: string, + value: string | null | undefined | 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 selectUser(user: SelectedUser | null) { + searchParams.delete("page"); + if (user?.id) { + searchParams.set("userId", user.id); + } else { + searchParams.delete("userId"); + } + filter({ searchParams }); + setUserFilterOpen(false); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + const columns: ExtendedColumnDef[] = useMemo(() => { const baseColumns: ExtendedColumnDef[] = [ { @@ -410,7 +466,25 @@ export default function UserDevicesTable({ { accessorKey: "userEmail", friendlyName: t("users"), - header: () => {t("users")}, + header: () => ( + { + selectUser(null); + }} + > + + + ), cell: ({ row }) => { const r = row.original; return r.userId ? ( @@ -818,7 +892,15 @@ export default function UserDevicesTable({ }); return baseColumns; - }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); + }, [ + hasRowsWithoutUserId, + t, + getSortDirection, + toggleSort, + orgId, + userFilterOpen, + selectedUser + ]); const statusFilterOptions = useMemo(() => { const allOptions = [ @@ -859,34 +941,6 @@ export default function UserDevicesTable({ return allOptions; }, [t, latestPlatformVersions]); - function handleFilterChange( - column: string, - value: string | null | undefined | 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());