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:
@@ -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__" : "")}
|
||||
|
||||
319
apps/console/src/pages/iam/enroll/EnrollDevicePage.tsx
Normal file
319
apps/console/src/pages/iam/enroll/EnrollDevicePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/console/src/pages/iam/enroll/EnrollDevicePageLoader.tsx
Normal file
57
apps/console/src/pages/iam/enroll/EnrollDevicePageLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
134
apps/console/src/pages/organizations/devices/DeviceLayout.tsx
Normal file
134
apps/console/src/pages/organizations/devices/DeviceLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
165
apps/console/src/pages/organizations/devices/DevicesPage.tsx
Normal file
165
apps/console/src/pages/organizations/devices/DevicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
68
apps/console/src/pages/organizations/devices/routes.ts
Normal file
68
apps/console/src/pages/organizations/devices/routes.ts
Normal 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[];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export default function EmployeeTabsLayout() {
|
||||
<TabLink to="approvals" end>
|
||||
{__("Approvals")}
|
||||
</TabLink>
|
||||
<TabLink to="devices" end>
|
||||
{__("Devices")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user