Add console devices UI

Add org admin device management, employee self-service enrollment,
posture views, and owner assignment across console routes.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-14 20:40:39 +02:00
parent d0dd87c6c7
commit 6dbbd70229
33 changed files with 3081 additions and 5 deletions

View File

@@ -42,10 +42,19 @@ export function PeopleSelectField<TFieldValues extends FieldValues = FieldValues
control,
...props
}: Props<TFieldValues>) {
const { __ } = useTranslate();
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" loading placeholder="Loading..." />}
fallback={(
<Select
variant="editor"
loading
placeholder={__("Select an owner")}
className="w-full"
/>
)}
>
<PeopleSelectWithQuery<TFieldValues>
organizationId={organizationId}
@@ -81,7 +90,6 @@ function PeopleSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
placeholder={__("Select an owner")}
onValueChange={value =>
field.onChange(value === "__NONE__" ? null : value)}
key={people?.length.toString() ?? "0"}
{...field}
className="w-full"
value={field.value ?? (props.optional ? "__NONE__" : "")}

View File

@@ -0,0 +1,319 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { LaptopIcon } from "@phosphor-icons/react";
import { sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Card } from "@probo/ui";
import { useCallback, useMemo, useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Link } from "react-router";
import { graphql } from "relay-runtime";
import type { EnrollDevicePageQuery } from "#/__generated__/iam/EnrollDevicePageQuery.graphql";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import {
EnrollDeviceButton,
type EnrollmentSession,
} from "../../organizations/enroll/_components/EnrollDeviceButton";
import { EnrollOrganizationPicker } from "./_components/EnrollOrganizationPicker";
export const enrollDevicePageQuery = graphql`
query EnrollDevicePageQuery {
viewer @required(action: THROW) {
profiles(
first: 1000
orderBy: { direction: ASC, field: ORGANIZATION_NAME }
filter: { state: ACTIVE }
) @required(action: THROW) {
edges @required(action: THROW) {
node @required(action: THROW) {
id
organization @required(action: THROW) {
id
name
}
}
}
}
}
}
`;
interface EnrollDevicePageProps {
queryRef: PreloadedQuery<EnrollDevicePageQuery>;
}
export function EnrollDevicePage({ queryRef }: EnrollDevicePageProps) {
const { __ } = useTranslate();
usePageTitle(__("Enroll device"));
const { viewer } = usePreloadedQuery<EnrollDevicePageQuery>(
enrollDevicePageQuery,
queryRef,
);
const organizations = useMemo(
() => viewer.profiles.edges.map(edge => edge.node.organization),
[viewer.profiles.edges],
);
const [manualOrganizationId, setManualOrganizationId] = useState<string | null>(
null,
);
const [step, setStep] = useState<"intro" | "organization" | "enroll">("intro");
const [enrollmentSession, setEnrollmentSession] = useState<EnrollmentSession | null>(
null,
);
const [isEnrollmentComplete, setIsEnrollmentComplete] = useState(false);
const handleEnrollmentComplete = useCallback(() => {
setIsEnrollmentComplete(true);
}, []);
const stepIndexByName = {
intro: 1,
organization: 2,
enroll: 3,
} as const;
const selectedOrganizationId = useMemo(() => {
if (
manualOrganizationId
&& organizations.some(org => org.id === manualOrganizationId)
) {
return manualOrganizationId;
}
return organizations[0]?.id ?? null;
}, [manualOrganizationId, organizations]);
const handleOrganizationChange = (organizationID: string) => {
setManualOrganizationId(organizationID);
setEnrollmentSession(current =>
current !== null && current.organizationId !== organizationID ? null : current,
);
};
const activeEnrollmentSession
= enrollmentSession !== null
&& selectedOrganizationId !== null
&& enrollmentSession.organizationId === selectedOrganizationId
? enrollmentSession
: null;
const activeStepIndex = stepIndexByName[step];
const steps = [
{
key: "intro",
title: __("Privacy"),
description: __("Review collected data"),
},
{
key: "organization",
title: __("Organization"),
description: __("Choose destination workspace"),
},
{
key: "enroll",
title: __("Open and wait"),
description: __("Finish setup in the desktop agent"),
},
] as const;
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 py-8">
{organizations.length === 0
? (
<Card className="space-y-4 border-border-low p-6">
<div className="space-y-1">
<h2 className="text-lg font-medium">
{__("Enrollment unavailable")}
</h2>
<p className="text-sm text-txt-secondary">
{__(
"You do not have permission to enroll devices in any organization.",
)}
</p>
</div>
<Button variant="secondary" asChild>
<Link to="/">{__("Back to organizations")}</Link>
</Button>
</Card>
)
: (
<Card className="overflow-hidden border-border-low p-0">
<div className="grid md:grid-cols-[260px_minmax(0,1fr)]">
<aside className="flex flex-col border-b border-border-low bg-subtle/30 p-6 md:min-h-[560px] md:border-b-0 md:border-r">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg border border-border-low bg-level-1 text-txt-primary">
<LaptopIcon size={18} weight="duotone" />
</div>
<div>
<p className="text-sm font-semibold text-txt-primary">{__("Device enrollment")}</p>
<p className="text-xs text-txt-secondary">{__("Setup")}</p>
</div>
</div>
<div className="mt-8 space-y-2">
{steps.map((item, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === activeStepIndex;
const isComplete = stepNumber < activeStepIndex;
return (
<div
key={item.key}
className={[
"rounded-lg px-3 py-2",
isActive ? "bg-level-1" : "",
].join(" ")}
>
<div className="flex items-start gap-2.5">
<span
className={[
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold",
isComplete || isActive
? "bg-primary text-invert"
: "bg-level-1 text-txt-secondary",
].join(" ")}
>
{stepNumber}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-txt-primary">{item.title}</p>
<p className="text-xs text-txt-secondary">{item.description}</p>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-8 md:mt-auto">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-txt-secondary">
{sprintf(__("Step %s of %s"), activeStepIndex, steps.length)}
</p>
<div className="mt-3 flex gap-2">
{steps.map((item, index) => (
<span
key={item.key}
className={[
"h-1.5 flex-1 rounded-full",
index < activeStepIndex ? "bg-primary" : "bg-border-low",
].join(" ")}
/>
))}
</div>
</div>
</aside>
<section className="space-y-6 p-6 md:p-8">
{step === "intro" && (
<section className="space-y-5">
<header className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{__("Before you start")}</h1>
<p className="text-sm leading-6 text-txt-secondary">
{__(
"Probo collects the following device metadata for inventory and posture reporting:",
)}
</p>
</header>
<ul className="list-disc space-y-1.5 pl-5 text-sm text-txt-secondary">
<li>{__("Device identity: hardware UUID, hostname, and serial number (when available).")}</li>
<li>{__("System details: platform, OS version, and Probo agent version.")}</li>
<li>{__("Activity signals: enrollment time, heartbeats, and posture check results.")}</li>
</ul>
<div className="flex flex-wrap gap-3 pt-2">
<Button onClick={() => setStep("organization")}>
{__("Continue")}
</Button>
</div>
</section>
)}
{step === "organization" && (
<section className="space-y-5">
<header className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{__("Choose organization")}</h1>
<p className="text-sm leading-6 text-txt-secondary">
{__(
"Pick which organization will own and manage this device.",
)}
</p>
</header>
<EnrollOrganizationPicker
organizations={organizations.map(organization => ({
id: organization.id,
name: organization.name,
}))}
selectedOrganizationId={selectedOrganizationId}
onChange={handleOrganizationChange}
/>
<div className="flex flex-wrap gap-3">
<Button
onClick={() => setStep("enroll")}
disabled={selectedOrganizationId == null}
>
{__("Continue")}
</Button>
<Button variant="secondary" onClick={() => setStep("intro")}>
{__("Back")}
</Button>
</div>
</section>
)}
{step === "enroll" && (
<section className="space-y-5">
<header className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{__("Open the Probo agent")}</h1>
<p className="text-sm leading-6 text-txt-secondary">
{__(
"Open the desktop agent to finish setup, then keep this page open until enrollment is confirmed.",
)}
</p>
</header>
<CoreRelayProvider>
<EnrollDeviceButton
organizationId={selectedOrganizationId}
session={activeEnrollmentSession}
onSessionCreated={setEnrollmentSession}
onComplete={handleEnrollmentComplete}
/>
</CoreRelayProvider>
{!isEnrollmentComplete && (
<div>
<Button variant="secondary" onClick={() => setStep("organization")}>
{__("Back")}
</Button>
</div>
)}
</section>
)}
</section>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { EnrollDevicePageQuery } from "#/__generated__/iam/EnrollDevicePageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
import {
EnrollDevicePage,
enrollDevicePageQuery,
} from "./EnrollDevicePage";
function EnrollDevicePageQueryLoader() {
const [queryRef, loadQuery] = useQueryLoader<EnrollDevicePageQuery>(
enrollDevicePageQuery,
);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <PageSkeleton />;
}
return <EnrollDevicePage queryRef={queryRef} />;
}
export default function EnrollDevicePageLoader() {
return (
<IAMRelayProvider>
<Suspense fallback={<PageSkeleton />}>
<EnrollDevicePageQueryLoader />
</Suspense>
</IAMRelayProvider>
);
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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";
interface OrganizationOption {
id: string;
name: string;
}
interface EnrollOrganizationPickerProps {
organizations: OrganizationOption[];
selectedOrganizationId: string | null;
onChange: (organizationID: string) => void;
}
export function EnrollOrganizationPicker(
{
organizations,
selectedOrganizationId,
onChange,
}: EnrollOrganizationPickerProps,
) {
const { __ } = useTranslate();
return (
<section className="space-y-4">
<label htmlFor="organization-select" className="space-y-2 text-xs font-medium text-txt-secondary">
<span>{__("Organization")}</span>
<select
id="organization-select"
value={selectedOrganizationId ?? ""}
onChange={event => 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 => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</select>
</label>
</section>
);
}

View File

@@ -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 && (
<SidebarItem
label={__("Devices")}
icon={LaptopIcon}
to={`${prefix}/devices`}
/>
)}
{organization.canListData && (
<SidebarItem
label={__("Data")}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<DeviceLayoutQuery>;
}
export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const pendingLabel = __("(pending)");
const { device, organization } = usePreloadedQuery<DeviceLayoutQuery>(
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 (
<div className="flex flex-col gap-6 h-full">
<Breadcrumb
items={[
{
label: __("Devices"),
to: `/organizations/${organizationId}/devices`,
},
{ label: hostnameLabel },
]}
/>
<PageHeader
title={hostnameLabel}
description={displayValue(device.platform, pendingLabel)}
>
{!isRevoked && canRevokeDevice && (
<Button
variant="danger"
onClick={() =>
confirmRevoke({ id: device.id, hostname: device.hostname })}
disabled={isRevoking}
>
{__("Revoke")}
</Button>
)}
</PageHeader>
<DeviceDetailsCard deviceFragmentRef={device} />
<Tabs>
<TabLink
to={`/organizations/${organizationId}/devices/${device.id}/postures`}
>
{__("Postures")}
</TabLink>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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>(
deviceLayoutQuery,
);
useEffect(() => {
if (!queryRef) {
loadQuery({ deviceId, organizationId });
}
});
if (!queryRef) {
return <PageSkeleton />;
}
return <DeviceLayout queryRef={queryRef} />;
}
export default function DeviceLayoutLoader() {
const { deviceId } = useParams();
return (
<CoreRelayProvider>
<Suspense key={deviceId} fallback={<PageSkeleton />}>
<DeviceLayoutQueryLoader />
</Suspense>
</CoreRelayProvider>
);
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<DevicesPageQuery>;
}
export function DevicesPage({ queryRef }: DevicesPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
usePageTitle(__("Devices"));
const { organization } = usePreloadedQuery<DevicesPageQuery>(
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 (
<div className="space-y-6">
<PageHeader
title={__("Devices")}
description={__(
"Manage computers enrolled with the Probo posture agent.",
)}
>
{organization.canCreateDevice && (
<CreateDeviceDialog
organizationId={organizationId}
onCreated={() => {
pagination.refetch({}, { fetchPolicy: "store-and-network" });
}}
>
<Button icon={IconPlusLarge}>{__("Add device")}</Button>
</CreateDeviceDialog>
)}
</PageHeader>
<SortableTable
{...pagination}
refetch={
pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]
}
>
<Thead>
<Tr>
<SortableTh field="HOSTNAME">{__("Hostname")}</SortableTh>
<Th>{__("Owner")}</Th>
<Th>{__("State")}</Th>
<Th>{__("Platform")}</Th>
<Th>{__("OS version")}</Th>
<SortableTh field="LAST_SEEN_AT">{__("Last seen")}</SortableTh>
<Th>
<PostureColumnHeader />
</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{devices.map(device => (
<DeviceRow
key={device.id}
fKey={device}
canAssignDevice={organization.canAssignDevice ?? false}
canRevoke={organization.canRevokeDevice ?? false}
/>
))}
</Tbody>
</SortableTable>
</div>
);
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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>(devicesPageQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<DevicesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Card className="space-y-4" padded>
<div className="grid grid-cols-3 gap-4">
<DetailField
label={__("State")}
value={
<Badge variant={stateVariant(device.state)}>{device.state}</Badge>
}
/>
<DetailField
label={__("Owner")}
value={device.owner?.fullName ?? __("Unassigned")}
/>
<DetailField
label={__("Hardware UUID")}
value={displayValue(device.hardwareUuid, pendingLabel)}
/>
<DetailField
label={__("Serial number")}
value={displayValue(device.serialNumber, pendingLabel)}
/>
<DetailField
label={__("Platform")}
value={displayValue(device.platform, pendingLabel)}
/>
<DetailField
label={__("OS version")}
value={displayValue(device.osVersion, pendingLabel)}
/>
<DetailField
label={__("Agent version")}
value={displayValue(device.agentVersion, pendingLabel)}
/>
<DetailField
label={__("Enrolled at")}
value={
device.enrolledAt ? formatDate(device.enrolledAt) : pendingLabel
}
/>
<DetailField
label={__("Last seen")}
value={device.lastSeenAt ? formatDate(device.lastSeenAt) : __("Never")}
/>
</div>
</Card>
);
}
function DetailField(props: { label: string; value: ReactNode }) {
return (
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{props.label}
</div>
<div className="text-sm text-txt-primary">{props.value}</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<>
<ReassignDeviceDialog
ref={reassignDialogRef}
deviceKey={device}
organizationId={organizationId}
/>
<Tr to={`/organizations/${organizationId}/devices/${device.id}`}>
<Td>{displayValue(device.hostname, pendingLabel)}</Td>
<Td>{device.owner?.fullName ?? __("Unassigned")}</Td>
<Td>
<Badge variant={stateVariant(device.state)}>{device.state}</Badge>
</Td>
<Td>{displayValue(device.platform, pendingLabel)}</Td>
<Td>{displayValue(device.osVersion, pendingLabel)}</Td>
<Td>{device.lastSeenAt ? formatDate(device.lastSeenAt) : __("Never")}</Td>
<Td>
<span className="text-txt-success">{summary.pass}</span>
{" / "}
<span className={summary.fail > 0 ? "text-txt-danger" : undefined}>
{summary.fail}
</span>
{" / "}
{summary.total}
</Td>
<Td noLink width={50} className="text-end">
{hasActions && (
<ActionDropdown>
{canAssignDevice && (
<DropdownItem
icon={IconUser}
onClick={() => reassignDialogRef.current?.open()}
>
{__("Re-assign")}
</DropdownItem>
)}
{canRevoke && (
<DropdownItem
onClick={() =>
confirmRevoke({ id: device.id, hostname: device.hostname })}
disabled={isRevoking}
variant="danger"
icon={IconTrashCan}
>
{__("Revoke")}
</DropdownItem>
)}
</ActionDropdown>
)}
</Td>
</Tr>
</>
);
}
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 };
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<span className="inline-flex items-center gap-1">
{__("Posture")}
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex text-txt-tertiary hover:text-txt-secondary"
aria-label={__("How posture summary is calculated")}
>
<IconCircleInfo size={14} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 max-w-xs rounded-md border bg-level-0 p-3 text-xs font-normal text-txt-secondary shadow-md"
sideOffset={4}
align="start"
>
<p>
{__(
"Shown as pass / fail / total.",
)}
</p>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</span>
);
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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";
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<useRevokeDeviceMutation>(
revokeDeviceMutation,
);
const confirmRevoke = useCallback(
(device: RevokeDeviceInput) => {
confirm(
() =>
new Promise<void>((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;
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<CreateDeviceDialogMutation>(
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 (
<Dialog
ref={dialogRef}
trigger={children}
onClose={handleClose}
closable={!isCreating}
title={<Breadcrumb items={[__("Devices"), __("New device")]} />}
>
<form onSubmit={e => void onSubmit(e)} className="space-y-4">
<DialogContent padded className="space-y-4">
{enrollment
? (
<EnrollmentInstructions
enrollmentToken={enrollment.enrollmentToken}
serverUrl={enrollment.serverUrl}
/>
)
: (
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
optional
/>
)}
</DialogContent>
{enrollment
? (
<footer className="flex justify-end items-center p-3 border-t border-t-border-low gap-2">
<Button
type="button"
onClick={closeDialog}
>
{__("Close")}
</Button>
</footer>
)
: (
<DialogFooter>
<Button disabled={formState.isSubmitting || isCreating} type="submit">
{__("Create")}
</Button>
</DialogFooter>
)}
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<typeof useDialogRef>;
}
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<ReassignDeviceDialogMutation>(
reassignDeviceMutation,
);
const onSubmit = (formData: z.infer<typeof schema>) => {
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 (
<Dialog
ref={ref}
onClose={handleClose}
onOpenChange={setOpen}
title={<Breadcrumb items={[__("Devices"), __("Re-assign")]} />}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)} className="space-y-4">
<DialogContent padded className="space-y-4">
{open && (
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
optional
/>
)}
</DialogContent>
<DialogFooter>
<Button disabled={formState.isSubmitting || isInFlight} type="submit">
{__("Re-assign")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<DevicePosturesPageQuery>;
}
export function DevicePosturesPage({ queryRef }: DevicePosturesPageProps) {
const { device } = usePreloadedQuery<DevicePosturesPageQuery>(
devicePosturesPageQuery,
queryRef,
);
if (device.__typename !== "Device") {
throw new Error("invalid type for device node");
}
return <DevicePostureList deviceFragmentRef={device} />;
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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>(
devicePosturesPageQuery,
);
useEffect(() => {
if (!queryRef) {
loadQuery({ deviceId });
}
});
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<DevicePosturesPage queryRef={queryRef} />
</Suspense>
);
}
export default function DevicePosturesPageLoader() {
return (
<CoreRelayProvider>
<DevicePosturesPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Table>
<Thead>
<Tr>
<Th>{__("Check")}</Th>
<Th>{__("Status")}</Th>
<Th className="text-end">{__("Observed at")}</Th>
</Tr>
</Thead>
<Tbody>
{device.latestPostures.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
{__("No posture checks recorded")}
</Td>
</Tr>
)}
{device.latestPostures.map(posture => (
<DevicePostureListItem key={posture.id} postureKey={posture} />
))}
</Tbody>
</Table>
);
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Tr>
<Td>{getPostureCheckLabel(__, posture.checkKey)}</Td>
<Td>
<Badge variant={statusVariant(posture.status)}>
{getPostureStatusLabel(__, posture.status)}
</Badge>
</Td>
<Td className="text-end whitespace-nowrap">
{dateTimeFormat(posture.observedAt, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})}
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<string, string> = {
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;
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<string, string> = {
PASS: "Pass",
FAIL: "Fail",
UNKNOWN: "Unknown",
NOT_APPLICABLE: "Not applicable",
};
export function getPostureStatusLabel(__: Translator, status: string) {
const label = statusLabels[status];
return label ? __(label) : status;
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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[];

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<EmployeeDevicesPageQuery>;
}
export function EmployeeDevicesPage({ queryRef }: EmployeeDevicesPageProps) {
const { __ } = useTranslate();
usePageTitle(__("Devices"));
const { viewer, organization } = usePreloadedQuery<EmployeeDevicesPageQuery>(
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 (
<div className="space-y-6">
<header className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">{__("Your devices")}</h1>
{canEnrollDevice && (
<Button to="/enroll" icon={IconPlusLarge}>
{__("Enroll new device")}
</Button>
)}
</header>
<Card>
{devices.length > 0
? (
<table className="w-full table-fixed">
<Thead>
<Tr>
<Th className="text-left">{__("Hostname")}</Th>
<Th className="w-32 text-left">{__("State")}</Th>
<Th className="w-32 text-left">{__("Platform")}</Th>
<Th className="w-40 text-left">{__("OS version")}</Th>
<Th className="w-40 text-left">{__("Last seen")}</Th>
</Tr>
</Thead>
<Tbody>
{devices.map(device => (
<EmployeeDeviceListItem
key={device.id}
deviceKey={device}
/>
))}
</Tbody>
</table>
)
: (
<div className="px-4 py-12 text-center">
<h3 className="text-lg font-semibold">
{__("No devices enrolled yet")}
</h3>
</div>
)}
</Card>
{canEnrollDevice && (
<CreateDeviceForm onDeviceCreated={handleDeviceCreated} />
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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>(
employeeDevicesPageQuery,
);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PageSkeleton />;
}
return <EmployeeDevicesPage queryRef={queryRef} />;
}
export default function EmployeeDevicesPageLoader() {
return (
<Suspense fallback={<PageSkeleton />}>
<EmployeeDevicesPageQueryLoader />
</Suspense>
);
}

View File

@@ -35,6 +35,9 @@ export default function EmployeeTabsLayout() {
<TabLink to="approvals" end>
{__("Approvals")}
</TabLink>
<TabLink to="devices" end>
{__("Devices")}
</TabLink>
</Tabs>
<Outlet />
</div>

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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<CreateDeviceFormMutation>(
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 (
<>
<p className="text-center text-xs text-txt-secondary">
{__("Can't enroll new device?")}
{" "}
<button
type="button"
onClick={handleManualEnroll}
disabled={isCreating}
className="text-txt-primary underline hover:no-underline disabled:opacity-60"
>
{__("Try creating it manually")}
</button>
</p>
<Dialog
ref={dialogRef}
onClose={handleClose}
closable={!(isCreating && !enrollment)}
title={__("Manual enrollment")}
>
<DialogContent padded className="space-y-4">
{isCreating && !enrollment
? <p>{__("Creating device…")}</p>
: null}
{enrollment
? (
<EnrollmentInstructions
enrollmentToken={enrollment.enrollmentToken}
serverUrl={enrollment.serverUrl}
/>
)
: null}
</DialogContent>
{enrollment
? (
<footer className="flex items-center justify-end gap-2 border-t border-t-border-low p-3">
<Button type="button" onClick={closeDialog}>
{__("Close")}
</Button>
</footer>
)
: null}
</Dialog>
</>
);
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Tr>
<Td>{displayValue(device.hostname, pendingLabel)}</Td>
<Td>
<Badge variant={stateVariant(device.state)}>{device.state}</Badge>
</Td>
<Td>{displayValue(device.platform, pendingLabel)}</Td>
<Td>{displayValue(device.osVersion, pendingLabel)}</Td>
<Td>{device.lastSeenAt ? formatDate(device.lastSeenAt) : __("Never")}</Td>
</Tr>
);
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 (
<div className="space-y-4">
<h2 className="font-medium">{__("Enrollment token generated")}</h2>
<p className="text-sm text-txt-secondary">
{__(
"Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.",
)}
</p>
<CopyableCodeBlock code={enrollmentToken} />
<details>
<summary className="cursor-pointer text-sm font-medium">
{__("Manual install (CLI / MDM)")}
</summary>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">
{__(
"Install on macOS or Linux (run from a shell with sudo access)",
)}
</h3>
<CopyableCodeBlock code={unixCommand} />
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{__(
"Install on Windows (run from an elevated PowerShell session)",
)}
</h3>
<CopyableCodeBlock code={windowsCommand} />
</div>
<p className="text-xs text-txt-secondary">
{__(
"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.",
)}
</p>
</div>
</details>
</div>
);
}
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 (
<Card className="rounded-lg border">
<div className="flex items-center justify-end border-b border-border-low px-1 py-1">
<Button type="button" variant="secondary" onClick={handleCopy}>
{__("Copy")}
</Button>
</div>
<pre className="overflow-x-auto whitespace-pre p-4 text-sm font-mono rounded-b-lg text-invert bg-accent">
<code>{code}</code>
</pre>
</Card>
);
}

View File

@@ -0,0 +1,302 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<EnrollmentSession, "deviceId" | "deepLink"> | 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<string | null>(null);
const [enrollDevice, isCreating]
= useMutation<EnrollDeviceButtonMutation>(enrollDeviceButtonMutation);
useEffect(() => {
if (!isWaitingForActivity || !deviceId) {
return;
}
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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<EnrollDeviceButtonStatusQuery>(
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 (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-txt-success">
<IconCircleCheck size={18} />
{deviceHostname
? sprintf(__("%s is enrolled."), deviceHostname)
: __("This device is enrolled.")}
</div>
<p className="text-sm text-txt-secondary">
{__("You can close this window.")}
</p>
</div>
);
}
if (isWaitingForActivity) {
return (
<div className="flex items-center gap-2 text-sm text-txt-secondary">
<IconArrowsClockwise size={18} className="animate-spin" />
{__("Waiting for the agent's first check-in…")}
</div>
);
}
return (
<div className="space-y-2">
{hasTimedOut && (
<p className="text-sm text-txt-secondary">
{__(
"We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again.",
)}
</p>
)}
<Button
onClick={handleOpenAgent}
disabled={isCreating || organizationId == null}
>
{isCreating
? __("Preparing…")
: hasTimedOut
? __("Try again")
: __("Open Probo agent")}
</Button>
</div>
);
}
export function EnrollDeviceButton(props: EnrollDeviceButtonProps) {
return (
<EnrollDeviceButtonContent
key={props.organizationId ?? "none"}
{...props}
/>
);
}

View File

@@ -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,

View File

@@ -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<T>({
);
}
export function Option({ children, ...props }: ComponentProps<typeof Item>) {
export function Option({
children,
className,
...props
}: ComponentProps<typeof Item>) {
const hasSingleChildren = Children.count(children) <= 1;
return (
<Item
{...props}
className="flex gap-2 items-center min-h-8 py-1 text-sm font-medium text-txt-primary hover:bg-tertiary-hover active:bg-tertiary-pressed cursor-pointer px-[10px] text-start"
className={clsx(
"flex gap-2 items-center min-h-8 py-1 text-sm font-medium text-txt-primary outline-none cursor-pointer px-[10px] text-start",
"data-[highlighted]:bg-tertiary-hover data-[highlighted]:outline-none",
"active:bg-tertiary-pressed",
className,
)}
>
<ItemText asChild>
<span

View File

@@ -72,6 +72,7 @@ type Props = {
className?: string;
ref?: DialogRef;
onClose?: () => 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?.();
}