From 9baac6a354962bc657149964bab1fa68b4fba292 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 26 Aug 2026 23:37:41 -0700 Subject: [PATCH 01/16] 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 | 5 + 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, 1690 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 11bdbbe370a..6b22e9bb03d 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', 'oauthDeviceVerification', 'taskChooseOrganization', @@ -154,6 +155,7 @@ const componentControls: Record = { pricingTable: buildComponentControls('pricingTable'), apiKeys: buildComponentControls('apiKeys'), configureSSO: buildComponentControls('configureSSO'), + configureDirectorySync: buildComponentControls('configureDirectorySync'), oauthConsent: buildComponentControls('oauthConsent'), oauthDeviceVerification: buildComponentControls('oauthDeviceVerification'), taskChooseOrganization: buildComponentControls('taskChooseOrganization'), @@ -430,6 +432,9 @@ void (async () => { '/oauth-device-verification': { mount: '__internal_mountOAuthDeviceVerification', component: 'oauthDeviceVerification', + '/configure-directory-sync': { + mount: '__internal_mountConfigureDirectorySync', + component: 'configureDirectorySync', }, '/task-choose-organization': { mount: 'mountTaskChooseOrganization', diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index e7c5d8d344e..4725d21ea3a 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -313,6 +313,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 7785a50d4b9..d902f4611d4 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.`, @@ -88,6 +89,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 49f7875870c..3c6058e648f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1984,6 +1984,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 32deb108f1b..d23b9c9f9d3 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -63,6 +63,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 68480473c3e..51ed12b53c4 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1218,6 +1218,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 9fc063b9a40..5658e719b73 100644 --- a/packages/ui/src/contexts/ClerkUIComponentsContext.tsx +++ b/packages/ui/src/contexts/ClerkUIComponentsContext.tsx @@ -15,6 +15,7 @@ import type { ReactNode } from 'react'; import type { AvailableComponentName, AvailableComponentProps } from '../types'; import { APIKeysContext, + ConfigureDirectorySyncContext, ConfigureSSOContext, CreateOrganizationContext, GoogleOneTapContext, @@ -124,6 +125,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 15887d4be13..6371933ed60 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 9d4a9a87148..001b6b6eeab 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'), OAuthDeviceVerification: () => import( @@ -134,6 +138,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(() => @@ -200,6 +208,7 @@ export const ClerkComponents = { PlanDetails, APIKeys, ConfigureSSO, + ConfigureDirectorySync, OAuthConsent, OAuthDeviceVerification, SubscriptionDetails, diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index e221bd8c487..2c9f086e1c3 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -656,9 +656,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 31e10f59920..1772a6c520b 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -155,6 +155,11 @@ export type ConfigureSSOCtx = ConfigureSSOProps & { mode?: ComponentMode; }; +export type ConfigureDirectorySyncCtx = ConfigureSSOProps & { + componentName: 'ConfigureDirectorySync'; + mode?: ComponentMode; +}; + export type CheckoutCtx = __internal_CheckoutProps & { componentName: 'Checkout'; } & NewSubscriptionRedirectUrl; @@ -259,6 +264,7 @@ export type AvailableComponentCtx = | CheckoutCtx | APIKeysCtx | ConfigureSSOCtx + | ConfigureDirectorySyncCtx | OAuthConsentCtx | OAuthDeviceVerificationCtx | SubscriptionDetailsCtx From 64070a2fa1e2d474582729e6776d8a07f6387952 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Fri, 28 Aug 2026 15:43:27 -0700 Subject: [PATCH 02/16] Add a note about email domain restrictions --- .../ConfigureDirectorySync/steps/TestSyncStep.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx index 190fdf83d65..00362cc758d 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -92,6 +92,20 @@ export const TestSyncStep = (): JSX.Element => { Users appear here as your identity provider provisions them, most recent activity first. + + ({ fontWeight: t.fontWeights.$medium })} + > + Note: + {' '} + only users with an email address from a configured domain will be processed. + + {rows.length === 0 ? ( Date: Fri, 28 Aug 2026 18:18:02 -0700 Subject: [PATCH 03/16] feat(self-serve-ds): pass the directory resource to the users hook Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U54pszNFtqsBNpQhXaGvaa --- .../ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx index 314102bc2ab..c7a87ab5d31 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -75,10 +75,7 @@ export const ConfigureDirectorySyncProvider = ({ rotateDirectorySyncToken, } = __internal_useOrganizationDirectorySync({ enterpriseConnectionId }); - const usersHook = __internal_useOrganizationDirectorySyncUsers({ - enterpriseConnectionId, - enabled: Boolean(directory), - }); + const usersHook = __internal_useOrganizationDirectorySyncUsers({ directory }); const [revealedToken, setRevealedToken] = React.useState(null); From c2adf6e098f3a2774993d05ea5bead3fbf7bdd78 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Mon, 31 Aug 2026 17:08:35 -0700 Subject: [PATCH 04/16] Misc UI fixes 1. Mock directory.update()/delete() on the resource instead of the removed organization.updateDirectorySync/deleteDirectorySync; also mock getDomains so a retry loop can't wedge the page's loading gate. 2. Security section + wizard:show spinner while loading and an error alert on failure instead of falsely rendering "unconfigured"; wizard shows the skeleton while loading. 3. Standalone mount:wrapped in the shared ConfigureSSOProtect permission gate. 4. Clerk API surface:added __internal_(un)mountConfigureDirectorySync to the shared Clerk interface and IsomorphicClerk, so framework SDKs can reach the mount; added @clerk/react to the changeset. 5. Activate step:"Done"/"Skip for now" render only when the host supplies onExit (they were no-ops standalone). 6. Navbar:DirectorySyncNavbar is now a thin wrapper over ConfigureSSONavbar (with a new title prop), restoring mobile behavior. 7. TestSyncStep:|| instead of ?? so an empty display name falls through. 8. Bundle limits: bumped via bundlewatch:fix (554KB / 81KB). --- .changeset/dir-sync-self-serve-wiring.md | 1 + packages/clerk-js/bundlewatch.config.json | 4 +- packages/react/src/isomorphicClerk.ts | 21 +++ packages/shared/src/types/clerk.ts | 18 +++ .../ConfigureDirectorySync.tsx | 5 +- .../ConfigureDirectorySyncWizard.tsx | 7 +- .../DirectorySyncNavbar.tsx | 124 ++---------------- .../SecurityDirectorySyncSection.tsx | 46 ++++++- .../steps/ActivateDirectorySyncStep.tsx | 45 ++++--- .../steps/TestSyncStep.tsx | 2 +- .../ConfigureSSO/ConfigureSSONavbar.tsx | 16 ++- .../OrganizationSecurityPage.test.tsx | 23 +++- packages/ui/src/elements/Navbar.tsx | 2 +- 13 files changed, 156 insertions(+), 158 deletions(-) diff --git a/.changeset/dir-sync-self-serve-wiring.md b/.changeset/dir-sync-self-serve-wiring.md index da7626a3eb6..b752983c0a0 100644 --- a/.changeset/dir-sync-self-serve-wiring.md +++ b/.changeset/dir-sync-self-serve-wiring.md @@ -1,6 +1,7 @@ --- '@clerk/clerk-js': minor '@clerk/localizations': minor +'@clerk/react': minor '@clerk/shared': minor '@clerk/ui': minor --- diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 83c11c2961c..ee2a0c86855 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "553KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "79.5KB" }, + { "path": "./dist/clerk.js", "maxSize": "554KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "122.5KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "320KB" }, { "path": "./dist/clerk.native.js", "maxSize": "79KB" }, diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 790e1ac099b..8bdbbd935ad 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -166,6 +166,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountPricingTableNodes = new Map(); private premountAPIKeysNodes = new Map(); private premountConfigureSSONodes = new Map(); + private premountConfigureDirectorySyncNodes = new Map(); private premountOAuthConsentNodes = new Map(); private premountOAuthDeviceVerificationNodes = new Map(); private premountTaskChooseOrganizationNodes = new Map(); @@ -801,6 +802,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.__internal_mountConfigureSSO(node, props); }); + this.premountConfigureDirectorySyncNodes.forEach((props, node) => { + clerkjs.__internal_mountConfigureDirectorySync(node, props); + }); + this.premountOAuthConsentNodes.forEach((props, node) => { clerkjs.__internal_mountOAuthConsent(node, props); }); @@ -1387,6 +1392,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_mountConfigureDirectorySync = (node: HTMLDivElement, props?: ConfigureSSOProps): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__internal_mountConfigureDirectorySync(node, props); + } else { + this.premountConfigureDirectorySyncNodes.set(node, props); + } + }; + + __internal_unmountConfigureDirectorySync = (node: HTMLDivElement): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__internal_unmountConfigureDirectorySync(node); + } else { + this.premountConfigureDirectorySyncNodes.delete(node); + } + }; + __internal_mountOAuthConsent = (node: HTMLDivElement, props?: OAuthConsentProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_mountOAuthConsent(node, props); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 3c6058e648f..578f1d15555 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -816,6 +816,24 @@ export interface Clerk { */ __internal_unmountConfigureSSO: (targetNode: HTMLDivElement) => void; + /** + * Mount a configure Directory Sync component at the target element. + * + * @param targetNode - Target to mount the ConfigureDirectorySync component. + * @param props - Configuration parameters. + * @hidden + */ + __internal_mountConfigureDirectorySync: (targetNode: HTMLDivElement, props?: ConfigureSSOProps) => void; + + /** + * Unmount a configure Directory Sync 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 + */ + __internal_unmountConfigureDirectorySync: (targetNode: HTMLDivElement) => void; + /** * Mounts a OAuth consent component at the target element. * diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx index 847de73bfe7..6094fb48d73 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx @@ -7,6 +7,7 @@ import { withCardStateProvider } from '@/elements/contexts'; import { ProfileCard } from '@/elements/ProfileCard'; import { Route, Switch } from '@/router'; +import { ConfigureSSOProtect } from '../ConfigureSSO/ConfigureSSO'; import { ConfigureDirectorySyncWizard } from './ConfigureDirectorySyncWizard'; import { DirectorySyncNavbar } from './DirectorySyncNavbar'; @@ -35,7 +36,9 @@ const AuthenticatedContent = withCoreUserGuard(() => { sx={t => ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} > - + + + ); diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx index 1d5ad7497a7..7950758534d 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { CardStateProvider } from '@/elements/contexts'; import { ConfigureSSOHeader } from '../ConfigureSSO/ConfigureSSOHeader'; +import { ConfigureSSOSkeleton } from '../ConfigureSSO/ConfigureSSOSkeleton'; import { Step } from '../ConfigureSSO/elements/Step'; import { Wizard, type WizardStepConfig } from '../ConfigureSSO/elements/Wizard'; import { ConfigureDirectorySyncProvider, useConfigureDirectorySync } from './ConfigureDirectorySyncContext'; @@ -29,7 +30,7 @@ export const ConfigureDirectorySyncWizard = (props: ConfigureDirectorySyncWizard ); const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Element => { - const { connection, directory } = useConfigureDirectorySync(); + const { connection, directory, isLoading } = useConfigureDirectorySync(); const hasSsoConnection = Boolean(connection); const hasDirectory = Boolean(directory); const isDirectorySyncActive = directory?.enabled ?? false; @@ -50,6 +51,10 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme [hasSsoConnection, hasDirectory, isDirectorySyncActive], ); + if (isLoading) { + return ; + } + return ( ; }>; /** - * Simplified copy of ConfigureSSONavbar (no NavBar/mobile handling) carrying - * the Directory Sync title. + * ConfigureSSO's responsive navbar carrying the Directory Sync title. The + * title stays hardcoded until the flow gets its own localization surface. */ -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} - - ); -}; +export const DirectorySyncNavbar = ({ children, contentRef }: DirectorySyncNavbarProps): JSX.Element => ( + + {children} + +); diff --git a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx index a76870179eb..96a9a166382 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -4,6 +4,7 @@ import { } from '@clerk/shared/react'; import { useState } from 'react'; +import { Alert } from '@/ui/elements/Alert'; import { Card } from '@/ui/elements/Card'; import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; import { ProfileSection } from '@/ui/elements/Section'; @@ -11,7 +12,7 @@ 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 { Badge, Button, Col, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables'; import { ResetConnectionDialog } from '../ConfigureSSO/ResetConnectionDialog'; type SecurityDirectorySyncSectionProps = { @@ -49,16 +50,27 @@ export const SecurityDirectorySyncSection = ({ contentRef, onConfigure, }: SecurityDirectorySyncSectionProps): JSX.Element => { - const { data: connections } = __internal_useOrganizationEnterpriseConnections(); + const { + data: connections, + isLoading: isLoadingConnections, + error: connectionsError, + } = __internal_useOrganizationEnterpriseConnections(); const connection = connections?.[0]; const { data: directory, + isLoading: isLoadingDirectory, + error: directoryError, updateDirectorySync, deleteDirectorySync, } = __internal_useOrganizationDirectorySync({ enterpriseConnectionId: connection?.id ?? null, }); + // A 404 (no directory yet) resolves to `data: null` — errors here are real failures. + const isLoading = isLoadingConnections || (Boolean(connection) && isLoadingDirectory); + const error = connectionsError ?? directoryError; + const isSettled = !isLoading && !error; + const status: DirectorySyncStatus = directory ? (directory.enabled ? 'active' : 'inactive') : 'unconfigured'; const badge = STATUS_BADGES[status]; @@ -68,13 +80,33 @@ export const SecurityDirectorySyncSection = ({ id='directorySync' centered={false} badge={ - + isSettled ? ( + + ) : undefined } > - {status === 'unconfigured' ? ( + {isLoading ? ( + ({ paddingBlock: t.space.$5 })} + > + + + ) : error ? ( + + ) : status === 'unconfigured' ? ( { {isActive ? ( - + // Exit-only: without a host-supplied onExit (the standalone mount) there is nowhere to go. + onExit && ( + + ) ) : ( { Activate Directory Sync - + isDisabled={card.isLoading} + onClick={onExit} + > + Skip for now + ({ marginInlineStart: t.space.$1 })} + /> + + )} )} diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx index 00362cc758d..8cde869028f 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -28,7 +28,7 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. as='span' sx={t => ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })} > - {user.identifier ?? displayName ?? user.userId} + {user.identifier || displayName || user.userId} {displayName && user.identifier && ( ; + title?: LocalizationKey | string; }>; -export const ConfigureSSONavbar = ({ children, contentRef }: ConfigureSSONavbarProps) => { +export const ConfigureSSONavbar = ({ + children, + contentRef, + title = localizationKeys('configureSSO.navbar.title'), +}: ConfigureSSONavbarProps) => { const { parsedOptions } = useAppearance(); const { organizationSettings, @@ -25,7 +31,7 @@ export const ConfigureSSONavbar = ({ children, contentRef }: ConfigureSSONavbarP ({ fontSize: t.fontSizes.$lg })} containerSx={{ flexDirection: 'column-reverse', @@ -94,14 +100,14 @@ export const ConfigureSSONavbar = ({ children, contentRef }: ConfigureSSONavbarP flex: 1, })} > - + {children} ); }; -const ConfigureSSOMobileNavbar = () => { +const ConfigureSSOMobileNavbar = ({ title }: { title: LocalizationKey | string }) => { const { parsedOptions } = useAppearance(); const { organizationSettings, @@ -177,7 +183,7 @@ const ConfigureSSOMobileNavbar = () => { ({ fontSize: t.fontSizes.$lg })} /> diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index d3ca7c6e64d..a9bc25b9b12 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -1,5 +1,5 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen, waitFor } from '@/test/utils'; @@ -490,6 +490,7 @@ describe('OrganizationSecurityPage', () => { f.withEnterpriseSso({ selfServeSSO: true, selfServeDirectorySync: true }); }; + // The mutations live on the DirectorySyncResource resolved by getDirectorySync. const directory = (overrides: Record = {}) => ({ id: 'scimdir_1', @@ -499,6 +500,9 @@ describe('OrganizationSecurityPage', () => { enabled: true, attributeMapping: {}, apiKey: null, + update: vi.fn(), + delete: vi.fn(), + rotateToken: vi.fn(), ...overrides, }) as any; @@ -508,6 +512,9 @@ describe('OrganizationSecurityPage', () => { data: [], total_count: 0, } as any); + // The page-level loading gate also waits on the domains query; an unmocked + // fetch resolves undefined and error-retries, wedging the gate open. + fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [], total_count: 0 } as any); }; it('is hidden when the instance is not flagged into self-serve Directory Sync', async () => { @@ -555,10 +562,11 @@ describe('OrganizationSecurityPage', () => { it('deactivates from the menu and settles on the revalidated directory', async () => { const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); withActiveConnection(fixtures); + const activeDirectory = directory(); + activeDirectory.update.mockResolvedValue(directory({ enabled: false })); fixtures.clerk.organization?.getDirectorySync - .mockResolvedValueOnce(directory()) + .mockResolvedValueOnce(activeDirectory) .mockResolvedValue(directory({ enabled: false })); - fixtures.clerk.organization?.updateDirectorySync.mockResolvedValue(directory({ enabled: false })); const { userEvent } = renderPage(wrapper); @@ -566,7 +574,7 @@ describe('OrganizationSecurityPage', () => { 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 }); + expect(activeDirectory.update).toHaveBeenCalledWith({ enabled: false }); await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Activate' })).toBeInTheDocument()); @@ -575,8 +583,9 @@ describe('OrganizationSecurityPage', () => { 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 activeDirectory = directory(); + activeDirectory.delete.mockResolvedValue({ id: 'scimdir_1', deleted: true }); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValueOnce(activeDirectory).mockResolvedValue(null); const { userEvent } = renderPage(wrapper); @@ -591,7 +600,7 @@ describe('OrganizationSecurityPage', () => { await userEvent.type(screen.getByRole('textbox'), 'Org1'); await userEvent.click(confirmButton); - expect(fixtures.clerk.organization?.deleteDirectorySync).toHaveBeenCalledWith('ent_1'); + expect(activeDirectory.delete).toHaveBeenCalledWith(); await waitFor(() => expect(screen.getByRole('button', { name: 'Set up Directory Sync' })).toBeInTheDocument()); }); diff --git a/packages/ui/src/elements/Navbar.tsx b/packages/ui/src/elements/Navbar.tsx index 46dd6e59222..cd693942701 100644 --- a/packages/ui/src/elements/Navbar.tsx +++ b/packages/ui/src/elements/Navbar.tsx @@ -43,7 +43,7 @@ export type NavbarRoute = { external?: boolean; }; type NavBarProps = { - title: LocalizationKey; + title: LocalizationKey | string; titleSx?: ThemableCssProp; containerSx?: ThemableCssProp; description?: LocalizationKey; From c67d16f6b15b6033830b29ab3d6b4e231630fb85 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 2 Sep 2026 08:39:22 -0700 Subject: [PATCH 05/16] Initial UI review updates --- packages/shared/src/types/localization.ts | 1 + .../ConfigureDirectorySyncWizard.tsx | 20 +- .../SecurityDirectorySyncSection.tsx | 31 +- .../ConfigureDirectorySyncWizard.test.tsx | 112 +++++++ .../steps/AttributeMappingStep.tsx | 74 ++--- .../steps/ConfigureStep.tsx | 307 ++++++++++++++++++ .../steps/ConnectionStep.tsx | 146 --------- .../steps/EndpointTokenStep.tsx | 142 -------- .../OrganizationSecurityPage.test.tsx | 21 +- 9 files changed, 499 insertions(+), 355 deletions(-) create mode 100644 packages/ui/src/components/ConfigureDirectorySync/__tests__/ConfigureDirectorySyncWizard.test.tsx create mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/ConfigureStep.tsx delete mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx delete mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/EndpointTokenStep.tsx diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 51ed12b53c4..5da0c272b34 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1221,6 +1221,7 @@ export type __internal_LocalizationResource = { directorySyncSection: { title: LocalizationValue; badge__unconfigured: LocalizationValue; + badge__ssoRequired: LocalizationValue; badge__active: LocalizationValue; badge__inactive: LocalizationValue; description: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx index 7950758534d..6f660bcf5fe 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -9,8 +9,7 @@ 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 { ConfigureStep } from './steps/ConfigureStep'; import { TestSyncStep } from './steps/TestSyncStep'; export type ConfigureDirectorySyncWizardProps = { @@ -37,8 +36,7 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme const steps = React.useMemo( () => [ - { id: 'connection', label: 'Connection', isComplete: () => hasSsoConnection }, - { id: 'endpoint', label: 'Endpoint', isReachable: () => hasSsoConnection && hasDirectory }, + { id: 'configure', label: 'Configure', isComplete: () => hasSsoConnection && hasDirectory }, { id: 'attributes', label: 'Attributes', isReachable: () => hasSsoConnection && hasDirectory }, { id: 'test', label: 'Test', isReachable: () => hasSsoConnection && hasDirectory }, { @@ -58,22 +56,14 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme return ( - + - - - - - - - - - + diff --git a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx index 96a9a166382..c240d121f38 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -56,6 +56,7 @@ export const SecurityDirectorySyncSection = ({ error: connectionsError, } = __internal_useOrganizationEnterpriseConnections(); const connection = connections?.[0]; + const hasSsoConnection = Boolean(connection); const { data: directory, isLoading: isLoadingDirectory, @@ -112,15 +113,29 @@ export const SecurityDirectorySyncSection = ({ gap={4} > - + + + ({ + gap: t.space.$1x5, + padding: `0 ${t.space.$4} ${t.space.$4}`, + paddingInlineStart: t.space.$8, + listStyle: 'decimal', + })} + > + {instructions.map(instruction => ( + ({ fontSize: t.fontSizes.$sm })} + > + {instruction} + + ))} + + + + )} + + + {isGoogle && ( + + )} + + {!connection.active && !isGoogle && ( + + )} + + {directory ? ( + <> + ({ gap: t.space.$1x5 })}> + SCIM endpoint URL + + + + ({ gap: t.space.$1x5 })}> + Bearer token + ({ gap: t.space.$2 })} + > + {revealedToken ? ( + + ) : ( + + )} + + + ({ gap: t.space.$1x5 })} + > + + ({ fontSize: t.fontSizes.$sm })} + > + This token is only shown once. Generate a new token if you lose it. + + + + + ) : ( + canProvision && + !card.error && ( + ({ paddingBlock: t.space.$5 })} + > + + + ) + )} + + )} + + {card.error && ( + + )} + + {!directory && canProvision && card.error && ( + + )} + + + + + goNext()} + isDisabled={!directory} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx deleted file mode 100644 index f2229d5d5d5..00000000000 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/ConnectionStep.tsx +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index a949e4b3cac..00000000000 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/EndpointTokenStep.tsx +++ /dev/null @@ -1,142 +0,0 @@ -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/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index a9bc25b9b12..cb92e09a2bd 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -538,10 +538,27 @@ describe('OrganizationSecurityPage', () => { renderPage(wrapper); - expect(await screen.findByRole('button', { name: 'Set up Directory Sync' })).toBeInTheDocument(); + const startButton = await screen.findByRole('button', { name: 'Start configuration' }); + expect(startButton).toBeEnabled(); + expect(screen.queryByText('SSO Required')).not.toBeInTheDocument(); expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1); }); + it('disables setup and flags SSO as required when no connection exists', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); + fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [], total_count: 0 } as any); + + renderPage(wrapper); + + expect(await screen.findByText('SSO Required')).toBeInTheDocument(); + const startButtons = screen.getAllByRole('button', { name: 'Start configuration' }); + expect(startButtons).toHaveLength(2); + expect(startButtons[0]).toBeEnabled(); + expect(startButtons[1]).toBeDisabled(); + expect(fixtures.clerk.organization?.getDirectorySync).not.toHaveBeenCalled(); + }); + it('lists Edit and Deactivate for an active directory', async () => { const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); withActiveConnection(fixtures); @@ -601,7 +618,7 @@ describe('OrganizationSecurityPage', () => { await userEvent.click(confirmButton); expect(activeDirectory.delete).toHaveBeenCalledWith(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Set up Directory Sync' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Start configuration' })).toBeInTheDocument()); }); it('opens the Directory Sync wizard from Edit', async () => { From da808e0b2ebe0ecf53c6a8e88e2014b23814095d Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 2 Sep 2026 14:06:38 -0700 Subject: [PATCH 06/16] Remove activation step --- .../ConfigureDirectorySyncWizard.tsx | 18 +-- .../steps/ActivateDirectorySyncStep.tsx | 130 ------------------ .../steps/TestSyncStep.tsx | 18 +-- 3 files changed, 11 insertions(+), 155 deletions(-) delete mode 100644 packages/ui/src/components/ConfigureDirectorySync/steps/ActivateDirectorySyncStep.tsx diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx index 6f660bcf5fe..9745af22515 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -7,7 +7,6 @@ import { ConfigureSSOSkeleton } from '../ConfigureSSO/ConfigureSSOSkeleton'; 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 { ConfigureStep } from './steps/ConfigureStep'; import { TestSyncStep } from './steps/TestSyncStep'; @@ -32,21 +31,14 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme const { connection, directory, isLoading } = useConfigureDirectorySync(); const hasSsoConnection = Boolean(connection); const hasDirectory = Boolean(directory); - const isDirectorySyncActive = directory?.enabled ?? false; const steps = React.useMemo( () => [ { id: 'configure', label: 'Configure', isComplete: () => 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], + [hasSsoConnection, hasDirectory], ); if (isLoading) { @@ -83,14 +75,6 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme - - - - - - - - ); }; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/ActivateDirectorySyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/ActivateDirectorySyncStep.tsx deleted file mode 100644 index 15098fe7561..00000000000 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/ActivateDirectorySyncStep.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Button, Col, Flex, Heading, Icon, Text } from '@/customizables'; -import { useCardState } from '@/elements/contexts'; -import { ChevronRight, DuotoneShieldCheck } from '@/icons'; -import { Alert } from '@/ui/elements/Alert'; -import { handleError } from '@/utils/errorHandler'; - -import { Step } from '../../ConfigureSSO/elements/Step'; -import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; - -export const ActivateDirectorySyncStep = (): JSX.Element => { - const { directory, setDirectoryEnabled, onExit } = useConfigureDirectorySync(); - const card = useCardState(); - - const isActive = directory?.enabled ?? false; - - const handleActivate = async (): Promise => { - if (!directory || card.isLoading) { - return; - } - - card.setError(undefined); - card.setLoading(); - try { - await setDirectoryEnabled(true); - } catch (err) { - handleError(err as Error, [], card.setError); - } finally { - card.setIdle(); - } - }; - - return ( - - - ({ textAlign: 'center', maxWidth: '24rem', gap: t.space.$3x5 })} - > - ({ width: t.sizes.$8, height: t.sizes.$8 })} - /> - - - {isActive ? 'Directory Sync is active' : 'Directory Sync configured'} - - {isActive - ? 'Your identity provider now manages who belongs to this organization.' - : 'Once activated, your identity provider manages who belongs to this organization — members are added, updated, and removed automatically.'} - - - - {!isActive && ( - ({ fontSize: t.fontSizes.$sm })} - > - When your identity provider deprovisions a user, they keep their Clerk account but are signed out of all - sessions and lose access. - - )} - - {card.error && ( - - )} - - - {isActive ? ( - // Exit-only: without a host-supplied onExit (the standalone mount) there is nowhere to go. - onExit && ( - - ) - ) : ( - - - - {onExit && ( - - )} - - )} - - - ); -}; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx index 8cde869028f..e9266690c69 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -1,7 +1,7 @@ import type { DirectorySyncUserResource } from '@clerk/shared/types'; import React from 'react'; -import { Badge, Col, Flex, Spinner, Text } from '@/customizables'; +import { Badge, Button, Col, Flex, Spinner, Text } from '@/customizables'; import { Alert } from '@/ui/elements/Alert'; import { Step } from '../../ConfigureSSO/elements/Step'; @@ -60,11 +60,10 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. }; export const TestSyncStep = (): JSX.Element => { - const { goNext, goPrev } = useWizard(); - const { providerMeta, users } = useConfigureDirectorySync(); + const { goPrev } = useWizard(); + const { providerMeta, users, onExit } = 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 @@ -161,10 +160,13 @@ export const TestSyncStep = (): JSX.Element => { goPrev()} /> - goNext()} - isDisabled={!hasProvisionedUser} - /> + ); From e9168a7bdec752ccecccf4bb6e6694dc3b6c33c0 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 2 Sep 2026 14:58:32 -0700 Subject: [PATCH 07/16] Tweak IdP instructions --- .../src/components/ConfigureDirectorySync/providerMeta.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts b/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts index 357d0d30cf1..6a11db7505e 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts +++ b/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts @@ -14,9 +14,9 @@ export const DIRECTORY_SYNC_PROVIDERS: Record Date: Thu, 3 Sep 2026 10:18:53 -0700 Subject: [PATCH 08/16] Fix sandbox --- packages/clerk-js/sandbox/app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 6b22e9bb03d..bf9d3c8b9bf 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -432,6 +432,7 @@ void (async () => { '/oauth-device-verification': { mount: '__internal_mountOAuthDeviceVerification', component: 'oauthDeviceVerification', + }, '/configure-directory-sync': { mount: '__internal_mountConfigureDirectorySync', component: 'configureDirectorySync', From 268695ed772c023fc5854a3d34d5a2c5a5b84771 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 8 Sep 2026 15:18:04 -0700 Subject: [PATCH 09/16] Initial review addressing --- packages/clerk-js/src/core/clerk.ts | 7 +- packages/localizations/src/en-US.ts | 103 +++++++++++++++ packages/react/src/isomorphicClerk.ts | 17 +-- packages/shared/src/types/localization.ts | 78 ++++++++++++ .../ConfigureDirectorySync.tsx | 5 +- .../ConfigureDirectorySyncContext.tsx | 95 +++++--------- .../ConfigureDirectorySyncWizard.tsx | 19 ++- .../DirectorySyncNavbar.tsx | 8 +- .../SecurityDirectorySyncSection.tsx | 18 +-- .../ConfigureDirectorySyncWizard.test.tsx | 21 ++++ .../ConfigureDirectorySync/providerMeta.ts | 43 +++---- .../steps/AttributeMappingStep.tsx | 47 +++++-- .../steps/ConfigureStep.tsx | 117 ++++++++++++------ .../steps/TestSyncStep.tsx | 86 ++++++++----- .../ConfigureSSO/ConfigureSSONavbar.tsx | 4 +- .../OrganizationSecurityPage.test.tsx | 17 +++ .../src/customizables/elementDescriptors.ts | 25 ++++ packages/ui/src/elements/Navbar.tsx | 2 +- packages/ui/src/elements/contexts/index.tsx | 1 + packages/ui/src/internal/appearance.ts | 29 +++++ 20 files changed, 542 insertions(+), 200 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 61e20734923..6b719fefaf6 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1721,8 +1721,9 @@ export class Clerk implements ClerkInterface { /** * 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. + * Directory Sync provisions members through the organization's SSO connection, + * so it requires organizations to be enabled and the self-serve Directory Sync + * feature to be turned on for the instance. * * @param targetNode Target to mount the ConfigureDirectorySync component. * @param props Configuration parameters. @@ -1769,7 +1770,7 @@ export class Clerk implements ClerkInterface { .then(controls => controls.mountComponent({ name: component, - appearanceKey: 'configureSSO', + appearanceKey: 'configureDirectorySync', node, props, }), diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d8b2c7c8b4c..d78a6c85341 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -221,6 +221,88 @@ export const enUS: LocalizationResource = { yearPerUnit: 'Year per {{unitName}}', years: 'Years', }, + configureDirectorySync: { + attributeMappingStep: { + columns: { + clerkAttribute: 'Clerk user attributes', + directoryAttribute: 'Directory attributes', + }, + subtitle: 'Standard directory attributes are pre-configured by Clerk. Attributes not listed here are ignored.', + title: 'Attribute review', + }, + configureStep: { + actionLabel__generateToken: 'Generate new token', + actionLabel__retry: 'Try again', + domainsLabel: 'Domains:', + error__ssoRequired: { + subtitle: + 'Directory Sync requires an SSO connection. Configure and verify your SSO connection first, then return here to set up provisioning.', + title: 'Single Sign-On is not configured yet', + }, + formFieldInputPlaceholder__token: 'Generate a new token to reveal it', + formFieldLabel__endpointUrl: 'SCIM endpoint URL', + formFieldLabel__token: 'Bearer token', + instructions: { + actionLabel__toggle: 'View instructions', + custom: { + step1: 'Create a SCIM 2.0 provisioning integration in your identity provider.', + step2: 'Paste the SCIM endpoint URL as the base URL for the integration.', + step3: 'Configure the integration to authenticate with the bearer token below.', + step4: 'Enable provisioning for user create, update, and deactivate events.', + }, + entra: { + step1: + 'In the Microsoft Entra admin center, open Enterprise applications and select the application used for your SSO connection.', + step2: + 'Under Connectivity, paste the SCIM endpoint URL as the Tenant URL and the bearer token as the Secret Token, then select Test Connection.', + step3: 'Select Provisioning and set the provisioning mode to Automatic.', + step4: 'Assign the users and groups to provision, then turn provisioning On.', + }, + okta: { + step1: 'In the Okta Admin Console, open the application used for your SSO connection.', + step2: 'Open the Provisioning tab and select the Integration setting.', + step3: 'Paste the SCIM endpoint URL and bearer token found below.', + step4: 'For provisioning actions, enable pushing of New Users, Profile Updates, and Groups.', + }, + }, + notice__tokenShownOnce: 'This token is only shown once. Generate a new token if you lose it.', + subtitle: 'Add these credentials to your identity provider to configure Directory Sync', + title: 'Configure', + warning__googleUnsupported: { + subtitle: + 'Google Workspace provisions through a credential-based integration instead of SCIM push. Set up Directory Sync for this connection from the Clerk Dashboard.', + title: 'Google Workspace connections are not supported here', + }, + warning__ssoInactive: + 'Your SSO connection is configured but not active. Members can be provisioned now, but they can only sign in once SSO is activated.', + }, + navbar: { + title: 'Configure Directory Sync', + }, + providers: { + custom: 'Custom SCIM provider', + entra: 'Microsoft Entra ID', + google: 'Google Workspace', + okta: 'Okta Workforce', + }, + stepper: { + attributes: 'Attributes', + configure: 'Configure', + test: 'Test', + }, + testStep: { + actionLabel__complete: 'Complete', + badge__active: 'Active', + badge__deprovisioned: 'Deprovisioned', + description: 'Users appear here as your identity provider provisions them, most recent activity first.', + empty__waitingForFirstUser: 'Waiting for the first provisioned user…', + error__loadUsers: 'Could not load provisioned users', + note: 'only users with an email address from a configured domain will be processed.', + noteLabel: 'Note:', + subtitle: 'Assign or push a test user from {{provider}} to verify provisioning reaches Clerk.', + title: 'Test provisioning', + }, + }, configureSSO: { activate: { activateButton: 'Activate SSO', @@ -1232,6 +1314,27 @@ export const enUS: LocalizationResource = { title: 'Remove domain', }, securityPage: { + directorySyncSection: { + badge__active: 'Active', + badge__inactive: 'Inactive', + badge__ssoRequired: 'SSO Required', + badge__unconfigured: 'Unconfigured', + description: + "Automatically add, update, and remove organization members from your identity provider's directory. Requires an SSO connection.", + error__load: 'Could not load Directory Sync', + menuAction__activate: 'Activate', + menuAction__deactivate: 'Deactivate', + menuAction__edit: 'Edit', + menuAction__remove: 'Remove', + primaryButton__startConfiguration: 'Start configuration', + removeDialog: { + confirmButton: 'Remove Directory Sync', + subtitle: + 'Are you sure you want to remove Directory Sync? This action is irreversible: the directory and its bearer token are deleted and your identity provider will no longer be able to provision members. Existing members keep their memberships.', + title: 'Remove Directory Sync', + }, + title: 'Directory Sync', + }, removeDialog: { confirmButton: 'Remove connection', subtitle: diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 8bdbbd935ad..1bceeeacdb3 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -802,9 +802,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.__internal_mountConfigureSSO(node, props); }); - this.premountConfigureDirectorySyncNodes.forEach((props, node) => { - clerkjs.__internal_mountConfigureDirectorySync(node, props); - }); + if (typeof clerkjs.__internal_mountConfigureDirectorySync === 'function') { + this.premountConfigureDirectorySyncNodes.forEach((props, node) => { + clerkjs.__internal_mountConfigureDirectorySync(node, props); + }); + } this.premountOAuthConsentNodes.forEach((props, node) => { clerkjs.__internal_mountOAuthConsent(node, props); @@ -1394,18 +1396,19 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { __internal_mountConfigureDirectorySync = (node: HTMLDivElement, props?: ConfigureSSOProps): void => { if (this.clerkjs && this.loaded) { - this.clerkjs.__internal_mountConfigureDirectorySync(node, props); + if (typeof this.clerkjs.__internal_mountConfigureDirectorySync === 'function') { + this.clerkjs.__internal_mountConfigureDirectorySync(node, props); + } } else { this.premountConfigureDirectorySyncNodes.set(node, props); } }; __internal_unmountConfigureDirectorySync = (node: HTMLDivElement): void => { - if (this.clerkjs && this.loaded) { + if (this.clerkjs && this.loaded && typeof this.clerkjs.__internal_unmountConfigureDirectorySync === 'function') { this.clerkjs.__internal_unmountConfigureDirectorySync(node); - } else { - this.premountConfigureDirectorySyncNodes.delete(node); } + this.premountConfigureDirectorySyncNodes.delete(node); }; __internal_mountOAuthConsent = (node: HTMLDivElement, props?: OAuthConsentProps) => { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 5da0c272b34..2689cc55d05 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1225,6 +1225,7 @@ export type __internal_LocalizationResource = { badge__active: LocalizationValue; badge__inactive: LocalizationValue; description: LocalizationValue; + error__load: LocalizationValue; primaryButton__startConfiguration: LocalizationValue; menuAction__edit: LocalizationValue; menuAction__activate: LocalizationValue; @@ -1468,6 +1469,83 @@ export type __internal_LocalizationResource = { message: LocalizationValue; }; }; + configureDirectorySync: { + navbar: { + title: LocalizationValue; + }; + stepper: { + configure: LocalizationValue; + attributes: LocalizationValue; + test: LocalizationValue; + }; + providers: { + okta: LocalizationValue; + entra: LocalizationValue; + google: LocalizationValue; + custom: LocalizationValue; + }; + configureStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + error__ssoRequired: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + warning__googleUnsupported: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + warning__ssoInactive: LocalizationValue; + domainsLabel: LocalizationValue; + instructions: { + actionLabel__toggle: LocalizationValue; + okta: { + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + }; + entra: { + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + }; + custom: { + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + }; + }; + formFieldLabel__endpointUrl: LocalizationValue; + formFieldLabel__token: LocalizationValue; + formFieldInputPlaceholder__token: LocalizationValue; + actionLabel__generateToken: LocalizationValue; + notice__tokenShownOnce: LocalizationValue; + actionLabel__retry: LocalizationValue; + }; + attributeMappingStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + columns: { + directoryAttribute: LocalizationValue; + clerkAttribute: LocalizationValue; + }; + }; + testStep: { + title: LocalizationValue; + subtitle: LocalizationValue<'provider'>; + description: LocalizationValue; + noteLabel: LocalizationValue; + note: LocalizationValue; + empty__waitingForFirstUser: LocalizationValue; + badge__active: LocalizationValue; + badge__deprovisioned: LocalizationValue; + error__loadUsers: LocalizationValue; + actionLabel__complete: LocalizationValue; + }; + }; configureSSO: { missingManageEnterpriseConnectionsPermission: { title: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx index 6094fb48d73..50dd4efdd78 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx @@ -13,12 +13,11 @@ 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. + * ConfigureSSO's shell. */ const ConfigureDirectorySyncInternal = (): JSX.Element => { return ( - + diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx index c7a87ab5d31..efe8ee00e73 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -57,6 +57,11 @@ type ConfigureDirectorySyncProviderProps = PropsWithChildren<{ onExit?: () => void; }>; +type RevealedToken = { + enterpriseConnectionId: string; + token: string; +}; + export const ConfigureDirectorySyncProvider = ({ onExit, children, @@ -75,29 +80,28 @@ export const ConfigureDirectorySyncProvider = ({ rotateDirectorySyncToken, } = __internal_useOrganizationDirectorySync({ enterpriseConnectionId }); - const usersHook = __internal_useOrganizationDirectorySyncUsers({ directory }); + const users = __internal_useOrganizationDirectorySyncUsers({ directory }); - const [revealedToken, setRevealedToken] = React.useState(null); + // The token is stored with the connection it was issued for, so a response + // that lands after the connection changed is never shown for the new one. + const [revealed, setRevealed] = React.useState(null); + const revealedToken = revealed && revealed.enterpriseConnectionId === enterpriseConnectionId ? revealed.token : null; - React.useEffect(() => { - // The token belongs to the current connection's directory; drop it if the - // connection changes mid-session. - setRevealedToken(null); - }, [enterpriseConnectionId]); + const revealFrom = (result: DirectorySyncResource | undefined): void => { + if (result?.apiKey) { + setRevealed({ enterpriseConnectionId: result.enterpriseConnectionId, token: result.apiKey }); + } + }; const createDirectory = React.useCallback(async () => { const created = await createDirectorySync(); - if (created?.apiKey) { - setRevealedToken(created.apiKey); - } + revealFrom(created); return created; }, [createDirectorySync]); const rotateToken = React.useCallback(async () => { const rotated = await rotateDirectorySyncToken(); - if (rotated?.apiKey) { - setRevealedToken(rotated.apiKey); - } + revealFrom(rotated); return rotated; }, [rotateDirectorySyncToken]); @@ -109,58 +113,19 @@ export const ConfigureDirectorySyncProvider = ({ 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, - ], - ); + const value: ConfigureDirectorySyncData = { + isLoading: isLoadingConnections || (Boolean(enterpriseConnectionId) && isLoadingDirectory), + connection, + provider, + providerMeta: provider ? DIRECTORY_SYNC_PROVIDERS[provider] : undefined, + directory, + revealedToken, + createDirectory, + rotateToken, + setDirectoryEnabled, + users, + onExit, + }; return {children}; }; diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx index 9745af22515..438ea05b842 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { localizationKeys } from '@/customizables'; import { CardStateProvider } from '@/elements/contexts'; import { ConfigureSSOHeader } from '../ConfigureSSO/ConfigureSSOHeader'; @@ -34,9 +35,21 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme const steps = React.useMemo( () => [ - { id: 'configure', label: 'Configure', isComplete: () => hasSsoConnection && hasDirectory }, - { id: 'attributes', label: 'Attributes', isReachable: () => hasSsoConnection && hasDirectory }, - { id: 'test', label: 'Test', isReachable: () => hasSsoConnection && hasDirectory }, + { + id: 'configure', + label: localizationKeys('configureDirectorySync.stepper.configure'), + isComplete: () => hasSsoConnection && hasDirectory, + }, + { + id: 'attributes', + label: localizationKeys('configureDirectorySync.stepper.attributes'), + isReachable: () => hasSsoConnection && hasDirectory, + }, + { + id: 'test', + label: localizationKeys('configureDirectorySync.stepper.test'), + isReachable: () => hasSsoConnection && hasDirectory, + }, ], [hasSsoConnection, hasDirectory], ); diff --git a/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx b/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx index c39c63ad52e..173659904b2 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx @@ -1,19 +1,17 @@ import React from 'react'; +import { localizationKeys } from '@/customizables'; + import { ConfigureSSONavbar } from '../ConfigureSSO/ConfigureSSONavbar'; type DirectorySyncNavbarProps = React.PropsWithChildren<{ contentRef: React.RefObject; }>; -/** - * ConfigureSSO's responsive navbar carrying the Directory Sync title. The - * title stays hardcoded until the flow gets its own localization surface. - */ export const DirectorySyncNavbar = ({ children, contentRef }: DirectorySyncNavbarProps): JSX.Element => ( {children} diff --git a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx index c240d121f38..4a82d6cf51a 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -67,7 +67,7 @@ export const SecurityDirectorySyncSection = ({ enterpriseConnectionId: connection?.id ?? null, }); - // A 404 (no directory yet) resolves to `data: null` — errors here are real failures. + // The hook maps a 404 (no directory yet) to `data: null`, so any error here is unexpected. const isLoading = isLoadingConnections || (Boolean(connection) && isLoadingDirectory); const error = connectionsError ?? directoryError; const isSettled = !isLoading && !error; @@ -104,7 +104,7 @@ export const SecurityDirectorySyncSection = ({ ) : error ? ( ) : status === 'unconfigured' ? ( @@ -141,7 +141,7 @@ export const SecurityDirectorySyncSection = ({ updateDirectorySync({ enabled })} + updateEnabled={enabled => updateDirectorySync({ enabled })} onDelete={deleteDirectorySync} organizationName={organizationName} contentRef={contentRef} @@ -155,7 +155,7 @@ export const SecurityDirectorySyncSection = ({ type ConfiguredContentProps = { isActive: boolean; - setActive: (enabled: boolean) => Promise; + updateEnabled: (enabled: boolean) => Promise; onDelete: () => Promise; organizationName: string; contentRef: React.RefObject; @@ -164,7 +164,7 @@ type ConfiguredContentProps = { const ConfiguredContent = ({ isActive, - setActive, + updateEnabled, onDelete, organizationName, contentRef, @@ -173,7 +173,7 @@ const ConfiguredContent = ({ const card = useCardState(); const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false); - const onSetActive = async (enabled: boolean) => { + const handleUpdateEnabled = async (enabled: boolean) => { if (card.isLoading) { return; } @@ -182,7 +182,7 @@ const ConfiguredContent = ({ card.setLoading(); try { - await setActive(enabled); + await updateEnabled(enabled); } catch (err) { handleError(err as Error, [], card.setError); } finally { @@ -212,12 +212,12 @@ const ConfiguredContent = ({ 'organizationProfile.securityPage.directorySyncSection.menuAction__deactivate', ), isDisabled: card.isLoading, - onClick: () => void onSetActive(false), + onClick: () => void handleUpdateEnabled(false), } : { label: localizationKeys('organizationProfile.securityPage.directorySyncSection.menuAction__activate'), isDisabled: card.isLoading, - onClick: () => void onSetActive(true), + onClick: () => void handleUpdateEnabled(true), }, { label: localizationKeys('organizationProfile.securityPage.directorySyncSection.menuAction__remove'), diff --git a/packages/ui/src/components/ConfigureDirectorySync/__tests__/ConfigureDirectorySyncWizard.test.tsx b/packages/ui/src/components/ConfigureDirectorySync/__tests__/ConfigureDirectorySyncWizard.test.tsx index e34a65f1eac..dc697d65f46 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/__tests__/ConfigureDirectorySyncWizard.test.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/__tests__/ConfigureDirectorySyncWizard.test.tsx @@ -110,3 +110,24 @@ describe('ConfigureDirectorySyncWizard configure step', () => { expect(fixtures.clerk.organization?.getDirectorySync).not.toHaveBeenCalled(); }); }); + +describe('ConfigureDirectorySyncWizard test step', () => { + it('shows the load error instead of waiting for users when the request fails', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([oktaConnection]); + const existing = directory(); + existing.getUsers.mockRejectedValue(new Error('users unavailable')); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(existing); + + const { userEvent } = render(, { wrapper }); + + expect(await screen.findByDisplayValue('https://api.example.com/scim/v2')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByText('Attribute review')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByText('Could not load provisioned users')).toBeInTheDocument(); + expect(screen.getByText('users unavailable')).toBeInTheDocument(); + expect(screen.queryByText('Waiting for the first provisioned user…')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts b/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts index 6a11db7505e..21317196fc6 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts +++ b/packages/ui/src/components/ConfigureDirectorySync/providerMeta.ts @@ -1,48 +1,43 @@ import type { DirectorySyncProvider } from '@clerk/shared/types'; +import type { LocalizationKey } from '@/customizables'; +import { localizationKeys } from '@/customizables'; + export interface DirectorySyncProviderMeta { - name: string; + name: LocalizationKey; /** Whether the IdP can push SCIM to Clerk's endpoint (self-serve supported). */ supportsScim: boolean; /** Where the admin pastes the endpoint + token, as numbered instructions. */ - instructions: string[]; + instructions: LocalizationKey[]; } +const instructionKeys = (provider: 'okta' | 'entra' | 'custom'): LocalizationKey[] => [ + localizationKeys(`configureDirectorySync.configureStep.instructions.${provider}.step1`), + localizationKeys(`configureDirectorySync.configureStep.instructions.${provider}.step2`), + localizationKeys(`configureDirectorySync.configureStep.instructions.${provider}.step3`), + localizationKeys(`configureDirectorySync.configureStep.instructions.${provider}.step4`), +]; + export const DIRECTORY_SYNC_PROVIDERS: Record = { okta: { - name: 'Okta Workforce', + name: localizationKeys('configureDirectorySync.providers.okta'), supportsScim: true, - instructions: [ - 'In the Okta Admin Console, open the application used for your SSO connection.', - 'Open the Provisioning tab and select the Integration setting.', - 'Paste the SCIM endpoint URL and bearer token found below.', - 'For provisioning actions, enable pushing of New Users, Profile Updates, and Groups.', - ], + instructions: instructionKeys('okta'), }, entra: { - name: 'Microsoft Entra ID', + name: localizationKeys('configureDirectorySync.providers.entra'), supportsScim: true, - instructions: [ - 'In the Microsoft Entra admin center, open Enterprise applications and select the application used for your SSO connection.', - 'Under Connectivity, paste the SCIM endpoint URL as the Tenant URL and the bearer token as the Secret Token, then select Test Connection.', - 'Select Provisioning and set the provisioning mode to Automatic.', - 'Assign the users and groups to provision, then turn provisioning On.', - ], + instructions: instructionKeys('entra'), }, google: { - name: 'Google Workspace', + name: localizationKeys('configureDirectorySync.providers.google'), supportsScim: false, instructions: [], }, custom: { - name: 'Custom SCIM provider', + name: localizationKeys('configureDirectorySync.providers.custom'), supportsScim: true, - instructions: [ - 'Create a SCIM 2.0 provisioning integration in your identity provider.', - 'Paste the SCIM endpoint URL as the base URL for the integration.', - 'Configure the integration to authenticate with the bearer token below.', - 'Enable provisioning for user create, update, and deactivate events.', - ], + instructions: instructionKeys('custom'), }, }; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx index 69b8fd7d838..9f6d9e554c9 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/AttributeMappingStep.tsx @@ -1,11 +1,16 @@ -import { Table, Tbody, Td, Text, Th, Thead, Tr } from '@/customizables'; +import type { LocalizationKey } from '@/customizables'; +import { descriptors, localizationKeys, 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'; -const MonoText = ({ children }: { children: string }): JSX.Element => ( +type ColumnId = 'directory' | 'clerk'; + +const AttributeValue = ({ column, children }: { column: ColumnId; children: string }): JSX.Element => ( ({ fontFamily: 'monospace', fontSize: t.fontSizes.$sm })} > @@ -13,14 +18,21 @@ const MonoText = ({ children }: { children: string }): JSX.Element => ( ); -const HeaderText = ({ children }: { children: string }): JSX.Element => ( +const ColumnHeader = ({ + column, + localizationKey, +}: { + column: ColumnId; + localizationKey: LocalizationKey; +}): JSX.Element => ( ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$normal })} - > - {children} - + /> ); export const AttributeMappingStep = (): JSX.Element => { @@ -34,13 +46,14 @@ export const AttributeMappingStep = (): JSX.Element => { return ( <> ({ gap: t.space.$5 })}> ({ 'tr > th': { paddingBlock: t.space.$2, paddingInline: t.space.$4 }, 'tr > td': { paddingBlock: t.space.$3 }, @@ -49,10 +62,20 @@ export const AttributeMappingStep = (): JSX.Element => { @@ -60,10 +83,10 @@ export const AttributeMappingStep = (): JSX.Element => { {rows.map(({ clerkAttribute, scimPath }) => ( ))} diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/ConfigureStep.tsx index 54812ad828a..d0f52a1442d 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/ConfigureStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/ConfigureStep.tsx @@ -1,6 +1,19 @@ import { useEffect, useRef, useState } from 'react'; -import { Badge, Button, Col, descriptors, Flex, Icon, Input, Spinner, Text } from '@/customizables'; +import type { LocalizationKey } from '@/customizables'; +import { + Badge, + Button, + Col, + descriptors, + Flex, + Icon, + Input, + localizationKeys, + Spinner, + Text, + useLocalizations, +} from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { Collapsible } from '@/elements/Collapsible'; import { useCardState } from '@/elements/contexts'; @@ -12,19 +25,21 @@ import { Step } from '../../ConfigureSSO/elements/Step'; import { useWizard } from '../../ConfigureSSO/elements/Wizard'; import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; -const FieldLabel = ({ children }: { children: string }): JSX.Element => ( +const FieldLabel = ({ id, localizationKey }: { id: string; localizationKey: LocalizationKey }): JSX.Element => ( ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })} - > - {children} - + /> ); export const ConfigureStep = (): JSX.Element => { const { goNext } = useWizard(); const { connection, provider, providerMeta, directory, createDirectory, revealedToken, rotateToken } = useConfigureDirectorySync(); + const { t } = useLocalizations(); const card = useCardState(); const [isInstructionsOpen, setIsInstructionsOpen] = useState(false); @@ -62,8 +77,8 @@ export const ConfigureStep = (): JSX.Element => { return ( <> @@ -71,12 +86,13 @@ export const ConfigureStep = (): JSX.Element => { {!connection ? ( ) : ( <> ({ borderRadius: t.radii.$md, borderWidth: t.borderWidths.$normal, @@ -87,14 +103,17 @@ export const ConfigureStep = (): JSX.Element => { > ({ gap: t.space.$2, padding: t.space.$4 })}> ({ fontWeight: t.fontWeights.$medium })} > - {providerMeta?.name ?? connection.name} + {connection.name} {domains.length > 0 && ( ({ gap: t.space.$1x5 })} @@ -102,12 +121,16 @@ export const ConfigureStep = (): JSX.Element => { ({ fontSize: t.fontSizes.$sm })} - > - Domains: - + /> {domains.map(domain => ( - {domain} + + {domain} + ))} )} @@ -123,6 +146,7 @@ export const ConfigureStep = (): JSX.Element => { })} > ({ gap: t.space.$1x5, @@ -165,13 +191,13 @@ export const ConfigureStep = (): JSX.Element => { > {instructions.map(instruction => ( ({ fontSize: t.fontSizes.$sm })} - > - {instruction} - + /> ))} @@ -182,23 +208,31 @@ export const ConfigureStep = (): JSX.Element => { {isGoogle && ( )} {!connection.active && !isGoogle && ( )} {directory ? ( <> ({ gap: t.space.$1x5 })}> - SCIM endpoint URL + { ({ gap: t.space.$1x5 })}> - Bearer token + ({ gap: t.space.$2 })} > {revealedToken ? ( { /> ) : ( )} + /> ({ gap: t.space.$1x5 })} > @@ -250,10 +294,11 @@ export const ConfigureStep = (): JSX.Element => { ({ fontSize: t.fontSizes.$sm })} - > - This token is only shown once. Generate a new token if you lose it. - + /> @@ -285,13 +330,13 @@ export const ConfigureStep = (): JSX.Element => { {!directory && canProvision && card.error && ( + /> )} diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx index e9266690c69..5cc2c3de1ea 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -1,18 +1,30 @@ import type { DirectorySyncUserResource } from '@clerk/shared/types'; import React from 'react'; -import { Badge, Button, Col, Flex, Spinner, Text } from '@/customizables'; +import { + Badge, + Button, + Col, + descriptors, + Flex, + localizationKeys, + Spinner, + Text, + useLocalizations, +} from '@/customizables'; import { Alert } from '@/ui/elements/Alert'; import { Step } from '../../ConfigureSSO/elements/Step'; import { useWizard } from '../../ConfigureSSO/elements/Wizard'; import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext'; +import { DIRECTORY_SYNC_PROVIDERS } from '../providerMeta'; const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX.Element => { const displayName = [user.firstName, user.lastName].filter(Boolean).join(' '); return ( ({ @@ -25,6 +37,7 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. > ({ gap: t.space.$0x5 })}> ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })} > @@ -32,6 +45,7 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. {displayName && user.identifier && ( ({ fontSize: t.fontSizes.$sm })} @@ -46,6 +60,7 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. > {user.provisionedAt && ( ({ fontSize: t.fontSizes.$xs })} @@ -53,7 +68,16 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. {user.provisionedAt.toLocaleString()} )} - {user.active ? 'Active' : 'Deprovisioned'} + ); @@ -62,13 +86,13 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. export const TestSyncStep = (): JSX.Element => { const { goPrev } = useWizard(); const { providerMeta, users, onExit } = useConfigureDirectorySync(); + const { t } = useLocalizations(); const rows = users.data ?? []; + const providerName = t((providerMeta ?? DIRECTORY_SYNC_PROVIDERS.custom).name); - // 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. + // The users hook lives in the wizard provider, which stays mounted across + // steps, so polling is armed and released by this step's own lifecycle. const { startPolling, stopPolling } = users; React.useEffect(() => { startPolling(); @@ -78,8 +102,8 @@ export const TestSyncStep = (): JSX.Element => { return ( <> @@ -87,9 +111,8 @@ export const TestSyncStep = (): JSX.Element => { - Users appear here as your identity provider provisions them, most recent activity first. - + localizationKey={localizationKeys('configureDirectorySync.testStep.description')} + /> { ({ fontWeight: t.fontWeights.$medium })} - > - Note: - {' '} - only users with an email address from a configured domain will be processed. + />{' '} + - {rows.length === 0 ? ( + {users.error ? ( + + ) : rows.length === 0 ? ( ({ @@ -119,18 +152,19 @@ export const TestSyncStep = (): JSX.Element => { })} > - Waiting for the first provisioned user… - + localizationKey={localizationKeys('configureDirectorySync.testStep.empty__waitingForFirstUser')} + /> ) : ( ({ borderRadius: t.radii.$md, borderWidth: t.borderWidths.$normal, @@ -147,26 +181,18 @@ export const TestSyncStep = (): JSX.Element => { ))} )} - - {users.error && ( - - )} goPrev()} /> + localizationKey={localizationKeys('configureDirectorySync.testStep.actionLabel__complete')} + /> ); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx index 39d33d3e88d..9f39a58b4f7 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx @@ -11,7 +11,7 @@ import { mqu } from '@/styledSystem'; type ConfigureSSONavbarProps = React.PropsWithChildren<{ contentRef: React.RefObject; - title?: LocalizationKey | string; + title?: LocalizationKey; }>; export const ConfigureSSONavbar = ({ @@ -107,7 +107,7 @@ export const ConfigureSSONavbar = ({ ); }; -const ConfigureSSOMobileNavbar = ({ title }: { title: LocalizationKey | string }) => { +const ConfigureSSOMobileNavbar = ({ title }: { title: LocalizationKey }) => { const { parsedOptions } = useAppearance(); const { organizationSettings, diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index cb92e09a2bd..8606479a43e 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -544,6 +544,23 @@ describe('OrganizationSecurityPage', () => { expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1); }); + it('surfaces a load error when the directory request fails for any other reason', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockRejectedValue( + new ClerkAPIResponseError('Server error', { + status: 500, + data: [{ code: 'internal_error', message: 'Something went wrong', long_message: 'Something went wrong' }], + }), + ); + + renderPage(wrapper); + + expect(await screen.findByText('Could not load Directory Sync')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Start configuration' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1); + }); + it('disables setup and flags SSO as required when no connection exists', async () => { const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 3720f78cd17..a56c415f19a 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -647,6 +647,31 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'configureSSORemoveDomainDialogCancelButton', 'configureSSORemoveDomainDialogSubmitButton', + 'configureDirectorySyncConnectionCard', + 'configureDirectorySyncConnectionCardName', + 'configureDirectorySyncConnectionCardDomains', + 'configureDirectorySyncConnectionCardDomainBadge', + 'configureDirectorySyncInstructionsToggle', + 'configureDirectorySyncInstructionsList', + 'configureDirectorySyncInstructionsListItem', + 'configureDirectorySyncFieldLabel', + 'configureDirectorySyncEndpointUrlInput', + 'configureDirectorySyncTokenInput', + 'configureDirectorySyncGenerateTokenButton', + 'configureDirectorySyncTokenNotice', + 'configureDirectorySyncRetryButton', + 'configureDirectorySyncAttributeMappingTable', + 'configureDirectorySyncAttributeMappingHeader', + 'configureDirectorySyncAttributeMappingValue', + 'configureDirectorySyncUsersList', + 'configureDirectorySyncUsersRow', + 'configureDirectorySyncUserIdentifier', + 'configureDirectorySyncUserName', + 'configureDirectorySyncUserTimestamp', + 'configureDirectorySyncUserStatusBadge', + 'configureDirectorySyncUsersEmpty', + 'configureDirectorySyncCompleteButton', + 'web3SolanaWalletButtonsRoot', 'web3SolanaWalletButtons', 'web3SolanaWalletButtonsIconButton', diff --git a/packages/ui/src/elements/Navbar.tsx b/packages/ui/src/elements/Navbar.tsx index cd693942701..46dd6e59222 100644 --- a/packages/ui/src/elements/Navbar.tsx +++ b/packages/ui/src/elements/Navbar.tsx @@ -43,7 +43,7 @@ export type NavbarRoute = { external?: boolean; }; type NavBarProps = { - title: LocalizationKey | string; + title: LocalizationKey; titleSx?: ThemableCssProp; containerSx?: ThemableCssProp; description?: LocalizationKey; diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index 2225919151c..0c4c8ac8c6f 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -99,6 +99,7 @@ export type FlowMetadata = { | 'pricingTable' | 'apiKeys' | 'configureSSO' + | 'configureDirectorySync' | 'oauthConsent' | 'oauthDeviceVerification' | 'subscriptionDetails' diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 24c31e4e307..cbf31a1a00f 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -783,6 +783,31 @@ export type ElementsConfig = { configureSSORemoveDomainDialogCancelButton: WithOptions; configureSSORemoveDomainDialogSubmitButton: WithOptions; + configureDirectorySyncConnectionCard: WithOptions; + configureDirectorySyncConnectionCardName: WithOptions; + configureDirectorySyncConnectionCardDomains: WithOptions; + configureDirectorySyncConnectionCardDomainBadge: WithOptions; + configureDirectorySyncInstructionsToggle: WithOptions; + configureDirectorySyncInstructionsList: WithOptions; + configureDirectorySyncInstructionsListItem: WithOptions; + configureDirectorySyncFieldLabel: WithOptions; + configureDirectorySyncEndpointUrlInput: WithOptions; + configureDirectorySyncTokenInput: WithOptions; + configureDirectorySyncGenerateTokenButton: WithOptions; + configureDirectorySyncTokenNotice: WithOptions; + configureDirectorySyncRetryButton: WithOptions; + configureDirectorySyncAttributeMappingTable: WithOptions; + configureDirectorySyncAttributeMappingHeader: WithOptions; + configureDirectorySyncAttributeMappingValue: WithOptions; + configureDirectorySyncUsersList: WithOptions; + configureDirectorySyncUsersRow: WithOptions; + configureDirectorySyncUserIdentifier: WithOptions; + configureDirectorySyncUserName: WithOptions; + configureDirectorySyncUserTimestamp: WithOptions; + configureDirectorySyncUserStatusBadge: WithOptions; + configureDirectorySyncUsersEmpty: WithOptions; + configureDirectorySyncCompleteButton: WithOptions; + web3SolanaWalletButtonsRoot: WithOptions; web3SolanaWalletButtons: WithOptions; web3SolanaWalletButtonsIconButton: WithOptions; @@ -1241,6 +1266,10 @@ export type Appearance = T & * Theme overrides that only apply to the `` component */ configureSSO?: T; + /** + * Theme overrides that only apply to the `` component + */ + configureDirectorySync?: T; /** * Theme overrides that only apply to the `` component */ From 25167556135056e2f0e769dbc4a22df564de63ea Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 8 Sep 2026 15:24:53 -0700 Subject: [PATCH 10/16] Only poll while test step is active --- .../ConfigureDirectorySyncContext.tsx | 23 +------------------ .../steps/TestSyncStep.tsx | 13 ++++++----- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx index efe8ee00e73..a7c976d0d7d 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -1,30 +1,13 @@ import { __internal_useOrganizationDirectorySync, - __internal_useOrganizationDirectorySyncUsers, __internal_useOrganizationEnterpriseConnections, } from '@clerk/shared/react'; -import type { - DirectorySyncProvider, - DirectorySyncResource, - DirectorySyncUserResource, - EnterpriseConnectionResource, -} from '@clerk/shared/types'; +import type { DirectorySyncProvider, DirectorySyncResource, 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. * @@ -46,7 +29,6 @@ export interface ConfigureDirectorySyncData { createDirectory: () => Promise; rotateToken: () => Promise; setDirectoryEnabled: (enabled: boolean) => Promise; - users: DirectorySyncUsersView; onExit?: () => void; } @@ -80,8 +62,6 @@ export const ConfigureDirectorySyncProvider = ({ rotateDirectorySyncToken, } = __internal_useOrganizationDirectorySync({ enterpriseConnectionId }); - const users = __internal_useOrganizationDirectorySyncUsers({ directory }); - // The token is stored with the connection it was issued for, so a response // that lands after the connection changed is never shown for the new one. const [revealed, setRevealed] = React.useState(null); @@ -123,7 +103,6 @@ export const ConfigureDirectorySyncProvider = ({ createDirectory, rotateToken, setDirectoryEnabled, - users, onExit, }; diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx index 5cc2c3de1ea..293eed3a3eb 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -1,3 +1,4 @@ +import { __internal_useOrganizationDirectorySyncUsers } from '@clerk/shared/react'; import type { DirectorySyncUserResource } from '@clerk/shared/types'; import React from 'react'; @@ -85,19 +86,19 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX. export const TestSyncStep = (): JSX.Element => { const { goPrev } = useWizard(); - const { providerMeta, users, onExit } = useConfigureDirectorySync(); + const { providerMeta, directory, onExit } = useConfigureDirectorySync(); const { t } = useLocalizations(); + const users = __internal_useOrganizationDirectorySyncUsers({ directory }); const rows = users.data ?? []; const providerName = t((providerMeta ?? DIRECTORY_SYNC_PROVIDERS.custom).name); - // The users hook lives in the wizard provider, which stays mounted across - // steps, so polling is armed and released by this step's own lifecycle. - const { startPolling, stopPolling } = users; + // Poll while this step is visible; the list doubles as a live feed while the + // admin pushes test users from the IdP. Unmounting the step ends the poll. + const { startPolling } = users; React.useEffect(() => { startPolling(); - return () => stopPolling(); - }, [startPolling, stopPolling]); + }, [startPolling]); return ( <> From b98eade6b651e3175ac5d9cb5e3b723312e4e096 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 9 Sep 2026 11:36:48 -0700 Subject: [PATCH 11/16] Remove Clerk references --- packages/localizations/src/en-US.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d78a6c85341..8edf7462ac2 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -224,10 +224,10 @@ export const enUS: LocalizationResource = { configureDirectorySync: { attributeMappingStep: { columns: { - clerkAttribute: 'Clerk user attributes', + clerkAttribute: 'User attributes', directoryAttribute: 'Directory attributes', }, - subtitle: 'Standard directory attributes are pre-configured by Clerk. Attributes not listed here are ignored.', + subtitle: 'Standard directory attributes are pre-configured. Attributes not listed here are ignored.', title: 'Attribute review', }, configureStep: { @@ -269,8 +269,7 @@ export const enUS: LocalizationResource = { subtitle: 'Add these credentials to your identity provider to configure Directory Sync', title: 'Configure', warning__googleUnsupported: { - subtitle: - 'Google Workspace provisions through a credential-based integration instead of SCIM push. Set up Directory Sync for this connection from the Clerk Dashboard.', + subtitle: 'Google Workspace provisions through a credential-based integration instead of SCIM push.', title: 'Google Workspace connections are not supported here', }, warning__ssoInactive: @@ -299,7 +298,7 @@ export const enUS: LocalizationResource = { error__loadUsers: 'Could not load provisioned users', note: 'only users with an email address from a configured domain will be processed.', noteLabel: 'Note:', - subtitle: 'Assign or push a test user from {{provider}} to verify provisioning reaches Clerk.', + subtitle: 'Assign or push a test user from {{provider}} to verify provisioning.', title: 'Test provisioning', }, }, From e33c74b64dd79b643693cbe155865190b2203945 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 9 Sep 2026 14:11:35 -0700 Subject: [PATCH 12/16] refactor(ui): poll directory users via the hook's poll flag The test step no longer arms polling from a mount effect; it passes `poll: true` for as long as it is mounted, matching the updated useOrganizationDirectorySyncUsers API. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WNqo3qSSDhvNwuAsydZFV4 --- .../ConfigureDirectorySync/steps/TestSyncStep.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx index 293eed3a3eb..8fce5d83ee5 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -1,6 +1,5 @@ import { __internal_useOrganizationDirectorySyncUsers } from '@clerk/shared/react'; import type { DirectorySyncUserResource } from '@clerk/shared/types'; -import React from 'react'; import { Badge, @@ -88,18 +87,13 @@ export const TestSyncStep = (): JSX.Element => { const { goPrev } = useWizard(); const { providerMeta, directory, onExit } = useConfigureDirectorySync(); const { t } = useLocalizations(); - const users = __internal_useOrganizationDirectorySyncUsers({ directory }); + // The list doubles as a live feed while the admin pushes test users from the + // IdP, so poll for as long as this step is mounted. + const users = __internal_useOrganizationDirectorySyncUsers({ directory, poll: true }); const rows = users.data ?? []; const providerName = t((providerMeta ?? DIRECTORY_SYNC_PROVIDERS.custom).name); - // Poll while this step is visible; the list doubles as a live feed while the - // admin pushes test users from the IdP. Unmounting the step ends the poll. - const { startPolling } = users; - React.useEffect(() => { - startPolling(); - }, [startPolling]); - return ( <> Date: Thu, 10 Sep 2026 08:19:11 -0700 Subject: [PATCH 13/16] Move Google warning to first page of UI --- packages/localizations/src/en-US.ts | 2 +- .../SecurityDirectorySyncSection.tsx | 48 +++++++++++-------- .../OrganizationSecurityPage.test.tsx | 17 +++++++ 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 8edf7462ac2..0204b8772dc 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -269,8 +269,8 @@ export const enUS: LocalizationResource = { subtitle: 'Add these credentials to your identity provider to configure Directory Sync', title: 'Configure', warning__googleUnsupported: { + title: 'Google Workspace connections are not configurable via self-serve', subtitle: 'Google Workspace provisions through a credential-based integration instead of SCIM push.', - title: 'Google Workspace connections are not supported here', }, warning__ssoInactive: 'Your SSO connection is configured but not active. Members can be provisioned now, but they can only sign in once SSO is activated.', diff --git a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx index 4a82d6cf51a..5bbfe2dd5de 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -14,6 +14,7 @@ import { handleError } from '@/utils/errorHandler'; import type { LocalizationKey } from '../../customizables'; import { Badge, Button, Col, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables'; import { ResetConnectionDialog } from '../ConfigureSSO/ResetConnectionDialog'; +import { directorySyncProviderForConnection } from './providerMeta'; type SecurityDirectorySyncSectionProps = { organizationName: string; @@ -57,6 +58,7 @@ export const SecurityDirectorySyncSection = ({ } = __internal_useOrganizationEnterpriseConnections(); const connection = connections?.[0]; const hasSsoConnection = Boolean(connection); + const isGoogle = connection ? directorySyncProviderForConnection(connection.provider) === 'google' : false; const { data: directory, isLoading: isLoadingDirectory, @@ -113,29 +115,37 @@ export const SecurityDirectorySyncSection = ({ gap={4} > - -
- Directory attributes + - Clerk User attributes +
- {scimPath} + {scimPath} - {clerkAttribute} + {clerkAttribute}