diff --git a/messages/en-US.json b/messages/en-US.json index f310c7aeb..c2a534e3d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2738,6 +2738,11 @@ "clientUpdatedDescription": "The client has been updated.", "clientUpdateFailed": "Failed to update client", "clientUpdateError": "An error occurred while updating the client.", + "machineClientRolesDescription": "Assign roles to this machine client. It will be able to access the private resources granted to these roles.", + "machineClientRolesUpdated": "Roles updated", + "machineClientRolesUpdatedDescription": "The machine client's roles have been updated.", + "machineClientRolesUpdateFailed": "Failed to update roles", + "machineClientRolesUpdateError": "An error occurred while updating the machine client's roles.", "sitesFetchFailed": "Failed to fetch sites", "sitesFetchError": "An error occurred while fetching sites.", "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 21b49cf7a..491a014b7 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -1,6 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, idp, idpOidcConfig, olms, users } from "@server/db"; +import { + clientOrgRoles, + db, + idp, + idpOidcConfig, + olms, + roles, + users +} from "@server/db"; import { clients, currentFingerprint } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -239,6 +247,8 @@ export type GetClientResponse = NonNullable< userType: string | null; idpName: string | null; idpVariant: string | null; + // roles assigned directly to the client (machine clients only) + roles: { roleId: number; name: string }[]; }; registry.registerPath({ @@ -394,6 +404,17 @@ export async function getClient( } } + const clientRoles = await db + .select({ roleId: roles.roleId, name: roles.name }) + .from(clientOrgRoles) + .innerJoin(roles, eq(roles.roleId, clientOrgRoles.roleId)) + .where( + and( + eq(clientOrgRoles.clientId, client.clients.clientId), + eq(clientOrgRoles.orgId, client.clients.orgId) + ) + ); + const data: GetClientResponse = { ...client.clients, name: clientName, @@ -407,7 +428,8 @@ export async function getClient( posture: postureData, userType, idpName, - idpVariant + idpVariant, + roles: clientRoles }; return response(res, { diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 3a05c41c5..01448a80f 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,5 +1,6 @@ import { clientLabels, + clientOrgRoles, clients, clientSitesAssociationsCache, currentFingerprint, @@ -8,6 +9,7 @@ import { olms, orgs, roleClients, + roles, sites, userClients, users, @@ -25,6 +27,7 @@ import { asc, desc, eq, + exists, inArray, isNull, like, @@ -138,6 +141,30 @@ const listClientsSchema = z.strictObject({ .openapi({ type: "array", description: "Filter by client labels" + }), + role_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) + ? val + : typeof val === "string" + ? val.split(",") + : [val]; + const ids = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + return ids.length > 0 ? [...new Set(ids)] : undefined; + }, z.array(z.number().int().positive()).optional()) + .catch(undefined) + .openapi({ + type: "array", + items: { type: "integer" }, + description: + "Filter clients that have any of these role ids assigned (repeat query param or comma-separated)" }) }); @@ -195,6 +222,7 @@ type ClientWithSites = Awaited>[0] & { }>; olmUpdateAvailable?: boolean; labels?: Array>; + roles: Array<{ roleId: number; name: string }>; }; type OlmWithUpdateAvailable = ClientWithSites; @@ -253,7 +281,8 @@ export async function listClients( status, sort_by, order, - labels: labelFilter + labels: labelFilter, + role_id: roleIdFilter } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); @@ -352,6 +381,23 @@ export async function listClients( ); } + if (roleIdFilter && roleIdFilter.length > 0) { + conditions.push( + exists( + db + .select() + .from(clientOrgRoles) + .where( + and( + eq(clientOrgRoles.clientId, clients.clientId), + eq(clientOrgRoles.orgId, orgId), + inArray(clientOrgRoles.roleId, roleIdFilter) + ) + ) + ) + ); + } + if (query) { const q = "%" + query.toLowerCase() + "%"; const queryList = [ @@ -421,6 +467,25 @@ export async function listClients( .orderBy(asc(clientLabels.clientLabelId)); } + const rolesForClients = + clientIds.length > 0 + ? await db + .select({ + clientId: clientOrgRoles.clientId, + roleId: roles.roleId, + name: roles.name + }) + .from(clientOrgRoles) + .innerJoin(roles, eq(roles.roleId, clientOrgRoles.roleId)) + .where( + and( + inArray(clientOrgRoles.clientId, clientIds), + eq(clientOrgRoles.orgId, orgId) + ) + ) + .orderBy(asc(roles.name)) + : []; + // Group site associations by client ID const sitesByClient = siteAssociations.reduce( (acc, association) => { @@ -451,7 +516,10 @@ export async function listClients( sites: sitesByClient[client.clientId] || [], labels: labelsForClients.filter( (l) => l.clientId === client.clientId - ) + ), + roles: rolesForClients + .filter((r) => r.clientId === client.clientId) + .map(({ roleId, name }) => ({ roleId, name })) }; }); diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx index 0e3d9b09d..8e787b997 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx @@ -34,6 +34,10 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import ActionBanner from "@app/components/ActionBanner"; import { Shield, ShieldOff } from "lucide-react"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -42,14 +46,40 @@ const GeneralFormSchema = z.object({ type GeneralFormValues = z.infer; +const RolesFormSchema = z.object({ + roles: z.array( + z.object({ + id: z.string(), + text: z.string(), + isAdmin: z.boolean().optional() + }) + ) +}); + +type RolesFormValues = z.infer; + export default function GeneralPage() { const t = useTranslations(); const { client, updateClient } = useClientContext(); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); const [loading, setLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const router = useRouter(); const [, startTransition] = useTransition(); + const [savingRoles, setSavingRoles] = useState(false); + const { isPaidUser } = usePaidStatus(); + const canAssignRoles = isPaidUser(tierMatrix.fullRbac); + + const rolesForm = useForm({ + resolver: zodResolver(RolesFormSchema), + defaultValues: { + roles: (client?.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) + } + }); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -113,6 +143,41 @@ export default function GeneralPage() { } } + async function onSubmitRoles(data: RolesFormValues) { + if (!client?.clientId) return; + setSavingRoles(true); + + try { + const roleIds = data.roles.map((r) => parseInt(r.id, 10)); + await api.post(`/client/${client.clientId}/roles`, { roleIds }); + + updateClient({ + roles: data.roles.map((r) => ({ + roleId: parseInt(r.id, 10), + name: r.text + })) + }); + + toast({ + title: t("machineClientRolesUpdated"), + description: t("machineClientRolesUpdatedDescription") + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("machineClientRolesUpdateFailed"), + description: formatAxiosError( + e, + t("machineClientRolesUpdateError") + ) + }); + } finally { + setSavingRoles(false); + } + } + const handleUnblock = async () => { if (!client?.clientId) return; setIsRefreshing(true); @@ -230,6 +295,56 @@ export default function GeneralPage() { + + {!env.flags.disableEnterpriseFeatures && ( + + + + {t("roles")} + + + {t("machineClientRolesDescription")} + + + + + + + +
+ + + + +
+
+ + + + +
+ )} ); } diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index 066fdc3ea..4e8f57da5 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -5,6 +5,7 @@ import MachineClientsBanner from "@app/components/MachineClientsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; +import type { ListRolesResponse } from "@server/routers/role/listRoles"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Pagination } from "@server/types/Pagination"; @@ -34,17 +35,37 @@ export default async function ClientsPage(props: ClientsPageProps) { pageSize: 20 }; - try { - const machineRes = await internal.get< - AxiosResponse - >( - `/org/${params.orgId}/clients?${searchParams.toString()}`, - await authCookieHeader() - ); + const cookieHeader = await authCookieHeader(); + + const [machineRes, rolesRes] = await Promise.all([ + internal + .get>( + `/org/${params.orgId}/clients?${searchParams.toString()}`, + cookieHeader + ) + .catch(() => {}), + internal + .get>( + `/org/${params.orgId}/roles?pageSize=500&page=1`, + cookieHeader + ) + .catch(() => {}) + ]); + + if (machineRes && machineRes.status === 200) { const responseData = machineRes.data.data; machineClients = responseData.clients; pagination = responseData.pagination; - } catch (e) {} + } + + const orgRoles = + rolesRes && rolesRes.status === 200 + ? (rolesRes.data.data.roles ?? []) + : []; + const roleFilterOptions = orgRoles.map((r) => ({ + value: String(r.roleId), + label: r.name + })); function formatSize(mb: number): string { if (mb >= 1024 * 1024) { @@ -77,7 +98,8 @@ export default async function ClientsPage(props: ClientsPageProps) { archived: client.archived || false, blocked: client.blocked || false, approvalState: client.approvalState ?? "approved", - labels: client.labels ?? [] + labels: client.labels ?? [], + roleLabels: (client.roles ?? []).map((r) => r.name) }; }; @@ -96,6 +118,7 @@ export default async function ClientsPage(props: ClientsPageProps) { machineClients={machineClientRows} orgId={params.orgId} rowCount={pagination.total} + roleFilterOptions={roleFilterOptions} pagination={{ pageIndex: pagination.page - 1, pageSize: pagination.pageSize diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 4143b37eb..2ba7525b0 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -32,6 +32,7 @@ import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; @@ -43,6 +44,7 @@ import { import { useQuery } from "@tanstack/react-query"; import semver from "semver"; import { InfoPopup } from "./ui/info-popup"; +import UserRoleBadges from "./UserRoleBadges"; export type ClientRow = { id: number; @@ -68,20 +70,25 @@ export type ClientRow = { name: string; color: string; }>; + roleLabels: string[]; }; +type FilterOption = { value: string; label: string }; + type ClientTableProps = { machineClients: ClientRow[]; orgId: string; pagination: PaginationState; rowCount: number; + roleFilterOptions: FilterOption[]; }; export default function MachineClientsTable({ machineClients, orgId, pagination, - rowCount + rowCount, + roleFilterOptions }: ClientTableProps) { const router = useRouter(); @@ -437,6 +444,27 @@ export default function MachineClientsTable({ friendlyName: t("address"), header: () => {t("address")} }, + { + id: "role", + accessorFn: (row) => row.roleLabels.join(", "), + friendlyName: t("roles"), + header: () => ( + + handleFilterChange("role_id", values) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("roles")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + ) + }, { id: "labels", accessorKey: "labels", @@ -540,7 +568,7 @@ export default function MachineClientsTable({ } return baseColumns; - }, [hasRowsWithoutUserId, orgId, t, searchParams]); + }, [hasRowsWithoutUserId, orgId, t, searchParams, roleFilterOptions]); function handleFilterChange( column: string,