diff --git a/apps/employee-portal/package.json b/apps/employee-portal/package.json index 9a4c0cb794..1830ce7554 100644 --- a/apps/employee-portal/package.json +++ b/apps/employee-portal/package.json @@ -13,6 +13,7 @@ "@base-ui/react": "^1.6.0", "@phosphor-icons/react": "^2.1.10", "@probo/helpers": "1.0.0", + "@probo/i18n": "1.0.0", "@probo/react-lazy": "1.0.0", "@probo/relay": "1.0.0", "@probo/routes": "1.0.0", diff --git a/apps/employee-portal/src/_locales/en-US.json b/apps/employee-portal/src/_locales/en-US.json index d8aa411017..1306c38cb6 100644 --- a/apps/employee-portal/src/_locales/en-US.json +++ b/apps/employee-portal/src/_locales/en-US.json @@ -67,6 +67,12 @@ "pendingCount_one": "{{count}} document to approve", "pendingCount_other": "{{count}} documents to approve", "action": "Start approving" + }, + "devices": { + "title": "Devices", + "description": "Manage your work devices.", + "connected": "Device connected", + "action": "Register a device" } } }, diff --git a/apps/employee-portal/src/pages/HomePage.tsx b/apps/employee-portal/src/pages/HomePage.tsx index 0bc7d74c9b..1d4f75bc6a 100644 --- a/apps/employee-portal/src/pages/HomePage.tsx +++ b/apps/employee-portal/src/pages/HomePage.tsx @@ -22,12 +22,13 @@ import { Heading } from "@probo/ui/src/v2/typography/Heading"; import { Text } from "@probo/ui/src/v2/typography/Text"; import { useTranslation } from "react-i18next"; import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; -import { useParams } from "react-router"; import type { HomePageQuery } from "#/__generated__/core/HomePageQuery.graphql"; import { NotFoundError } from "#/lib/relay/errors"; -import { DashboardCard } from "#/pages/_components/DashboardCard"; +import { ApprovalDashboardCard } from "#/pages/_components/ApprovalDashboardCard"; +import { DeviceCard } from "#/pages/_components/DeviceCard"; import { GetStartedCard } from "#/pages/_components/GetStartedCard"; +import { SignatureDashboardCard } from "#/pages/_components/SignatureDashboardCard"; import { useViewerFirstName } from "#/pages/iam/_lib/ViewerIdentityContext"; export const homePageQuery = graphql` @@ -37,38 +38,38 @@ export const homePageQuery = graphql` organizationId: $organizationId first: 1 filter: { signed: false } - ) @required(action: THROW) { + ) { totalCount - edges @required(action: THROW) { - node @required(action: THROW) { - id - } - } } completedSignatures: signableDocuments( organizationId: $organizationId filter: { signed: true } - ) @required(action: THROW) { + ) { totalCount } pendingApprovals: approvableDocuments( organizationId: $organizationId first: 1 filter: { approvalStates: [PENDING] } - ) @required(action: THROW) { + ) { totalCount - edges @required(action: THROW) { - node @required(action: THROW) { - id - } - } } approvedDocuments: approvableDocuments( organizationId: $organizationId filter: { approvalStates: [APPROVED] } - ) @required(action: THROW) { + ) { totalCount } + ...GetStartedCard_viewer @arguments(organizationId: $organizationId) + ...SignatureDashboardCard_viewer @arguments(organizationId: $organizationId) + ...ApprovalDashboardCard_viewer @arguments(organizationId: $organizationId) + ...DeviceCard_viewer @arguments(organizationId: $organizationId) + } + organization: node(id: $organizationId) { + __typename + ... on Organization { + ...DeviceCard_organization + } } } `; @@ -79,26 +80,20 @@ interface HomePageProps { export function HomePage({ queryRef }: HomePageProps) { const { t } = useTranslation(); - const { organizationId } = useParams(); const firstName = useViewerFirstName(); - const { viewer } = usePreloadedQuery(homePageQuery, queryRef); + const { viewer, organization } = usePreloadedQuery( + homePageQuery, + queryRef, + ); - if (organizationId == null) { - throw new NotFoundError("organizationId is required"); + if (organization == null || organization.__typename !== "Organization") { + throw new NotFoundError("invalid type for organization node"); } - const pendingSignatureCount = viewer.pendingSignatures.totalCount; - const signedCount = viewer.completedSignatures.totalCount; - const pendingApprovalCount = viewer.pendingApprovals.totalCount; - const approvedCount = viewer.approvedDocuments.totalCount; - - const firstPendingSignatureId = viewer.pendingSignatures.edges[0]?.node.id ?? null; - const firstPendingApprovalId = viewer.pendingApprovals.edges[0]?.node.id ?? null; - const showGetStarted - = (pendingSignatureCount > 0 || pendingApprovalCount > 0) - && signedCount === 0 - && approvedCount === 0; + = (viewer.pendingSignatures.totalCount > 0 || viewer.pendingApprovals.totalCount > 0) + && viewer.completedSignatures.totalCount === 0 + && viewer.approvedDocuments.totalCount === 0; const welcome = firstName === "" ? t("homePage.welcomeFallback") @@ -117,30 +112,20 @@ export function HomePage({ queryRef }: HomePageProps) {
{showGetStarted && (
- +
)} - 0} + 0} + /> + 0} /> - 0} +
diff --git a/apps/employee-portal/src/pages/HomePageSkeleton.tsx b/apps/employee-portal/src/pages/HomePageSkeleton.tsx index 3fa2b16b9b..175bc7c0ff 100644 --- a/apps/employee-portal/src/pages/HomePageSkeleton.tsx +++ b/apps/employee-portal/src/pages/HomePageSkeleton.tsx @@ -33,6 +33,7 @@ export function HomePageSkeleton() { + ); diff --git a/apps/employee-portal/src/pages/_components/ApprovalDashboardCard.tsx b/apps/employee-portal/src/pages/_components/ApprovalDashboardCard.tsx new file mode 100644 index 0000000000..0ec968f4c9 --- /dev/null +++ b/apps/employee-portal/src/pages/_components/ApprovalDashboardCard.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { graphql, useFragment } from "react-relay"; + +import type { ApprovalDashboardCard_viewer$key } from "#/__generated__/core/ApprovalDashboardCard_viewer.graphql"; + +import { DashboardCard } from "./DashboardCard"; + +const approvalDashboardCardFragment = graphql` + fragment ApprovalDashboardCard_viewer on Viewer + @argumentDefinitions(organizationId: { type: "ID!" }) + @throwOnFieldError { + pendingApprovals: approvableDocuments( + organizationId: $organizationId + first: 1 + filter: { approvalStates: [PENDING] } + ) { + totalCount + edges { + node { + id + } + } + } + approvedDocuments: approvableDocuments( + organizationId: $organizationId + filter: { approvalStates: [APPROVED] } + ) { + totalCount + } + } +`; + +export interface ApprovalDashboardCardProps { + viewerKey: ApprovalDashboardCard_viewer$key; + wash?: boolean; +} + +export function ApprovalDashboardCard({ + viewerKey, + wash = false, +}: ApprovalDashboardCardProps) { + const viewer = useFragment(approvalDashboardCardFragment, viewerKey); + + return ( + + ); +} diff --git a/apps/employee-portal/src/pages/_components/DashboardCard.tsx b/apps/employee-portal/src/pages/_components/DashboardCard.tsx index 993bfa4ba8..f409ab6f3a 100644 --- a/apps/employee-portal/src/pages/_components/DashboardCard.tsx +++ b/apps/employee-portal/src/pages/_components/DashboardCard.tsx @@ -32,12 +32,14 @@ import { Heading } from "@probo/ui/src/v2/typography/Heading"; import { Text } from "@probo/ui/src/v2/typography/Text"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { useParams } from "react-router"; + +import { NotFoundError } from "#/lib/relay/errors"; import { dashboardCard } from "./variants"; export interface DashboardCardProps { kind: "signatures" | "approvals"; - organizationId: string; pendingCount: number; completedCount: number; firstPendingId: string | null; @@ -46,15 +48,19 @@ export interface DashboardCardProps { export function DashboardCard({ kind, - organizationId, pendingCount, completedCount, firstPendingId, wash = false, }: DashboardCardProps) { const { t } = useTranslation(); + const { organizationId } = useParams(); const slots = dashboardCard({ wash }); + if (organizationId == null) { + throw new NotFoundError("organizationId is required"); + } + const listPath = kind === "signatures" ? `/${organizationId}/signatures` : `/${organizationId}/approvals`; diff --git a/apps/employee-portal/src/pages/_components/DeviceCard.tsx b/apps/employee-portal/src/pages/_components/DeviceCard.tsx new file mode 100644 index 0000000000..e2c72e84a2 --- /dev/null +++ b/apps/employee-portal/src/pages/_components/DeviceCard.tsx @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { ArrowRightIcon, DevicesIcon, TrayIcon } from "@phosphor-icons/react"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; +import { Card } from "@probo/ui/src/v2/Card/Card"; +import { Link } from "@probo/ui/src/v2/Link/Link"; +import { Heading } from "@probo/ui/src/v2/typography/Heading"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; +import { graphql, useFragment } from "react-relay"; +import { useParams } from "react-router"; + +import type { DeviceCard_organization$key } from "#/__generated__/core/DeviceCard_organization.graphql"; +import type { DeviceCard_viewer$key } from "#/__generated__/core/DeviceCard_viewer.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; + +import { dashboardCard, deviceCard } from "./variants"; + +const deviceCardViewerFragment = graphql` + fragment DeviceCard_viewer on Viewer + @argumentDefinitions(organizationId: { type: "ID!" }) + @throwOnFieldError { + enrolledDevices( + organizationId: $organizationId + first: 20 + orderBy: { field: LAST_SEEN_AT, direction: DESC } + ) { + edges { + node { + state + } + } + } + } +`; + +const deviceCardOrganizationFragment = graphql` + fragment DeviceCard_organization on Organization @throwOnFieldError { + canEnrollDevice: permission(action: "itam:device:enroll") + } +`; + +export interface DeviceCardProps { + viewerKey: DeviceCard_viewer$key; + organizationKey: DeviceCard_organization$key; +} + +export function DeviceCard({ + viewerKey, + organizationKey, +}: DeviceCardProps) { + const { t } = useTranslation(); + const { organizationId } = useParams(); + const slots = dashboardCard(); + const status = deviceCard(); + const viewer = useFragment(deviceCardViewerFragment, viewerKey); + const organization = useFragment(deviceCardOrganizationFragment, organizationKey); + + if (organizationId == null) { + throw new NotFoundError("organizationId is required"); + } + + const connected = viewer.enrolledDevices.edges.some(({ node }) => { + return node.state === "ACTIVE" || node.state === "PENDING"; + }); + const canEnroll = organization.canEnrollDevice; + + return ( + +
+ +
+ + {t("homePage.dashboard.devices.title")} + + + {t("homePage.dashboard.devices.description")} + +
+ + {t("homePage.dashboard.view")} + +
+
+ {connected + ? ( +
+ + + + + {t("homePage.dashboard.devices.connected")} + +
+ ) + : ( +
+ + {canEnroll + ? ( + } + > + {t("homePage.dashboard.devices.action")} + + ) + : null} +
+ )} +
+
+ ); +} diff --git a/apps/employee-portal/src/pages/_components/GetStartedCard.tsx b/apps/employee-portal/src/pages/_components/GetStartedCard.tsx index b3c28a2fab..97d8555e3b 100644 --- a/apps/employee-portal/src/pages/_components/GetStartedCard.tsx +++ b/apps/employee-portal/src/pages/_components/GetStartedCard.tsx @@ -23,27 +23,64 @@ import { Card } from "@probo/ui/src/v2/Card/Card"; import { Heading } from "@probo/ui/src/v2/typography/Heading"; import { Text } from "@probo/ui/src/v2/typography/Text"; import { useTranslation } from "react-i18next"; +import { graphql, useFragment } from "react-relay"; +import { useParams } from "react-router"; + +import type { GetStartedCard_viewer$key } from "#/__generated__/core/GetStartedCard_viewer.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; import { GetStartedStep } from "./GetStartedStep"; import { getStartedCard } from "./variants"; +const getStartedCardFragment = graphql` + fragment GetStartedCard_viewer on Viewer + @argumentDefinitions(organizationId: { type: "ID!" }) + @throwOnFieldError { + pendingSignatures: signableDocuments( + organizationId: $organizationId + first: 1 + filter: { signed: false } + ) { + totalCount + edges { + node { + id + } + } + } + pendingApprovals: approvableDocuments( + organizationId: $organizationId + first: 1 + filter: { approvalStates: [PENDING] } + ) { + totalCount + edges { + node { + id + } + } + } + } +`; + export interface GetStartedCardProps { - organizationId: string; - pendingSignatureCount: number; - pendingApprovalCount: number; - firstPendingSignatureId: string | null; - firstPendingApprovalId: string | null; + viewerKey: GetStartedCard_viewer$key; } -export function GetStartedCard({ - organizationId, - pendingSignatureCount, - pendingApprovalCount, - firstPendingSignatureId, - firstPendingApprovalId, -}: GetStartedCardProps) { +export function GetStartedCard({ viewerKey }: GetStartedCardProps) { const { t } = useTranslation(); + const { organizationId } = useParams(); const slots = getStartedCard(); + const viewer = useFragment(getStartedCardFragment, viewerKey); + + if (organizationId == null) { + throw new NotFoundError("organizationId is required"); + } + + const pendingSignatureCount = viewer.pendingSignatures.totalCount; + const pendingApprovalCount = viewer.pendingApprovals.totalCount; + const firstPendingSignatureId = viewer.pendingSignatures.edges[0]?.node.id ?? null; + const firstPendingApprovalId = viewer.pendingApprovals.edges[0]?.node.id ?? null; const steps = []; diff --git a/apps/employee-portal/src/pages/_components/PageHeader.tsx b/apps/employee-portal/src/pages/_components/PageHeader.tsx index bda97b37fb..822ac03572 100644 --- a/apps/employee-portal/src/pages/_components/PageHeader.tsx +++ b/apps/employee-portal/src/pages/_components/PageHeader.tsx @@ -22,6 +22,7 @@ import { CaretRightIcon } from "@phosphor-icons/react"; import { Link } from "@probo/ui/src/v2/Link/Link"; import { Heading } from "@probo/ui/src/v2/typography/Heading"; import { Text } from "@probo/ui/src/v2/typography/Text"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; @@ -31,16 +32,27 @@ import { pageHeader } from "./variants"; export interface PageHeaderProps { homeLabel: string; + parent?: { + label: string; + to: string; + }; currentLabel: string; title: string; + actions?: ReactNode; } -export function PageHeader({ homeLabel, currentLabel, title }: PageHeaderProps) { +export function PageHeader({ + homeLabel, + parent, + currentLabel, + title, + actions, +}: PageHeaderProps) { const { t } = useTranslation(); const { organizationId } = useParams(); const slots = pageHeader(); - if (organizationId == null) { + if (organizationId === undefined) { throw new NotFoundError("organizationId is required"); } @@ -52,17 +64,41 @@ export function PageHeader({ homeLabel, currentLabel, title }: PageHeaderProps) size={2} color="neutral" underline={false} + className={slots.crumb()} > {homeLabel} - + {parent === undefined + ? null + : ( + <> + + {parent.label} + + + + )} + {currentLabel} - - {title} - +
+ + {title} + + {actions != null && ( +
+ {actions} +
+ )} +
); } diff --git a/apps/employee-portal/src/pages/_components/SignatureDashboardCard.tsx b/apps/employee-portal/src/pages/_components/SignatureDashboardCard.tsx new file mode 100644 index 0000000000..f6e1901927 --- /dev/null +++ b/apps/employee-portal/src/pages/_components/SignatureDashboardCard.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { graphql, useFragment } from "react-relay"; + +import type { SignatureDashboardCard_viewer$key } from "#/__generated__/core/SignatureDashboardCard_viewer.graphql"; + +import { DashboardCard } from "./DashboardCard"; + +const signatureDashboardCardFragment = graphql` + fragment SignatureDashboardCard_viewer on Viewer + @argumentDefinitions(organizationId: { type: "ID!" }) + @throwOnFieldError { + pendingSignatures: signableDocuments( + organizationId: $organizationId + first: 1 + filter: { signed: false } + ) { + totalCount + edges { + node { + id + } + } + } + completedSignatures: signableDocuments( + organizationId: $organizationId + filter: { signed: true } + ) { + totalCount + } + } +`; + +export interface SignatureDashboardCardProps { + viewerKey: SignatureDashboardCard_viewer$key; + wash?: boolean; +} + +export function SignatureDashboardCard({ + viewerKey, + wash = false, +}: SignatureDashboardCardProps) { + const viewer = useFragment(signatureDashboardCardFragment, viewerKey); + + return ( + + ); +} diff --git a/apps/employee-portal/src/pages/_components/variants.ts b/apps/employee-portal/src/pages/_components/variants.ts index a5d744665b..a120418d4d 100644 --- a/apps/employee-portal/src/pages/_components/variants.ts +++ b/apps/employee-portal/src/pages/_components/variants.ts @@ -93,11 +93,22 @@ export const dashboardCard = tv({ }, }); +export const deviceCard = tv({ + slots: { + status: "flex size-8 items-center justify-center", + pip: "size-3 rounded-full bg-green-8 ring-8 ring-green-3", + }, +}); + export const pageHeader = tv({ slots: { root: "flex flex-col gap-4", crumbs: "flex items-center gap-3", + crumb: "font-medium", + crumbCurrent: "font-medium text-sand-a11", chevron: "size-3 shrink-0 text-sand-11", + titleRow: "flex flex-wrap items-center justify-between gap-3", + actions: "flex shrink-0 items-center gap-3", }, }); diff --git a/apps/employee-portal/src/pages/devices/AddManuallyPage.tsx b/apps/employee-portal/src/pages/devices/AddManuallyPage.tsx new file mode 100644 index 0000000000..0f0542329d --- /dev/null +++ b/apps/employee-portal/src/pages/devices/AddManuallyPage.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; +import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState"; +import { Spinner } from "@probo/ui/src/v2/Spinner/Spinner"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { useParams } from "react-router"; + +import type { AddManuallyPageQuery } from "#/__generated__/core/AddManuallyPageQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; +import { PageHeader } from "#/pages/_components/PageHeader"; +import { EnrollmentInstructions } from "#/pages/devices/_components/EnrollmentInstructions"; +import { addManuallyPage } from "#/pages/devices/_components/variants"; +import { useEnrollDeviceManually } from "#/pages/devices/_lib/useEnrollDeviceManually"; + +export const addManuallyPageQuery = graphql` + query AddManuallyPageQuery($organizationId: ID!) @throwOnFieldError { + organization: node(id: $organizationId) { + __typename + ... on Organization { + canEnrollDevice: permission(action: "itam:device:enroll") + } + } + } +`; + +interface AddManuallyPageProps { + queryRef: PreloadedQuery; +} + +export function AddManuallyPage({ queryRef }: AddManuallyPageProps) { + const { t } = useTranslation("devices"); + const { t: tApp } = useTranslation(); + const { organizationId } = useParams(); + const slots = addManuallyPage(); + const { organization } = usePreloadedQuery( + addManuallyPageQuery, + queryRef, + ); + const { start, retry, isCreating, enrollment, failed } = useEnrollDeviceManually(); + const canEnroll = organization?.__typename === "Organization" + && organization.canEnrollDevice; + + useEffect(() => { + if (!canEnroll) { + return; + } + start(); + }, [canEnroll, start]); + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + if (organization?.__typename !== "Organization") { + throw new NotFoundError("invalid type for organization node"); + } + + const header = ( + + ); + const devicesTo = `/${organizationId}/devices`; + + if (!canEnroll) { + return ( +
+ {header} + + {t("unavailable.home")} + + )} + /> +
+ ); + } + + if (failed && enrollment === null) { + return ( +
+ {header} + + + + {t("addManually.back")} + + + )} + /> +
+ ); + } + + return ( +
+ {header} + {enrollment === null + ? ( +
+ + + {t("addManually.creating")} + +
+ ) + : ( + <> + + + {t("addManually.done")} + + + )} +
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/AddManuallyPageLoader.tsx b/apps/employee-portal/src/pages/devices/AddManuallyPageLoader.tsx new file mode 100644 index 0000000000..b3b2109a9b --- /dev/null +++ b/apps/employee-portal/src/pages/devices/AddManuallyPageLoader.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { AddManuallyPageQuery } from "#/__generated__/core/AddManuallyPageQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; + +import { AddManuallyPage, addManuallyPageQuery } from "./AddManuallyPage"; +import { AddManuallyPageSkeleton } from "./AddManuallyPageSkeleton"; + +export default function AddManuallyPageLoader() { + const { organizationId } = useParams(); + const [queryRef, loadQuery] = useQueryLoader( + addManuallyPageQuery, + ); + + useEffect(() => { + if (organizationId == null) { + return; + } + loadQuery({ organizationId }, { fetchPolicy: "network-only" }); + }, [organizationId, loadQuery]); + + if (organizationId == null) { + throw new NotFoundError("organizationId is required"); + } + + const currentQueryRef = queryRef != null + && queryRef.variables.organizationId === organizationId + ? queryRef + : null; + + if (currentQueryRef == null) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/employee-portal/src/pages/devices/AddManuallyPageSkeleton.tsx b/apps/employee-portal/src/pages/devices/AddManuallyPageSkeleton.tsx new file mode 100644 index 0000000000..1be3fe8791 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/AddManuallyPageSkeleton.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CardSkeleton } from "@probo/ui/src/v2/Card/CardSkeleton"; +import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton"; +import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton"; + +import { addManuallyPage } from "./_components/variants"; + +export function AddManuallyPageSkeleton() { + const slots = addManuallyPage(); + + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/DevicesPage.tsx b/apps/employee-portal/src/pages/devices/DevicesPage.tsx new file mode 100644 index 0000000000..1e1b16c1f5 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/DevicesPage.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; + +import type { DevicesPageQuery } from "#/__generated__/core/DevicesPageQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; + +import { DevicesList } from "./_components/DevicesList"; +import { devicesPage } from "./_components/variants"; + +export const devicesPageQuery = graphql` + query DevicesPageQuery($organizationId: ID!, $first: Int) @throwOnFieldError { + viewer @required(action: THROW) { + ...DevicesList_viewer @arguments(organizationId: $organizationId, first: $first) + } + organization: node(id: $organizationId) { + __typename + ... on Organization { + ...DevicesList_organization + } + } + } +`; + +interface DevicesPageProps { + queryRef: PreloadedQuery; +} + +export function DevicesPage({ queryRef }: DevicesPageProps) { + const slots = devicesPage(); + const { viewer, organization } = usePreloadedQuery( + devicesPageQuery, + queryRef, + ); + + if (organization?.__typename !== "Organization") { + throw new NotFoundError("invalid type for organization node"); + } + + return ( +
+ +
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/DevicesPageLoader.tsx b/apps/employee-portal/src/pages/devices/DevicesPageLoader.tsx new file mode 100644 index 0000000000..c3f8103936 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/DevicesPageLoader.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { DevicesPageQuery } from "#/__generated__/core/DevicesPageQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; +import { DOCUMENT_LIST_PAGE_SIZE } from "#/pages/_lib/documentList"; + +import { DevicesPage, devicesPageQuery } from "./DevicesPage"; +import { DevicesPageSkeleton } from "./DevicesPageSkeleton"; + +export default function DevicesPageLoader() { + const { organizationId } = useParams(); + const [queryRef, loadQuery] = useQueryLoader( + devicesPageQuery, + ); + + useEffect(() => { + if (organizationId === undefined) { + return; + } + loadQuery( + { organizationId, first: DOCUMENT_LIST_PAGE_SIZE }, + { fetchPolicy: "network-only" }, + ); + }, [organizationId, loadQuery]); + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + if (queryRef === undefined || queryRef === null + || queryRef.variables.organizationId !== organizationId) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/employee-portal/src/pages/devices/DevicesPageSkeleton.tsx b/apps/employee-portal/src/pages/devices/DevicesPageSkeleton.tsx new file mode 100644 index 0000000000..04f6a29ebd --- /dev/null +++ b/apps/employee-portal/src/pages/devices/DevicesPageSkeleton.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { DocumentListPageSkeleton } from "#/pages/_components/DocumentListPageSkeleton"; + +export function DevicesPageSkeleton() { + return ; +} diff --git a/apps/employee-portal/src/pages/devices/RegisterDevicePage.tsx b/apps/employee-portal/src/pages/devices/RegisterDevicePage.tsx new file mode 100644 index 0000000000..e9c70ff38e --- /dev/null +++ b/apps/employee-portal/src/pages/devices/RegisterDevicePage.tsx @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; +import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { useParams, useSearchParams } from "react-router"; + +import type { RegisterDevicePageQuery } from "#/__generated__/core/RegisterDevicePageQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; +import { PageHeader } from "#/pages/_components/PageHeader"; +import { DownloadStep } from "#/pages/devices/_components/DownloadStep"; +import { OpenAgentStep } from "#/pages/devices/_components/OpenAgentStep"; +import { ProgressStep } from "#/pages/devices/_components/ProgressStep"; +import { ReviewStep } from "#/pages/devices/_components/ReviewStep"; +import { registerDevicePage } from "#/pages/devices/_components/variants"; +import { + maxRegisterDeviceStep, + parseRegisterDeviceStep, + REGISTER_DEVICE_STEPS, + type RegisterDeviceStep, + registerDeviceStepIndex, +} from "#/pages/devices/_lib/registerDeviceSteps"; +import { useEnrollDevice } from "#/pages/devices/_lib/useEnrollDevice"; + +export const registerDevicePageQuery = graphql` + query RegisterDevicePageQuery($organizationId: ID!) @throwOnFieldError { + organization: node(id: $organizationId) { + __typename + ... on Organization { + canEnrollDevice: permission(action: "itam:device:enroll") + } + } + } +`; + +interface RegisterDevicePageProps { + queryRef: PreloadedQuery; +} + +export function RegisterDevicePage({ queryRef }: RegisterDevicePageProps) { + const { t } = useTranslation("devices"); + const { t: tApp } = useTranslation(); + const { organizationId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const slots = registerDevicePage(); + const { organization } = usePreloadedQuery( + registerDevicePageQuery, + queryRef, + ); + const requested = parseRegisterDeviceStep(searchParams.get("step")); + const [reached, setReached] = useState(requested); + const enrollment = useEnrollDevice(); + const step = registerDeviceStepIndex(requested) <= registerDeviceStepIndex(reached) + ? requested + : reached; + + useEffect(() => { + if (requested === step) { + return; + } + + setSearchParams((current) => { + const params = new URLSearchParams(current); + if (step === "review") { + params.delete("step"); + } else { + params.set("step", step); + } + return params; + }, { replace: true }); + }, [requested, setSearchParams, step]); + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + if (organization?.__typename !== "Organization") { + throw new NotFoundError("invalid type for organization node"); + } + + function goToStep(next: RegisterDeviceStep) { + if (registerDeviceStepIndex(next) > registerDeviceStepIndex(reached)) { + return; + } + + setSearchParams((current) => { + const params = new URLSearchParams(current); + if (next === "review") { + params.delete("step"); + } else { + params.set("step", next); + } + return params; + }, { replace: true }); + } + + function advanceTo(next: RegisterDeviceStep) { + setReached(current => maxRegisterDeviceStep(current, next)); + setSearchParams((current) => { + const params = new URLSearchParams(current); + params.set("step", next); + return params; + }, { replace: true }); + } + + const header = ( + + ); + + if (!organization.canEnrollDevice) { + return ( +
+ {header} + + {t("unavailable.home")} + + )} + /> +
+ ); + } + + return ( +
+ {header} +
+
    + {REGISTER_DEVICE_STEPS.map((key, index) => { + const state = registerDeviceStepIndex(key) < registerDeviceStepIndex(step) + ? "complete" + : key === step + ? "current" + : "upcoming"; + + return ( +
  1. + goToStep(key) : undefined} + /> +
  2. + ); + })} +
+
+ {step === "review" && ( + advanceTo("download")} /> + )} + {step === "download" && ( + advanceTo("enroll")} /> + )} + {step === "enroll" && } +
+
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/RegisterDevicePageLoader.tsx b/apps/employee-portal/src/pages/devices/RegisterDevicePageLoader.tsx new file mode 100644 index 0000000000..8c3d897aa2 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/RegisterDevicePageLoader.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { RegisterDevicePageQuery } from "#/__generated__/core/RegisterDevicePageQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; + +import { RegisterDevicePage, registerDevicePageQuery } from "./RegisterDevicePage"; +import { RegisterDevicePageSkeleton } from "./RegisterDevicePageSkeleton"; + +export default function RegisterDevicePageLoader() { + const { organizationId } = useParams(); + const [queryRef, loadQuery] = useQueryLoader( + registerDevicePageQuery, + ); + + useEffect(() => { + if (organizationId == null) { + return; + } + loadQuery({ organizationId }, { fetchPolicy: "network-only" }); + }, [organizationId, loadQuery]); + + if (organizationId == null) { + throw new NotFoundError("organizationId is required"); + } + + const currentQueryRef = queryRef != null + && queryRef.variables.organizationId === organizationId + ? queryRef + : null; + + if (currentQueryRef == null) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/employee-portal/src/pages/devices/RegisterDevicePageSkeleton.tsx b/apps/employee-portal/src/pages/devices/RegisterDevicePageSkeleton.tsx new file mode 100644 index 0000000000..9967de20a4 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/RegisterDevicePageSkeleton.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CardSkeleton } from "@probo/ui/src/v2/Card/CardSkeleton"; +import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton"; +import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton"; + +import { registerDevicePage } from "./_components/variants"; + +export function RegisterDevicePageSkeleton() { + const slots = registerDevicePage(); + + return ( +
+
+ + +
+
+
+ + + +
+
+ +
+
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/CopyableCodeBlock.tsx b/apps/employee-portal/src/pages/devices/_components/CopyableCodeBlock.tsx new file mode 100644 index 0000000000..3e75af7d6f --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/CopyableCodeBlock.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Toast } from "@base-ui/react/toast"; +import { CopySimpleIcon } from "@phosphor-icons/react"; +import { Card } from "@probo/ui/src/v2/Card/Card"; +import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton"; +import { useTranslation } from "react-i18next"; + +import { copyableCodeBlock } from "./variants"; + +export interface CopyableCodeBlockProps { + code: string; +} + +export function CopyableCodeBlock({ code }: CopyableCodeBlockProps) { + const { t } = useTranslation("devices"); + const { t: tApp } = useTranslation(); + const toast = Toast.useToastManager(); + const slots = copyableCodeBlock(); + + function handleCopy() { + const onFailure = () => { + toast.add({ + title: tApp("common.error"), + description: t("addManually.copyFailed"), + type: "error", + }); + }; + + if (!navigator.clipboard?.writeText) { + onFailure(); + return; + } + + try { + navigator.clipboard.writeText(code).then( + () => { + toast.add({ title: t("addManually.copied"), type: "success" }); + }, + onFailure, + ); + } catch { + onFailure(); + } + } + + return ( + +
+ + + +
+
+        {code}
+      
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/DeviceListItem.tsx b/apps/employee-portal/src/pages/devices/_components/DeviceListItem.tsx new file mode 100644 index 0000000000..790d251c77 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/DeviceListItem.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { relativeDateFormat } from "@probo/i18n"; +import { TableRow } from "@probo/ui/src/v2/Table/TableRow"; +import { TableRowHeaderCell } from "@probo/ui/src/v2/Table/TableRowHeaderCell"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; +import { graphql, useFragment } from "react-relay"; + +import type { DeviceListItem_device$key } from "#/__generated__/core/DeviceListItem_device.graphql"; +import { + formatDeviceOs, + isDeviceConnected, +} from "#/pages/devices/_lib/deviceDisplay"; + +import { deviceListItem } from "./variants"; + +const deviceListItemFragment = graphql` + fragment DeviceListItem_device on Device @throwOnFieldError { + hostname + platform + osVersion + lastSeenAt + state + } +`; + +export interface DeviceListItemProps { + deviceKey: DeviceListItem_device$key; +} + +export function DeviceListItem({ deviceKey }: DeviceListItemProps) { + const { t, i18n } = useTranslation("devices"); + const device = useFragment(deviceListItemFragment, deviceKey); + const connected = isDeviceConnected(device.state); + const slots = deviceListItem({ connected }); + const platformLabel = device.platform === undefined || device.platform === null + ? null + : t(`list.platforms.${device.platform}`); + const os = formatDeviceOs(platformLabel, device.osVersion); + const lastActive = device.lastSeenAt === undefined || device.lastSeenAt === null + ? t("list.never") + : relativeDateFormat(i18n.language, device.lastSeenAt) || t("list.justNow"); + const hostname = device.hostname === undefined + || device.hostname === null + || device.hostname === "" + ? t("list.pendingHostname") + : device.hostname; + + return ( + + +
+ + {hostname} + +
+ + + {t("list.lastActive")} + + + {lastActive} + + + {os === null + ? null + : ( + + {os} + + )} + + + + + + {connected ? t("list.connected") : t("list.disconnected")} + + +
+
+
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/DevicesEmpty.tsx b/apps/employee-portal/src/pages/devices/_components/DevicesEmpty.tsx new file mode 100644 index 0000000000..b6ff4dbffc --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/DevicesEmpty.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { LaptopIcon } from "@phosphor-icons/react"; +import { Heading } from "@probo/ui/src/v2/typography/Heading"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { devicesEmpty } from "./variants"; + +export interface DevicesEmptyProps { + action?: ReactNode; +} + +export function DevicesEmpty({ action }: DevicesEmptyProps) { + const { t } = useTranslation("devices"); + const slots = devicesEmpty(); + + return ( +
+
+
+
+ + + + + {t("empty.title")} + + + {t("empty.description")} + +
+ {action !== undefined && ( +
+ {action} +
+ )} +
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/DevicesList.tsx b/apps/employee-portal/src/pages/devices/_components/DevicesList.tsx new file mode 100644 index 0000000000..11d41341ba --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/DevicesList.tsx @@ -0,0 +1,221 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { PlusIcon } from "@phosphor-icons/react"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; +import { Pagination } from "@probo/ui/src/v2/Pagination/Pagination"; +import { Table } from "@probo/ui/src/v2/Table/Table"; +import { TableBody } from "@probo/ui/src/v2/Table/TableBody"; +import { TableColumnHeaderCell } from "@probo/ui/src/v2/Table/TableColumnHeaderCell"; +import { TableHeader } from "@probo/ui/src/v2/Table/TableHeader"; +import { TableRow } from "@probo/ui/src/v2/Table/TableRow"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql, useFragment, useRefetchableFragment } from "react-relay"; +import { useParams } from "react-router"; + +import type { DevicesList_organization$key } from "#/__generated__/core/DevicesList_organization.graphql"; +import type { DevicesList_viewer$key } from "#/__generated__/core/DevicesList_viewer.graphql"; +import type { DevicesListRefetchQuery } from "#/__generated__/core/DevicesListRefetchQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; +import type { CursorPaginationVariables } from "#/lib/relay/useCursorPagination"; +import { useCursorPagination } from "#/lib/relay/useCursorPagination"; +import { PageHeader } from "#/pages/_components/PageHeader"; +import { DOCUMENT_LIST_PAGE_SIZE } from "#/pages/_lib/documentList"; + +import { DeviceListItem } from "./DeviceListItem"; +import { DevicesEmpty } from "./DevicesEmpty"; +import { devicesList } from "./variants"; + +const devicesListViewerFragment = graphql` + fragment DevicesList_viewer on Viewer + @argumentDefinitions( + organizationId: { type: "ID!" } + first: { type: "Int" } + after: { type: "CursorKey" } + last: { type: "Int" } + before: { type: "CursorKey" } + ) + @refetchable(queryName: "DevicesListRefetchQuery") + @throwOnFieldError { + enrolledDevices( + organizationId: $organizationId + first: $first + after: $after + last: $last + before: $before + orderBy: { field: LAST_SEEN_AT, direction: DESC } + ) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + node { + id + ...DeviceListItem_device + } + } + } + } +`; + +const devicesListOrganizationFragment = graphql` + fragment DevicesList_organization on Organization @throwOnFieldError { + canEnrollDevice: permission(action: "itam:device:enroll") + } +`; + +export interface DevicesListProps { + viewerKey: DevicesList_viewer$key; + organizationKey: DevicesList_organization$key; +} + +export function DevicesList({ + viewerKey, + organizationKey, +}: DevicesListProps) { + const { t } = useTranslation("devices"); + const { t: tApp } = useTranslation(); + const { organizationId } = useParams(); + const organization = useFragment(devicesListOrganizationFragment, organizationKey); + const [data, refetch] = useRefetchableFragment< + DevicesListRefetchQuery, + DevicesList_viewer$key + >(devicesListViewerFragment, viewerKey); + + const refetchPage = useCallback((variables: CursorPaginationVariables) => { + refetch(variables, { fetchPolicy: "store-or-network" }); + }, [refetch]); + + const { enrolledDevices } = data; + const { isPending, goPrevious, goNext } = useCursorPagination( + refetchPage, + enrolledDevices.pageInfo, + DOCUMENT_LIST_PAGE_SIZE, + ); + const slots = devicesList({ busy: isPending }); + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + const empty = enrolledDevices.edges.length === 0 + && !enrolledDevices.pageInfo.hasPreviousPage; + const canEnroll = organization.canEnrollDevice; + const registerTo = `/${organizationId}/devices/register`; + const addManuallyTo = `/${organizationId}/devices/add-manually`; + const addManuallyLink = ( + + {empty ? t("empty.addManually") : t("list.addManually")} + + ); + + return ( + <> + + } + > + {t("list.register")} + + {addManuallyLink} + + ) + : undefined} + /> + {empty + ? ( + + } + > + {t("empty.register")} + + {addManuallyLink} + + ) + : undefined} + /> + ) + : ( +
+
+ + + + + {t("list.columns.hostname")} + + + {t("list.columns.details")} + + + + + {enrolledDevices.edges.map(({ node }) => ( + + ))} + +
+
+ +
+ )} + + ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/DownloadStep.tsx b/apps/employee-portal/src/pages/devices/_components/DownloadStep.tsx new file mode 100644 index 0000000000..8f469e3dca --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/DownloadStep.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { AppleLogoIcon, DownloadSimpleIcon, WindowsLogoIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { ButtonAnchor } from "@probo/ui/src/v2/Button/ButtonAnchor"; +import { List } from "@probo/ui/src/v2/List/List"; +import { ListItem } from "@probo/ui/src/v2/List/ListItem"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { AGENT_INSTALL_URL } from "#/pages/devices/_lib/registerDeviceSteps"; + +import { RegisterDeviceCard } from "./RegisterDeviceCard"; +import { downloadList } from "./variants"; + +export interface DownloadStepProps { + onContinue: () => void; +} + +export function DownloadStep({ onContinue }: DownloadStepProps) { + const { t } = useTranslation("devices"); + const slots = downloadList(); + + return ( + } + title={t("download.title")} + description={t("download.description")} + action={( + + )} + > + } + > + {t("download.featured")} + + + } + className={slots.item()} + metaClassName={slots.meta()} + /> + } + className={slots.item()} + metaClassName={slots.meta()} + /> + + + ); +} + +function DownloadRow({ + title, + meta, + icon, + className, + metaClassName, +}: { + title: string; + meta: string; + icon: ReactNode; + className: string; + metaClassName: string; +}) { + const { t } = useTranslation("devices"); + + return ( + + + {title} + + + {meta} + + + {t("download.download")} + + + ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/EnrollmentInstructions.tsx b/apps/employee-portal/src/pages/devices/_components/EnrollmentInstructions.tsx new file mode 100644 index 0000000000..2145d8ca53 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/EnrollmentInstructions.tsx @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Callout } from "@probo/ui/src/v2/Callout/Callout"; +import { Anchor } from "@probo/ui/src/v2/Link/Anchor"; +import { Tabs } from "@probo/ui/src/v2/Tabs/Tabs"; +import { TabsIndicator } from "@probo/ui/src/v2/Tabs/TabsIndicator"; +import { TabsList } from "@probo/ui/src/v2/Tabs/TabsList"; +import { TabsTab } from "@probo/ui/src/v2/Tabs/TabsTab"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; + +import { CopyableCodeBlock } from "./CopyableCodeBlock"; +import { enrollmentInstructions } from "./variants"; + +const AGENT_RELEASES_URL + = "https://github.com/getprobo/probo/releases?q=probo-agent"; + +const UNIX_DOWNLOAD_COMMAND = `curl -fsSL "https://github.com/getprobo/probo/releases/download/probo-agent/vX.Y.Z/probo-agent_OS_ARCH.tar.gz" -o /tmp/probo-agent.tar.gz +tar -xzf /tmp/probo-agent.tar.gz -C /tmp +sudo install -m 0755 /tmp/probo-agent_OS_ARCH/probo-agent /usr/local/bin/probo-agent +rm -rf /tmp/probo-agent.tar.gz /tmp/probo-agent_OS_ARCH`; + +const WINDOWS_DOWNLOAD_COMMAND = `$zip = "$env:TEMP\\probo-agent.zip" +$dst = "$env:ProgramFiles\\Probo" +Invoke-WebRequest -Uri "https://github.com/getprobo/probo/releases/download/probo-agent/vX.Y.Z/probo-agent_Windows_ARCH.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\\probo-agent_Windows_ARCH\\probo-agent.exe" "$dst\\probo-agent.exe" +Remove-Item -Recurse -Force $zip, "$env:TEMP\\probo-agent_Windows_ARCH"`; + +type InstallOs = "unix" | "windows"; + +export interface EnrollmentInstructionsProps { + enrollmentToken: string; + serverUrl: string; +} + +export function EnrollmentInstructions({ + enrollmentToken, + serverUrl, +}: EnrollmentInstructionsProps) { + const { t } = useTranslation("devices"); + const slots = enrollmentInstructions(); + const [os, setOs] = useState("unix"); + const downloadComment = `# ${t("addManually.token.downloadStep")}`; + const enrollComment = `# ${t("addManually.token.enrollStep")}`; + const unixDownloadCommand = `${downloadComment} +${UNIX_DOWNLOAD_COMMAND}`; + const unixInstallCommand = `${enrollComment} +sudo /usr/local/bin/probo-agent install \\ + --server ${serverUrl} \\ + --enrollment-token '${enrollmentToken}'`; + const windowsDownloadCommand = `${downloadComment} +${WINDOWS_DOWNLOAD_COMMAND}`; + const windowsInstallCommand = `${enrollComment} +& "$env:ProgramFiles\\Probo\\probo-agent.exe" install \` + --server ${serverUrl} \` + --enrollment-token '${enrollmentToken}'`; + + function handleOsChange(value: string | number | null) { + if (value === "unix" || value === "windows") { + setOs(value); + } + } + + return ( +
+ +
+ + {t("addManually.token.title")} + + + {t("addManually.token.description")} + + +
+
+
+ + + ), + }} + /> + + + + {t("addManually.token.tabUnix")} + {t("addManually.token.tabWindows")} + + + + {os === "unix" + ? ( +
+ + {t("addManually.token.installUnix")} + + + +
+ ) + : ( +
+ + {t("addManually.token.installWindows")} + + + +
+ )} + + {t("addManually.token.securityNotice")} + +
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/OpenAgentStep.tsx b/apps/employee-portal/src/pages/devices/_components/OpenAgentStep.tsx new file mode 100644 index 0000000000..988267e771 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/OpenAgentStep.tsx @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { AppWindowIcon, CheckCircleIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { ButtonLink } from "@probo/ui/src/v2/Button/ButtonLink"; +import { Spinner } from "@probo/ui/src/v2/Spinner/Spinner"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router"; + +import { NotFoundError } from "#/lib/relay/errors"; +import { useEnrollDevice } from "#/pages/devices/_lib/useEnrollDevice"; + +import { RegisterDeviceCard } from "./RegisterDeviceCard"; + +export interface OpenAgentStepProps { + enrollment: ReturnType; +} + +export function OpenAgentStep({ enrollment }: OpenAgentStepProps) { + const { t } = useTranslation("devices"); + const { organizationId } = useParams(); + const { + openAgent, + isCreating, + isWaiting, + isComplete, + hasTimedOut, + failed, + hostname, + } = enrollment; + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + if (isComplete) { + return ( + } + title={hostname === null + ? t("enroll.enrolled") + : t("enroll.enrolledWithHostname", { hostname })} + description={t("enroll.description")} + action={( + + {t("enroll.home")} + + )} + /> + ); + } + + if (isWaiting) { + return ( + } + title={t("enroll.title")} + description={t("enroll.description")} + > +
+ + + {t("enroll.waiting")} + +
+
+ ); + } + + return ( + } + title={t("enroll.title")} + description={t("enroll.description")} + action={( + + )} + > + {hasTimedOut + ? ( + + {t("enroll.timedOut")} + + ) + : failed + ? ( + + {t("enroll.failed")} + + ) + : null} + + ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/ProgressStep.tsx b/apps/employee-portal/src/pages/devices/_components/ProgressStep.tsx new file mode 100644 index 0000000000..478218a1c5 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/ProgressStep.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CheckIcon } from "@phosphor-icons/react"; +import { Text } from "@probo/ui/src/v2/typography/Text"; + +import { progressStep } from "./variants"; + +export type ProgressStepState = "complete" | "current" | "upcoming"; + +export interface ProgressStepProps { + number: string; + title: string; + description: string; + state: ProgressStepState; + onSelect?: () => void; +} + +export function ProgressStep({ + number, + title, + description, + state, + onSelect, +}: ProgressStepProps) { + const slots = progressStep({ state }); + const selectable = state === "complete" && onSelect != null; + + const body = ( + <> + + {state === "complete" + ? + : number} + + + + {title} + + + {description} + + + + ); + + const current = state === "current" ? "step" as const : undefined; + + if (selectable) { + return ( + + ); + } + + return ( +
+ {body} +
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/RegisterDeviceCard.tsx b/apps/employee-portal/src/pages/devices/_components/RegisterDeviceCard.tsx new file mode 100644 index 0000000000..1e4ce3efb5 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/RegisterDeviceCard.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Card } from "@probo/ui/src/v2/Card/Card"; +import { Heading } from "@probo/ui/src/v2/typography/Heading"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import type { ReactNode } from "react"; + +import { registerDeviceCard } from "./variants"; + +export interface RegisterDeviceCardProps { + icon: ReactNode; + title: string; + description: string; + children?: ReactNode; + action?: ReactNode; +} + +export function RegisterDeviceCard({ + icon, + title, + description, + children, + action, +}: RegisterDeviceCardProps) { + const slots = registerDeviceCard(); + + return ( + +
+ {icon} + + {title} + + + {description} + +
+ {children != null && ( +
+ {children} +
+ )} + {action} +
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/ReviewStep.tsx b/apps/employee-portal/src/pages/devices/_components/ReviewStep.tsx new file mode 100644 index 0000000000..5b389e3e97 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/ReviewStep.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { + CheckIcon, + DesktopIcon, + IdentificationCardIcon, + LaptopIcon, + PulseIcon, + ShieldCheckIcon, +} from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { RegisterDeviceCard } from "./RegisterDeviceCard"; +import { reviewGrid } from "./variants"; + +export interface ReviewStepProps { + onContinue: () => void; +} + +export function ReviewStep({ onContinue }: ReviewStepProps) { + const { t } = useTranslation("devices"); + const slots = reviewGrid(); + + return ( + } + title={t("review.title")} + description={t("review.description")} + action={( + + )} + > +
+ } + title={t("review.thisDevice.title")} + items={[ + t("review.thisDevice.hostname"), + t("review.thisDevice.platform"), + t("review.thisDevice.osVersion"), + ]} + /> + } + title={t("review.identity.title")} + items={[ + t("review.identity.hardwareUuid"), + t("review.identity.hostname"), + t("review.identity.serialNumber"), + ]} + /> + } + title={t("review.system.title")} + items={[ + t("review.system.platform"), + t("review.system.osVersion"), + t("review.system.agentVersion"), + ]} + /> + } + title={t("review.activity.title")} + items={[ + t("review.activity.enrollmentTime"), + t("review.activity.heartbeats"), + t("review.activity.posture"), + ]} + /> +
+
+ ); +} + +function ReviewCell({ + icon, + title, + items, +}: { + icon: ReactNode; + title: string; + items: string[]; +}) { + const slots = reviewGrid(); + + return ( +
+
+ {icon} + + {title} + +
+
    + {items.map(item => ( +
  • + + + {item} + +
  • + ))} +
+
+ ); +} diff --git a/apps/employee-portal/src/pages/devices/_components/variants.ts b/apps/employee-portal/src/pages/devices/_components/variants.ts new file mode 100644 index 0000000000..8879db8f55 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_components/variants.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { tv } from "tailwind-variants/lite"; + +export const registerDevicePage = tv({ + slots: { + main: "mx-auto flex w-full max-w-5xl flex-col gap-10 px-8 pt-8 pb-32", + body: "grid grid-cols-1 gap-4 md:grid-cols-[15.25rem_minmax(0,1fr)]", + stepper: "flex w-full list-none flex-col gap-2", + stage: "min-w-0", + }, +}); + +export const progressStep = tv({ + slots: { + root: [ + "flex w-full items-start gap-3 rounded-5 p-3 text-left", + "outline-none focus-visible:ring-2 focus-visible:ring-sand-8", + ], + badge: "flex size-5 shrink-0 items-center justify-center rounded-full text-1 font-bold", + icon: "size-3.5", + copy: "flex min-w-0 flex-1 flex-col justify-center gap-1", + description: "text-sand-a9", + }, + variants: { + state: { + complete: { + root: "cursor-pointer border-0 bg-transparent", + badge: "bg-sand-12 text-sand-2", + }, + current: { + root: "bg-sand-a3", + badge: "bg-sand-12 text-sand-2", + }, + upcoming: { + badge: "bg-sand-a3 text-sand-12", + }, + }, + }, +}); + +export const registerDeviceCard = tv({ + slots: { + frame: "flex flex-col items-center gap-8 p-16", + header: "flex w-full flex-col items-center gap-4 text-center", + icon: "size-8 shrink-0 text-sand-12 [&_svg]:size-8", + body: "flex w-full flex-col items-center gap-8", + }, +}); + +export const reviewGrid = tv({ + slots: { + root: "grid w-full grid-cols-1 overflow-hidden rounded-5 border border-sand-4 sm:grid-cols-2", + cell: [ + "flex flex-col gap-8 border-sand-4 p-8", + "max-sm:not-last:border-b", + "sm:odd:border-r sm:[&:nth-child(-n+2)]:border-b", + ], + heading: "flex items-center gap-3", + headingIcon: "size-4 shrink-0 text-sand-12 [&_svg]:size-4", + items: "flex flex-col gap-2", + item: "flex items-center gap-3", + itemIcon: "size-4 shrink-0 text-sand-11", + }, +}); + +export const downloadList = tv({ + slots: { + item: [ + "h-auto min-h-12 flex-wrap gap-3 px-4", + "sm:h-12 sm:flex-nowrap sm:gap-8 sm:px-8", + ], + meta: "w-full sm:w-24 sm:shrink-0", + }, +}); + +export const devicesPage = tv({ + slots: { + main: "mx-auto flex w-full max-w-5xl flex-col gap-10 px-8 pt-8 pb-32", + }, +}); + +export const devicesEmpty = tv({ + slots: { + frame: [ + "relative flex min-h-128 flex-col items-center justify-center gap-6", + "overflow-hidden rounded-5 border border-sand-3 bg-sand-1 px-8 py-12", + ], + wash: [ + "pointer-events-none absolute inset-x-0 top-0 z-0 h-full", + "bg-[radial-gradient(ellipse_70%_52%_at_50%_-8%,rgb(230_255_3_/_0.72)_0%,rgb(230_255_3_/_0.28)_35%,transparent_62%)]", + "mask-[linear-gradient(to_bottom,black_0%,black_40%,transparent_100%)]", + ], + content: "relative z-1 flex w-full flex-col items-center gap-6", + copy: "flex w-full flex-col items-center gap-4", + icon: "size-8 shrink-0 text-sand-a9 [&_svg]:size-8", + description: "text-sand-a11", + actions: "flex items-center gap-3", + }, +}); + +export const devicesList = tv({ + slots: { + body: "flex flex-col gap-3", + frame: "overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150", + table: "rounded-none", + header: "sr-only", + pager: "flex justify-center", + }, + variants: { + busy: { + true: { + frame: "opacity-60", + }, + false: {}, + }, + }, + defaultVariants: { + busy: false, + }, +}); + +export const deviceListItem = tv({ + slots: { + // Size 2 cells are h-11 px-3 py-3; Figma is a 48px row with px-8, a 32px + // gap between the title and the metadata cluster, and gap-4 inside it. + cell: "h-12 p-0", + row: "flex h-full items-center gap-8 px-8", + title: "min-w-0 flex-1 truncate", + meta: "flex shrink-0 items-center gap-4", + timestamp: "flex items-center gap-[3px]", + timestampLabel: "text-sand-a8", + timestampValue: "w-32 text-sand-a11", + os: "w-24 text-sand-a11", + status: "flex w-32 items-center gap-1", + pipWrap: "flex size-4 shrink-0 items-center justify-center", + pip: "size-1.5 rounded-full", + statusLabel: "text-sand-a11", + }, + variants: { + connected: { + true: { + pip: "bg-green-8 ring-4 ring-green-3", + }, + false: { + pip: "bg-sand-8 ring-4 ring-sand-3", + }, + }, + }, +}); + +export const addManuallyPage = tv({ + slots: { + main: "mx-auto flex w-full max-w-5xl flex-col gap-10 px-8 pt-8 pb-32", + creating: "flex items-center gap-2", + errorActions: "flex items-center gap-3", + }, +}); + +export const enrollmentInstructions = tv({ + slots: { + root: "flex flex-col gap-10", + token: "flex flex-col gap-3", + install: "flex flex-col gap-4", + group: "flex flex-col gap-3", + }, +}); + +export const copyableCodeBlock = tv({ + slots: { + root: "overflow-hidden", + toolbar: "flex items-center justify-end border-b border-sand-a3 px-2 py-1", + pre: "overflow-x-auto whitespace-pre bg-sand-3 p-4 font-mono text-2 text-sand-12", + }, +}); diff --git a/apps/employee-portal/src/pages/devices/_lib/deviceDisplay.ts b/apps/employee-portal/src/pages/devices/_lib/deviceDisplay.ts new file mode 100644 index 0000000000..029bd331ba --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_lib/deviceDisplay.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export function isDeviceConnected(state: string): boolean { + return state === "ACTIVE"; +} + +export function formatDeviceOs( + platformLabel: string | null | undefined, + osVersion: string | null | undefined, +): string | null { + const hasPlatform = platformLabel !== undefined + && platformLabel !== null + && platformLabel !== ""; + const hasVersion = osVersion !== undefined + && osVersion !== null + && osVersion !== ""; + + if (hasPlatform && hasVersion) { + return `${platformLabel} ${osVersion}`; + } + if (hasPlatform) { + return platformLabel; + } + if (hasVersion) { + return osVersion; + } + return null; +} diff --git a/apps/employee-portal/src/pages/devices/_lib/registerDeviceSteps.ts b/apps/employee-portal/src/pages/devices/_lib/registerDeviceSteps.ts new file mode 100644 index 0000000000..0bc9a9a287 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_lib/registerDeviceSteps.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export const AGENT_INSTALL_URL = "https://pro.bo/install"; + +export const REGISTER_DEVICE_STEPS = ["review", "download", "enroll"] as const; + +export type RegisterDeviceStep = (typeof REGISTER_DEVICE_STEPS)[number]; + +export function parseRegisterDeviceStep(value: string | null): RegisterDeviceStep { + if (value === "download" || value === "enroll") { + return value; + } + + return "review"; +} + +export function registerDeviceStepIndex(step: RegisterDeviceStep): number { + return REGISTER_DEVICE_STEPS.indexOf(step); +} + +export function maxRegisterDeviceStep( + left: RegisterDeviceStep, + right: RegisterDeviceStep, +): RegisterDeviceStep { + return registerDeviceStepIndex(left) >= registerDeviceStepIndex(right) ? left : right; +} diff --git a/apps/employee-portal/src/pages/devices/_lib/useEnrollDevice.ts b/apps/employee-portal/src/pages/devices/_lib/useEnrollDevice.ts new file mode 100644 index 0000000000..b3b25b7b0d --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_lib/useEnrollDevice.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useEffect, useState } from "react"; +import { fetchQuery, graphql, useRelayEnvironment } from "react-relay"; +import { useParams } from "react-router"; + +import type { useEnrollDeviceMutation } from "#/__generated__/core/useEnrollDeviceMutation.graphql"; +import type { useEnrollDeviceStatusQuery } from "#/__generated__/core/useEnrollDeviceStatusQuery.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; +import { useMutation } from "#/lib/relay/useMutation"; + +const POLL_INTERVAL_MS = 3000; +const POLL_TIMEOUT_MS = 15 * 60 * 1000; + +const enrollDeviceMutation = graphql` + mutation useEnrollDeviceMutation($input: EnrollDeviceInput!) { + enrollDevice(input: $input) { + enrollmentUrl + device { + id + } + } + } +`; + +const enrollDeviceStatusQuery = graphql` + query useEnrollDeviceStatusQuery($deviceId: ID!) @throwOnFieldError { + viewer @required(action: THROW) { + enrolledDevice(id: $deviceId) { + id + state + hostname + } + } + } +`; + +export function useEnrollDevice() { + const { organizationId } = useParams(); + const environment = useRelayEnvironment(); + const [enrollDevice, isCreating] = useMutation( + enrollDeviceMutation, + { errorToast: false }, + ); + const [deviceId, setDeviceId] = useState(null); + const [deepLink, setDeepLink] = useState(null); + const [isWaiting, setIsWaiting] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [hasTimedOut, setHasTimedOut] = useState(false); + const [hostname, setHostname] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!isWaiting || deviceId === null) { + return; + } + + let cancelled = false; + let timeoutId: ReturnType | undefined; + const deadline = Date.now() + POLL_TIMEOUT_MS; + + const scheduleNext = () => { + if (!cancelled) { + timeoutId = setTimeout(runPoll, POLL_INTERVAL_MS); + } + }; + + const finishTimeout = () => { + setIsWaiting(false); + setHasTimedOut(true); + }; + + const runPoll = async () => { + if (cancelled) { + return; + } + + if (document.hidden) { + if (Date.now() > deadline) { + finishTimeout(); + return; + } + scheduleNext(); + return; + } + + if (Date.now() > deadline) { + finishTimeout(); + return; + } + + try { + const data = await fetchQuery( + environment, + enrollDeviceStatusQuery, + { deviceId }, + { fetchPolicy: "network-only" }, + ).toPromise(); + + if (cancelled) { + return; + } + + const device = data?.viewer.enrolledDevice; + if (device === undefined || device === null) { + scheduleNext(); + return; + } + + setHostname(device.hostname ?? null); + + if (device.state === "ACTIVE") { + setIsComplete(true); + setIsWaiting(false); + return; + } + } catch { + if (Date.now() > deadline) { + finishTimeout(); + return; + } + } + + if (Date.now() > deadline) { + finishTimeout(); + return; + } + + scheduleNext(); + }; + + scheduleNext(); + + return () => { + cancelled = true; + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; + }, [deviceId, environment, isWaiting]); + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + const enrolledOrganizationId = organizationId; + + async function openAgent() { + setHasTimedOut(false); + setFailed(false); + + try { + if (deepLink !== null) { + setIsWaiting(true); + window.location.assign(deepLink); + return; + } + + const response = await enrollDevice({ + variables: { + input: { organizationId: enrolledOrganizationId }, + }, + }); + const payload = response.enrollDevice; + setDeviceId(payload.device.id); + setDeepLink(payload.enrollmentUrl); + setIsWaiting(true); + window.location.assign(payload.enrollmentUrl); + } catch { + setFailed(true); + } + } + + return { + openAgent, + isCreating, + isWaiting, + isComplete, + hasTimedOut, + failed, + hostname, + }; +} diff --git a/apps/employee-portal/src/pages/devices/_lib/useEnrollDeviceManually.ts b/apps/employee-portal/src/pages/devices/_lib/useEnrollDeviceManually.ts new file mode 100644 index 0000000000..336e91cb9b --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_lib/useEnrollDeviceManually.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useCallback, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql } from "react-relay"; +import { useParams } from "react-router"; + +import type { useEnrollDeviceManuallyMutation } from "#/__generated__/core/useEnrollDeviceManuallyMutation.graphql"; +import { NotFoundError } from "#/lib/relay/errors"; +import { useMutation } from "#/lib/relay/useMutation"; + +const enrollDeviceMutation = graphql` + mutation useEnrollDeviceManuallyMutation($input: EnrollDeviceInput!) { + enrollDevice(input: $input) { + enrollmentToken + serverUrl + device { + id + } + } + } +`; + +export type ManualEnrollment = { + enrollmentToken: string; + serverUrl: string; +}; + +export function useEnrollDeviceManually() { + const { t } = useTranslation("devices"); + const { organizationId } = useParams(); + const [enrollDevice, isCreating] = useMutation( + enrollDeviceMutation, + { successMessage: t("addManually.created"), errorToast: false }, + ); + const [enrollment, setEnrollment] = useState(null); + const [failed, setFailed] = useState(false); + const startedRef = useRef(false); + + if (organizationId === undefined) { + throw new NotFoundError("organizationId is required"); + } + + const enrolledOrganizationId = organizationId; + + const create = useCallback(async () => { + setFailed(false); + try { + const response = await enrollDevice({ + variables: { + input: { organizationId: enrolledOrganizationId }, + }, + }); + setEnrollment({ + enrollmentToken: response.enrollDevice.enrollmentToken, + serverUrl: response.enrollDevice.serverUrl, + }); + } catch { + setFailed(true); + } + }, [enrollDevice, enrolledOrganizationId]); + + const start = useCallback(() => { + if (startedRef.current) { + return; + } + startedRef.current = true; + void create(); + }, [create]); + + const retry = useCallback(() => { + void create(); + }, [create]); + + return { + start, + retry, + isCreating, + enrollment, + failed, + }; +} diff --git a/apps/employee-portal/src/pages/devices/_locales/en-US.json b/apps/employee-portal/src/pages/devices/_locales/en-US.json new file mode 100644 index 0000000000..438467f62c --- /dev/null +++ b/apps/employee-portal/src/pages/devices/_locales/en-US.json @@ -0,0 +1,134 @@ +{ + "title": "Register this device", + "breadcrumb": "Devices", + "registerBreadcrumb": "Register", + "addManuallyBreadcrumb": "Add manually", + "list": { + "title": "Devices", + "register": "Register new device", + "addManually": "Add manually", + "lastActive": "Last active", + "never": "Never", + "justNow": "Just now", + "pendingHostname": "Pending", + "connected": "Connected", + "disconnected": "Disconnected", + "columns": { + "hostname": "Device", + "details": "Details" + }, + "platforms": { + "DARWIN": "macOS", + "WINDOWS": "Windows", + "LINUX": "Linux", + "FREEBSD": "FreeBSD" + } + }, + "empty": { + "title": "No devices yet", + "description": "Register a device to connect it to your organization and monitor its security status.", + "register": "Register device", + "addManually": "Add manually" + }, + "unavailable": { + "title": "Enrollment unavailable", + "description": "You do not have permission to enroll devices in this organization.", + "home": "Back to home" + }, + "steps": { + "review": { + "title": "Review device information", + "description": "See what Probo Agent will save." + }, + "download": { + "title": "Download the agent", + "description": "Install the desktop agent" + }, + "enroll": { + "title": "Open Probo Agent", + "description": "Finish registering this device." + } + }, + "review": { + "title": "Review collected data", + "description": "Probo collects the following device metadata for inventory and posture reporting.", + "understand": "I understand", + "thisDevice": { + "title": "This device", + "hostname": "Hostname", + "platform": "Platform", + "osVersion": "OS version" + }, + "identity": { + "title": "Device identity", + "hardwareUuid": "Hardware UUID", + "hostname": "Hostname", + "serialNumber": "Serial number, when available" + }, + "system": { + "title": "System details", + "platform": "Platform", + "osVersion": "OS version", + "agentVersion": "Probo Agent version" + }, + "activity": { + "title": "Activity signals", + "enrollmentTime": "Enrollment time", + "heartbeats": "Agent heartbeats", + "posture": "Posture check results" + } + }, + "download": { + "title": "Download Probo Agent", + "description": "Install the app to secure this device and connect it to your organization.", + "featured": "Probo Agent for macOS", + "continue": "Continue", + "download": "Download", + "downloadNamed": "Download {{title}}", + "macosIntel": { + "title": "macOS Intel", + "meta": "macOS 13.0+" + }, + "windows": { + "title": "Windows", + "meta": "Windows 11+" + } + }, + "enroll": { + "title": "Continue in the app", + "description": "Open Probo Agent to finish registering this device.", + "open": "Open Probo Agent", + "preparing": "Preparing…", + "waiting": "Waiting for the agent's first check-in…", + "timedOut": "We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again.", + "failed": "We couldn't start enrollment. Try again.", + "tryAgain": "Try again", + "enrolled": "This device is enrolled.", + "enrolledWithHostname": "{{hostname}} is enrolled.", + "home": "Back to home" + }, + "addManually": { + "title": "Manual enrollment", + "creating": "Creating device…", + "failed": "We couldn't create an enrollment token. Try again.", + "created": "Device created. Copy the enrollment token now — it will not be shown again.", + "done": "Done", + "retry": "Try again", + "back": "Back to devices", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Failed to copy to clipboard", + "token": { + "title": "Enrollment token generated", + "description": "Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.", + "releaseListHint": "Browse probo-agent releases on GitHub (tags probo-agent/v*), then pick the archive for your OS and architecture (for example probo-agent_Darwin_arm64.tar.gz, probo-agent_Linux_x86_64.tar.gz, or probo-agent_Windows_x86_64.zip). Replace vX.Y.Z, OS, and ARCH in the download command with the values from the asset you chose.", + "tabUnix": "macOS / Linux", + "tabWindows": "Windows", + "installUnix": "Install on macOS or Linux (run from a shell with sudo access)", + "installWindows": "Install on Windows (run from an elevated PowerShell session)", + "downloadStep": "Download and place the binary", + "enrollStep": "Configure the device and start the agent", + "securityNotice": "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." + } + } +} diff --git a/apps/employee-portal/src/pages/devices/routes.ts b/apps/employee-portal/src/pages/devices/routes.ts new file mode 100644 index 0000000000..1e32a50d23 --- /dev/null +++ b/apps/employee-portal/src/pages/devices/routes.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { lazy } from "@probo/react-lazy"; +import type { AppRoute } from "@probo/routes"; + +import { AddManuallyPageSkeleton } from "./AddManuallyPageSkeleton"; +import { DevicesPageSkeleton } from "./DevicesPageSkeleton"; +import { RegisterDevicePageSkeleton } from "./RegisterDevicePageSkeleton"; + +export const devicesRoutes = [ + { + path: "devices/add-manually", + Fallback: AddManuallyPageSkeleton, + Component: lazy(() => import("#/pages/devices/AddManuallyPageLoader")), + }, + { + path: "devices/register", + Fallback: RegisterDevicePageSkeleton, + Component: lazy(() => import("#/pages/devices/RegisterDevicePageLoader")), + }, + { + path: "devices", + Fallback: DevicesPageSkeleton, + Component: lazy(() => import("#/pages/devices/DevicesPageLoader")), + }, +] satisfies AppRoute[]; diff --git a/apps/employee-portal/src/routes.tsx b/apps/employee-portal/src/routes.tsx index 36a8f5c1ce..42725b69fd 100644 --- a/apps/employee-portal/src/routes.tsx +++ b/apps/employee-portal/src/routes.tsx @@ -25,6 +25,7 @@ import { createBrowserRouter } from "react-router"; import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary"; import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary"; import { approvalsRoutes } from "#/pages/approvals/routes"; +import { devicesRoutes } from "#/pages/devices/routes"; import { HomePageSkeleton } from "#/pages/HomePageSkeleton"; import { MainLayoutSkeleton } from "#/pages/iam/MainLayoutSkeleton"; import { OrganizationsPageSkeleton } from "#/pages/iam/OrganizationsPageSkeleton"; @@ -53,6 +54,7 @@ const routes = [ }, ...signaturesRoutes, ...approvalsRoutes, + ...devicesRoutes, { path: "*", Component: lazy(() => import("#/pages/NotFoundPage")), diff --git a/package-lock.json b/package-lock.json index 1a47855802..50f9a4b4fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -783,6 +783,7 @@ "@base-ui/react": "^1.6.0", "@phosphor-icons/react": "^2.1.10", "@probo/helpers": "1.0.0", + "@probo/i18n": "1.0.0", "@probo/react-lazy": "1.0.0", "@probo/relay": "1.0.0", "@probo/routes": "1.0.0",