diff --git a/src/assets/integrations/okta.png b/src/assets/integrations/okta.png new file mode 100644 index 0000000..4efc21e Binary files /dev/null and b/src/assets/integrations/okta.png differ diff --git a/src/components/ui/MinimalList.tsx b/src/components/ui/MinimalList.tsx index b870ec8..56600e7 100644 --- a/src/components/ui/MinimalList.tsx +++ b/src/components/ui/MinimalList.tsx @@ -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 ( ); })} diff --git a/src/interfaces/IdentityProvider.ts b/src/interfaces/IdentityProvider.ts index 013fd53..9f426c1 100644 --- a/src/interfaces/IdentityProvider.ts +++ b/src/interfaces/IdentityProvider.ts @@ -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; diff --git a/src/modules/integrations/idp-sync/IdentityProviderTab.tsx b/src/modules/integrations/idp-sync/IdentityProviderTab.tsx index 7aa29b9..da78763 100644 --- a/src/modules/integrations/idp-sync/IdentityProviderTab.tsx +++ b/src/modules/integrations/idp-sync/IdentityProviderTab.tsx @@ -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() { Learn more about{" "} - + Identity Provider @@ -51,23 +55,24 @@ export default function IdentityProviderTab() { <> + ) : ( <> + )} -
+

Please contact us at{" "} diff --git a/src/modules/integrations/idp-sync/azure-ad/AzureAD.tsx b/src/modules/integrations/idp-sync/azure-ad/AzureAD.tsx index e64f2d6..a85d981 100644 --- a/src/modules/integrations/idp-sync/azure-ad/AzureAD.tsx +++ b/src/modules/integrations/idp-sync/azure-ad/AzureAD.tsx @@ -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 ( <>

@@ -137,7 +143,7 @@ const ConfigurationButton = ({ config }: ConfigurationProps) => { disabled={!config.enabled} > - Synced {dayjs().to(logs?.[0]?.timestamp)} + {lastSync} diff --git a/src/modules/integrations/idp-sync/okta-scim/Okta.tsx b/src/modules/integrations/idp-sync/okta-scim/Okta.tsx new file mode 100644 index 0000000..aa6807d --- /dev/null +++ b/src/modules/integrations/idp-sync/okta-scim/Okta.tsx @@ -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( + "/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 ? ( + + ) : ( + <> + setSetupModal(true)} + > + {integration && } + + { + setEnabled(true); + mutate("/integrations/okta-scim-idp"); + }} + /> + + ); +}; + +type ConfigurationProps = { + config: OktaIntegration; +}; +const ConfigurationButton = ({ config }: ConfigurationProps) => { + const { data: logs } = useFetchApi( + `/integrations/okta-scim-idp/${config.id}/logs`, + ); + + const [configModal, setConfigModal] = useState(false); + + return ( + <> +
+ + + +
+ + + ); +}; diff --git a/src/modules/integrations/idp-sync/okta-scim/OktaConfiguration.tsx b/src/modules/integrations/idp-sync/okta-scim/OktaConfiguration.tsx new file mode 100644 index 0000000..ef096ab --- /dev/null +++ b/src/modules/integrations/idp-sync/okta-scim/OktaConfiguration.tsx @@ -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 ( + <> + + {okta && ( + { + onOpenChange(false); + onSuccess && onSuccess(); + }} + config={okta} + /> + )} + + + ); +} + +type ModalProps = { + onSuccess: () => void; + config: OktaIntegration; +}; + +export function ConfigurationContent({ onSuccess, config }: ModalProps) { + const { mutate } = useSWRConfig(); + const { confirm } = useDialog(); + + const [tab, setTab] = useState("settings"); + + const oktaRequest = useApiCall( + "/integrations/okta-scim-idp", + ); + + const clientSecretPlaceholder = "******************************"; + const [authToken, setAuthToken] = useState( + config.auth_token || clientSecretPlaceholder, + ); + + const [groupPrefixes, setGroupPrefixes] = useState( + config.group_prefixes || [], + ); + const [userGroupPrefixes, setUserGroupPrefixes] = useState( + 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 ( + + + + + + setTab(v)} + className={"mt-6"} + > + + + + Settings + + + + Group Sync + + + + User Sync + + + + Danger Zone + + + +
+ + + + + Auth Token + + } + value={authToken} + /> + + + +
+
+ + +
+ + + By default,{" "} + All Groups{" "} + will be synchronized from your IdP to NetBird.
+ If you want to synchronize only groups that start with a specific + prefix, you can add them below. +
+
+ +
+ + +
+ + + By default,{" "} + All Users{" "} + will be synchronized from your IdP to NetBird.
+ If you want to synchronize only users that belong to a specific + group, you can add them below. +
+
+ +
+ + +
+ + + 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. + +
+ +
+
+
+ + + + + + + + +
+ ); +} diff --git a/src/modules/integrations/idp-sync/okta-scim/OktaSetup.tsx b/src/modules/integrations/idp-sync/okta-scim/OktaSetup.tsx new file mode 100644 index 0000000..3d8ad25 --- /dev/null +++ b/src/modules/integrations/idp-sync/okta-scim/OktaSetup.tsx @@ -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 ( + <> + + {open && ( + { + onOpenChange(false); + onSuccess && onSuccess(); + }} + /> + )} + + + ); +} + +type ModalProps = { + onSuccess: () => void; + authToken: string; + setAuthToken: (token: string) => void; +}; + +export function SetupContent({ + onSuccess, + authToken, + setAuthToken, +}: ModalProps) { + const integrationsRequest = useApiCall( + "/integrations/okta-scim-idp", + ); + const authTokenRequest = useApiCall( + "/integrations/okta-scim-idp", + ).post; + + const [step, setStep] = useState(0); + const maxSteps = 7; + + // const [groupPrefixes, setGroupPrefixes] = useState([]); + // const [userGroupPrefixes, setUserGroupPrefixes] = useState([]); + + 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 ( + step > 0 && e.preventDefault()} + onInteractOutside={(e) => step > 0 && e.preventDefault()} + onPointerDownOutside={(e) => step > 0 && e.preventDefault()} + > + + + {step > 0 && ( +
+ {Array.from({ length: maxSteps }).map((_, index) => ( +
= index + 1 && "bg-netbird", + )} + /> + ))} +
+ )} + + + + {step == 0 && ( +
+
+ + Required Permissions +
+

+ Ensure that you have an an{" "} + + Okta user account + {" "} + with the following{" "} + + permissions + + .{" "} + { + "If you don't have the required permissions, ask your Okta administrator to grant them to you." + } +

+
+
+ + Add Okta applications +
+
+ + Configure Okta applications +
+
+
+ )} + + {step == 1 && ( +
+

+ + Create and configure Okta application +

+ + +

Navigate to your Okta Admin Dashboard

+
+ +

+ Click Applications in the left menu then click on + Applications +

+
+ +

+ Click Create App Integration, select{" "} + SAML 2.0 and click Next +

+
+
+
+ )} + + {step == 2 && ( +
+

+ + Create SAML Integration +

+ + +

+ Enter NetBird SCIM as the{" "} + App name and click Next +

+
+ +

+ Enter http://localhost as the Single sign-on + URL and Audience URI (SP Entity ID) and click Next +

+ +
+ +

+ Select App type as + This is an internal app that we have created + and click Finish +

+
+
+
+ )} + + {step == 3 && ( +
+

+ + Enable and configure SCIM provisioning +

+ + +

Navigate to your Okta Admin Dashboard

+
+ +

+ Click Applications in the left menu then click on + Applications +

+
+ +

+ Select the NetBird SCIM application we created + earlier +

+
+ +

+ Click General tab and in App Settings{" "} + click Edit to update the settings +

+
+ +

+ Tick Enable SCIM provisioning and click{" "} + Save +

+ +
+
+
+ )} + + {step == 4 && ( +
+

+ + Enable and configure SCIM provisioning +

+ + +

+ Click Provisioning tab and under{" "} + SCIM connection click + Edit +

+
+ +

+ Fill in the form with the following details +

+ + Push New Users
+ Push Profile Updates
+ Push Groups +
+ ), + noCopy: true, + }, + { + label: "Authentication Mode", + value: "HTTP Header", + noCopy: true, + }, + { + label: "Authorization (Bearer)", + value: authToken, + }, + ]} + /> + + +

