diff --git a/.changeset/dir-sync-self-serve-wiring.md b/.changeset/dir-sync-self-serve-wiring.md index 7154d681b95..56444bf4bdd 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 0c841d72c11..ca524780b41 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": "122KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "320KB" }, { "path": "./dist/clerk.native.js", "maxSize": "79KB" }, diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 11bdbbe370a..bf9d3c8b9bf 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'), @@ -431,6 +433,10 @@ void (async () => { mount: '__internal_mountOAuthDeviceVerification', component: 'oauthDeviceVerification', }, + '/configure-directory-sync': { + mount: '__internal_mountConfigureDirectorySync', + component: 'configureDirectorySync', + }, '/task-choose-organization': { mount: 'mountTaskChooseOrganization', component: 'taskChooseOrganization', diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 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/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/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 51ea3a6a4bc..7db8d109eff 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 bf5325b7e62..40c7eab0ebb 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. * @@ -1984,6 +2002,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 03a51460dc5..1d1ef51d9de 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 f1f6e9dd7bf..538b1dc94f5 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1206,6 +1206,24 @@ export type __internal_LocalizationResource = { tooltip__noRole: LocalizationValue; tooltipLabel: LocalizationValue; }; + directorySyncSection: { + title: LocalizationValue; + badge__unconfigured: LocalizationValue; + badge__ssoRequired: 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..6094fb48d73 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx @@ -0,0 +1,48 @@ +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 { ConfigureSSOProtect } from '../ConfigureSSO/ConfigureSSO'; +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..c7a87ab5d31 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -0,0 +1,174 @@ +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({ 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..9745af22515 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -0,0 +1,80 @@ +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'; +import { AttributeMappingStep } from './steps/AttributeMappingStep'; +import { ConfigureStep } from './steps/ConfigureStep'; +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, isLoading } = useConfigureDirectorySync(); + const hasSsoConnection = Boolean(connection); + const hasDirectory = Boolean(directory); + + 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 }, + ], + [hasSsoConnection, hasDirectory], + ); + + if (isLoading) { + return ; + } + + 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..c39c63ad52e --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/DirectorySyncNavbar.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +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 new file mode 100644 index 00000000000..c240d121f38 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -0,0 +1,255 @@ +import { + __internal_useOrganizationDirectorySync, + __internal_useOrganizationEnterpriseConnections, +} 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'; +import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; +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'; + +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, + isLoading: isLoadingConnections, + error: connectionsError, + } = __internal_useOrganizationEnterpriseConnections(); + const connection = connections?.[0]; + const hasSsoConnection = Boolean(connection); + 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]; + + return ( + + ) : undefined + } + > + {isLoading ? ( + ({ paddingBlock: t.space.$5 })} + > + + + ) : error ? ( + + ) : status === 'unconfigured' ? ( + + + + + + + ({ + 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/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx new file mode 100644 index 00000000000..e9266690c69 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx @@ -0,0 +1,173 @@ +import type { DirectorySyncUserResource } from '@clerk/shared/types'; +import React from 'react'; + +import { Badge, Button, 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 { goPrev } = useWizard(); + const { providerMeta, users, onExit } = useConfigureDirectorySync(); + + const rows = users.data ?? []; + + // 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. + + + + ({ fontWeight: t.fontWeights.$medium })} + > + Note: + {' '} + only users with an email address from a configured domain will be processed. + + + {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()} /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx index 28fffea2d2d..39d33d3e88d 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx @@ -2,6 +2,7 @@ import { __internal_useOrganizationBase } from '@clerk/shared/react/index'; import React from 'react'; import { useEnvironment } from '@/contexts'; +import type { LocalizationKey } from '@/customizables'; import { Box, Col, descriptors, Flex, Heading, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; import { ApplicationLogo } from '@/elements/ApplicationLogo'; import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; @@ -10,9 +11,14 @@ import { mqu } from '@/styledSystem'; type ConfigureSSONavbarProps = React.PropsWithChildren<{ contentRef: React.RefObject; + 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/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 }); + }; + + // The mutations live on the DirectorySyncResource resolved by getDirectorySync. + const directory = (overrides: Record = {}) => + ({ + id: 'scimdir_1', + enterpriseConnectionId: 'ent_1', + endpointUrl: 'https://api.example.com/scim/v2', + provider: 'okta', + enabled: true, + attributeMapping: {}, + apiKey: null, + update: vi.fn(), + delete: vi.fn(), + rotateToken: vi.fn(), + ...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); + // 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 () => { + 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); + + 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); + 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); + const activeDirectory = directory(); + activeDirectory.update.mockResolvedValue(directory({ enabled: false })); + fixtures.clerk.organization?.getDirectorySync + .mockResolvedValueOnce(activeDirectory) + .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(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()); + }); + + it('removes the directory through the type-to-confirm dialog', async () => { + const { wrapper, fixtures } = await createFixtures(withDirectorySyncFixtures); + withActiveConnection(fixtures); + const activeDirectory = directory(); + activeDirectory.delete.mockResolvedValue({ id: 'scimdir_1', deleted: true }); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValueOnce(activeDirectory).mockResolvedValue(null); + + 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(activeDirectory.delete).toHaveBeenCalledWith(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Start configuration' })).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/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; 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 a35c7f8adb6..2f8e6701ba8 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -627,9 +627,13 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { }; }; - const withEnterpriseSso = (opts?: { selfServeSSO?: boolean }) => { + const withEnterpriseSso = (opts?: { selfServeSSO?: boolean; selfServeDirectorySync?: boolean }) => { us.saml = { enabled: true }; - us.enterprise_sso = { enabled: true, self_serve_sso: opts?.selfServeSSO ?? false }; + us.enterprise_sso = { + enabled: true, + self_serve_sso: opts?.selfServeSSO ?? false, + self_serve_directory_sync: opts?.selfServeDirectorySync ?? false, + }; }; const withBackupCode = (opts?: Partial) => { diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 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