diff --git a/apps/console/src/components/form/PeopleSelectField.tsx b/apps/console/src/components/form/PeopleSelectField.tsx index 53439d493..2fb6b83eb 100644 --- a/apps/console/src/components/form/PeopleSelectField.tsx +++ b/apps/console/src/components/form/PeopleSelectField.tsx @@ -42,10 +42,19 @@ export function PeopleSelectField) { + const { __ } = useTranslate(); + return ( } + fallback={( + onChange(event.target.value)} + className="w-full rounded-lg border border-border-low bg-level-1 px-3 py-2 text-sm text-txt-primary outline-none transition-colors focus:border-border-solid" + > + {organizations.map(organization => ( + + ))} + + + + ); +} diff --git a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx index a56732206..ab0ff17d2 100644 --- a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx +++ b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { CookieIcon } from "@phosphor-icons/react"; +import { CookieIcon, LaptopIcon } from "@phosphor-icons/react"; import { useTranslate } from "@probo/i18n"; import { IconBank, @@ -60,6 +60,7 @@ const fragment = graphql` canListThirdParties: permission(action: "core:thirdParty:list") canListDocuments: permission(action: "core:document:list") canListAssets: permission(action: "core:asset:list") + canListDevices: permission(action: "itam:device:list") canListData: permission(action: "core:datum:list") canListAudits: permission(action: "core:audit:list") canListFindings: permission(action: "core:finding:list") @@ -156,6 +157,13 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) { to={`${prefix}/assets`} /> )} + {organization.canListDevices && ( + + )} {organization.canListData && ( . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + PageHeader, + TabLink, + Tabs, +} from "@probo/ui"; +import { + type PreloadedQuery, + usePreloadedQuery, +} from "react-relay"; +import { Outlet } from "react-router"; +import { graphql } from "relay-runtime"; + +import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { DeviceDetailsCard } from "./_components/DeviceDetailsCard"; +import { displayValue } from "./_lib/deviceDisplay"; +import { useRevokeDevice } from "./_lib/useRevokeDevice"; + +export const deviceLayoutQuery = graphql` + query DeviceLayoutQuery($deviceId: ID!, $organizationId: ID!) { + device: node(id: $deviceId) @required(action: THROW) { + __typename + ... on Device { + id + state + hostname + platform + ...DeviceDetailsCard_deviceFragment + } + } + organization: node(id: $organizationId) @required(action: THROW) { + __typename + ... on Organization { + canRevokeDevice: permission(action: "itam:device:revoke") + } + } + } +`; + +interface DeviceLayoutProps { + queryRef: PreloadedQuery; +} + +export function DeviceLayout({ queryRef }: DeviceLayoutProps) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const pendingLabel = __("(pending)"); + + const { device, organization } = usePreloadedQuery( + deviceLayoutQuery, + queryRef, + ); + if (device.__typename !== "Device") { + throw new Error("invalid type for device node"); + } + if (organization.__typename !== "Organization") { + throw new Error("invalid type for organization node"); + } + + usePageTitle(displayValue(device.hostname, pendingLabel)); + + const hostnameLabel = displayValue(device.hostname, pendingLabel); + + const [confirmRevoke, isRevoking] = useRevokeDevice(); + + const isRevoked = device.state === "REVOKED"; + const canRevokeDevice = organization.canRevokeDevice ?? false; + + return ( +
+ + + {!isRevoked && canRevokeDevice && ( + + )} + + + + + + + {__("Postures")} + + + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/DeviceLayoutLoader.tsx b/apps/console/src/pages/organizations/devices/DeviceLayoutLoader.tsx new file mode 100644 index 000000000..530ffab87 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/DeviceLayoutLoader.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { CoreRelayProvider } from "#/providers/CoreRelayProvider"; + +import { DeviceLayout, deviceLayoutQuery } from "./DeviceLayout"; + +function DeviceLayoutQueryLoader() { + const { deviceId } = useParams(); + const organizationId = useOrganizationId(); + if (!deviceId) { + throw new Error(":deviceId missing in route params"); + } + + const [queryRef, loadQuery] = useQueryLoader( + deviceLayoutQuery, + ); + + useEffect(() => { + if (!queryRef) { + loadQuery({ deviceId, organizationId }); + } + }); + + if (!queryRef) { + return ; + } + + return ; +} + +export default function DeviceLayoutLoader() { + const { deviceId } = useParams(); + + return ( + + }> + + + + ); +} diff --git a/apps/console/src/pages/organizations/devices/DevicesPage.tsx b/apps/console/src/pages/organizations/devices/DevicesPage.tsx new file mode 100644 index 000000000..b53bf24bc --- /dev/null +++ b/apps/console/src/pages/organizations/devices/DevicesPage.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { Button, IconPlusLarge, PageHeader, Tbody, Th, Thead, Tr } from "@probo/ui"; +import type { ComponentProps } from "react"; +import { + type PreloadedQuery, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DevicesPageFragment$key } from "#/__generated__/core/DevicesPageFragment.graphql"; +import type { DevicesPageFragment_RefetchQuery } from "#/__generated__/core/DevicesPageFragment_RefetchQuery.graphql"; +import type { DevicesPageQuery } from "#/__generated__/core/DevicesPageQuery.graphql"; +import { SortableTable, SortableTh } from "#/components/SortableTable"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { DeviceRow } from "./_components/DeviceRow"; +import { PostureColumnHeader } from "./_components/PostureColumnHeader"; +import { CreateDeviceDialog } from "./dialogs/CreateDeviceDialog"; + +export const devicesPageQuery = graphql` + query DevicesPageQuery($organizationId: ID!) { + organization: node(id: $organizationId) @required(action: THROW) { + __typename + ... on Organization { + id + canAssignDevice: permission(action: "itam:device:assign") + canRevokeDevice: permission(action: "itam:device:revoke") + canCreateDevice: permission(action: "itam:device:create") + ...DevicesPageFragment + } + } + } +`; + +const devicesPageFragment = graphql` + fragment DevicesPageFragment on Organization + @refetchable(queryName: "DevicesPageFragment_RefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { + type: "DeviceOrder" + defaultValue: { direction: DESC, field: CREATED_AT } + } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + devices( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) @connection(key: "DevicesPage_devices", filters: ["orderBy"]) { + edges { + node { + id + ...DeviceRowFragment + } + } + } + } +`; + +interface DevicesPageProps { + queryRef: PreloadedQuery; +} + +export function DevicesPage({ queryRef }: DevicesPageProps) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + + usePageTitle(__("Devices")); + + const { organization } = usePreloadedQuery( + devicesPageQuery, + queryRef, + ); + if (organization.__typename !== "Organization") { + throw new Error("invalid type for organization node"); + } + + const pagination = usePaginationFragment< + DevicesPageFragment_RefetchQuery, + DevicesPageFragment$key + >(devicesPageFragment, organization); + + const devices = pagination.data.devices.edges.map(edge => edge.node); + + return ( +
+ + {organization.canCreateDevice && ( + { + pagination.refetch({}, { fetchPolicy: "store-and-network" }); + }} + > + + + )} + + + ["refetch"] + } + > + + + {__("Hostname")} + {__("Owner")} + {__("State")} + {__("Platform")} + {__("OS version")} + {__("Last seen")} + + + + + + + + {devices.map(device => ( + + ))} + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/DevicesPageLoader.tsx b/apps/console/src/pages/organizations/devices/DevicesPageLoader.tsx new file mode 100644 index 000000000..8d1929604 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/DevicesPageLoader.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { DevicesPageQuery } from "#/__generated__/core/DevicesPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { DevicesPage, devicesPageQuery } from "./DevicesPage"; + +export default function DevicesPageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] + = useQueryLoader(devicesPageQuery); + + useEffect(() => { + loadQuery({ organizationId }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/devices/_components/DeviceDetailsCard.tsx b/apps/console/src/pages/organizations/devices/_components/DeviceDetailsCard.tsx new file mode 100644 index 000000000..62d262808 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/_components/DeviceDetailsCard.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatDate } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { Badge, Card } from "@probo/ui"; +import type { ReactNode } from "react"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DeviceDetailsCard_deviceFragment$key } from "#/__generated__/core/DeviceDetailsCard_deviceFragment.graphql"; + +import { displayValue, stateVariant } from "../_lib/deviceDisplay"; + +const deviceFragment = graphql` + fragment DeviceDetailsCard_deviceFragment on Device { + state + hardwareUuid + serialNumber + platform + osVersion + agentVersion + enrolledAt + lastSeenAt + owner { + fullName + } + } +`; + +export function DeviceDetailsCard(props: { + deviceFragmentRef: DeviceDetailsCard_deviceFragment$key; +}) { + const { __ } = useTranslate(); + const pendingLabel = __("(pending)"); + const device = useFragment(deviceFragment, props.deviceFragmentRef); + + return ( + +
+ {device.state} + } + /> + + + + + + + + +
+
+ ); +} + +function DetailField(props: { label: string; value: ReactNode }) { + return ( +
+
+ {props.label} +
+
{props.value}
+
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx b/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx new file mode 100644 index 000000000..61c443245 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/_components/DeviceRow.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatDate } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Badge, + DropdownItem, + IconTrashCan, + IconUser, + Td, + Tr, + useDialogRef, +} from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DeviceRowFragment$key } from "#/__generated__/core/DeviceRowFragment.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { displayValue, stateVariant } from "../_lib/deviceDisplay"; +import { useRevokeDevice } from "../_lib/useRevokeDevice"; +import { ReassignDeviceDialog } from "../dialogs/ReassignDeviceDialog"; + +const deviceRowFragment = graphql` + fragment DeviceRowFragment on Device { + id + state + hostname + platform + osVersion + lastSeenAt + owner { + id + fullName + } + latestPostures { + id + status + } + ...ReassignDeviceDialog_device + } +`; + +interface DeviceRowProps { + canAssignDevice: boolean; + canRevoke: boolean; + fKey: DeviceRowFragment$key; +} + +export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const reassignDialogRef = useDialogRef(); + const pendingLabel = __("(pending)"); + + const device = useFragment(deviceRowFragment, fKey); + + const [confirmRevoke, isRevoking] = useRevokeDevice(); + + const summary = postureSummary(device.latestPostures); + const isRevoked = device.state === "REVOKED"; + const hasActions = !isRevoked && (canRevoke || canAssignDevice); + + return ( + <> + + + {displayValue(device.hostname, pendingLabel)} + {device.owner?.fullName ?? __("Unassigned")} + + {device.state} + + {displayValue(device.platform, pendingLabel)} + {displayValue(device.osVersion, pendingLabel)} + {device.lastSeenAt ? formatDate(device.lastSeenAt) : __("Never")} + + {summary.pass} + {" / "} + 0 ? "text-txt-danger" : undefined}> + {summary.fail} + + {" / "} + {summary.total} + + + {hasActions && ( + + {canAssignDevice && ( + reassignDialogRef.current?.open()} + > + {__("Re-assign")} + + )} + {canRevoke && ( + + confirmRevoke({ id: device.id, hostname: device.hostname })} + disabled={isRevoking} + variant="danger" + icon={IconTrashCan} + > + {__("Revoke")} + + )} + + )} + + + + ); +} + +function postureSummary( + postures: readonly { status: string }[], +): { pass: number; fail: number; total: number } { + let pass = 0; + let fail = 0; + for (const p of postures) { + switch (p.status) { + case "PASS": + pass += 1; + break; + case "FAIL": + fail += 1; + break; + default: + // UNKNOWN and NOT_APPLICABLE count toward total only. + break; + } + } + return { pass, fail, total: postures.length }; +} diff --git a/apps/console/src/pages/organizations/devices/_components/PostureColumnHeader.tsx b/apps/console/src/pages/organizations/devices/_components/PostureColumnHeader.tsx new file mode 100644 index 000000000..0107f6fc3 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/_components/PostureColumnHeader.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useTranslate } from "@probo/i18n"; +import { IconCircleInfo } from "@probo/ui"; +import * as Popover from "@radix-ui/react-popover"; + +export function PostureColumnHeader() { + const { __ } = useTranslate(); + + return ( + + {__("Posture")} + + + + + + +