+ Click on Test Connector Configuration to verify if + the SCIM configuration is working. After the test is completed, + make sure Create Users,{" "} + Update User Attributes, and{" "} + Push Groups were successful. +

+
+ +

+ Click Save +

+
+ +
+ )} + + {step == 5 && ( +
+

+ + Configure SCIM provisioning to NetBird +

+ + +

+ Go to the Provisioning tab, and select the{" "} + To App settings and click Edit +

+
+ +

+ Enable Create Users,{" "} + Update User Attributes, and{" "} + Deactivate Users and click Save +

+ +
+
+
+ )} + + {step == 6 && ( +
+

+ + Assign members to NetBird +

+ + +

+ Go to the Assignments tab, select the{" "} + Assign and click Assign to Groups +

+ +
+ +

+ Select the groups you want to provision, and then select{" "} + Assign and click Save and Go Back +

+
+ +

+ Select Done after you have finished assigning + groups. At this point, all members of the groups assigned to the + application will be synced to NetBird. +

+
+
+
+ )} + + {step == 7 && ( +
+

+ + Assign groups to NetBird +

+ + +

+ Go to the Push Groups tab, select{" "} + Push Groups and click{" "} + Find groups by name +

+ +
+ +

+ Search groups to push and then click Save. The + selected groups will then be synced to NetBird. +

+
+
+
+ )} + + + {step > 0 && ( + + )} + {step >= 0 && step < maxSteps && ( + + )} + {step == maxSteps && ( + + )} + + {step == 0 && ( +
+ +
+ Estimated setup time: + 10-20 Minutes +
+
+ )} +
+ ); +} diff --git a/src/modules/integrations/idp-sync/okta-scim/images/okta-groups-assignments.png b/src/modules/integrations/idp-sync/okta-scim/images/okta-groups-assignments.png new file mode 100644 index 0000000..adf364e Binary files /dev/null and b/src/modules/integrations/idp-sync/okta-scim/images/okta-groups-assignments.png differ diff --git a/src/modules/integrations/idp-sync/okta-scim/images/okta-saml-configuration.png b/src/modules/integrations/idp-sync/okta-scim/images/okta-saml-configuration.png new file mode 100644 index 0000000..00f3c95 Binary files /dev/null and b/src/modules/integrations/idp-sync/okta-scim/images/okta-saml-configuration.png differ diff --git a/src/modules/integrations/idp-sync/okta-scim/images/okta-scim-provisioning-enabled.png b/src/modules/integrations/idp-sync/okta-scim/images/okta-scim-provisioning-enabled.png new file mode 100644 index 0000000..682e2fa Binary files /dev/null and b/src/modules/integrations/idp-sync/okta-scim/images/okta-scim-provisioning-enabled.png differ diff --git a/src/modules/integrations/idp-sync/okta-scim/images/okta-scim-to-app-sync-enabled.png b/src/modules/integrations/idp-sync/okta-scim/images/okta-scim-to-app-sync-enabled.png new file mode 100644 index 0000000..3118610 Binary files /dev/null and b/src/modules/integrations/idp-sync/okta-scim/images/okta-scim-to-app-sync-enabled.png differ diff --git a/src/modules/integrations/idp-sync/okta-scim/images/okta-sync-groups.png b/src/modules/integrations/idp-sync/okta-scim/images/okta-sync-groups.png new file mode 100644 index 0000000..0fb7585 Binary files /dev/null and b/src/modules/integrations/idp-sync/okta-scim/images/okta-sync-groups.png differ diff --git a/src/modules/integrations/idp-sync/useIntegrations.tsx b/src/modules/integrations/idp-sync/useIntegrations.tsx index 4dd98b9..a89dd4c 100644 --- a/src/modules/integrations/idp-sync/useIntegrations.tsx +++ b/src/modules/integrations/idp-sync/useIntegrations.tsx @@ -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, }; }; diff --git a/src/utils/api.tsx b/src/utils/api.tsx index 22b9b00..c2495a2 100644 --- a/src/utils/api.tsx +++ b/src/utils/api.tsx @@ -26,16 +26,28 @@ async function apiRequest( 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); };