mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Add Okta SCIM integration (#361)
* Add Okta integration (wip) * Update okta setup dialog * Add okta integration images * Add error handling for 500 status codes * Add okta integration * Fix lint warnings * Update azures last sync time * Remove 'on' from step, disable copy for HTTP Header * Update text for custom IDP
This commit is contained in:
BIN
src/assets/integrations/okta.png
Normal file
BIN
src/assets/integrations/okta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -5,7 +5,9 @@ import * as React from "react";
|
||||
type Props = {
|
||||
data: {
|
||||
label: string;
|
||||
value: string;
|
||||
value: string | React.ReactNode;
|
||||
noCopy?: boolean;
|
||||
tooltip?: boolean;
|
||||
}[];
|
||||
className?: string;
|
||||
};
|
||||
@@ -16,10 +18,11 @@ export const MinimalList = ({ data, className }: Props) => {
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Card.ListItem
|
||||
copy
|
||||
copy={!item.noCopy}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
key={index}
|
||||
tooltip={item.tooltip !== false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface AzureADIntegration {
|
||||
user_group_prefixes: string[];
|
||||
}
|
||||
|
||||
export interface OktaIntegration {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
group_prefixes: string[];
|
||||
user_group_prefixes: string[];
|
||||
auth_token: string;
|
||||
}
|
||||
|
||||
export interface IdentityProviderLog {
|
||||
id: number;
|
||||
level: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import IntegrationIcon from "@/assets/icons/IntegrationIcon";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import { AzureAD } from "@/modules/integrations/idp-sync/azure-ad/AzureAD";
|
||||
import { GoogleWorkspace } from "@/modules/integrations/idp-sync/google-workspace/GoogleWorkspace";
|
||||
import { Okta } from "@/modules/integrations/idp-sync/okta-scim/Okta";
|
||||
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
|
||||
|
||||
export default function IdentityProviderTab() {
|
||||
@@ -40,7 +41,10 @@ export default function IdentityProviderTab() {
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink href={"#"} target={"_blank"}>
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/idp-sync"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Identity Provider
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
@@ -51,23 +55,24 @@ export default function IdentityProviderTab() {
|
||||
<>
|
||||
<SkeletonIntegration loadingHeight={196} />
|
||||
<SkeletonIntegration loadingHeight={196} />
|
||||
<SkeletonIntegration loadingHeight={196} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GoogleWorkspace />
|
||||
<AzureAD />
|
||||
<Okta />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col gap-6 max-w-md mt-10"}>
|
||||
<div className={"flex flex-col gap-6 max-w-lg mt-10"}>
|
||||
<div
|
||||
className={
|
||||
"bg-netbird-950 px-6 py-4 rounded-md border border-netbird-500 "
|
||||
}
|
||||
>
|
||||
<Label className={"!text-netbird-100 text-md"}>
|
||||
Looking to enable a custom Identity Provider like Okta or
|
||||
Jumpcloud?
|
||||
Looking to enable a custom IDP like Jumpcloud?
|
||||
</Label>
|
||||
<p className={"!text-netbird-200 mt-2"}>
|
||||
Please contact us at{" "}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { notify } from "@components/Notification";
|
||||
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty } from "lodash";
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import integrationImage from "@/assets/integrations/entra-id.png";
|
||||
import {
|
||||
@@ -116,6 +117,11 @@ const ConfigurationButton = ({ config }: ConfigurationProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const lastSync = useMemo(() => {
|
||||
if (isEmpty(logs)) return "Not synchronized";
|
||||
return "Synced " + dayjs().to(logs?.[0]?.timestamp);
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"flex gap-2"}>
|
||||
@@ -137,7 +143,7 @@ const ConfigurationButton = ({ config }: ConfigurationProps) => {
|
||||
disabled={!config.enabled}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Synced {dayjs().to(logs?.[0]?.timestamp)}
|
||||
{lastSync}
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
|
||||
|
||||
131
src/modules/integrations/idp-sync/okta-scim/Okta.tsx
Normal file
131
src/modules/integrations/idp-sync/okta-scim/Okta.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import Button from "@components/Button";
|
||||
import { notify } from "@components/Notification";
|
||||
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import dayjs from "dayjs";
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import integrationImage from "@/assets/integrations/okta.png";
|
||||
import {
|
||||
IdentityProviderLog,
|
||||
OktaIntegration,
|
||||
} from "@/interfaces/IdentityProvider";
|
||||
import OktaConfiguration from "@/modules/integrations/idp-sync/okta-scim/OktaConfiguration";
|
||||
import OktaSetup from "@/modules/integrations/idp-sync/okta-scim/OktaSetup";
|
||||
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
|
||||
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
|
||||
|
||||
export const Okta = () => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const [setupModal, setSetupModal] = useState(false);
|
||||
|
||||
const {
|
||||
okta: integration,
|
||||
isAnyIntegrationEnabled,
|
||||
isOktaLoading,
|
||||
} = useIntegrations();
|
||||
const oktaRequest = useApiCall<OktaIntegration>(
|
||||
"/integrations/okta-scim-idp",
|
||||
);
|
||||
|
||||
const [enabled, setEnabled] = useState(
|
||||
integration ? integration.enabled : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(integration?.enabled ?? false);
|
||||
}, [integration]);
|
||||
|
||||
const toggleSwitch = async (state: boolean) => {
|
||||
if (!integration) return setSetupModal(true);
|
||||
|
||||
notify({
|
||||
title: "Okta Integration",
|
||||
description: `Okta was successfully ${state ? "enabled" : "disabled"}`,
|
||||
promise: oktaRequest
|
||||
.put(
|
||||
{
|
||||
enabled: state,
|
||||
},
|
||||
"/" + integration.id,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/integrations/okta-scim-idp");
|
||||
setEnabled(state);
|
||||
}),
|
||||
loadingMessage: "Updating integration...",
|
||||
});
|
||||
};
|
||||
|
||||
return isOktaLoading ? (
|
||||
<SkeletonIntegration loadingHeight={197} />
|
||||
) : (
|
||||
<>
|
||||
<IntegrationCard
|
||||
name="Okta"
|
||||
description="Okta is a platform to provision and manage user accounts in cloud-based applications."
|
||||
url={{
|
||||
title: "okta.com",
|
||||
href: "https://www.okta.com/",
|
||||
}}
|
||||
image={integrationImage}
|
||||
data={integration}
|
||||
disabled={enabled ? false : isAnyIntegrationEnabled}
|
||||
switchState={enabled}
|
||||
onEnabledChange={toggleSwitch}
|
||||
onSetup={() => setSetupModal(true)}
|
||||
>
|
||||
{integration && <ConfigurationButton config={integration} />}
|
||||
</IntegrationCard>
|
||||
<OktaSetup
|
||||
open={setupModal}
|
||||
onOpenChange={setSetupModal}
|
||||
onSuccess={() => {
|
||||
setEnabled(true);
|
||||
mutate("/integrations/okta-scim-idp");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ConfigurationProps = {
|
||||
config: OktaIntegration;
|
||||
};
|
||||
const ConfigurationButton = ({ config }: ConfigurationProps) => {
|
||||
const { data: logs } = useFetchApi<IdentityProviderLog[]>(
|
||||
`/integrations/okta-scim-idp/${config.id}/logs`,
|
||||
);
|
||||
|
||||
const [configModal, setConfigModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"flex gap-2"}>
|
||||
<Button
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"w-full items-center pointer-events-none"}
|
||||
disabled={!config.enabled}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Synced {dayjs().to(logs?.[0]?.timestamp)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"items-center"}
|
||||
onClick={() => {
|
||||
setConfigModal(true);
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<OktaConfiguration open={configModal} onOpenChange={setConfigModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import { useHasChanges } from "@hooks/useHasChanges";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
AlertOctagon,
|
||||
Cog,
|
||||
FolderGit2,
|
||||
KeyRound,
|
||||
RefreshCcw,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import integrationImage from "@/assets/integrations/entra-id.png";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { OktaIntegration } from "@/interfaces/IdentityProvider";
|
||||
import { GroupPrefixInput } from "@/modules/integrations/idp-sync/GroupPrefixInput";
|
||||
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
|
||||
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function OktaConfiguration({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const { okta } = useIntegrations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||
{okta && (
|
||||
<ConfigurationContent
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess && onSuccess();
|
||||
}}
|
||||
config={okta}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess: () => void;
|
||||
config: OktaIntegration;
|
||||
};
|
||||
|
||||
export function ConfigurationContent({ onSuccess, config }: ModalProps) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const [tab, setTab] = useState<string>("settings");
|
||||
|
||||
const oktaRequest = useApiCall<OktaIntegration>(
|
||||
"/integrations/okta-scim-idp",
|
||||
);
|
||||
|
||||
const clientSecretPlaceholder = "******************************";
|
||||
const [authToken, setAuthToken] = useState(
|
||||
config.auth_token || clientSecretPlaceholder,
|
||||
);
|
||||
|
||||
const [groupPrefixes, setGroupPrefixes] = useState<string[]>(
|
||||
config.group_prefixes || [],
|
||||
);
|
||||
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>(
|
||||
config.user_group_prefixes || [],
|
||||
);
|
||||
|
||||
const { hasChanges, updateRef } = useHasChanges([
|
||||
authToken,
|
||||
groupPrefixes,
|
||||
userGroupPrefixes,
|
||||
]);
|
||||
|
||||
const regenerateAuthToken = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Regenerate Auth Token?`,
|
||||
description:
|
||||
"Are you sure you want to regenerate the auth token? You will need to update the token in your Okta configuration.",
|
||||
confirmText: "Regenerate",
|
||||
cancelText: "Cancel",
|
||||
type: "default",
|
||||
});
|
||||
|
||||
if (!choice) return;
|
||||
|
||||
notify({
|
||||
title: "Okta Integration",
|
||||
description: `Auth token for Okta was successfully regenerated`,
|
||||
promise: oktaRequest.post({}, `/${config.id}/token`).then((r) => {
|
||||
mutate("/integrations/okta-scim-idp");
|
||||
setAuthToken(r.auth_token);
|
||||
updateRef([r.auth_token, groupPrefixes, userGroupPrefixes]);
|
||||
}),
|
||||
loadingMessage: "Updating your auth token...",
|
||||
});
|
||||
};
|
||||
|
||||
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: "Okta Integration",
|
||||
description: `Okta was successfully deleted`,
|
||||
promise: oktaRequest.del({}, `/${config.id}`).then(() => {
|
||||
mutate("/integrations/okta-scim-idp");
|
||||
onSuccess();
|
||||
}),
|
||||
loadingMessage: "Deleting integration...",
|
||||
});
|
||||
};
|
||||
|
||||
const updateIntegration = async () => {
|
||||
notify({
|
||||
title: "Okta Integration",
|
||||
description: `Okta was successfully updated`,
|
||||
promise: oktaRequest
|
||||
.put(
|
||||
{
|
||||
group_prefixes: groupPrefixes || [],
|
||||
user_group_prefixes: userGroupPrefixes || [],
|
||||
},
|
||||
`/${config.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/integrations/okta-scim-idp");
|
||||
onSuccess();
|
||||
}),
|
||||
loadingMessage: "Updating integration...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
maxWidthClass={cn("relative max-w-xl")}
|
||||
showClose={true}
|
||||
className={""}
|
||||
autoFocus={false}
|
||||
>
|
||||
<GradientFadedBackground />
|
||||
|
||||
<IntegrationModalHeader
|
||||
image={integrationImage}
|
||||
title={"Okta Configuration"}
|
||||
description={"Sync your users and groups from Okta to NetBird."}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={(v) => setTab(v)}
|
||||
className={"mt-6"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"settings"}>
|
||||
<Cog
|
||||
size={16}
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"group-sync"}>
|
||||
<FolderGit2
|
||||
size={16}
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Group Sync
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"user-sync"}>
|
||||
<UserCircle
|
||||
size={16}
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
User Sync
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"danger"}>
|
||||
<AlertOctagon
|
||||
size={16}
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
Danger Zone
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value={"settings"} className={"px-8 text-sm"}>
|
||||
<div className={"flex-col gap-3 flex"}>
|
||||
<Card className={"w-full"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
copy={!authToken.includes("*")}
|
||||
copyText={"Auth token"}
|
||||
label={
|
||||
<>
|
||||
<KeyRound size={16} />
|
||||
Auth Token
|
||||
</>
|
||||
}
|
||||
value={authToken}
|
||||
/>
|
||||
</Card.List>
|
||||
</Card>
|
||||
<Button variant={"secondary"} onClick={regenerateAuthToken}>
|
||||
<RefreshCcw size={16} />
|
||||
Regenerate Auth Token
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"group-sync"} className={"px-8"}>
|
||||
<div>
|
||||
<Label>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<FolderGit2 size={16} />
|
||||
Synchronize Groups
|
||||
</div>
|
||||
</Label>
|
||||
<HelpText className={"max-w-lg mt-2"}>
|
||||
By default,{" "}
|
||||
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
|
||||
will be synchronized from your IdP to NetBird. <br />
|
||||
If you want to synchronize only groups that start with a specific
|
||||
prefix, you can add them below.
|
||||
</HelpText>
|
||||
</div>
|
||||
<GroupPrefixInput value={groupPrefixes} onChange={setGroupPrefixes} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"user-sync"} className={"px-8"}>
|
||||
<div>
|
||||
<Label>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<UserCircle size={16} />
|
||||
Synchronize Users
|
||||
</div>
|
||||
</Label>
|
||||
<HelpText className={"max-w-lg mt-2"}>
|
||||
By default,{" "}
|
||||
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
|
||||
will be synchronized from your IdP to NetBird. <br />
|
||||
If you want to synchronize only users that belong to a specific
|
||||
group, you can add them below.
|
||||
</HelpText>
|
||||
</div>
|
||||
<GroupPrefixInput
|
||||
addText={"Add user group filter"}
|
||||
text={"User group starts with..."}
|
||||
value={userGroupPrefixes}
|
||||
onChange={setUserGroupPrefixes}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"danger"} className={"px-8"}>
|
||||
<div>
|
||||
<Label>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<AlertOctagon size={16} />
|
||||
Delete Integration
|
||||
</div>
|
||||
</Label>
|
||||
<HelpText className={"max-w-lg mt-2"}>
|
||||
Deleting this integration will remove the ability to sync users
|
||||
and groups from your IdP to NetBird. If you delete the integration
|
||||
you will need to reconfigure it again to enable the
|
||||
synchronization.
|
||||
</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"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
disabled={!hasChanges}
|
||||
onClick={updateIntegration}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
491
src/modules/integrations/idp-sync/okta-scim/OktaSetup.tsx
Normal file
491
src/modules/integrations/idp-sync/okta-scim/OktaSetup.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import Steps from "@components/Steps";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import { Lightbox } from "@components/ui/Lightbox";
|
||||
import { Mark } from "@components/ui/Mark";
|
||||
import { MinimalList } from "@components/ui/MinimalList";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCirclePlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isEmpty } from "lodash";
|
||||
import {
|
||||
Box,
|
||||
Clock4,
|
||||
FolderGit2,
|
||||
PlusCircle,
|
||||
Settings2,
|
||||
Share2,
|
||||
Shield,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import integrationImage from "@/assets/integrations/okta.png";
|
||||
import { OktaIntegration } from "@/interfaces/IdentityProvider";
|
||||
import oktaGroupsAssignments from "@/modules/integrations/idp-sync/okta-scim/images/okta-groups-assignments.png";
|
||||
import oktaSAMLConfig from "@/modules/integrations/idp-sync/okta-scim/images/okta-saml-configuration.png";
|
||||
import oktaSCIMProvisioning from "@/modules/integrations/idp-sync/okta-scim/images/okta-scim-provisioning-enabled.png";
|
||||
import oktaSCIMToApp from "@/modules/integrations/idp-sync/okta-scim/images/okta-scim-to-app-sync-enabled.png";
|
||||
import oktaSyncGroups from "@/modules/integrations/idp-sync/okta-scim/images/okta-sync-groups.png";
|
||||
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function OktaSetup({ open, onOpenChange, onSuccess }: Props) {
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
{open && (
|
||||
<SetupContent
|
||||
authToken={authToken}
|
||||
setAuthToken={setAuthToken}
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess && onSuccess();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
onSuccess: () => void;
|
||||
authToken: string;
|
||||
setAuthToken: (token: string) => void;
|
||||
};
|
||||
|
||||
export function SetupContent({
|
||||
onSuccess,
|
||||
authToken,
|
||||
setAuthToken,
|
||||
}: ModalProps) {
|
||||
const integrationsRequest = useApiCall<OktaIntegration[]>(
|
||||
"/integrations/okta-scim-idp",
|
||||
);
|
||||
const authTokenRequest = useApiCall<OktaIntegration>(
|
||||
"/integrations/okta-scim-idp",
|
||||
).post;
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const maxSteps = 7;
|
||||
|
||||
// const [groupPrefixes, setGroupPrefixes] = useState<string[]>([]);
|
||||
// const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getAuthToken = async () => {
|
||||
const integration = await integrationsRequest.get();
|
||||
if (!isEmpty(integration)) {
|
||||
const integrationId = integration[0].id;
|
||||
if (authToken != "") return authToken;
|
||||
const okta = await authTokenRequest({}, `${integrationId}/token`);
|
||||
if (!okta) return "";
|
||||
return okta.auth_token;
|
||||
} else {
|
||||
const okta = await authTokenRequest({});
|
||||
if (!okta) return "";
|
||||
return okta.auth_token;
|
||||
}
|
||||
};
|
||||
|
||||
getAuthToken().then((t) => setAuthToken(t));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
maxWidthClass={cn(
|
||||
"relative",
|
||||
step == 0 ? "max-w-md" : step == 4 ? "max-w-3xl" : "max-w-2xl",
|
||||
)}
|
||||
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 Okta"}
|
||||
description={
|
||||
"Start syncing your users and groups from Okta to NetBird. Follow the steps below to get started."
|
||||
}
|
||||
/>
|
||||
|
||||
{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 an an{" "}
|
||||
<span className={"text-nb-gray-100 font-semibold"}>
|
||||
Okta user account
|
||||
</span>{" "}
|
||||
with the following{" "}
|
||||
<span className={"text-nb-gray-100 font-semibold"}>
|
||||
permissions
|
||||
</span>
|
||||
.{" "}
|
||||
{
|
||||
"If you don't have the required permissions, ask your Okta administrator to grant them to you."
|
||||
}
|
||||
</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"} />
|
||||
Add Okta applications
|
||||
</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"} />
|
||||
Configure Okta applications
|
||||
</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"}>
|
||||
<Box size={20} />
|
||||
Create and configure Okta application
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>Navigate to your Okta Admin Dashboard</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
Click <Mark>Applications</Mark> in the left menu then click on
|
||||
<Mark>Applications</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Click <Mark>Create App Integration</Mark>, select{" "}
|
||||
<Mark>SAML 2.0</Mark> and click <Mark>Next</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</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"}>
|
||||
<IconCirclePlus size={20} />
|
||||
Create SAML Integration
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"font-normal"}>
|
||||
Enter <Mark copy>NetBird SCIM</Mark> as the{" "}
|
||||
<Mark>App name</Mark> and click <Mark>Next</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
Enter <Mark copy>http://localhost</Mark> as the Single sign-on
|
||||
URL and Audience URI (SP Entity ID) and click <Mark>Next</Mark>
|
||||
</p>
|
||||
<Lightbox image={oktaSAMLConfig} />
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Select App type as
|
||||
<Mark>This is an internal app that we have created</Mark>
|
||||
and click <Mark>Finish</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step == 3 && (
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
|
||||
<p className={"font-medium flex gap-3 items-center text-base"}>
|
||||
<Share2 size={20} />
|
||||
Enable and configure SCIM provisioning
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>Navigate to your Okta Admin Dashboard</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
Click <Mark>Applications</Mark> in the left menu then click on
|
||||
<Mark>Applications</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3}>
|
||||
<p className={"font-normal"}>
|
||||
Select the <Mark>NetBird SCIM</Mark> application we created
|
||||
earlier
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={4}>
|
||||
<p className={"font-normal"}>
|
||||
Click <Mark>General</Mark> tab and in <Mark>App Settings</Mark>{" "}
|
||||
click <Mark>Edit</Mark> to update the settings
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={5} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Tick <Mark>Enable SCIM provisioning</Mark> and click{" "}
|
||||
<Mark>Save</Mark>
|
||||
</p>
|
||||
<Lightbox image={oktaSCIMProvisioning} />
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step == 4 && (
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
|
||||
<p className={"font-medium flex gap-3 items-center text-base"}>
|
||||
<Share2 size={20} />
|
||||
Enable and configure SCIM provisioning
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"font-normal"}>
|
||||
Click <Mark>Provisioning</Mark> tab and under{" "}
|
||||
<Mark>SCIM connection</Mark> click
|
||||
<Mark>Edit</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
Fill in the form with the following details
|
||||
</p>
|
||||
<MinimalList
|
||||
data={[
|
||||
{
|
||||
label: "SCIM connector base URL",
|
||||
value: "https://api.netbird.io/api/scim/v2",
|
||||
},
|
||||
{
|
||||
label: "Unique identifier field for users",
|
||||
value: "userName",
|
||||
},
|
||||
{
|
||||
label: "Supported provisioning actions",
|
||||
value: (
|
||||
<div className={"text-right"}>
|
||||
Push New Users <br />
|
||||
Push Profile Updates <br />
|
||||
Push Groups
|
||||
</div>
|
||||
),
|
||||
noCopy: true,
|
||||
},
|
||||
{
|
||||
label: "Authentication Mode",
|
||||
value: "HTTP Header",
|
||||
noCopy: true,
|
||||
},
|
||||
{
|
||||
label: "Authorization (Bearer)",
|
||||
value: authToken,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3}>
|
||||
<p className={"font-normal"}>
|
||||
Click on <Mark>Test Connector Configuration</Mark> to verify if
|
||||
the SCIM configuration is working. After the test is completed,
|
||||
make sure <Mark>Create Users</Mark>,{" "}
|
||||
<Mark>Update User Attributes</Mark>, and{" "}
|
||||
<Mark>Push Groups</Mark> were successful.
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={4} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Click <Mark>Save</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step == 5 && (
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
|
||||
<p className={"font-medium flex gap-3 items-center text-base"}>
|
||||
<Box size={20} />
|
||||
Configure SCIM provisioning to NetBird
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>
|
||||
Go to the <Mark>Provisioning</Mark> tab, and select the{" "}
|
||||
<Mark>To App</Mark> settings and click <Mark>Edit</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Enable <Mark>Create Users</Mark>,{" "}
|
||||
<Mark>Update User Attributes</Mark>, and{" "}
|
||||
<Mark>Deactivate Users</Mark> and click <Mark>Save</Mark>
|
||||
</p>
|
||||
<Lightbox image={oktaSCIMToApp} />
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step == 6 && (
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
|
||||
<p className={"font-medium flex gap-3 items-center text-base"}>
|
||||
<UserCircle size={20} />
|
||||
Assign members to NetBird
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>
|
||||
Go to the <Mark>Assignments</Mark> tab, select the{" "}
|
||||
<Mark>Assign</Mark> and click <Mark>Assign to Groups</Mark>
|
||||
</p>
|
||||
<Lightbox image={oktaGroupsAssignments} />
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
Select the groups you want to provision, and then select{" "}
|
||||
<Mark>Assign</Mark> and click <Mark>Save and Go Back</Mark>
|
||||
</p>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Select <Mark>Done</Mark> after you have finished assigning
|
||||
groups. At this point, all members of the groups assigned to the
|
||||
application will be synced to NetBird.
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step == 7 && (
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
|
||||
<p className={"font-medium flex gap-3 items-center text-base"}>
|
||||
<FolderGit2 size={20} />
|
||||
Assign groups to NetBird
|
||||
</p>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>
|
||||
Go to the <Mark>Push Groups</Mark> tab, select{" "}
|
||||
<Mark>Push Groups</Mark> and click{" "}
|
||||
<Mark>Find groups by name</Mark>
|
||||
</p>
|
||||
<Lightbox image={oktaSyncGroups} />
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Search groups to push and then click <Mark>Save</Mark>. The
|
||||
selected groups will then be synced to NetBird.
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</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"}
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={authToken == ""}
|
||||
>
|
||||
{step == 0 ? "Get Started" : "Continue"}
|
||||
<IconArrowRight size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{step == maxSteps && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => {
|
||||
onSuccess();
|
||||
}}
|
||||
>
|
||||
Finish Setup
|
||||
</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"}> 10-20 Minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -2,6 +2,7 @@ import useFetchApi from "@utils/api";
|
||||
import {
|
||||
AzureADIntegration,
|
||||
GoogleWorkspaceIntegration,
|
||||
OktaIntegration,
|
||||
} from "@/interfaces/IdentityProvider";
|
||||
|
||||
export const useIntegrations = () => {
|
||||
@@ -11,17 +12,24 @@ export const useIntegrations = () => {
|
||||
const { data: googleIntegrations, isLoading: isGoogleLoading } = useFetchApi<
|
||||
GoogleWorkspaceIntegration[]
|
||||
>("/integrations/google-idp");
|
||||
const { data: oktaIntegration, isLoading: isOktaLoading } = useFetchApi<
|
||||
OktaIntegration[]
|
||||
>("/integrations/okta-scim-idp");
|
||||
|
||||
const azure = azureIntegrations?.[0];
|
||||
const google = googleIntegrations?.[0];
|
||||
const okta = oktaIntegration?.[0];
|
||||
|
||||
const isAnyIntegrationEnabled = azure?.enabled || google?.enabled;
|
||||
const isAnyIntegrationEnabled =
|
||||
azure?.enabled || google?.enabled || okta?.enabled;
|
||||
|
||||
return {
|
||||
azure,
|
||||
google,
|
||||
okta,
|
||||
isAnyIntegrationEnabled,
|
||||
isAzureLoading,
|
||||
isGoogleLoading,
|
||||
isOktaLoading,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,16 +26,28 @@ async function apiRequest<T>(
|
||||
data?: any,
|
||||
) {
|
||||
const origin = config.apiOrigin;
|
||||
|
||||
const res = await oidcFetch(`${origin}/api${url}`, {
|
||||
method,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = (await res.json()) as ErrorResponse;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const error = (await res.json()) as ErrorResponse;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (e) {
|
||||
if (!res.ok) {
|
||||
const error = {
|
||||
code: res.status,
|
||||
message: res.statusText,
|
||||
} as ErrorResponse;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export function useNetBirdFetch(ignoreError: boolean = false) {
|
||||
@@ -160,6 +172,9 @@ export function useApiErrorHandling(ignoreError = false) {
|
||||
if (err.code == 500 && err.message == "internal server error") {
|
||||
return setError(err);
|
||||
}
|
||||
if (err.code > 400 && err.code <= 500) {
|
||||
return setError(err);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user