Compare commits

..
Author SHA1 Message Date
bcmmbaga 8a75b0de3e implement workspace one edr 2026-06-25 21:25:49 +03:00
9 changed files with 1144 additions and 103 deletions

No files matched your search

@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path d="M17 20.5L23.2 43.5H28.1L32 29.3L35.9 43.5H40.8L47 20.5H41.7L38.2 35.6L34.3 20.5H29.7L25.8 35.6L22.3 20.5H17Z" fill="#7DD3FC"/>
<path d="M45.5 43.5H51V20.5H46.9L41.8 24.1L44.2 28.1L45.5 27.2V43.5Z" fill="#F8FAFC"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

-98
View File
@@ -1,98 +0,0 @@
"use client";
import Button from "@components/Button";
import Image from "next/image";
import * as React from "react";
import { ArrowRight } from "lucide-react";
import NetBirdLogoFull from "@/assets/netbird-full.svg";
type Props = {
onLogin: () => void;
};
export const LoginScreen = ({ onLogin }: Props) => {
return (
<div className={"flex w-screen h-screen bg-nb-gray-950"}>
<div
className={
"relative flex flex-col w-full lg:w-1/2 px-8 sm:px-16 lg:px-24"
}
>
<div className={"pt-10"}>
<Image src={NetBirdLogoFull} height={24} alt={"NetBird Logo"} />
</div>
<div className={"flex flex-col justify-center flex-1 max-w-md"}>
<h1 className={"text-3xl font-medium text-white mb-2"}>
Welcome back
</h1>
<p className={"text-nb-gray-300 mb-8"}>
Sign in to access your NetBird network.
</p>
<Button variant={"primary"} size={"lg"} onClick={onLogin}>
Sign in
<ArrowRight size={16} />
</Button>
<p className={"text-sm text-nb-gray-400 mt-8"}>
Don&apos;t have an account yet?{" "}
<a
href={"https://netbird.io/pricing"}
target={"_blank"}
rel={"noreferrer"}
className={"text-netbird underline underline-offset-4"}
>
Get started
</a>
</p>
</div>
</div>
<div
className={
"hidden lg:flex flex-col items-center justify-center w-1/2 bg-nb-gray-920 border-l border-nb-gray-900 px-16"
}
>
<div
className={
"relative w-full max-w-md rounded-2xl bg-nb-gray-950 border border-nb-gray-900 p-6 shadow-xl"
}
>
<div className={"flex items-center justify-between mb-6"}>
<span className={"text-sm text-nb-gray-300"}>Connected peers</span>
<span className={"flex items-center gap-2 text-xs text-nb-gray-400"}>
<span className={"w-2 h-2 rounded-full bg-netbird"} /> Active
<span className={"w-2 h-2 rounded-full bg-nb-blue ml-2"} /> Idle
</span>
</div>
<div className={"flex flex-col gap-3"}>
{[90, 76, 64, 48, 33, 22].map((w, i) => (
<div key={i} className={"h-3 w-full rounded-full bg-nb-gray-900"}>
<div
className={
i % 2 === 0
? "h-3 rounded-full bg-netbird"
: "h-3 rounded-full bg-nb-blue"
}
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
</div>
<p className={"text-sm text-nb-gray-400 mt-10"}>
Secure networking, zero config
</p>
<h2
className={
"text-2xl font-medium text-white text-center mt-2 max-w-md"
}
>
Connect your devices into a secure private network in minutes.
</h2>
</div>
</div>
);
};
+13 -4
View File
@@ -2,7 +2,6 @@ import { OidcSecure, useOidc } from "@axa-fr/react-oidc";
import { usePathname } from "next/navigation";
import * as React from "react";
import { useEffect } from "react";
import { LoginScreen } from "@/auth/LoginScreen";
const QUERY_PARAMS_KEY = "netbird-query-params";
@@ -73,9 +72,19 @@ export const SecureProvider = ({ children }: Props) => {
}
}, [isAuthenticated, currentPath]);
if (!isAuthenticated) {
return <LoginScreen onLogin={() => login(currentPath)} />;
}
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) {
timeout = setTimeout(async () => {
if (!isAuthenticated) {
await login(currentPath);
}
}, 1500);
}
return () => {
clearTimeout(timeout);
};
}, [currentPath, isAuthenticated, login]);
return (
<>
+12
View File
@@ -18,6 +18,18 @@ export interface IntuneIntegration {
enabled: boolean;
}
export interface WorkspaceOneIntegration {
api_url: string;
token_url: string;
client_id: string;
client_secret?: string;
api_key?: string;
last_synced_interval: number;
last_synced_at?: string;
groups: string[] | Group[];
enabled: boolean;
}
export interface SentinelOneIntegration {
api_token: string;
api_url: string;
+2
View File
@@ -14,6 +14,7 @@ import { useIntegrations } from "../idp-sync/useIntegrations";
import { Intune } from "./intune/Intune";
import { FleetDM } from "@/modules/integrations/edr/fleetdm/FleetDM";
import { Huntress } from "@/modules/integrations/edr/huntress/Huntress";
import { WorkspaceOne } from "@/modules/integrations/edr/workspace-one/WorkspaceOne";
type Props = {
account: Account;
@@ -67,6 +68,7 @@ export default function EDRTab({ account }: Props) {
<SentinelOne account={account} />
<Huntress account={account} />
<FleetDM account={account} />
<WorkspaceOne account={account} />
</div>
</LockedFeatureOverlay>
</div>
@@ -7,6 +7,7 @@ import {
HuntressIntegration,
IntuneIntegration,
SentinelOneIntegration,
WorkspaceOneIntegration,
} from "@/interfaces/EDR";
export const useIntegrations = () => {
@@ -52,18 +53,28 @@ export const useIntegrations = () => {
permission?.edr?.read,
);
const { data: workspaceOne, isLoading: isWorkspaceOneLoading } =
useFetchApi<WorkspaceOneIntegration>(
"/integrations/edr/workspaceone",
false,
false,
permission?.edr?.read,
);
const isCrowdstrikeEnabled = !!crowdstrike?.enabled;
const isIntuneEnabled = !!intune?.enabled;
const isSentinelOneEnabled = !!sentinelOne?.enabled;
const isHuntressEnabled = !!huntress?.enabled;
const isFleetDMEnabled = !!fleetdm?.enabled;
const isWorkspaceOneEnabled = !!workspaceOne?.enabled;
const isAnyIntegrationEnabled =
isCrowdstrikeEnabled ||
isIntuneEnabled ||
isSentinelOneEnabled ||
isHuntressEnabled ||
isFleetDMEnabled;
isFleetDMEnabled ||
isWorkspaceOneEnabled;
const activeIntegrationName = useMemo(() => {
if (isCrowdstrikeEnabled) return "CrowdStrike";
@@ -71,6 +82,7 @@ export const useIntegrations = () => {
if (isSentinelOneEnabled) return "SentinelOne";
if (isHuntressEnabled) return "Huntress";
if (isFleetDMEnabled) return "FleetDM";
if (isWorkspaceOneEnabled) return "Workspace ONE";
return "";
}, [
isCrowdstrikeEnabled,
@@ -78,6 +90,7 @@ export const useIntegrations = () => {
isSentinelOneEnabled,
isHuntressEnabled,
isFleetDMEnabled,
isWorkspaceOneEnabled,
]);
return {
@@ -101,6 +114,10 @@ export const useIntegrations = () => {
isFleetDMLoading,
isFleetDMEnabled,
workspaceOne,
isWorkspaceOneLoading,
isWorkspaceOneEnabled,
isAnyIntegrationEnabled,
activeIntegrationName,
};
@@ -0,0 +1,162 @@
import Button from "@components/Button";
import { notify } from "@components/Notification";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import { useApiCall } from "@utils/api";
import dayjs from "dayjs";
import { isEmpty } from "lodash";
import { HistoryIcon, Settings } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/workspace-one.svg";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
import { WorkspaceOneIntegration } from "@/interfaces/EDR";
import { Group } from "@/interfaces/Group";
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
import { useIntegrations } from "@/modules/integrations/edr/useIntegrations";
import WorkspaceOneConfiguration from "@/modules/integrations/edr/workspace-one/WorkspaceOneConfiguration";
import WorkspaceOneSetup from "@/modules/integrations/edr/workspace-one/WorkspaceOneSetup";
type Props = {
account: Account;
};
export const WorkspaceOne = ({ account }: Props) => {
const { mutate } = useSWRConfig();
const [setupModal, setSetupModal] = useState(false);
const { permission } = usePermissions();
const { confirm } = useDialog();
const {
workspaceOne: integration,
isAnyIntegrationEnabled,
isWorkspaceOneLoading: isLoading,
} = useIntegrations();
const integrationRequest = useApiCall<WorkspaceOneIntegration>(
"/integrations/edr/workspaceone",
);
const [enabled, setEnabled] = useState(!!integration?.enabled);
useEffect(() => {
setEnabled(!!integration?.enabled);
}, [integration]);
const toggleSwitch = async () => {
if (!integration?.api_url) return setSetupModal(true);
const isCurrentlyEnabled = integration.enabled;
const choice = isCurrentlyEnabled
? await confirm({
title: `Disable Workspace ONE?`,
description: `Are you sure you want to disable the Workspace ONE integration?`,
confirmText: "Disable",
cancelText: "Cancel",
type: "warning",
})
: true;
if (!choice) return;
const groups =
integration.groups?.map((group) => (group as Group).id) || [];
notify({
title: "Workspace ONE Integration",
description: `Workspace ONE was successfully ${
isCurrentlyEnabled ? "disabled" : "enabled"
}`,
promise: integrationRequest
.put({
...integration,
groups,
client_secret: undefined,
api_key: undefined,
enabled: !isCurrentlyEnabled,
})
.then(() => {
mutate("/integrations/edr/workspaceone");
}),
loadingMessage: "Updating integration...",
});
};
return isLoading ? (
<SkeletonIntegration loadingHeight={196} />
) : (
<>
<IntegrationCard
name={"Workspace ONE"}
description="Unified endpoint management platform for enforcing access based on Workspace ONE device compliance."
url={{
title: "omnissa.com",
href: "https://www.omnissa.com/products/workspace-one-unified-endpoint-management/",
}}
image={integrationImage}
data={integration?.api_url ? integration : undefined}
disabled={
enabled
? !permission.edr.update
: isAnyIntegrationEnabled || !permission.edr.create
}
switchState={enabled}
onEnabledChange={toggleSwitch}
onSetup={() => setSetupModal(true)}
>
{integration && <ConfigurationButton config={integration} />}
</IntegrationCard>
<WorkspaceOneSetup
account={account}
open={setupModal}
onOpenChange={setSetupModal}
onSuccess={() => setEnabled(true)}
/>
</>
);
};
type ConfigurationProps = {
config: WorkspaceOneIntegration;
};
const ConfigurationButton = ({ config }: ConfigurationProps) => {
const [configModal, setConfigModal] = useState(false);
const lastSync = useMemo(() => {
if (isEmpty(config?.last_synced_at)) return "Not synchronized";
return "Synced " + dayjs().to(config?.last_synced_at);
}, [config]);
return (
<>
<div className={"flex gap-2 justify-between"}>
<div
className={
"text-xs flex gap-2 items-center text-nb-gray-300 font-medium"
}
>
<HistoryIcon size={14} />
{lastSync}
</div>
<Button
variant={"secondary"}
size={"xs"}
className={"items-center"}
onClick={() => {
setConfigModal(true);
}}
>
<Settings size={14} />
Settings
</Button>
</div>
<WorkspaceOneConfiguration
open={configModal}
onOpenChange={setConfigModal}
config={config}
/>
</>
);
};
@@ -0,0 +1,458 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { useHasChanges } from "@hooks/useHasChanges";
import { IconInfoCircle } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import {
AlertOctagon,
Cog,
ExternalLinkIcon,
FolderGit2,
GlobeIcon,
KeyRound,
RefreshCcw,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/workspace-one.svg";
import FullTooltip from "@/components/FullTooltip";
import { PeerGroupSelector } from "@/components/PeerGroupSelector";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { WorkspaceOneIntegration } from "@/interfaces/EDR";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
config: WorkspaceOneIntegration;
};
export default function WorkspaceOneConfiguration({
open,
onOpenChange,
onSuccess,
config,
}: Props) {
const { isLoading } = useGroups();
return (
!isLoading && (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<ConfigurationContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
config={config}
/>
</Modal>
)
);
}
type ModalProps = {
onSuccess: () => void;
config: WorkspaceOneIntegration;
};
export function ConfigurationContent({
onSuccess,
config,
}: Readonly<ModalProps>) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const [tab, setTab] = useState<string>("peer-approval");
const integrationRequest = useApiCall<WorkspaceOneIntegration>(
"/integrations/edr/workspaceone",
);
const [apiUrl, setApiUrl] = useState(config.api_url || "");
const [tokenUrl, setTokenUrl] = useState(config.token_url || "");
const [clientId, setClientId] = useState(config.client_id || "");
const secretPlaceholder = "******************************";
const [clientSecret, setClientSecret] = useState(secretPlaceholder);
const [apiKey, setApiKey] = useState(secretPlaceholder);
const [lastSyncedInterval, setLastSyncedInterval] = useState(
config.last_synced_interval?.toString() || "24",
);
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
initial: config.groups.map((g) => {
const isString = typeof g === "string";
return isString ? g : g.id;
}) as string[],
});
const deleteIntegration = async () => {
const choice = await confirm({
title: `Delete integration?`,
description: "Are you sure you want to delete this integration?",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: "Workspace ONE Integration",
description: `Workspace ONE was successfully deleted`,
promise: integrationRequest.del({}).then(() => {
mutate("/integrations/edr/workspaceone");
onSuccess();
}),
loadingMessage: "Deleting integration...",
});
};
const updateIntegration = async () => {
const savedGroups = await saveGroups();
notify({
title: "Workspace ONE Integration",
description: `Workspace ONE was successfully updated`,
promise: integrationRequest
.put({
api_url: apiUrl,
token_url: tokenUrl,
client_id: clientId,
client_secret:
secretPlaceholder === clientSecret ? undefined : clientSecret,
api_key: secretPlaceholder === apiKey ? undefined : apiKey,
groups: savedGroups.map((group) => group.id) || [],
last_synced_interval: Number(lastSyncedInterval || 24),
enabled: config.enabled,
})
.then(() => {
mutate("/integrations/edr/workspaceone");
onSuccess();
}),
loadingMessage: "Updating integration...",
});
};
const apiUrlError = useMemo(() => {
if (apiUrl === "") return "";
if (!validator.isValidUrl(apiUrl)) {
return "Please enter a valid url, e.g., https://cn123.awmdm.com";
}
return "";
}, [apiUrl]);
const tokenUrlError = useMemo(() => {
if (tokenUrl === "") return "";
if (!validator.isValidUrl(tokenUrl)) {
return "Please enter a valid OAuth token URL";
}
return "";
}, [tokenUrl]);
const { hasChanges } = useHasChanges([
apiUrl,
tokenUrl,
clientId,
clientSecret,
apiKey,
groups,
lastSyncedInterval,
]);
const canSave =
hasChanges &&
groups.length &&
apiUrl.length &&
tokenUrl.length &&
clientId.length &&
apiUrlError === "" &&
tokenUrlError === "";
return (
<ModalContent
maxWidthClass={cn("relative max-w-[650px]")}
showClose={true}
className={""}
autoFocus={false}
>
<GradientFadedBackground />
<IntegrationModalHeader
image={integrationImage}
title={"Workspace ONE Configuration"}
description={
"Restrict network access to devices based on Workspace ONE UEM compliance."
}
/>
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
className={"mt-6"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"peer-approval"}>
<FolderGit2
size={14}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Peer Approval
</TabsTrigger>
<TabsTrigger value={"settings"}>
<Cog
size={14}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Settings
</TabsTrigger>
<TabsTrigger value={"danger"}>
<AlertOctagon
size={14}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Danger Zone
</TabsTrigger>
</TabsList>
<TabsContent value={"peer-approval"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<FolderGit2 size={14} />
Groups
</div>
</Label>
<HelpText className={"mt-2"}>
Select groups you want to apply the Workspace ONE integration to
</HelpText>
<PeerGroupSelector
values={groups}
onChange={setGroups}
showResourceCounter={false}
/>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"px-8 text-sm"}>
<div className={"mb-3 flex flex-row gap-3 w-full justify-between"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<RefreshCcw size={14} />
Sync Window
</div>
</Label>
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
Example: This property is set to 24 hours. A laptop has not
synced with Workspace ONE for 27 hours. Even if the previous
state was compliant, it will be blocked from network access.
</div>
}
>
<HelpText className={"max-w-sm mt-1"}>
Devices not synced with Workspace ONE in this time won&apos;t
have network access.
<IconInfoCircle
size={14}
className={"relative inline ml-1 -top-[1px]"}
/>
</HelpText>
</FullTooltip>
</div>
<Input
placeholder={"24"}
min={24}
max={336}
className={"w-full min-w-[130px] ml-auto"}
value={lastSyncedInterval}
type={"number"}
onChange={(e) => setLastSyncedInterval(e.target.value)}
customSuffix={"Hours"}
/>
</div>
<div className={"flex-col gap-3 flex"}>
<Input
autoCorrect={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<GlobeIcon size={16} />
API URL
</div>
}
placeholder={"https://cn123.awmdm.com"}
value={apiUrl}
error={apiUrlError}
onChange={(e) => setApiUrl(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<GlobeIcon size={16} />
Token URL
</div>
}
placeholder={"https://auth.example.com/connect/token"}
value={tokenUrl}
error={tokenUrlError}
onChange={(e) => setTokenUrl(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<KeyRound size={16} />
Client ID
</div>
}
placeholder={"OAuth client ID"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"password"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<KeyRound size={16} />
Client Secret
</div>
}
placeholder={"OAuth client secret"}
value={clientSecret}
onFocus={() => {
if (clientSecret == secretPlaceholder) {
setClientSecret("");
}
}}
onBlur={() => {
if (!clientSecret.length) {
setClientSecret(secretPlaceholder);
}
}}
onChange={(e) => setClientSecret(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"password"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<KeyRound size={16} />
REST API Key
</div>
}
placeholder={"Workspace ONE REST API key"}
value={apiKey}
onFocus={() => {
if (apiKey == secretPlaceholder) {
setApiKey("");
}
}}
onBlur={() => {
if (!apiKey.length) {
setApiKey(secretPlaceholder);
}
}}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</TabsContent>
<TabsContent value={"danger"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<AlertOctagon size={14} />
Delete Integration
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
Deleting this integration will remove the ability to enforce
compliance policies from Workspace ONE. If you delete the
integration you will need to reconfigure it again.
</HelpText>
</div>
<Button
variant={"danger"}
size={"xs"}
className={"mt-3"}
onClick={deleteIntegration}
>
Delete Integration
</Button>
</TabsContent>
</Tabs>
<div className={"h-6"}></div>
<ModalFooter className={"items-center gap-4"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://www.omnissa.com/products/workspace-one-unified-endpoint-management/"
}
target={"_blank"}
>
Workspace ONE
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-4"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
disabled={!canSave}
onClick={updateIntegration}
>
Save Changes
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
@@ -0,0 +1,474 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import {
IconArrowLeft,
IconArrowRight,
IconInfoCircle,
} from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { isEmpty } from "lodash";
import {
Clock4,
ExternalLinkIcon,
FolderGit2,
GlobeIcon,
KeyRound,
PlusCircle,
RefreshCcw,
Repeat,
Settings2,
Shield,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/workspace-one.svg";
import HelpText from "@/components/HelpText";
import { PeerGroupSelector } from "@/components/PeerGroupSelector";
import { Account } from "@/interfaces/Account";
import { WorkspaceOneIntegration } from "@/interfaces/EDR";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
account: Account;
};
export default function WorkspaceOneSetup({
open,
onOpenChange,
onSuccess,
account,
}: Props) {
return (
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<SetupContent
account={account}
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
/>
</Modal>
);
}
type ModalProps = {
onSuccess: () => void;
account: Account;
};
export function SetupContent({ onSuccess, account }: Readonly<ModalProps>) {
const { mutate } = useSWRConfig();
const integrationRequest = useApiCall<WorkspaceOneIntegration>(
"/integrations/edr/workspaceone",
);
const settingsRequest = useApiCall<Account>("/accounts/" + account.id);
const [step, setStep] = useState(0);
const maxSteps = 4;
const [apiUrl, setApiUrl] = useState("");
const [tokenUrl, setTokenUrl] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [apiKey, setApiKey] = useState("");
const [lastSyncedInterval, setLastSyncedInterval] = useState("24");
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
initial: [],
});
const apiUrlError = useMemo(() => {
if (apiUrl === "") return "";
if (!validator.isValidUrl(apiUrl)) {
return "Please enter a valid url, e.g., https://cn123.awmdm.com";
}
return "";
}, [apiUrl]);
const tokenUrlError = useMemo(() => {
if (tokenUrl === "") return "";
if (!validator.isValidUrl(tokenUrl)) {
return "Please enter a valid OAuth token URL";
}
return "";
}, [tokenUrl]);
const urlsEntered = !isEmpty(apiUrl) && !isEmpty(tokenUrl);
const credentialsEntered =
!isEmpty(clientId) && !isEmpty(clientSecret) && !isEmpty(apiKey);
const isDisabled =
(step == 1 &&
(!urlsEntered || apiUrlError !== "" || tokenUrlError !== "")) ||
(step == 2 && !credentialsEntered) ||
(step == 3 && groups.length == 0);
const connect = async () => {
await settingsRequest.put({
id: account.id,
settings: {
...account.settings,
peer_login_expiration_enabled:
account?.settings?.peer_login_expiration_enabled,
peer_login_expiration: account?.settings?.peer_login_expiration,
extra: {
...account.settings?.extra,
peer_approval_enabled: false,
},
},
});
const savedGroups = await saveGroups();
notify({
title: "Workspace ONE Integration",
description: `Workspace ONE was successfully connected to NetBird.`,
promise: integrationRequest
.post({
api_url: apiUrl,
token_url: tokenUrl,
client_id: clientId,
client_secret: clientSecret,
api_key: apiKey,
groups: savedGroups.map((group) => group.id) || [],
last_synced_interval: Number(lastSyncedInterval || 24),
})
.then(() => {
mutate("/accounts");
mutate("/integrations/edr/workspaceone");
onSuccess();
}),
loadingMessage: "Setting up integration...",
});
};
const modalWidth = {
0: "max-w-lg",
1: "max-w-xl",
2: "max-w-xl",
3: "max-w-xl",
4: "max-w-lg",
};
return (
<ModalContent
maxWidthClass={cn(
"relative",
modalWidth[step as keyof typeof modalWidth],
)}
showClose={true}
className={""}
onEscapeKeyDown={(e) => step > 0 && e.preventDefault()}
onInteractOutside={(e) => step > 0 && e.preventDefault()}
onPointerDownOutside={(e) => step > 0 && e.preventDefault()}
>
<GradientFadedBackground />
{step > 0 && (
<div className={"flex gap-2 w-full items-center justify-center mb-4"}>
{Array.from({ length: maxSteps }).map((_, index) => (
<div
key={index}
className={cn(
"w-8 h-1 rounded-full bg-nb-gray-800",
step >= index + 1 && "bg-netbird",
)}
/>
))}
</div>
)}
<IntegrationModalHeader
image={integrationImage}
title={"Connect NetBird with Workspace ONE"}
description={
"Restrict network access to devices that are managed and compliant in Workspace ONE UEM."
}
/>
{step == 0 && (
<div
className={
"px-8 py-3 flex z-0 flex-col gap-0 text-sm mb-3 text-center justify-center items-center"
}
>
<div
className={
"mt-6 text-base font-medium text-nb-gray-100 flex gap-2 items-center justify-center"
}
>
<Shield size={16} />
Required Permissions
</div>
<p className={"mt-2 !text-nb-gray-300 !leading-[1.5]"}>
Ensure that you have a Workspace ONE OAuth client and REST API key
that can read devices and compliance status.
</p>
<div
className={
"flex items-center flex-col gap-0 mt-2 w-full justify-center max-w-lg"
}
>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<PlusCircle size={14} className={"text-sky-500"} />
OAuth Client Credentials
</div>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<Settings2 size={14} className={"text-sky-500"} />
Device and Compliance Read Access
</div>
</div>
</div>
)}
{step == 1 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<GlobeIcon size={18} />
Enter your Workspace ONE URLs
</p>
<Steps>
<Steps.Step step={1}>
<p>Copy the Workspace ONE UEM REST API base URL.</p>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p>Copy the OAuth token URL for the same Workspace ONE tenant.</p>
</Steps.Step>
</Steps>
<div className={"mb-4 flex flex-col gap-3"}>
<Input
autoFocus={true}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[115px] flex items-center gap-2"}>
<GlobeIcon size={14} />
API URL
</div>
}
placeholder={"https://cn123.awmdm.com"}
value={apiUrl}
error={apiUrlError}
onChange={(e) => setApiUrl(e.target.value)}
/>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[115px] flex items-center gap-2"}>
<GlobeIcon size={14} />
Token URL
</div>
}
placeholder={"https://auth.example.com/connect/token"}
value={tokenUrl}
error={tokenUrlError}
onChange={(e) => setTokenUrl(e.target.value)}
/>
</div>
</div>
)}
{step == 2 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<KeyRound size={20} />
Enter Workspace ONE credentials
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Create or select an OAuth client in Workspace ONE with device
read access.
</p>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Paste the client credentials and REST API key below.
</p>
</Steps.Step>
</Steps>
<div className={"mb-4 flex flex-col gap-3"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[135px] flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
Client ID
</div>
}
placeholder={"OAuth client ID"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
<Input
type={"password"}
className={"w-full"}
customPrefix={
<div className={"min-w-[135px] flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
Client Secret
</div>
}
placeholder={"OAuth client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
<Input
type={"password"}
className={"w-full"}
customPrefix={
<div className={"min-w-[135px] flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
REST API Key
</div>
}
placeholder={"Workspace ONE REST API key"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
)}
{step == 3 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4 mb-3"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<FolderGit2 size={16} />
Peer Approval
</p>
<HelpText className={"max-w-lg mt-2"}>
Select groups you want to apply the Workspace ONE integration to
</HelpText>
<PeerGroupSelector
values={groups}
onChange={setGroups}
showResourceCounter={false}
/>
</div>
)}
{step == 4 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4 mb-3"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<RefreshCcw size={16} />
Workspace ONE Sync Window
</p>
<div className={"mt-2 flex flex-row gap-3"}>
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
Example: This property is set to 24 hours. A laptop has not
synced with Workspace ONE for 27 hours. Even if the previous
state was compliant, it will be blocked from network access.
</div>
}
>
<HelpText className={"max-w-lg"}>
Devices not synced with Workspace ONE in this time won&apos;t
have network access.
<IconInfoCircle
size={14}
className={"relative inline ml-1 -top-[1px]"}
/>
</HelpText>
</FullTooltip>
<Input
placeholder={"24"}
min={24}
max={336}
className={"w-full min-w-[130px]"}
value={lastSyncedInterval}
type={"number"}
onChange={(e) => setLastSyncedInterval(e.target.value)}
customSuffix={"Hours"}
/>
</div>
</div>
)}
<ModalFooter className={"items-center gap-4"}>
{step > 0 && (
<Button
variant={"secondary"}
className={"w-full"}
onClick={() => setStep(step - 1)}
>
<IconArrowLeft size={16} />
Back
</Button>
)}
{step >= 0 && step < maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={() => setStep(step + 1)}
>
{step == 0 ? "Get Started" : "Continue"}
<IconArrowRight size={16} />
</Button>
)}
{step == maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={connect}
>
<Repeat size={16} />
Connect
</Button>
)}
</ModalFooter>
{step == 0 && (
<div
className={
"text-center z-0 mt-2.5 text-xs text-nb-gray-300 flex items-center justify-center gap-2 font-normal"
}
>
<Clock4 size={12} />
<div>
Estimated setup time:
<span className={"font-medium"}> 5-10 Minutes</span>
</div>
<InlineLink
href={
"https://www.omnissa.com/products/workspace-one-unified-endpoint-management/"
}
target={"_blank"}
>
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
)}
</ModalContent>
);
}