+ {__( + "Shown as pass / fail / total.", + )} +

+
+
+
+
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts b/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts new file mode 100644 index 000000000..085a10996 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export function displayValue( + value: string | null | undefined, + pendingLabel: string, +) { + return value && value.length > 0 ? value : pendingLabel; +} + +export function stateVariant( + state: string, +): "success" | "danger" | "warning" | "info" { + switch (state) { + case "ACTIVE": + return "success"; + case "REVOKED": + return "danger"; + default: + return "warning"; + } +} + +export function statusVariant( + status: string, +): "success" | "danger" | "warning" | "info" { + switch (status) { + case "PASS": + return "success"; + case "FAIL": + return "danger"; + case "NOT_APPLICABLE": + return "info"; + default: + return "warning"; + } +} diff --git a/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts b/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts new file mode 100644 index 000000000..1a2b1ebd2 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/_lib/useRevokeDevice.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatError, sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { useConfirm, useToast } from "@probo/ui"; +import { useCallback } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { useRevokeDeviceMutation } from "#/__generated__/core/useRevokeDeviceMutation.graphql"; + +import { displayValue } from "./deviceDisplay"; + +const revokeDeviceMutation = graphql` + mutation useRevokeDeviceMutation($input: RevokeDeviceInput!) { + revokeDevice(input: $input) { + device { + id + revokedAt + state + ...DeviceDetailsCard_deviceFragment + } + } + } +`; + +interface RevokeDeviceInput { + id: string; + hostname: string | null | undefined; +} + +export function useRevokeDevice() { + const { __ } = useTranslate(); + const { toast } = useToast(); + const confirm = useConfirm(); + const pendingLabel = __("(pending)"); + + const [revokeDevice, isRevoking] = useMutation( + revokeDeviceMutation, + ); + + const confirmRevoke = useCallback( + (device: RevokeDeviceInput) => { + confirm( + () => + new Promise((resolve) => { + revokeDevice({ + variables: { input: { deviceId: device.id } }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + } else { + toast({ + title: __("Success"), + description: __("Device revoked"), + variant: "success", + }); + } + resolve(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to revoke device"), + error, + ), + variant: "error", + }); + resolve(); + }, + }); + }), + { + message: sprintf( + __( + "Revoke device \"%s\"? The agent on the device will stop reporting and must be re-enrolled.", + ), + displayValue(device.hostname, pendingLabel), + ), + variant: "danger", + label: __("Revoke"), + }, + ); + }, + [__, confirm, pendingLabel, revokeDevice, toast], + ); + + return [confirmRevoke, isRevoking] as const; +} diff --git a/apps/console/src/pages/organizations/devices/dialogs/CreateDeviceDialog.tsx b/apps/console/src/pages/organizations/devices/dialogs/CreateDeviceDialog.tsx new file mode 100644 index 000000000..a802c85ab --- /dev/null +++ b/apps/console/src/pages/organizations/devices/dialogs/CreateDeviceDialog.tsx @@ -0,0 +1,198 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + useDialogRef, + useToast, +} from "@probo/ui"; +import { type ReactNode, useState } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { CreateDeviceDialogMutation } from "#/__generated__/core/CreateDeviceDialogMutation.graphql"; +import { PeopleSelectField } from "#/components/form/PeopleSelectField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +import { EnrollmentInstructions } from "../../employee/_components/EnrollmentInstructions"; + +const createDeviceMutation = graphql` + mutation CreateDeviceDialogMutation($input: CreateDeviceInput!) { + createDevice(input: $input) { + enrollmentToken + serverUrl + device { + id + } + } + } +`; + +const schema = z.object({ + ownerId: z.string().nullable().optional(), +}); + +type Props = { + children: ReactNode; + organizationId: string; + onCreated: () => void; +}; + +export function CreateDeviceDialog({ + children, + organizationId, + onCreated, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const [enrollment, setEnrollment] = useState<{ + enrollmentToken: string; + serverUrl: string; + } | null>(null); + + const { control, handleSubmit, formState, reset } = useFormWithSchema(schema, { + defaultValues: { + ownerId: "", + }, + }); + + const [createDevice, isCreating] = useMutation( + createDeviceMutation, + ); + + const handleClose = () => { + const createdDevice = enrollment !== null; + setEnrollment(null); + reset(); + if (createdDevice) { + onCreated(); + } + }; + + const closeDialog = () => { + handleClose(); + dialogRef.current?.close(); + }; + + const onSubmit = handleSubmit((formData) => { + const ownerId + = formData.ownerId === null + ? null + : formData.ownerId || undefined; + + createDevice({ + variables: { + input: { + organizationId, + ownerId, + }, + }, + onCompleted(response, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + return; + } + + setEnrollment({ + enrollmentToken: response.createDevice.enrollmentToken, + serverUrl: response.createDevice.serverUrl, + }); + toast({ + title: __("Success"), + description: __( + "Device created. Copy the enrollment token now — it will not be shown again.", + ), + variant: "success", + }); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create device"), + error, + ), + variant: "error", + }); + }, + }); + }); + + return ( + } + > +
void onSubmit(e)} className="space-y-4"> + + {enrollment + ? ( + + ) + : ( + + )} + + {enrollment + ? ( +
+ +
+ ) + : ( + + + + )} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/dialogs/ReassignDeviceDialog.tsx b/apps/console/src/pages/organizations/devices/dialogs/ReassignDeviceDialog.tsx new file mode 100644 index 000000000..5454d58e8 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/dialogs/ReassignDeviceDialog.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useEffect, useMemo, useState } from "react"; +import { useFragment, useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { ReassignDeviceDialog_device$key } from "#/__generated__/core/ReassignDeviceDialog_device.graphql"; +import type { ReassignDeviceDialogMutation } from "#/__generated__/core/ReassignDeviceDialogMutation.graphql"; +import { PeopleSelectField } from "#/components/form/PeopleSelectField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const reassignDeviceDialogFragment = graphql` + fragment ReassignDeviceDialog_device on Device { + id + owner { + id + } + } +`; + +const reassignDeviceMutation = graphql` + mutation ReassignDeviceDialogMutation($input: SetDeviceOwnerInput!) { + setDeviceOwner(input: $input) { + device { + id + owner { + id + fullName + } + } + } + } +`; + +const schema = z.object({ + ownerId: z.string().nullable().optional(), +}); + +interface ReassignDeviceDialogProps { + deviceKey: ReassignDeviceDialog_device$key; + organizationId: string; + ref?: ReturnType; +} + +export function ReassignDeviceDialog({ + deviceKey, + organizationId, + ref: refProps, +}: ReassignDeviceDialogProps) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const ref = refProps ?? dialogRef; + + const device = useFragment(reassignDeviceDialogFragment, deviceKey); + const [open, setOpen] = useState(false); + + const defaultValues = useMemo( + () => ({ + ownerId: device.owner?.id ?? null, + }), + [device.owner?.id], + ); + + const { control, handleSubmit, formState, reset } = useFormWithSchema(schema, { + defaultValues, + }); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const handleClose = () => { + reset(defaultValues); + }; + + const [setDeviceOwner, isInFlight] = useMutation( + reassignDeviceMutation, + ); + + const onSubmit = (formData: z.infer) => { + const ownerId = formData.ownerId ?? undefined; + + setDeviceOwner({ + variables: { + input: { + deviceId: device.id, + ownerId, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + return; + } + + toast({ + title: __("Success"), + description: __("Device owner updated"), + variant: "success", + }); + handleClose(); + ref.current?.close(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to re-assign device"), + error, + ), + variant: "error", + }); + }, + }); + }; + + return ( + } + > +
void handleSubmit(onSubmit)(e)} className="space-y-4"> + + {open && ( + + )} + + + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/postures/DevicePosturesPage.tsx b/apps/console/src/pages/organizations/devices/postures/DevicePosturesPage.tsx new file mode 100644 index 000000000..003802937 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/postures/DevicePosturesPage.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DevicePosturesPageQuery } from "#/__generated__/core/DevicePosturesPageQuery.graphql"; + +import { DevicePostureList } from "./_components/DevicePostureList"; + +export const devicePosturesPageQuery = graphql` + query DevicePosturesPageQuery($deviceId: ID!) { + device: node(id: $deviceId) @required(action: THROW) { + __typename + ... on Device { + ...DevicePostureList_deviceFragment + } + } + } +`; + +interface DevicePosturesPageProps { + queryRef: PreloadedQuery; +} + +export function DevicePosturesPage({ queryRef }: DevicePosturesPageProps) { + const { device } = usePreloadedQuery( + devicePosturesPageQuery, + queryRef, + ); + if (device.__typename !== "Device") { + throw new Error("invalid type for device node"); + } + + return ; +} diff --git a/apps/console/src/pages/organizations/devices/postures/DevicePosturesPageLoader.tsx b/apps/console/src/pages/organizations/devices/postures/DevicePosturesPageLoader.tsx new file mode 100644 index 000000000..1f67a3614 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/postures/DevicePosturesPageLoader.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { DevicePosturesPageQuery } from "#/__generated__/core/DevicePosturesPageQuery.graphql"; +import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; +import { CoreRelayProvider } from "#/providers/CoreRelayProvider"; + +import { DevicePosturesPage, devicePosturesPageQuery } from "./DevicePosturesPage"; + +function DevicePosturesPageQueryLoader() { + const { deviceId } = useParams(); + if (!deviceId) { + throw new Error(":deviceId missing in route params"); + } + + const [queryRef, loadQuery] = useQueryLoader( + devicePosturesPageQuery, + ); + + useEffect(() => { + if (!queryRef) { + loadQuery({ deviceId }); + } + }); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} + +export default function DevicePosturesPageLoader() { + return ( + + + + ); +} diff --git a/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx b/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx new file mode 100644 index 000000000..a1f8ca2f6 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useTranslate } from "@probo/i18n"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DevicePostureList_deviceFragment$key } from "#/__generated__/core/DevicePostureList_deviceFragment.graphql"; + +import { DevicePostureListItem } from "./DevicePostureListItem"; + +const deviceFragment = graphql` + fragment DevicePostureList_deviceFragment on Device { + latestPostures { + id + ...DevicePostureListItem_postureFragment + } + } +`; + +interface DevicePostureListProps { + deviceFragmentRef: DevicePostureList_deviceFragment$key; +} + +export function DevicePostureList({ deviceFragmentRef }: DevicePostureListProps) { + const { __ } = useTranslate(); + const device = useFragment(deviceFragment, deviceFragmentRef); + + return ( + + + + + + + + + + {device.latestPostures.length === 0 && ( + + + + )} + {device.latestPostures.map(posture => ( + + ))} + +
{__("Check")}{__("Status")}{__("Observed at")}
+ {__("No posture checks recorded")} +
+ ); +} diff --git a/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx b/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx new file mode 100644 index 000000000..24a3d8d4b --- /dev/null +++ b/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useTranslate } from "@probo/i18n"; +import { Badge, Td, Tr } from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DevicePostureListItem_postureFragment$key } from "#/__generated__/core/DevicePostureListItem_postureFragment.graphql"; + +import { statusVariant } from "../../_lib/deviceDisplay"; +import { getPostureCheckLabel } from "../_lib/getPostureCheckLabel"; +import { getPostureStatusLabel } from "../_lib/getPostureStatusLabel"; + +const postureFragment = graphql` + fragment DevicePostureListItem_postureFragment on DevicePosture { + checkKey + status + observedAt + } +`; + +interface DevicePostureListItemProps { + postureKey: DevicePostureListItem_postureFragment$key; +} + +export function DevicePostureListItem({ postureKey }: DevicePostureListItemProps) { + const { __, dateTimeFormat } = useTranslate(); + const posture = useFragment(postureFragment, postureKey); + + return ( + + {getPostureCheckLabel(__, posture.checkKey)} + + + {getPostureStatusLabel(__, posture.status)} + + + + {dateTimeFormat(posture.observedAt, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + })} + + + ); +} diff --git a/apps/console/src/pages/organizations/devices/postures/_lib/getPostureCheckLabel.ts b/apps/console/src/pages/organizations/devices/postures/_lib/getPostureCheckLabel.ts new file mode 100644 index 000000000..7460191c5 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/postures/_lib/getPostureCheckLabel.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +type Translator = (s: string) => string; + +const checkKeyLabels: Record = { + DISK_ENCRYPTION: "Disk encryption", + SCREEN_LOCK: "Screen lock", + FIREWALL_ENABLED: "Firewall enabled", + TIME_SYNC: "Time sync", + OS_VERSION: "OS version", + AUTO_UPDATE: "Auto update", + PASSWORD_POLICY: "Password policy", + REMOTE_LOGIN: "Remote login", + MALWARE_PROTECTION: "Malware protection", +}; + +export function getPostureCheckLabel(__: Translator, checkKey: string) { + const label = checkKeyLabels[checkKey]; + return label ? __(label) : checkKey; +} diff --git a/apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts b/apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts new file mode 100644 index 000000000..b41765d1c --- /dev/null +++ b/apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +type Translator = (s: string) => string; + +const statusLabels: Record = { + PASS: "Pass", + FAIL: "Fail", + UNKNOWN: "Unknown", + NOT_APPLICABLE: "Not applicable", +}; + +export function getPostureStatusLabel(__: Translator, status: string) { + const label = statusLabels[status]; + return label ? __(label) : status; +} diff --git a/apps/console/src/pages/organizations/devices/routes.ts b/apps/console/src/pages/organizations/devices/routes.ts new file mode 100644 index 000000000..9f4235946 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/routes.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { lazy } from "@probo/react-lazy"; +import type { AppRoute } from "@probo/routes"; +import { Fragment } from "react"; +import { type LoaderFunctionArgs, redirect } from "react-router"; + +import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +const deviceTabs = () => [ + { + path: "", + loader: ({ + params: { organizationId, deviceId }, + }: LoaderFunctionArgs) => { + // eslint-disable-next-line + throw redirect( + `/organizations/${organizationId}/devices/${deviceId}/postures`, + ); + }, + Component: Fragment, + }, + { + path: "postures", + Fallback: LinkCardSkeleton, + Component: lazy( + () => + import("#/pages/organizations/devices/postures/DevicePosturesPageLoader"), + ), + }, +]; + +export const deviceRoutes = [ + { + path: "devices", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/devices/DevicesPageLoader"), + ), + }, + { + path: "devices/:deviceId", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/devices/DeviceLayoutLoader"), + ), + children: deviceTabs(), + }, +] satisfies AppRoute[]; diff --git a/apps/console/src/pages/organizations/employee/EmployeeDevicesPage.tsx b/apps/console/src/pages/organizations/employee/EmployeeDevicesPage.tsx new file mode 100644 index 000000000..f7e9e899c --- /dev/null +++ b/apps/console/src/pages/organizations/employee/EmployeeDevicesPage.tsx @@ -0,0 +1,154 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { Button, Card, IconPlusLarge, Tbody, Th, Thead, Tr } from "@probo/ui"; +import { useTransition } from "react"; +import { + graphql, + type PreloadedQuery, + usePreloadedQuery, + useRefetchableFragment, +} from "react-relay"; + +import type { EmployeeDevicesPage_viewer$key } from "#/__generated__/core/EmployeeDevicesPage_viewer.graphql"; +import type { EmployeeDevicesPageQuery } from "#/__generated__/core/EmployeeDevicesPageQuery.graphql"; +import type { EmployeeDevicesPageRefetchQuery } from "#/__generated__/core/EmployeeDevicesPageRefetchQuery.graphql"; + +import { CreateDeviceForm } from "./_components/CreateDeviceForm"; +import { EmployeeDeviceListItem } from "./_components/EmployeeDeviceListItem"; + +const employeeDevicesPageViewerFragment = graphql` + fragment EmployeeDevicesPage_viewer on Viewer + @refetchable(queryName: "EmployeeDevicesPageRefetchQuery") + @argumentDefinitions(organizationId: { type: "ID!" }) { + enrolledDevices( + organizationId: $organizationId + first: 100 + orderBy: { field: CREATED_AT, direction: DESC } + ) { + edges { + node { + id + ...EmployeeDeviceListItem_device + } + } + } + } +`; + +export const employeeDevicesPageQuery = graphql` + query EmployeeDevicesPageQuery($organizationId: ID!) { + viewer @required(action: THROW) { + ...EmployeeDevicesPage_viewer @arguments(organizationId: $organizationId) + } + organization: node(id: $organizationId) @required(action: THROW) { + __typename + ... on Organization { + canEnrollDevice: permission(action: "itam:device:enroll") + } + } + } +`; + +interface EmployeeDevicesPageProps { + queryRef: PreloadedQuery; +} + +export function EmployeeDevicesPage({ queryRef }: EmployeeDevicesPageProps) { + const { __ } = useTranslate(); + + usePageTitle(__("Devices")); + + const { viewer, organization } = usePreloadedQuery( + employeeDevicesPageQuery, + queryRef, + ); + if (organization.__typename !== "Organization") { + throw new Error("invalid type for organization node"); + } + + const [, startTransition] = useTransition(); + + const [viewerData, refetchDevices] = useRefetchableFragment< + EmployeeDevicesPageRefetchQuery, + EmployeeDevicesPage_viewer$key + >(employeeDevicesPageViewerFragment, viewer); + + const devices = viewerData.enrolledDevices.edges.map(edge => edge.node); + const canEnrollDevice = organization.canEnrollDevice ?? false; + + const handleDeviceCreated = () => { + startTransition(() => { + refetchDevices({}, { fetchPolicy: "store-and-network" }); + }); + }; + + return ( +
+
+

{__("Your devices")}

+ {canEnrollDevice && ( + + )} +
+ + + {devices.length > 0 + ? ( + + + + + + + + + + + + {devices.map(device => ( + + ))} + +
{__("Hostname")}{__("State")}{__("Platform")}{__("OS version")}{__("Last seen")}
+ ) + : ( +
+

+ {__("No devices enrolled yet")} +

+
+ )} + +
+ + {canEnrollDevice && ( + + )} +
+ ); +} diff --git a/apps/console/src/pages/organizations/employee/EmployeeDevicesPageLoader.tsx b/apps/console/src/pages/organizations/employee/EmployeeDevicesPageLoader.tsx new file mode 100644 index 000000000..292e98172 --- /dev/null +++ b/apps/console/src/pages/organizations/employee/EmployeeDevicesPageLoader.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { EmployeeDevicesPageQuery } from "#/__generated__/core/EmployeeDevicesPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { + EmployeeDevicesPage, + employeeDevicesPageQuery, +} from "./EmployeeDevicesPage"; + +function EmployeeDevicesPageQueryLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] = useQueryLoader( + employeeDevicesPageQuery, + ); + + useEffect(() => { + loadQuery({ organizationId }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return ; + } + + return ; +} + +export default function EmployeeDevicesPageLoader() { + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/employee/EmployeeTabsLayout.tsx b/apps/console/src/pages/organizations/employee/EmployeeTabsLayout.tsx index aed150054..5b096b814 100644 --- a/apps/console/src/pages/organizations/employee/EmployeeTabsLayout.tsx +++ b/apps/console/src/pages/organizations/employee/EmployeeTabsLayout.tsx @@ -35,6 +35,9 @@ export default function EmployeeTabsLayout() { {__("Approvals")} + + {__("Devices")} + diff --git a/apps/console/src/pages/organizations/employee/_components/CreateDeviceForm.tsx b/apps/console/src/pages/organizations/employee/_components/CreateDeviceForm.tsx new file mode 100644 index 000000000..e052629bc --- /dev/null +++ b/apps/console/src/pages/organizations/employee/_components/CreateDeviceForm.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useState } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { CreateDeviceFormMutation } from "#/__generated__/core/CreateDeviceFormMutation.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { EnrollmentInstructions } from "./EnrollmentInstructions"; + +const enrollDeviceMutation = graphql` + mutation CreateDeviceFormMutation($input: EnrollDeviceInput!) { + enrollDevice(input: $input) { + enrollmentToken + serverUrl + device { + id + } + } + } +`; + +interface CreateDeviceFormProps { + onDeviceCreated?: () => void; +} + +export function CreateDeviceForm({ onDeviceCreated }: CreateDeviceFormProps) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const [enrollment, setEnrollment] = useState<{ + enrollmentToken: string; + serverUrl: string; + } | null>(null); + + const organizationId = useOrganizationId(); + const [enrollDevice, isCreating] = useMutation( + enrollDeviceMutation, + ); + + const handleClose = () => { + const createdDevice = enrollment !== null; + setEnrollment(null); + if (createdDevice) { + onDeviceCreated?.(); + } + }; + + const closeDialog = () => { + handleClose(); + dialogRef.current?.close(); + }; + + const handleCreate = () => { + enrollDevice({ + variables: { + input: { + organizationId, + }, + }, + onCompleted(response, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + dialogRef.current?.close(); + return; + } + + setEnrollment({ + enrollmentToken: response.enrollDevice.enrollmentToken, + serverUrl: response.enrollDevice.serverUrl, + }); + dialogRef.current?.open(); + toast({ + title: __("Success"), + description: __( + "Device created. Copy the enrollment token now — it will not be shown again.", + ), + variant: "success", + }); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create device"), + error, + ), + variant: "error", + }); + dialogRef.current?.close(); + }, + }); + }; + + const handleManualEnroll = () => { + dialogRef.current?.open(); + if (!isCreating && !enrollment) { + handleCreate(); + } + }; + + return ( + <> +

+ {__("Can't enroll new device?")} + {" "} + +

+ + + + {isCreating && !enrollment + ?

{__("Creating device…")}

+ : null} + {enrollment + ? ( + + ) + : null} +
+ {enrollment + ? ( +
+ +
+ ) + : null} +
+ + ); +} diff --git a/apps/console/src/pages/organizations/employee/_components/EmployeeDeviceListItem.tsx b/apps/console/src/pages/organizations/employee/_components/EmployeeDeviceListItem.tsx new file mode 100644 index 000000000..0a776a1e5 --- /dev/null +++ b/apps/console/src/pages/organizations/employee/_components/EmployeeDeviceListItem.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatDate } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { Badge, Td, Tr } from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { EmployeeDeviceListItem_device$key } from "#/__generated__/core/EmployeeDeviceListItem_device.graphql"; + +const employeeDeviceListItemFragment = graphql` + fragment EmployeeDeviceListItem_device on Device { + state + hostname + platform + osVersion + lastSeenAt + } +`; + +interface EmployeeDeviceListItemProps { + deviceKey: EmployeeDeviceListItem_device$key; +} + +function displayValue(value: string | null | undefined, pendingLabel: string) { + return value && value.length > 0 ? value : pendingLabel; +} + +function stateVariant(state: string): "success" | "warning" | "info" { + switch (state) { + case "ACTIVE": + return "success"; + default: + return "warning"; + } +} + +export function EmployeeDeviceListItem({ deviceKey }: EmployeeDeviceListItemProps) { + const { __ } = useTranslate(); + const pendingLabel = __("(pending)"); + + const device = useFragment(employeeDeviceListItemFragment, deviceKey); + + return ( + + {displayValue(device.hostname, pendingLabel)} + + {device.state} + + {displayValue(device.platform, pendingLabel)} + {displayValue(device.osVersion, pendingLabel)} + {device.lastSeenAt ? formatDate(device.lastSeenAt) : __("Never")} + + ); +} diff --git a/apps/console/src/pages/organizations/employee/_components/EnrollmentInstructions.tsx b/apps/console/src/pages/organizations/employee/_components/EnrollmentInstructions.tsx new file mode 100644 index 000000000..719fda5b0 --- /dev/null +++ b/apps/console/src/pages/organizations/employee/_components/EnrollmentInstructions.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useTranslate } from "@probo/i18n"; +import { Button, Card, useToast } from "@probo/ui"; + +const RELEASE_BASE_URL + = "https://github.com/getprobo/probo/releases/latest/download"; + +interface EnrollmentInstructionsProps { + enrollmentToken: string; + serverUrl: string; +} + +export function EnrollmentInstructions( + { enrollmentToken, serverUrl }: EnrollmentInstructionsProps, +) { + const { __ } = useTranslate(); + + const unixCommand = `# 1. Download and install the probo-agent binary +OS=$(uname -s); ARCH=$(uname -m | sed 's/aarch64/arm64/; s/amd64/x86_64/') +NAME="probo-agent_\${OS}_\${ARCH}" +curl -fsSL "${RELEASE_BASE_URL}/\${NAME}.tar.gz" -o /tmp/probo-agent.tar.gz +tar -xzf /tmp/probo-agent.tar.gz -C /tmp +sudo install -m 0755 "/tmp/\${NAME}/probo-agent" /usr/local/bin/probo-agent +rm -rf /tmp/probo-agent.tar.gz "/tmp/\${NAME}" + +# 2. Configure the device and start the agent service +sudo /usr/local/bin/probo-agent install \\ + --server ${serverUrl} \\ + --enrollment-token '${enrollmentToken}'`; + + const windowsCommand = `# 1. Download and install the probo-agent binary +$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x86_64' } +$name = "probo-agent_Windows_$arch" +$zip = "$env:TEMP\\probo-agent.zip" +$dst = "$env:ProgramFiles\\Probo" +Invoke-WebRequest -Uri "${RELEASE_BASE_URL}/$name.zip" -OutFile $zip +Expand-Archive -Path $zip -DestinationPath $env:TEMP -Force +New-Item -ItemType Directory -Force -Path $dst | Out-Null +Move-Item -Force "$env:TEMP\\$name\\probo-agent.exe" "$dst\\probo-agent.exe" +Remove-Item -Recurse -Force $zip, "$env:TEMP\\$name" + +# 2. Configure the device and start the agent service +& "$dst\\probo-agent.exe" install \` + --server ${serverUrl} \` + --enrollment-token '${enrollmentToken}'`; + + return ( +
+

{__("Enrollment token generated")}

+

+ {__( + "Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.", + )} +

+ + +
+ + {__("Manual install (CLI / MDM)")} + + +
+
+

+ {__( + "Install on macOS or Linux (run from a shell with sudo access)", + )} +

+ +
+ +
+

+ {__( + "Install on Windows (run from an elevated PowerShell session)", + )} +

+ +
+ +

+ {__( + "The token is passed as a CLI flag (not via curl-piped-to-shell or sudo env vars). Once installed, the agent self-updates from GitHub Releases with cosign signature verification.", + )} +

+
+
+
+ ); +} + +function CopyableCodeBlock({ code }: { code: string }) { + const { __ } = useTranslate(); + const { toast } = useToast(); + + const handleCopy = () => { + navigator.clipboard.writeText(code).then( + () => { + toast({ + title: __("Copied"), + description: __("Copied to clipboard"), + variant: "success", + }); + }, + () => { + toast({ + title: __("Error"), + description: __("Failed to copy to clipboard"), + variant: "error", + }); + }, + ); + }; + + return ( + +
+ +
+
+        {code}
+      
+
+ ); +} diff --git a/apps/console/src/pages/organizations/enroll/_components/EnrollDeviceButton.tsx b/apps/console/src/pages/organizations/enroll/_components/EnrollDeviceButton.tsx new file mode 100644 index 000000000..58ca63a21 --- /dev/null +++ b/apps/console/src/pages/organizations/enroll/_components/EnrollDeviceButton.tsx @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { formatError, sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + IconArrowsClockwise, + IconCircleCheck, + useToast, +} from "@probo/ui"; +import { useEffect, useState } from "react"; +import { fetchQuery, useMutation, useRelayEnvironment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { EnrollDeviceButtonMutation } from "#/__generated__/core/EnrollDeviceButtonMutation.graphql"; +import type { EnrollDeviceButtonStatusQuery } from "#/__generated__/core/EnrollDeviceButtonStatusQuery.graphql"; + +const POLL_INTERVAL_MS = 3000; +const POLL_TIMEOUT_MS = 15 * 60 * 1000; + +const enrollDeviceButtonMutation = graphql` + mutation EnrollDeviceButtonMutation($input: EnrollDeviceInput!) { + enrollDevice(input: $input) { + enrollmentUrl + device { + id + } + } + } +`; + +const enrollDeviceButtonStatusQuery = graphql` + query EnrollDeviceButtonStatusQuery($deviceId: ID!) { + device: node(id: $deviceId) { + __typename + ... on Device { + id + state + hostname + } + } + } +`; + +export interface EnrollmentSession { + organizationId: string; + deviceId: string; + deepLink: string; +} + +interface EnrollDeviceButtonProps { + organizationId: string | null; + session?: EnrollmentSession | null; + onSessionCreated?: (session: EnrollmentSession) => void; + onComplete?: () => void; +} + +function EnrollDeviceButtonContent( + { + organizationId, + session, + onSessionCreated, + onComplete, + }: EnrollDeviceButtonProps, +) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const environment = useRelayEnvironment(); + const matchingSession + = session && organizationId && session.organizationId === organizationId + ? session + : null; + const [createdSession, setCreatedSession] = useState< + Pick | null + >(null); + const deepLink = matchingSession?.deepLink ?? createdSession?.deepLink ?? null; + const deviceId = matchingSession?.deviceId ?? createdSession?.deviceId ?? null; + const [isWaitingForActivity, setIsWaitingForActivity] = useState(false); + const [isEnrollmentComplete, setIsEnrollmentComplete] = useState(false); + const [hasTimedOut, setHasTimedOut] = useState(false); + const [deviceHostname, setDeviceHostname] = useState(null); + + const [enrollDevice, isCreating] + = useMutation(enrollDeviceButtonMutation); + + useEffect(() => { + if (!isWaitingForActivity || !deviceId) { + return; + } + + let cancelled = false; + let timeoutId: ReturnType | undefined; + const deadline = Date.now() + POLL_TIMEOUT_MS; + + const scheduleNext = () => { + if (!cancelled) { + timeoutId = setTimeout(runPoll, POLL_INTERVAL_MS); + } + }; + + const runPoll = async () => { + if (cancelled) { + return; + } + + if (document.hidden) { + scheduleNext(); + return; + } + + try { + const data = await fetchQuery( + environment, + enrollDeviceButtonStatusQuery, + { deviceId }, + { fetchPolicy: "network-only" }, + ).toPromise(); + + if (cancelled) { + return; + } + + const device = data?.device; + if (device?.__typename !== "Device") { + if (Date.now() > deadline) { + setIsWaitingForActivity(false); + setHasTimedOut(true); + return; + } + + scheduleNext(); + return; + } + + setDeviceHostname(device.hostname ?? null); + + if (device.state === "ACTIVE") { + setIsEnrollmentComplete(true); + setIsWaitingForActivity(false); + onComplete?.(); + return; + } + } catch { + if (Date.now() > deadline) { + setIsWaitingForActivity(false); + setHasTimedOut(true); + return; + } + + scheduleNext(); + return; + } + + if (Date.now() > deadline) { + setIsWaitingForActivity(false); + setHasTimedOut(true); + return; + } + + scheduleNext(); + }; + + scheduleNext(); + + return () => { + cancelled = true; + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; + }, [deviceId, environment, isWaitingForActivity, onComplete]); + + function openAgent(nextDeepLink: string) { + setHasTimedOut(false); + setIsWaitingForActivity(true); + window.location.assign(nextDeepLink); + } + + function handleOpenAgent() { + if (deepLink) { + openAgent(deepLink); + return; + } + + if (!organizationId) { + return; + } + + enrollDevice({ + variables: { + input: { + organizationId, + }, + }, + onCompleted(response, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + return; + } + + const payload = response.enrollDevice; + const nextDeepLink = payload.enrollmentUrl; + const nextDeviceId = payload.device.id; + setCreatedSession({ deviceId: nextDeviceId, deepLink: nextDeepLink }); + onSessionCreated?.({ + organizationId, + deviceId: nextDeviceId, + deepLink: nextDeepLink, + }); + openAgent(nextDeepLink); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create device"), + error, + ), + variant: "error", + }); + }, + }); + } + + if (isEnrollmentComplete) { + return ( +
+
+ + {deviceHostname + ? sprintf(__("%s is enrolled."), deviceHostname) + : __("This device is enrolled.")} +
+

+ {__("You can close this window.")} +

+
+ ); + } + + if (isWaitingForActivity) { + return ( +
+ + {__("Waiting for the agent's first check-in…")} +
+ ); + } + + return ( +
+ {hasTimedOut && ( +

+ {__( + "We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again.", + )} +

+ )} + +
+ ); +} + +export function EnrollDeviceButton(props: EnrollDeviceButtonProps) { + return ( + + ); +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 96d97ef89..2c2b4a656 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -37,6 +37,7 @@ import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading import { peopleRoutes } from "./pages/iam/organizations/people/routes"; import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes"; import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes"; +import { deviceRoutes } from "./pages/organizations/devices/routes"; import { riskRoutes } from "./pages/organizations/risks/routes"; import { thirdPartyRoutes } from "./pages/organizations/third-parties/routes"; import { CurrentUser } from "./providers/CurrentUser"; @@ -160,6 +161,12 @@ const routes = [ () => import("./pages/iam/oauthTokens/NewOAuthTokenPageLoader"), ), }, + { + path: "enroll", + Component: lazy( + () => import("./pages/iam/enroll/EnrollDevicePageLoader"), + ), + }, { Component: CenteredLayout, children: [ @@ -216,6 +223,13 @@ const routes = [ import("./pages/organizations/employee/EmployeeApprovalsPageLoader"), ), }, + { + path: "devices", + Component: lazy( + () => + import("./pages/organizations/employee/EmployeeDevicesPageLoader"), + ), + }, ], }, { @@ -318,6 +332,7 @@ const routes = [ ...measureRoutes, ...documentsRoutes, ...thirdPartyRoutes, + ...deviceRoutes, ...frameworkRoutes, ...taskRoutes, ...assetRoutes, diff --git a/packages/ui/src/Atoms/Select/Select.tsx b/packages/ui/src/Atoms/Select/Select.tsx index a796eba69..0be4ec950 100644 --- a/packages/ui/src/Atoms/Select/Select.tsx +++ b/packages/ui/src/Atoms/Select/Select.tsx @@ -32,6 +32,7 @@ import { Value, Viewport, } from "@radix-ui/react-select"; +import { clsx } from "clsx"; import { Children, type ComponentProps, @@ -211,12 +212,21 @@ export function Select({ ); } -export function Option({ children, ...props }: ComponentProps) { +export function Option({ + children, + className, + ...props +}: ComponentProps) { const hasSingleChildren = Children.count(children) <= 1; return ( void; + onOpenChange?: (open: boolean) => void; closable?: boolean; }; @@ -89,6 +90,7 @@ export function Dialog({ ref, defaultOpen, onClose, + onOpenChange: onOpenChangeProp, closable = true, }: Props) { const { overlay, content, header, title: titleClassname } = dialog(); @@ -100,9 +102,11 @@ export function Dialog({ ref.current = { open() { setOpen(true); + onOpenChangeProp?.(true); }, close() { setOpen(false); + onOpenChangeProp?.(false); }, }; } @@ -113,6 +117,7 @@ export function Dialog({ return; } setOpen(open); + onOpenChangeProp?.(open); if (!open) { onClose?.(); }