From 4ba606e85cc39dca6c7dec882a35c1153064cb15 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 26 Aug 2026 23:37:41 -0700 Subject: [PATCH] feat(self-serve-ds): mount and render ConfigureDirectorySync - @clerk/clerk-js: __internal_mountConfigureDirectorySync with guards mirroring ConfigureSSO's (orgs enabled, active org, self-serve directory sync feature). - @clerk/ui: ConfigureDirectorySync wizard over the organization's enterprise connection and directory (show-once token held in wizard session state, read-only attribute mapping from the directory, test step polls provisioned users), plus a Directory Sync section on the Security page. Google-provider connections are directed to the Dashboard. --- packages/clerk-js/sandbox/app.ts | 6 + packages/clerk-js/sandbox/template.html | 5 + packages/clerk-js/src/core/clerk.ts | 71 ++++++ .../src/internal/clerk-js/componentGuards.ts | 7 + .../shared/src/internal/clerk-js/warnings.ts | 7 +- packages/shared/src/types/clerk.ts | 1 + packages/shared/src/types/elementIds.ts | 1 + packages/shared/src/types/localization.ts | 17 ++ .../ConfigureDirectorySync.tsx | 45 ++++ .../ConfigureDirectorySyncContext.tsx | 177 +++++++++++++++ .../ConfigureDirectorySyncWizard.tsx | 101 +++++++++ .../DirectorySyncNavbar.tsx | 122 ++++++++++ .../SecurityDirectorySyncSection.tsx | 208 ++++++++++++++++++ .../ConfigureDirectorySync/providerMeta.ts | 66 ++++++ .../steps/ActivateDirectorySyncStep.tsx | 125 +++++++++++ .../steps/AttributeMappingStep.tsx | 91 ++++++++ .../steps/ConnectionStep.tsx | 146 ++++++++++++ .../steps/EndpointTokenStep.tsx | 142 ++++++++++++ .../steps/TestSyncStep.tsx | 157 +++++++++++++ .../OrganizationSecurityPage.tsx | 24 +- .../OrganizationSecurityPage.test.tsx | 126 +++++++++++ .../src/contexts/ClerkUIComponentsContext.tsx | 7 + .../components/ConfigureDirectorySync.ts | 20 ++ packages/ui/src/contexts/components/index.ts | 1 + packages/ui/src/lazyModules/components.ts | 9 + packages/ui/src/test/fixture-helpers.ts | 8 +- packages/ui/src/types.ts | 6 + 27 files changed, 1691 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts create mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/ActivateDirectorySyncStep.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/EndpointTokenStep.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx create mode 100644 packages/ui/src/contexts/components/ConfigureDirectorySync.ts diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 682f9b2f53e..a24db1952db 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -34,6 +34,7 @@ const AVAILABLE_COMPONENTS = [ 'pricingTable', 'apiKeys', 'configureSSO', + 'configureDirectorySync', 'oauthConsent', 'taskChooseOrganization', 'taskResetPassword', @@ -153,6 +154,7 @@ const componentControls: Record = { pricingTable: buildComponentControls('pricingTable'), apiKeys: buildComponentControls('apiKeys'), configureSSO: buildComponentControls('configureSSO'), + configureDirectorySync: buildComponentControls('configureDirectorySync'), oauthConsent: buildComponentControls('oauthConsent'), taskChooseOrganization: buildComponentControls('taskChooseOrganization'), taskResetPassword: buildComponentControls('taskResetPassword'), @@ -425,6 +427,10 @@ void (async () => { '/pricing-table': { mount: 'mountPricingTable', component: 'pricingTable' }, '/api-keys': { mount: 'mountAPIKeys', component: 'apiKeys' }, '/configure-sso': { mount: '__internal_mountConfigureSSO', component: 'configureSSO' }, + '/configure-directory-sync': { + mount: '__internal_mountConfigureDirectorySync', + component: 'configureDirectorySync', + }, '/task-choose-organization': { mount: 'mountTaskChooseOrganization', component: 'taskChooseOrganization', diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index f59c231dceb..83d7459eb4e 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -308,6 +308,11 @@ label="Configure SSO" component="" > + ui.ensureMounted()).then(controls => controls.unmountComponent({ node })); }; + /** + * Mount the Directory Sync onboarding component at the target element. + * Directory Sync rides on the self-serve SSO gates: it provisions through + * the organization's SSO connection, so the same preconditions apply. + * + * @param targetNode Target to mount the ConfigureDirectorySync component. + * @param props Configuration parameters. + * @hidden + */ + public __internal_mountConfigureDirectorySync = (node: HTMLDivElement, props?: ConfigureSSOProps) => { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'ConfigureDirectorySync', + onClose: () => { + throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('ConfigureDirectorySync'), { + code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, + }); + }, + }); + + if (!isOrganizationsEnabled) { + return; + } + + const userExists = !noUserExists(this); + if (noOrganizationExists(this) && userExists) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.createCannotRenderComponentWhenOrgDoesNotExist('ConfigureDirectorySync'), { + code: CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE, + }); + } + return; + } + + if (disabledSelfServeDirectorySyncFeature(this, this.environment)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderConfigureDirectorySyncComponentWhenDisabled, { + code: CANNOT_RENDER_SELF_SERVE_SSO_DISABLED_ERROR_CODE, + }); + } + return; + } + + this.assertComponentsReady(this.#clerkUI); + const component = 'ConfigureDirectorySync'; + void this.#clerkUI + .then(ui => ui.ensureMounted({ preloadHint: component })) + .then(controls => + controls.mountComponent({ + name: component, + appearanceKey: 'configureSSO', + node, + props, + }), + ); + + this.telemetry?.record(eventPrebuiltComponentMounted(component, props)); + }; + + /** + * Unmount the Directory Sync onboarding component from the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @param targetNode Target node to unmount the ConfigureDirectorySync component from. + * @hidden + */ + public __internal_unmountConfigureDirectorySync = (node: HTMLDivElement) => { + void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node })); + }; + public mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps) => { const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ for: 'organizations', diff --git a/packages/shared/src/internal/clerk-js/componentGuards.ts b/packages/shared/src/internal/clerk-js/componentGuards.ts index 0ab6a5a7595..bb81268a6b9 100644 --- a/packages/shared/src/internal/clerk-js/componentGuards.ts +++ b/packages/shared/src/internal/clerk-js/componentGuards.ts @@ -50,6 +50,13 @@ export const disabledSelfServeSSOFeature: ComponentGuard = (clerk, environment) return !environment?.userSettings.enterpriseSSO.self_serve_sso || !clerk.organization?.selfServeSSOEnabled; }; +export const disabledSelfServeDirectorySyncFeature: ComponentGuard = (clerk, environment) => { + return ( + disabledSelfServeSSOFeature(clerk, environment) || + !environment?.userSettings.enterpriseSSO.self_serve_directory_sync + ); +}; + export const disabledEmailAddressAttribute: ComponentGuard = (_, environment) => { return !environment?.userSettings.attributes.email_address?.enabled; }; diff --git a/packages/shared/src/internal/clerk-js/warnings.ts b/packages/shared/src/internal/clerk-js/warnings.ts index 4081f830339..066cc97faa1 100644 --- a/packages/shared/src/internal/clerk-js/warnings.ts +++ b/packages/shared/src/internal/clerk-js/warnings.ts @@ -12,7 +12,8 @@ const createMessageForDisabledOrganizations = ( | 'OrganizationList' | 'CreateOrganization' | 'TaskChooseOrganization' - | 'ConfigureSSO', + | 'ConfigureSSO' + | 'ConfigureDirectorySync', ) => { return formatWarning( `The <${componentName}/> cannot be rendered when the feature is turned off. Visit 'dashboard.clerk.com' to enable the feature. Since the feature is turned off, this is no-op.`, @@ -20,7 +21,7 @@ const createMessageForDisabledOrganizations = ( }; const createCannotRenderComponentWhenOrgDoesNotExist = ( - componentName: 'OrganizationProfile' | 'InviteMembers' | 'ConfigureSSO', + componentName: 'OrganizationProfile' | 'InviteMembers' | 'ConfigureSSO' | 'ConfigureDirectorySync', ) => { return formatWarning( `<${componentName}/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, @@ -86,6 +87,8 @@ const warnings = { ' cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotRenderConfigureSSOComponentWhenDisabled: 'The component cannot be rendered when self-serve SSO is disabled. Visit `https://dashboard.clerk.com` to enable the feature. Since self-serve SSO is disabled, this is no-op.', + cannotRenderConfigureDirectorySyncComponentWhenDisabled: + 'The component cannot be rendered when self-serve Directory Sync is disabled. Since self-serve Directory Sync is disabled, this is no-op.', cannotRenderConfigureSSOComponentWhenEmailAddressDisabled: 'The component cannot be rendered when email addresses are disabled on the instance. Visit `https://dashboard.clerk.com` to enable email addresses. Since email addresses are disabled, this is no-op.', }; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 0f57591517d..bf8b2694dcc 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1967,6 +1967,7 @@ export type __internal_AttemptToEnableEnvironmentSettingParams = { | 'CreateOrganization' | 'TaskChooseOrganization' | 'ConfigureSSO' + | 'ConfigureDirectorySync' | 'useOrganizationList' | 'useOrganization'; onClose?: () => void; diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 6975892a769..9b873b7f417 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -62,6 +62,7 @@ export type ProfileSectionId = | 'subscriptionsList' | 'paymentMethods' | 'sso' + | 'directorySync' | 'ssoStatus' | 'enableSso' | 'ssoDomain' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 26b9452fc15..8a2ff955541 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1206,6 +1206,23 @@ export type __internal_LocalizationResource = { tooltip__noRole: LocalizationValue; tooltipLabel: LocalizationValue; }; + directorySyncSection: { + title: LocalizationValue; + badge__unconfigured: LocalizationValue; + badge__active: LocalizationValue; + badge__inactive: LocalizationValue; + description: LocalizationValue; + primaryButton__startConfiguration: LocalizationValue; + menuAction__edit: LocalizationValue; + menuAction__activate: LocalizationValue; + menuAction__deactivate: LocalizationValue; + menuAction__remove: LocalizationValue; + removeDialog: { + title: LocalizationValue; + subtitle: LocalizationValue; + confirmButton: LocalizationValue; + }; + }; }; membersPage: { detailsTitle__emptyRow: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx new file mode 100644 index 00000000000..847de73bfe7 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx @@ -0,0 +1,45 @@ +import type { ConfigureSSOProps } from '@clerk/shared/types'; +import React from 'react'; + +import { withCoreUserGuard } from '@/contexts'; +import { Flow } from '@/customizables'; +import { withCardStateProvider } from '@/elements/contexts'; +import { ProfileCard } from '@/elements/ProfileCard'; +import { Route, Switch } from '@/router'; + +import { ConfigureDirectorySyncWizard } from './ConfigureDirectorySyncWizard'; +import { DirectorySyncNavbar } from './DirectorySyncNavbar'; + +/** + * Standalone host for the Directory Sync onboarding wizard, mirroring + * ConfigureSSO's shell. Reuses the configureSSO flow id/appearance until the + * flow gets its own appearance surface. + */ +const ConfigureDirectorySyncInternal = (): JSX.Element => { + return ( + + + + + + + + ); +}; + +const AuthenticatedContent = withCoreUserGuard(() => { + const contentRef = React.useRef(null); + + return ( + ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} + > + + + + + ); +}); + +export const ConfigureDirectorySync: React.ComponentType = + withCardStateProvider(ConfigureDirectorySyncInternal); diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx new file mode 100644 index 00000000000..314102bc2ab --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -0,0 +1,177 @@ +import { + __internal_useOrganizationDirectorySync, + __internal_useOrganizationDirectorySyncUsers, + __internal_useOrganizationEnterpriseConnections, +} from '@clerk/shared/react'; +import type { + DirectorySyncProvider, + DirectorySyncResource, + DirectorySyncUserResource, + EnterpriseConnectionResource, +} from '@clerk/shared/types'; +import React, { type PropsWithChildren } from 'react'; + +import type { DirectorySyncProviderMeta } from './providerMeta'; +import { DIRECTORY_SYNC_PROVIDERS, directorySyncProviderForConnection } from './providerMeta'; + +export interface DirectorySyncUsersView { + data: DirectorySyncUserResource[] | undefined; + totalCount: number | undefined; + error: Error | null; + isLoading: boolean; + isPolling: boolean; + startPolling: () => void; + stopPolling: () => void; + revalidate: () => Promise; +} + +/** + * Shared state for the ConfigureDirectorySync wizard, persisted across steps. + * + * The directory hangs 1:1 off the organization's (single) enterprise + * connection. `revealedToken` carries the show-once SCIM bearer token from the + * create/rotate response for the lifetime of this provider only — it is never + * fetchable again. + */ +export interface ConfigureDirectorySyncData { + isLoading: boolean; + connection: EnterpriseConnectionResource | undefined; + /** SCIM provider derived from the connection's IdP; `undefined` without a connection. */ + provider: DirectorySyncProvider | undefined; + providerMeta: DirectorySyncProviderMeta | undefined; + /** The directory, `null` when none has been created yet, `undefined` while loading. */ + directory: DirectorySyncResource | null | undefined; + /** The show-once bearer token, if it was revealed during this wizard session. */ + revealedToken: string | null; + createDirectory: () => Promise; + rotateToken: () => Promise; + setDirectoryEnabled: (enabled: boolean) => Promise; + users: DirectorySyncUsersView; + onExit?: () => void; +} + +const ConfigureDirectorySyncContext = React.createContext(null); +ConfigureDirectorySyncContext.displayName = 'ConfigureDirectorySyncContext'; + +type ConfigureDirectorySyncProviderProps = PropsWithChildren<{ + onExit?: () => void; +}>; + +export const ConfigureDirectorySyncProvider = ({ + onExit, + children, +}: ConfigureDirectorySyncProviderProps): JSX.Element => { + const { data: connections, isLoading: isLoadingConnections } = __internal_useOrganizationEnterpriseConnections(); + // The self-serve SSO flow enforces a single connection per organization; the + // directory hangs off that same connection. + const connection = connections?.[0]; + const enterpriseConnectionId = connection?.id ?? null; + + const { + data: directory, + isLoading: isLoadingDirectory, + createDirectorySync, + updateDirectorySync, + rotateDirectorySyncToken, + } = __internal_useOrganizationDirectorySync({ enterpriseConnectionId }); + + const usersHook = __internal_useOrganizationDirectorySyncUsers({ + enterpriseConnectionId, + enabled: Boolean(directory), + }); + + const [revealedToken, setRevealedToken] = React.useState(null); + + React.useEffect(() => { + // The token belongs to the current connection's directory; drop it if the + // connection changes mid-session. + setRevealedToken(null); + }, [enterpriseConnectionId]); + + const createDirectory = React.useCallback(async () => { + const created = await createDirectorySync(); + if (created?.apiKey) { + setRevealedToken(created.apiKey); + } + return created; + }, [createDirectorySync]); + + const rotateToken = React.useCallback(async () => { + const rotated = await rotateDirectorySyncToken(); + if (rotated?.apiKey) { + setRevealedToken(rotated.apiKey); + } + return rotated; + }, [rotateDirectorySyncToken]); + + const setDirectoryEnabled = React.useCallback( + (enabled: boolean) => updateDirectorySync({ enabled }), + [updateDirectorySync], + ); + + const provider = + directory?.provider ?? (connection ? directorySyncProviderForConnection(connection.provider) : undefined); + + const users = React.useMemo( + () => ({ + data: usersHook.data, + totalCount: usersHook.totalCount, + error: usersHook.error, + isLoading: usersHook.isLoading, + isPolling: usersHook.isPolling, + startPolling: usersHook.startPolling, + stopPolling: usersHook.stopPolling, + revalidate: usersHook.revalidate, + }), + [ + usersHook.data, + usersHook.totalCount, + usersHook.error, + usersHook.isLoading, + usersHook.isPolling, + usersHook.startPolling, + usersHook.stopPolling, + usersHook.revalidate, + ], + ); + + const value = React.useMemo( + () => ({ + isLoading: isLoadingConnections || (Boolean(enterpriseConnectionId) && isLoadingDirectory), + connection, + provider, + providerMeta: provider ? DIRECTORY_SYNC_PROVIDERS[provider] : undefined, + directory, + revealedToken, + createDirectory, + rotateToken, + setDirectoryEnabled, + users, + onExit, + }), + [ + isLoadingConnections, + isLoadingDirectory, + enterpriseConnectionId, + connection, + provider, + directory, + revealedToken, + createDirectory, + rotateToken, + setDirectoryEnabled, + users, + onExit, + ], + ); + + return {children}; +}; + +export const useConfigureDirectorySync = (): ConfigureDirectorySyncData => { + const ctx = React.useContext(ConfigureDirectorySyncContext); + if (!ctx) { + throw new Error('useConfigureDirectorySync called outside .'); + } + return ctx; +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx new file mode 100644 index 00000000000..1d5ad7497a7 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +import { CardStateProvider } from '@/elements/contexts'; + +import { ConfigureSSOHeader } from '../ConfigureSSO/ConfigureSSOHeader'; +import { Step } from '../ConfigureSSO/elements/Step'; +import { Wizard, type WizardStepConfig } from '../ConfigureSSO/elements/Wizard'; +import { ConfigureDirectorySyncProvider, useConfigureDirectorySync } from './ConfigureDirectorySyncContext'; +import { ActivateDirectorySyncStep } from './steps/ActivateDirectorySyncStep'; +import { AttributeMappingStep } from './steps/AttributeMappingStep'; +import { ConnectionStep } from './steps/ConnectionStep'; +import { EndpointTokenStep } from './steps/EndpointTokenStep'; +import { TestSyncStep } from './steps/TestSyncStep'; + +export type ConfigureDirectorySyncWizardProps = { + title?: React.ReactNode; + onExit?: () => void; +}; + +/** + * The self-serve Directory Sync onboarding flow. Mirrors the ConfigureSSO + * wizard's shape and reuses its chrome; state comes from the real + * organization enterprise connection and its SCIM directory. + */ +export const ConfigureDirectorySyncWizard = (props: ConfigureDirectorySyncWizardProps): JSX.Element => ( + + + +); + +const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Element => { + const { connection, directory } = useConfigureDirectorySync(); + const hasSsoConnection = Boolean(connection); + const hasDirectory = Boolean(directory); + const isDirectorySyncActive = directory?.enabled ?? false; + + const steps = React.useMemo( + () => [ + { id: 'connection', label: 'Connection', isComplete: () => hasSsoConnection }, + { id: 'endpoint', label: 'Endpoint', isReachable: () => hasSsoConnection && hasDirectory }, + { id: 'attributes', label: 'Attributes', isReachable: () => hasSsoConnection && hasDirectory }, + { id: 'test', label: 'Test', isReachable: () => hasSsoConnection && hasDirectory }, + { + id: 'activate', + label: 'Activate', + isReachable: () => hasSsoConnection && hasDirectory, + isComplete: () => isDirectorySyncActive, + }, + ], + [hasSsoConnection, hasDirectory, isDirectorySyncActive], + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx b/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx new file mode 100644 index 00000000000..fe9b1f7bb8c --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx @@ -0,0 +1,122 @@ +import { __internal_useOrganizationBase } from '@clerk/shared/react/index'; +import React from 'react'; + +import { useEnvironment } from '@/contexts'; +import { Box, Col, descriptors, Flex, Heading, Icon, Text, useAppearance } from '@/customizables'; +import { ApplicationLogo } from '@/elements/ApplicationLogo'; +import { BoxIcon } from '@/icons'; + +type DirectorySyncNavbarProps = React.PropsWithChildren<{ + contentRef: React.RefObject; +}>; + +/** + * Simplified copy of ConfigureSSONavbar (no NavBar/mobile handling) carrying + * the Directory Sync title. + */ +export const DirectorySyncNavbar = ({ children, contentRef }: DirectorySyncNavbarProps): JSX.Element => { + const { parsedOptions } = useAppearance(); + const { + organizationSettings, + displayConfig: { applicationName, logoImageUrl }, + } = useEnvironment(); + + const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); + + return ( + <> + ({ gap: t.space.$4, padding: t.space.$4 })} + > + ({ + gap: t.space.$2, + padding: `${t.space.$none} ${t.space.$3}`, + maxWidth: '100%', + })} + > + {hasLogo ? ( + ({ width: t.space.$9, height: t.space.$9, borderRadius: t.radii.$md, overflow: 'hidden' })} + /> + ) : ( + ({ + width: t.space.$9, + height: t.space.$9, + flexShrink: 0, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$primary500, + color: t.colors.$colorPrimaryForeground, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + })} + aria-hidden + > + ({ width: t.sizes.$4, height: t.sizes.$4 })} + /> + + )} + + + + {applicationName} + + {organizationSettings.enabled && } + + + + ({ fontSize: t.fontSizes.$lg, padding: `${t.space.$none} ${t.space.$3}` })} + > + Configure Directory Sync + + + + ({ + backgroundColor: t.colors.$colorBackground, + position: 'relative', + borderRadius: t.radii.$lg, + width: '100%', + overflow: 'hidden', + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + flex: 1, + })} + > + {children} + + + ); +}; + +const OrganizationSubtitle = (): JSX.Element | null => { + const organization = __internal_useOrganizationBase(); + + if (!organization) { + return null; + } + + return ( + ({ color: t.colors.$colorMutedForeground })} + > + {organization?.name} + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx new file mode 100644 index 00000000000..a76870179eb --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -0,0 +1,208 @@ +import { + __internal_useOrganizationDirectorySync, + __internal_useOrganizationEnterpriseConnections, +} from '@clerk/shared/react'; +import { useState } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileSection } from '@/ui/elements/Section'; +import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; +import { handleError } from '@/utils/errorHandler'; + +import type { LocalizationKey } from '../../customizables'; +import { Badge, Button, Col, Flex, localizationKeys, Text } from '../../customizables'; +import { ResetConnectionDialog } from '../ConfigureSSO/ResetConnectionDialog'; + +type SecurityDirectorySyncSectionProps = { + organizationName: string; + contentRef: React.RefObject; + onConfigure: () => void; +}; + +type DirectorySyncStatus = 'unconfigured' | 'active' | 'inactive'; + +const STATUS_BADGES: Record< + DirectorySyncStatus, + { colorScheme: 'primary' | 'success' | 'warning'; label: LocalizationKey } +> = { + unconfigured: { + colorScheme: 'primary', + label: localizationKeys('organizationProfile.securityPage.directorySyncSection.badge__unconfigured'), + }, + active: { + colorScheme: 'success', + label: localizationKeys('organizationProfile.securityPage.directorySyncSection.badge__active'), + }, + inactive: { + colorScheme: 'warning', + label: localizationKeys('organizationProfile.securityPage.directorySyncSection.badge__inactive'), + }, +}; + +/** + * The Directory Sync entry point on the organization Security page, rendered + * beneath the SSO section. + */ +export const SecurityDirectorySyncSection = ({ + organizationName, + contentRef, + onConfigure, +}: SecurityDirectorySyncSectionProps): JSX.Element => { + const { data: connections } = __internal_useOrganizationEnterpriseConnections(); + const connection = connections?.[0]; + const { + data: directory, + updateDirectorySync, + deleteDirectorySync, + } = __internal_useOrganizationDirectorySync({ + enterpriseConnectionId: connection?.id ?? null, + }); + + const status: DirectorySyncStatus = directory ? (directory.enabled ? 'active' : 'inactive') : 'unconfigured'; + const badge = STATUS_BADGES[status]; + + return ( + + } + > + {status === 'unconfigured' ? ( + + + + ) : ( + + + + + + )} + + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx new file mode 100644 index 00000000000..97c0d5f0b36 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx @@ -0,0 +1,91 @@ +import { Col, Table, Tbody, Td, Text, Th, Thead, Tr } from '@/customizables'; + +import { Step } from '../../ConfigureSSO/elements/Step'; +import { useWizard } from '../../ConfigureSSO/elements/Wizard'; +import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; + +/** Human labels for the Clerk attributes in the directory's attribute mapping. */ +const CLERK_ATTRIBUTE_LABELS: Record = { + userName: 'Username', + email: 'Email address', + firstName: 'First name', + familyName: 'Last name', +}; + +export const AttributeMappingStep = (): JSX.Element => { + const { goNext, goPrev } = useWizard(); + const { directory } = useConfigureDirectorySync(); + + const rows = Object.entries(directory?.attributeMapping ?? {}); + + return ( + <> + + + + ({ gap: t.space.$5 })}> + + These defaults cover most directories. Values sync on the next provisioning event. + + + ({ + 'tr > th:first-of-type': { paddingInlineStart: theme.space.$4 }, + })} + > + + + + + + + + {rows.map(([clerkAttribute, scimPath]) => ( + + + + + ))} + +
+ ({ fontSize: theme.fontSizes.$xs })}>Clerk attribute + + ({ fontSize: theme.fontSizes.$xs })}>SCIM attribute +
+ {CLERK_ATTRIBUTE_LABELS[clerkAttribute] ?? clerkAttribute} + + + {scimPath} + +
+ + ({ gap: t.space.$1 })}> + ({ fontSize: t.fontSizes.$sm })} + > + Attributes not listed here are ignored. Editing the mapping from this flow is coming later; it can be + adjusted from the Clerk Dashboard. + + +
+
+ + + goPrev()} /> + goNext()} /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx new file mode 100644 index 00000000000..f2229d5d5d5 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx @@ -0,0 +1,146 @@ +import { Badge, Col, Flex, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { Alert } from '@/ui/elements/Alert'; +import { handleError } from '@/utils/errorHandler'; + +import { Step } from '../../ConfigureSSO/elements/Step'; +import { useWizard } from '../../ConfigureSSO/elements/Wizard'; +import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; + +export const ConnectionStep = (): JSX.Element => { + const { goNext } = useWizard(); + const { connection, provider, providerMeta, directory, createDirectory } = useConfigureDirectorySync(); + const card = useCardState(); + + const hasSsoConnection = Boolean(connection); + const isGoogle = provider === 'google'; + const domains = connection?.domains ?? []; + + const handleContinue = async (): Promise => { + if (!connection || isGoogle || card.isLoading) { + return; + } + + if (directory) { + goNext(); + return; + } + + card.setError(undefined); + card.setLoading(); + try { + await createDirectory(); + goNext(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + <> + + + + ({ gap: t.space.$5 })}> + {hasSsoConnection && connection ? ( + <> + + Your identity provider and verified domains are inherited from the SSO connection — you won't be + asked for them again. + + + ({ + gap: t.space.$3, + padding: t.space.$4, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + })} + > + + ({ fontWeight: t.fontWeights.$medium })} + > + {providerMeta?.name ?? connection.name} + + + {connection.active ? 'Active' : 'Inactive'} + + + + {domains.length > 0 && ( + ({ gap: t.space.$1x5 })} + > + ({ fontSize: t.fontSizes.$sm })} + > + Domains: + + {domains.map(domain => ( + {domain} + ))} + + )} + + + {isGoogle && ( + + )} + + {!connection.active && !isGoogle && ( + + )} + + ) : ( + + )} + + {card.error && ( + + )} + + + + + void handleContinue()} + isLoading={card.isLoading} + isDisabled={!hasSsoConnection || isGoogle} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/EndpointTokenStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/EndpointTokenStep.tsx new file mode 100644 index 00000000000..a949e4b3cac --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/EndpointTokenStep.tsx @@ -0,0 +1,142 @@ +import { Button, Col, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { useCardState } from '@/elements/contexts'; +import { Checkmark, Clipboard } from '@/icons'; +import { Alert } from '@/ui/elements/Alert'; +import { handleError } from '@/utils/errorHandler'; + +import { Step } from '../../ConfigureSSO/elements/Step'; +import { useWizard } from '../../ConfigureSSO/elements/Wizard'; +import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; + +const LabeledClipboardField = ({ label, value }: { label: string; value: string }): JSX.Element => ( + ({ gap: t.space.$1x5 })}> + ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })} + > + {label} + + + +); + +export const EndpointTokenStep = (): JSX.Element => { + const { goNext, goPrev } = useWizard(); + const { directory, providerMeta, revealedToken, rotateToken } = useConfigureDirectorySync(); + const card = useCardState(); + + const handleRotate = async (): Promise => { + if (card.isLoading) { + return; + } + card.setError(undefined); + card.setLoading(); + try { + await rotateToken(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + <> + + + + ({ gap: t.space.$5 })}> + + Clerk hosts a SCIM 2.0 endpoint for your organization. Your identity provider pushes user changes to this + endpoint as they happen. + + + + + ({ gap: t.space.$2 })}> + {revealedToken ? ( + <> + + + + ) : ( + + )} + + {card.error && ( + + )} + + + + + {providerMeta && providerMeta.instructions.length > 0 && ( + ({ gap: t.space.$2 })}> + ({ fontWeight: t.fontWeights.$medium })} + > + In {providerMeta.name}: + + ({ gap: t.space.$1x5, paddingInlineStart: t.space.$5, listStyle: 'decimal' })} + > + {providerMeta.instructions.map(instruction => ( + + {instruction} + + ))} + + + )} + + + + + goPrev()} /> + goNext()} /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx new file mode 100644 index 00000000000..190fdf83d65 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -0,0 +1,157 @@ +import type { DirectorySyncUserResource } from '@clerk/shared/types'; +import React from 'react'; + +import { Badge, Col, Flex, Spinner, Text } from '@/customizables'; +import { Alert } from '@/ui/elements/Alert'; + +import { Step } from '../../ConfigureSSO/elements/Step'; +import { useWizard } from '../../ConfigureSSO/elements/Wizard'; +import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; + +const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX.Element => { + const displayName = [user.firstName, user.lastName].filter(Boolean).join(' '); + + return ( + ({ + padding: `${t.space.$2x5} ${t.space.$4}`, + borderBottomWidth: t.borderWidths.$normal, + borderBottomStyle: t.borderStyles.$solid, + borderBottomColor: t.colors.$borderAlpha100, + '&:last-of-type': { borderBottom: 'none' }, + })} + > + ({ gap: t.space.$0x5 })}> + ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })} + > + {user.identifier ?? displayName ?? user.userId} + + {displayName && user.identifier && ( + ({ fontSize: t.fontSizes.$sm })} + > + {displayName} + + )} + + ({ gap: t.space.$2 })} + > + {user.provisionedAt && ( + ({ fontSize: t.fontSizes.$xs })} + > + {user.provisionedAt.toLocaleString()} + + )} + {user.active ? 'Active' : 'Deprovisioned'} + + + ); +}; + +export const TestSyncStep = (): JSX.Element => { + const { goNext, goPrev } = useWizard(); + const { providerMeta, users } = useConfigureDirectorySync(); + + const rows = users.data ?? []; + const hasProvisionedUser = rows.length > 0; + + // Poll for the whole lifetime of this step: the list is ordered by most + // recent activity, so it doubles as a live feed while the admin pushes + // test users from the IdP. The context provider outlives the step, so + // polling must stop on step exit rather than riding on unmount of the hook. + const { startPolling, stopPolling } = users; + React.useEffect(() => { + startPolling(); + return () => stopPolling(); + }, [startPolling, stopPolling]); + + return ( + <> + + + + ({ gap: t.space.$5 })}> + + Users appear here as your identity provider provisions them, most recent activity first. + + + {rows.length === 0 ? ( + ({ + gap: t.space.$2, + padding: t.space.$8, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: 'dashed', + borderColor: t.colors.$borderAlpha150, + })} + > + + + Waiting for the first provisioned user… + + + ) : ( + ({ + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + overflow: 'hidden', + })} + > + {rows.map(user => ( + + ))} + + )} + + {users.error && ( + + )} + + + + + goPrev()} /> + goNext()} + isDisabled={!hasProvisionedUser} + /> + + + ); +}; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 66ea03774f0..c7445bcc6a3 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -4,8 +4,11 @@ import React, { useState } from 'react'; import { Header } from '@/ui/elements/Header'; import { ProfileCard } from '@/ui/elements/ProfileCard'; +import { useEnvironment } from '../../contexts'; import { Col, descriptors, Flex, Icon, localizationKeys, SimpleButton, Spinner, Text } from '../../customizables'; import { ChevronLeft } from '../../icons'; +import { ConfigureDirectorySyncWizard } from '../ConfigureDirectorySync/ConfigureDirectorySyncWizard'; +import { SecurityDirectorySyncSection } from '../ConfigureDirectorySync/SecurityDirectorySyncSection'; import { ConfigureSSOWizard } from '../ConfigureSSO/ConfigureSSOWizard'; import { useOrganizationEnterpriseConnection } from '../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; import { SecuritySsoSection } from './SecuritySsoSection'; @@ -37,7 +40,10 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag organizationDomainMutations, } = useOrganizationEnterpriseConnection(); - const [view, setView] = useState<'overview' | 'wizard'>('overview'); + const { userSettings } = useEnvironment(); + const showDirectorySync = userSettings.enterpriseSSO.self_serve_directory_sync; + + const [view, setView] = useState<'overview' | 'wizard' | 'directorySync'>('overview'); const [forceFirstStep, setForceFirstStep] = useState(false); const exitWizard = () => setView('overview'); @@ -92,6 +98,15 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag ); + if (view === 'directorySync') { + return ( + + ); + } + return view === 'overview' ? ( + {showDirectorySync && ( + setView('directorySync')} + /> + )} ) : ( { expect(screen.queryByText('Inactive')).not.toBeInTheDocument(); }); }); + + describe('directory sync section', () => { + const withDirectorySyncFixtures = (f: Parameters[0]>[0]) => { + withSecurityPageFixtures(f); + f.withEnterpriseSso({ selfServeSSO: true, selfServeDirectorySync: true }); + }; + + const directory = (overrides: Record = {}) => + ({ + id: 'scimdir_1', + enterpriseConnectionId: 'ent_1', + endpointUrl: 'https://api.example.com/scim/v2', + provider: 'okta', + enabled: true, + attributeMapping: {}, + apiKey: null, + ...overrides, + }) as any; + + const withActiveConnection = (fixtures: any) => { + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); + fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ + data: [], + total_count: 0, + } as any); + }; + + it('is hidden when the instance is not flagged into self-serve Directory Sync', async () => { + const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(directory()); + + renderPage(wrapper); + + await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1)); + expect(screen.queryByText('Directory Sync')).not.toBeInTheDocument(); + expect(fixtures.clerk.organization?.getDirectorySync).not.toHaveBeenCalled(); + }); + + it('offers setup instead of a menu when no directory exists', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockRejectedValue( + new ClerkAPIResponseError('Not found', { status: 404, data: [{ code: 'resource_not_found', message: '' }] }), + ); + + renderPage(wrapper); + + expect(await screen.findByRole('button', { name: 'Set up Directory Sync' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1); + }); + + it('lists Edit and Deactivate for an active directory', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(directory()); + + const { userEvent } = renderPage(wrapper); + + await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); + await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + + expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Deactivate' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Activate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage Directory Sync' })).not.toBeInTheDocument(); + }); + + it('deactivates from the menu and settles on the revalidated directory', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync + .mockResolvedValueOnce(directory()) + .mockResolvedValue(directory({ enabled: false })); + fixtures.clerk.organization?.updateDirectorySync.mockResolvedValue(directory({ enabled: false })); + + const { userEvent } = renderPage(wrapper); + + await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); + await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(screen.getByRole('menuitem', { name: 'Deactivate' })); + + expect(fixtures.clerk.organization?.updateDirectorySync).toHaveBeenCalledWith('ent_1', { enabled: false }); + + await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Activate' })).toBeInTheDocument()); + }); + + it('removes the directory through the type-to-confirm dialog', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValueOnce(directory()).mockResolvedValue(null); + fixtures.clerk.organization?.deleteDirectorySync.mockResolvedValue({ id: 'scimdir_1', deleted: true } as any); + + const { userEvent } = renderPage(wrapper); + + await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); + await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(screen.getByRole('menuitem', { name: 'Remove' })); + + expect(await screen.findByRole('heading', { name: 'Remove Directory Sync' })).toBeInTheDocument(); + const confirmButton = screen.getByRole('button', { name: 'Remove Directory Sync' }); + expect(confirmButton).toBeDisabled(); + + await userEvent.type(screen.getByRole('textbox'), 'Org1'); + await userEvent.click(confirmButton); + + expect(fixtures.clerk.organization?.deleteDirectorySync).toHaveBeenCalledWith('ent_1'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Set up Directory Sync' })).toBeInTheDocument()); + }); + + it('opens the Directory Sync wizard from Edit', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(directory()); + + const { userEvent } = renderPage(wrapper); + + await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); + await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' })); + + await waitFor(() => expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument()); + }); + }); }); diff --git a/packages/ui/src/contexts/ClerkUIComponentsContext.tsx b/packages/ui/src/contexts/ClerkUIComponentsContext.tsx index 14371aeceaa..c0233771837 100644 --- a/packages/ui/src/contexts/ClerkUIComponentsContext.tsx +++ b/packages/ui/src/contexts/ClerkUIComponentsContext.tsx @@ -14,6 +14,7 @@ import type { ReactNode } from 'react'; import type { AvailableComponentName, AvailableComponentProps } from '../types'; import { APIKeysContext, + ConfigureDirectorySyncContext, ConfigureSSOContext, CreateOrganizationContext, GoogleOneTapContext, @@ -122,6 +123,12 @@ export function ComponentContextProvider({ {children} ); + case 'ConfigureDirectorySync': + return ( + + {children} + + ); case 'OAuthConsent': { // Translate capital-A `oAuth*` props from the accounts portal into // the lowercase `oauth*` context shape the component reads. diff --git a/packages/ui/src/contexts/components/ConfigureDirectorySync.ts b/packages/ui/src/contexts/components/ConfigureDirectorySync.ts new file mode 100644 index 00000000000..85fba22fd81 --- /dev/null +++ b/packages/ui/src/contexts/components/ConfigureDirectorySync.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +import type { ConfigureDirectorySyncCtx } from '../../types'; + +export const ConfigureDirectorySyncContext = createContext(null); + +export const useConfigureDirectorySyncContext = () => { + const context = useContext(ConfigureDirectorySyncContext); + + if (!context || context.componentName !== 'ConfigureDirectorySync') { + throw new Error('Clerk: useConfigureDirectorySyncContext called outside ConfigureDirectorySync.'); + } + + const { componentName, ...ctx } = context; + + return { + ...ctx, + componentName, + }; +}; diff --git a/packages/ui/src/contexts/components/index.ts b/packages/ui/src/contexts/components/index.ts index 25b15a20fab..3d3cea9f9cc 100644 --- a/packages/ui/src/contexts/components/index.ts +++ b/packages/ui/src/contexts/components/index.ts @@ -1,5 +1,6 @@ export * from './APIKeys'; export * from './Checkout'; +export * from './ConfigureDirectorySync'; export * from './ConfigureSSO'; export * from './CreateOrganization'; export * from './GoogleOneTap'; diff --git a/packages/ui/src/lazyModules/components.ts b/packages/ui/src/lazyModules/components.ts index a6564663a43..581f7af1f23 100644 --- a/packages/ui/src/lazyModules/components.ts +++ b/packages/ui/src/lazyModules/components.ts @@ -31,6 +31,10 @@ const componentImportPaths = { SubscriptionDetails: () => import(/* webpackChunkName: "subscriptionDetails" */ '../components/SubscriptionDetails'), APIKeys: () => import(/* webpackChunkName: "apiKeys" */ '../components/APIKeys/APIKeys'), ConfigureSSO: () => import(/* webpackChunkName: "configureSSO" */ '../components/ConfigureSSO/ConfigureSSO'), + ConfigureDirectorySync: () => + import( + /* webpackChunkName: "configureDirectorySync" */ '../components/ConfigureDirectorySync/ConfigureDirectorySync' + ), OAuthConsent: () => import(/* webpackChunkName: "oauthConsent" */ '../components/OAuthConsent/OAuthConsent'), EnableOrganizationsPrompt: () => import(/* webpackChunkName: "enableOrganizationsPrompt" */ '../components/devPrompts/EnableOrganizationsPrompt'), @@ -130,6 +134,10 @@ export const ConfigureSSO = lazy(() => componentImportPaths.ConfigureSSO().then(module => ({ default: module.ConfigureSSO })), ); +export const ConfigureDirectorySync = lazy(() => + componentImportPaths.ConfigureDirectorySync().then(module => ({ default: module.ConfigureDirectorySync })), +); + export const Checkout = lazy(() => componentImportPaths.Checkout().then(module => ({ default: module.Checkout }))); export const TaskChooseOrganization = lazy(() => @@ -192,6 +200,7 @@ export const ClerkComponents = { PlanDetails, APIKeys, ConfigureSSO, + ConfigureDirectorySync, OAuthConsent, SubscriptionDetails, TaskChooseOrganization, diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index a35c7f8adb6..2f8e6701ba8 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -627,9 +627,13 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { }; }; - const withEnterpriseSso = (opts?: { selfServeSSO?: boolean }) => { + const withEnterpriseSso = (opts?: { selfServeSSO?: boolean; selfServeDirectorySync?: boolean }) => { us.saml = { enabled: true }; - us.enterprise_sso = { enabled: true, self_serve_sso: opts?.selfServeSSO ?? false }; + us.enterprise_sso = { + enabled: true, + self_serve_sso: opts?.selfServeSSO ?? false, + self_serve_directory_sync: opts?.selfServeDirectorySync ?? false, + }; }; const withBackupCode = (opts?: Partial) => { diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 3a674696c4b..c46ceca6b7a 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -152,6 +152,11 @@ export type ConfigureSSOCtx = ConfigureSSOProps & { mode?: ComponentMode; }; +export type ConfigureDirectorySyncCtx = ConfigureSSOProps & { + componentName: 'ConfigureDirectorySync'; + mode?: ComponentMode; +}; + export type CheckoutCtx = __internal_CheckoutProps & { componentName: 'Checkout'; } & NewSubscriptionRedirectUrl; @@ -252,6 +257,7 @@ export type AvailableComponentCtx = | CheckoutCtx | APIKeysCtx | ConfigureSSOCtx + | ConfigureDirectorySyncCtx | OAuthConsentCtx | SubscriptionDetailsCtx | PlanDetailsCtx