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,
|
control,
|
||||||
...props
|
...props
|
||||||
}: Props<TFieldValues>) {
|
}: Props<TFieldValues>) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field {...props}>
|
<Field {...props}>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<Select variant="editor" loading placeholder="Loading..." />}
|
fallback={(
|
||||||
|
<Select
|
||||||
|
variant="editor"
|
||||||
|
loading
|
||||||
|
placeholder={__("Select an owner")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<PeopleSelectWithQuery<TFieldValues>
|
<PeopleSelectWithQuery<TFieldValues>
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
@@ -81,7 +90,6 @@ function PeopleSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
|||||||
placeholder={__("Select an owner")}
|
placeholder={__("Select an owner")}
|
||||||
onValueChange={value =>
|
onValueChange={value =>
|
||||||
field.onChange(value === "__NONE__" ? null : value)}
|
field.onChange(value === "__NONE__" ? null : value)}
|
||||||
key={people?.length.toString() ?? "0"}
|
|
||||||
{...field}
|
{...field}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={field.value ?? (props.optional ? "__NONE__" : "")}
|
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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { CookieIcon } from "@phosphor-icons/react";
|
import { CookieIcon, LaptopIcon } from "@phosphor-icons/react";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
IconBank,
|
IconBank,
|
||||||
@@ -60,6 +60,7 @@ const fragment = graphql`
|
|||||||
canListThirdParties: permission(action: "core:thirdParty:list")
|
canListThirdParties: permission(action: "core:thirdParty:list")
|
||||||
canListDocuments: permission(action: "core:document:list")
|
canListDocuments: permission(action: "core:document:list")
|
||||||
canListAssets: permission(action: "core:asset:list")
|
canListAssets: permission(action: "core:asset:list")
|
||||||
|
canListDevices: permission(action: "itam:device:list")
|
||||||
canListData: permission(action: "core:datum:list")
|
canListData: permission(action: "core:datum:list")
|
||||||
canListAudits: permission(action: "core:audit:list")
|
canListAudits: permission(action: "core:audit:list")
|
||||||
canListFindings: permission(action: "core:finding:list")
|
canListFindings: permission(action: "core:finding:list")
|
||||||
@@ -156,6 +157,13 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
|
|||||||
to={`${prefix}/assets`}
|
to={`${prefix}/assets`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{organization.canListDevices && (
|
||||||
|
<SidebarItem
|
||||||
|
label={__("Devices")}
|
||||||
|
icon={LaptopIcon}
|
||||||
|
to={`${prefix}/devices`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{organization.canListData && (
|
{organization.canListData && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
label={__("Data")}
|
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>
|
<TabLink to="approvals" end>
|
||||||
{__("Approvals")}
|
{__("Approvals")}
|
||||||
</TabLink>
|
</TabLink>
|
||||||
|
<TabLink to="devices" end>
|
||||||
|
{__("Devices")}
|
||||||
|
</TabLink>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</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 { peopleRoutes } from "./pages/iam/organizations/people/routes";
|
||||||
import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";
|
import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";
|
||||||
import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
|
import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
|
||||||
|
import { deviceRoutes } from "./pages/organizations/devices/routes";
|
||||||
import { riskRoutes } from "./pages/organizations/risks/routes";
|
import { riskRoutes } from "./pages/organizations/risks/routes";
|
||||||
import { thirdPartyRoutes } from "./pages/organizations/third-parties/routes";
|
import { thirdPartyRoutes } from "./pages/organizations/third-parties/routes";
|
||||||
import { CurrentUser } from "./providers/CurrentUser";
|
import { CurrentUser } from "./providers/CurrentUser";
|
||||||
@@ -160,6 +161,12 @@ const routes = [
|
|||||||
() => import("./pages/iam/oauthTokens/NewOAuthTokenPageLoader"),
|
() => import("./pages/iam/oauthTokens/NewOAuthTokenPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "enroll",
|
||||||
|
Component: lazy(
|
||||||
|
() => import("./pages/iam/enroll/EnrollDevicePageLoader"),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Component: CenteredLayout,
|
Component: CenteredLayout,
|
||||||
children: [
|
children: [
|
||||||
@@ -216,6 +223,13 @@ const routes = [
|
|||||||
import("./pages/organizations/employee/EmployeeApprovalsPageLoader"),
|
import("./pages/organizations/employee/EmployeeApprovalsPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "devices",
|
||||||
|
Component: lazy(
|
||||||
|
() =>
|
||||||
|
import("./pages/organizations/employee/EmployeeDevicesPageLoader"),
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -318,6 +332,7 @@ const routes = [
|
|||||||
...measureRoutes,
|
...measureRoutes,
|
||||||
...documentsRoutes,
|
...documentsRoutes,
|
||||||
...thirdPartyRoutes,
|
...thirdPartyRoutes,
|
||||||
|
...deviceRoutes,
|
||||||
...frameworkRoutes,
|
...frameworkRoutes,
|
||||||
...taskRoutes,
|
...taskRoutes,
|
||||||
...assetRoutes,
|
...assetRoutes,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Value,
|
Value,
|
||||||
Viewport,
|
Viewport,
|
||||||
} from "@radix-ui/react-select";
|
} from "@radix-ui/react-select";
|
||||||
|
import { clsx } from "clsx";
|
||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
type ComponentProps,
|
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;
|
const hasSingleChildren = Children.count(children) <= 1;
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
{...props}
|
{...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>
|
<ItemText asChild>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
ref?: DialogRef;
|
ref?: DialogRef;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export function Dialog({
|
|||||||
ref,
|
ref,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
onOpenChange: onOpenChangeProp,
|
||||||
closable = true,
|
closable = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { overlay, content, header, title: titleClassname } = dialog();
|
const { overlay, content, header, title: titleClassname } = dialog();
|
||||||
@@ -100,9 +102,11 @@ export function Dialog({
|
|||||||
ref.current = {
|
ref.current = {
|
||||||
open() {
|
open() {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
onOpenChangeProp?.(true);
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
onOpenChangeProp?.(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -113,6 +117,7 @@ export function Dialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
|
onOpenChangeProp?.(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user