From 6439f430a57100753b2eb972a4b43abca1ee6425 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 19:54:33 -0300 Subject: [PATCH 1/8] Implement email verification step --- packages/localizations/src/en-US.ts | 20 +++ packages/shared/src/types/localization.ts | 20 +++ .../ConfigureSSO/steps/StepLayout.tsx | 16 +- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 146 +++++++++++++++++- packages/ui/src/icons/duotone-at-symbol.svg | 3 + packages/ui/src/icons/index.ts | 13 +- 6 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/icons/duotone-at-symbol.svg diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 090841b697f..6270f7494c6 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -204,6 +204,26 @@ export const enUS: LocalizationResource = { navbar: { title: 'Configure Single Sign-On (SSO)', }, + verifyEmailDomainStep: { + title: 'Verify email address', + subtitle: 'Verify the email address you want to enable the enterprise connection on.', + addEmailAddress: { + formTitle: 'We need your email', + formSubtitle: 'In order to start we will need your email address', + inputPlaceholder: 'name@company.com', + inputLabel: 'Email address', + }, + emailCode: { + formTitle: 'Verify your email address', + formSubtitle: 'Enter the verification code sent to {{identifier}}', + resendButton: "Didn't receive a code? Resend", + verified: { + title: 'We got your email', + subtitle: "You've verified your email address with the following email", + inputLabel: 'Verified email address', + }, + }, + }, }, createOrganization: { formButtonSubmit: 'Create organization', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 49ce96a0616..0bae1eca0da 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1296,6 +1296,26 @@ export type __internal_LocalizationResource = { navbar: { title: LocalizationValue; }; + verifyEmailDomainStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + addEmailAddress: { + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + inputPlaceholder: LocalizationValue; + inputLabel: LocalizationValue; + }; + emailCode: { + formTitle: LocalizationValue; + formSubtitle: LocalizationValue<'identifier'>; + resendButton: LocalizationValue; + verified: { + title: LocalizationValue; + subtitle: LocalizationValue; + inputLabel: LocalizationValue; + }; + }; + }; }; apiKeys: { formTitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx index 8845d841142..901718b0d3a 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { Col, Flex, Heading, Text } from '@/customizables'; +import { Col, Flex, Heading, type LocalizationKey, Text } from '@/customizables'; import { ConfigureSSOWizard } from '../wizard'; interface StepLayoutProps { - title?: React.ReactNode; - subtitle?: React.ReactNode; + title?: LocalizationKey | string; + subtitle?: LocalizationKey | string; children: React.ReactNode; } @@ -38,18 +38,16 @@ export const StepLayout = ({ title, subtitle, children }: StepLayoutProps): JSX. ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} - > - {title} - + localizationKey={title} + /> {subtitle ? ( ({ color: theme.colors.$colorMutedForeground })} - > - {subtitle} - + localizationKey={subtitle} + /> ) : null} ) : null} diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 9c37d3261d9..42e3db645b3 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -1,24 +1,154 @@ -import { Flow, Text } from '@/customizables'; +import { useReverification, useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { Col, Flow, Heading, Icon, Input, localizationKeys, Text, useLocalizations } from '@/customizables'; +import { useFieldOTP } from '@/elements/CodeControl'; +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { handleError } from '@/utils/errorHandler'; import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; import { StepLayout } from './StepLayout'; +import { DuotoneAtSymbol } from '@/icons'; export const VerifyDomainStep = (): JSX.Element => { const { goNext } = useConfigureSSOWizard(); + const card = useCardState(); + const { t } = useLocalizations(); + const { user } = useUser(); + + const primaryEmailAddress = user?.primaryEmailAddress; + const isVerified = primaryEmailAddress?.verification.status === 'verified'; + + const prepareEmailVerification = useReverification(() => + primaryEmailAddress?.prepareVerification({ strategy: 'email_code' }), + ); + const attemptEmailVerification = useReverification((code: string) => + primaryEmailAddress?.attemptVerification({ code }), + ); + + const prepare = React.useCallback( + () => prepareEmailVerification()?.catch(err => handleError(err, [], card.setError)), + [prepareEmailVerification, card], + ); - useRegisterContinueAction({ - handler: () => goNext(), - // TODO: Implement verification - isDisabled: true, + const codeSubmittedRef = React.useRef(false); + + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + codeSubmittedRef.current = true; + attemptEmailVerification(code) + .then(() => resolve()) + .catch(reject); + }, + onResendCodeClicked: () => { + void prepare(); + }, + onResolve: () => { + void goNext(); + }, }); + const { values, length } = otp.otpControl.otpInputProps; + const isCodeComplete = values.filter(Boolean).length === length; + const showVerifiedView = isVerified && !codeSubmittedRef.current; + + useRegisterContinueAction( + showVerifiedView + ? { + handler: () => { + void goNext(); + }, + } + : { + handler: otp.onFakeContinue, + isDisabled: !isCodeComplete, + isLoading: otp.isLoading, + }, + ); + + // Send the first code on mount (only when there's something to verify), + // and clear any stale card error that could be lingering from a previous step + React.useEffect(() => { + if (!isVerified) { + void prepare(); + } + card.setError(undefined); + return () => card.setError(undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - UI goes here + ({ + flex: 1, + justifyContent: 'center', + gap: t.space.$2, + paddingBlock: t.space.$8, + })} + > + {showVerifiedView && primaryEmailAddress ? ( + <> + ({ + width: t.sizes.$8, + height: t.sizes.$8, + color: t.colors.$neutralAlpha600, + })} + /> + ({ gap: t.space.$1, textAlign: 'center', maxWidth: t.sizes.$66 })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.verified.title')} + /> + ({ color: t.colors.$colorMutedForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.verified.subtitle')} + /> + + ({ width: '100%', maxWidth: t.sizes.$66, backgroundColor: t.colors.$neutralAlpha50 })} + /> + + ) : ( + <> + ({ gap: t.space.$1, textAlign: 'center' })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.formTitle')} + /> + ({ color: t.colors.$colorMutedForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.formSubtitle', { + identifier: primaryEmailAddress?.emailAddress ?? '', + })} + /> + + + + + )} + ); diff --git a/packages/ui/src/icons/duotone-at-symbol.svg b/packages/ui/src/icons/duotone-at-symbol.svg new file mode 100644 index 00000000000..6cbc8b51f9c --- /dev/null +++ b/packages/ui/src/icons/duotone-at-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index e282f04aae8..0372abd4f7f 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -14,17 +14,17 @@ export { default as AuthApp } from './auth-app.svg'; export { default as Billing } from './billing.svg'; export { default as Block } from './block.svg'; export { default as BoxIcon } from './box.svg'; -export { default as Caret } from './caret.svg'; export { default as CaretLeft } from './caret-left.svg'; export { default as CaretRight } from './caret-right.svg'; +export { default as Caret } from './caret.svg'; export { default as ChatAltIcon } from './chat-alt.svg'; -export { default as Check } from './check.svg'; export { default as CheckCircle } from './check-circle.svg'; +export { default as Check } from './check.svg'; export { default as CheckmarkFilled } from './checkmark-filled.svg'; export { default as ChevronDown } from './chevron-down.svg'; export { default as ChevronUpDown } from './chevron-up-down.svg'; -export { default as Clipboard } from './clipboard.svg'; export { default as ClipboardOutline } from './clipboard-outline.svg'; +export { default as Clipboard } from './clipboard.svg'; export { default as Close } from './close.svg'; export { default as Code } from './code.svg'; export { default as CogFilled } from './cog-filled.svg'; @@ -34,11 +34,12 @@ export { default as DeviceLaptop } from './device-laptop.svg'; export { default as DeviceMobile } from './device-mobile.svg'; export { default as DotCircle } from './dot-circle-horizontal.svg'; export { default as Download } from './download.svg'; +export { default as DuotoneAtSymbol } from './duotone-at-symbol.svg'; export { default as Email } from './email.svg'; export { default as ExclamationCircle } from './exclamation-circle.svg'; export { default as ExclamationTriangle } from './exclamation-triangle.svg'; -export { default as Eye } from './eye.svg'; export { default as EyeSlash } from './eye-slash.svg'; +export { default as Eye } from './eye.svg'; export { default as Fingerprint } from './fingerprint.svg'; export { default as Folder } from './folder.svg'; export { default as GenericPayment } from './generic-pay.svg'; @@ -52,8 +53,8 @@ export { default as Menu } from './menu.svg'; export { default as Minus } from './minus.svg'; export { default as Mobile } from './mobile-small.svg'; export { default as Organization } from './organization.svg'; -export { default as Pencil } from './pencil.svg'; export { default as PencilEdit } from './pencil-edit.svg'; +export { default as Pencil } from './pencil.svg'; export { default as Plans } from './plans.svg'; export { default as Plus } from './plus.svg'; export { default as Print } from './print.svg'; @@ -61,8 +62,8 @@ export { default as QuestionMark } from './question-mark.svg'; export { default as RequestAuthIcon } from './request-auth.svg'; export { default as RotateLeftRight } from './rotate-left-right.svg'; export { default as Selector } from './selector.svg'; -export { default as SignOut } from './signout.svg'; export { default as SignOutDouble } from './signout-double.svg'; +export { default as SignOut } from './signout.svg'; export { default as SpinnerJumbo } from './spinner-jumbo.svg'; export { default as SwitchArrowRight } from './switch-arrow-right.svg'; export { default as SwitchArrows } from './switch-arrows.svg'; From 19b054fcf5e5d6f5f38d61f63871b4848107ba9c Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 20:03:02 -0300 Subject: [PATCH 2/8] Implement add email address step --- .../ConfigureSSO/steps/ProvideEmailStep.tsx | 120 +++++++++++++++++- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx index bf5afe4762e..752f1b27f17 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -1,24 +1,130 @@ -import { Flow, Text } from '@/customizables'; +import { useReverification, useUser } from '@clerk/shared/react/index'; +import React from 'react'; + +import { Col, Flow, Form, Heading, Icon, Input, localizationKeys, Text, useLocalizations } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { DuotoneAtSymbol } from '@/icons'; +import { handleError } from '@/utils/errorHandler'; import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; import { StepLayout } from './StepLayout'; export const ProvideEmail = (): JSX.Element => { const { goNext } = useConfigureSSOWizard(); + const { user } = useUser(); + const card = useCardState(); + const { t } = useLocalizations(); + const [email, setEmail] = React.useState(''); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const createEmailAddress = useReverification((value: string) => user?.createEmailAddress({ email: value })); + + const canSubmit = !isSubmitting; + + const submit = React.useCallback(async () => { + if (!canSubmit) { + return; + } + + setIsSubmitting(true); + card.setError(undefined); + + try { + await createEmailAddress(email); + await goNext(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + setIsSubmitting(false); + } + }, [canSubmit, email, createEmailAddress, card, goNext]); useRegisterContinueAction({ - handler: () => { - return goNext(); - }, + handler: submit, + isDisabled: !canSubmit, + isLoading: isSubmitting, }); + // Clear any stale card error when this step mounts so it doesn't leak in + // from a previous flow / step + React.useEffect(() => { + card.setError(undefined); + return () => card.setError(undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - UI goes here +
{ + e.preventDefault(); + void submit(); + }} + sx={{ flex: 1, display: 'flex' }} + > + ({ + flex: 1, + justifyContent: 'center', + gap: t.space.$5, + maxWidth: t.sizes.$66, + marginInline: 'auto', + textAlign: 'center', + width: '100%', + paddingBlock: t.space.$8, + })} + > + ({ + width: t.sizes.$8, + height: t.sizes.$8, + color: t.colors.$neutralAlpha600, + })} + /> + + ({ gap: t.space.$1 })}> + ({ color: t.colors.$colorForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.addEmailAddress.formTitle')} + /> + ({ color: t.colors.$colorMutedForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.addEmailAddress.formSubtitle')} + /> + + + ({ gap: t.space.$1x5, width: '100%' })}> + setEmail(e.currentTarget.value)} + hasError={Boolean(card.error)} + isDisabled={isSubmitting} + aria-label={t(localizationKeys('configureSSO.verifyEmailDomainStep.addEmailAddress.inputLabel'))} + /> + {card.error ? ( + ({ color: t.colors.$danger500, fontSize: t.fontSizes.$sm, textAlign: 'start' })} + > + {card.error} + + ) : null} + + +
); From 7446311deeb08216385c9c74bbb725fd55f7fb0a Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 21:29:15 -0300 Subject: [PATCH 3/8] Handle updating email address to primary --- .../components/ConfigureSSO/ConfigureSSO.tsx | 4 +- .../ConfigureSSO/steps/ProvideEmailStep.tsx | 4 +- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 48 +++++++++++++------ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f1585e7a8de..413764823df 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -128,7 +128,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { const ConfigureSSOSteps = () => { const { user } = useUser(); - const primaryEmailAddress = user?.primaryEmailAddress; + const hasEmailAddress = Boolean(user?.emailAddresses?.length); return ( @@ -138,7 +138,7 @@ const ConfigureSSOSteps = () => { label='Verify domain' > - {!primaryEmailAddress && ( + {!hasEmailAddress && ( /^\S+@\S+\.\S+$/.test(str); + export const ProvideEmail = (): JSX.Element => { const { goNext } = useConfigureSSOWizard(); const { user } = useUser(); @@ -18,7 +20,7 @@ export const ProvideEmail = (): JSX.Element => { const [isSubmitting, setIsSubmitting] = React.useState(false); const createEmailAddress = useReverification((value: string) => user?.createEmailAddress({ email: value })); - const canSubmit = !isSubmitting; + const canSubmit = isEmail(email) && !isSubmitting; const submit = React.useCallback(async () => { if (!canSubmit) { diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 42e3db645b3..42a1e50648d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -5,26 +5,29 @@ import { Col, Flow, Heading, Icon, Input, localizationKeys, Text, useLocalizatio import { useFieldOTP } from '@/elements/CodeControl'; import { useCardState } from '@/elements/contexts'; import { Form } from '@/elements/Form'; +import { DuotoneAtSymbol } from '@/icons'; import { handleError } from '@/utils/errorHandler'; import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; import { StepLayout } from './StepLayout'; -import { DuotoneAtSymbol } from '@/icons'; -export const VerifyDomainStep = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); +export const VerifyDomainStep = (): JSX.Element | null => { + const { goNext, goToStep } = useConfigureSSOWizard(); const card = useCardState(); const { t } = useLocalizations(); const { user } = useUser(); - const primaryEmailAddress = user?.primaryEmailAddress; - const isVerified = primaryEmailAddress?.verification.status === 'verified'; + const emailToVerify = + user?.primaryEmailAddress ?? user?.emailAddresses?.find(e => e.verification.status !== 'verified'); + const isVerified = emailToVerify?.verification.status === 'verified'; + const isAlreadyPrimary = Boolean(emailToVerify && emailToVerify.id === user?.primaryEmailAddressId); const prepareEmailVerification = useReverification(() => - primaryEmailAddress?.prepareVerification({ strategy: 'email_code' }), + emailToVerify?.prepareVerification({ strategy: 'email_code' }), ); - const attemptEmailVerification = useReverification((code: string) => - primaryEmailAddress?.attemptVerification({ code }), + const attemptEmailVerification = useReverification((code: string) => emailToVerify?.attemptVerification({ code })); + const setPrimaryEmailAddress = useReverification((emailAddressId: string) => + user?.update({ primaryEmailAddressId: emailAddressId }), ); const prepare = React.useCallback( @@ -44,7 +47,15 @@ export const VerifyDomainStep = (): JSX.Element => { onResendCodeClicked: () => { void prepare(); }, - onResolve: () => { + onResolve: async () => { + if (emailToVerify && !isAlreadyPrimary) { + try { + await setPrimaryEmailAddress(emailToVerify.id); + } catch (err) { + handleError(err as Error, [], card.setError); + return; + } + } void goNext(); }, }); @@ -67,10 +78,16 @@ export const VerifyDomainStep = (): JSX.Element => { }, ); + React.useEffect(() => { + if (!emailToVerify) { + void goToStep('provide-email'); + } + }, [emailToVerify, goToStep]); + // Send the first code on mount (only when there's something to verify), // and clear any stale card error that could be lingering from a previous step React.useEffect(() => { - if (!isVerified) { + if (emailToVerify && !isVerified) { void prepare(); } card.setError(undefined); @@ -78,6 +95,10 @@ export const VerifyDomainStep = (): JSX.Element => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (!emailToVerify) { + return null; + } + return ( { paddingBlock: t.space.$8, })} > - {showVerifiedView && primaryEmailAddress ? ( + {showVerifiedView ? ( <> { ({ width: '100%', maxWidth: t.sizes.$66, backgroundColor: t.colors.$neutralAlpha50 })} @@ -137,11 +158,10 @@ export const VerifyDomainStep = (): JSX.Element => { variant='body' sx={t => ({ color: t.colors.$colorMutedForeground })} localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.formSubtitle', { - identifier: primaryEmailAddress?.emailAddress ?? '', + identifier: emailToVerify.emailAddress, })} /> - Date: Wed, 6 May 2026 16:16:09 -0300 Subject: [PATCH 4/8] Implement POC --- packages/clerk-js/src/core/resources/User.ts | 62 ++- .../src/core/resources/__tests__/User.test.ts | 22 +- .../components/ConfigureSSO/ConfigureSSO.tsx | 40 +- .../ConfigureSSO/ConfigureSSOContext.tsx | 72 +++- .../steps/ConfigureCreateAppStep.tsx | 303 +++++++++++++- .../ConfigureSSO/steps/ConfirmationStep.tsx | 368 +++++++++++++++++- .../ConfigureSSO/steps/SelectProviderStep.tsx | 205 ++++++++++ .../steps/TestConfigurationStep.tsx | 261 ++++++++++++- .../components/ConfigureSSO/steps/index.ts | 1 + packages/ui/src/elements/contexts/index.tsx | 1 + 10 files changed, 1286 insertions(+), 49 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index af80f6704bb..d8bc28b2507 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -43,7 +43,6 @@ import type { VerifyTOTPParams, Web3WalletResource, } from '@clerk/shared/types'; -import { deepCamelToSnake } from '@clerk/shared/underscore'; import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; @@ -551,25 +550,64 @@ export class User extends BaseResource implements UserResource { * Serializes `CreateMeEnterpriseConnectionParams` / `UpdateMeEnterpriseConnectionParams` * for the `/me/enterprise_connections` FAPI endpoints. * - * Uses `deepCamelToSnake` but preserves `saml.attributeMapping` and `customAttributes` as-is. Their keys are + * The handler expects a flat form body where SAML and OIDC fields are + * prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather + * than nested under `saml`/`oidc` objects. `attribute_mapping` and + * `custom_attributes` stay as object values and are JSON-stringified + * by the form serializer downstream — their inner keys are * user-supplied data and must not be camel→snake transformed. */ function toMeEnterpriseConnectionBody( params: CreateMeEnterpriseConnectionParams | UpdateMeEnterpriseConnectionParams, ): Record { - const originalAttributeMapping = - params.saml && typeof params.saml === 'object' ? params.saml.attributeMapping : undefined; - const originalCustomAttributes = 'customAttributes' in params ? params.customAttributes : undefined; - - const body = deepCamelToSnake(params) as Record; - - if (originalAttributeMapping !== undefined && body.saml && typeof body.saml === 'object') { - body.saml.attribute_mapping = originalAttributeMapping; + const body: Record = {}; + + // Top-level fields. `provider` is only on Create, the rest are shared + setIfDefined(body, 'provider', (params as CreateMeEnterpriseConnectionParams).provider); + setIfDefined(body, 'name', params.name); + setIfDefined(body, 'organization_id', params.organizationId); + setIfDefined(body, 'active', (params as UpdateMeEnterpriseConnectionParams).active); + setIfDefined(body, 'sync_user_attributes', (params as UpdateMeEnterpriseConnectionParams).syncUserAttributes); + setIfDefined( + body, + 'disable_additional_identifications', + (params as UpdateMeEnterpriseConnectionParams).disableAdditionalIdentifications, + ); + setIfDefined(body, 'custom_attributes', (params as UpdateMeEnterpriseConnectionParams).customAttributes); + + if (params.saml) { + setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId); + setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl); + setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate); + setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl); + setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata); + setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping); + setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains); + setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated); + setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn); } - if (originalCustomAttributes !== undefined) { - body.custom_attributes = originalCustomAttributes; + if (params.oidc) { + setIfDefined(body, 'oidc_client_id', params.oidc.clientId); + setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret); + setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl); + setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl); + setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl); + setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl); + setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce); } return body; } + +/** + * Adds `value` under `key` only when the caller actually provided it. + * Mirrors the SDK's existing semantics: `undefined` means "don't send + * this field"; `null` is forwarded so users can explicitly clear a + * value via the form-encoded body + */ +function setIfDefined(target: Record, key: string, value: unknown): void { + if (value !== undefined) { + target[key] = value; + } +} diff --git a/packages/clerk-js/src/core/resources/__tests__/User.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.test.ts index 0dad85bc27e..0f43cf341bd 100644 --- a/packages/clerk-js/src/core/resources/__tests__/User.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/User.test.ts @@ -184,7 +184,7 @@ describe('User', () => { provider: 'saml_okta', name: 'New SSO', organization_id: 'org_1', - saml: { idp_entity_id: 'https://idp.example.com' }, + saml_idp_entity_id: 'https://idp.example.com', }, }); @@ -291,13 +291,11 @@ describe('User', () => { body: { provider: 'saml_okta', name: 'New SSO', - saml: { - idp_entity_id: 'https://idp.example.com', - attribute_mapping: { - emailAddress: 'mail', - firstName: 'givenName', - 'custom:role': 'role', - }, + saml_idp_entity_id: 'https://idp.example.com', + saml_attribute_mapping: { + emailAddress: 'mail', + firstName: 'givenName', + 'custom:role': 'role', }, }, }); @@ -359,11 +357,9 @@ describe('User', () => { CustomValue: 'y', nestedCamelKey: { innerCamelKey: 'z' }, }, - saml: { - attribute_mapping: { - emailAddress: 'mail', - firstName: 'givenName', - }, + saml_attribute_mapping: { + emailAddress: 'mail', + firstName: 'givenName', }, }, }); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 413764823df..22bb4919c57 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -12,7 +12,14 @@ import { BoxIcon } from '@/icons'; import { Route, Switch } from '@/router'; import { ConfigureSSOFlowProvider } from './ConfigureSSOContext'; -import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomainStep } from './steps'; +import { + ConfigureCreateApp, + ConfirmationStep, + ProvideEmail, + SelectProviderStep, + TestConfigurationStep, + VerifyDomainStep, +} from './steps'; import { ConfigureSSOWizard } from './wizard'; const ConfigureSSOInternal = () => { @@ -34,8 +41,14 @@ const AuthenticatedContent = withCoreUserGuard(() => { const { parsedOptions } = useAppearance(); const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); - const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } = - __internal_useUserEnterpriseConnections({ enabled: true }); + const { + data: enterpriseConnections, + isLoading: isLoadingEnterpriseConnections, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + revalidate: revalidateEnterpriseConnections, + } = __internal_useUserEnterpriseConnections({ enabled: true }); // Currently FAPI only supports one enterprise connection per user const enterpriseConnection = enterpriseConnections?.[0]; @@ -116,6 +129,10 @@ const AuthenticatedContent = withCoreUserGuard(() => { @@ -132,6 +149,13 @@ const ConfigureSSOSteps = () => { return ( + + + { path='configure' label='Configure' > - - {/* TODO: Implement configure steps */} - - - - + void; + createEnterpriseConnection: ( + params: CreateMeEnterpriseConnectionParams, + ) => Promise; + updateEnterpriseConnection: ( + enterpriseConnectionId: string, + params: UpdateMeEnterpriseConnectionParams, + ) => Promise; + deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise; + revalidate: () => Promise; } interface ConfigureSSOFlowProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; isLoading: boolean; + createEnterpriseConnection: ( + params: CreateMeEnterpriseConnectionParams, + ) => Promise; + updateEnterpriseConnection: ( + enterpriseConnectionId: string, + params: UpdateMeEnterpriseConnectionParams, + ) => Promise; + deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise; + revalidate: () => Promise; } const ConfigureSSOFlowContext = React.createContext(null); @@ -30,14 +67,45 @@ ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext'; export const ConfigureSSOFlowProvider = ({ enterpriseConnection, isLoading, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + revalidate, children, }: PropsWithChildren): JSX.Element => { + const [selectedProvider, setSelectedProvider] = React.useState( + enterpriseConnection?.provider as ConfigureSSOSupportedProvider | undefined, + ); + + // Adopt the provider of the existing connection once it's fetched, so + // the user lands on the configure step pre-populated when they + // re-enter the wizard + React.useEffect(() => { + if (enterpriseConnection?.provider && !selectedProvider) { + setSelectedProvider(enterpriseConnection.provider as ConfigureSSOSupportedProvider); + } + }, [enterpriseConnection?.provider, selectedProvider]); + const value = React.useMemo( () => ({ enterpriseConnection, isLoading, + selectedProvider, + setSelectedProvider, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + revalidate, }), - [enterpriseConnection, isLoading], + [ + enterpriseConnection, + isLoading, + selectedProvider, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + revalidate, + ], ); return {children}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx index 15193247e84..83be26eb74c 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx @@ -1,23 +1,316 @@ -import { Flow, Text } from '@/customizables'; +import { useUser } from '@clerk/shared/react'; +import React from 'react'; +import { Box, Button, Col, descriptors, Flex, Flow, Icon, Input, Spinner, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { useClipboard } from '@/hooks'; +import { Check, Copy } from '@/icons'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSOFlow } from '../ConfigureSSOContext'; import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; import { StepLayout } from './StepLayout'; +const isUrl = (value: string) => /^https?:\/\/\S+/.test(value); + export const ConfigureCreateApp = (): JSX.Element => { const { goNext } = useConfigureSSOWizard(); + const { selectedProvider, enterpriseConnection, createEnterpriseConnection, updateEnterpriseConnection } = + useConfigureSSOFlow(); + const { user } = useUser(); + const card = useCardState(); + + const primaryEmail = user?.primaryEmailAddress?.emailAddress ?? ''; + const emailDomain = getEmailDomain(primaryEmail); + + const [metadataUrl, setMetadataUrl] = React.useState(enterpriseConnection?.samlConnection?.idpMetadataUrl ?? ''); + const [isCreating, setIsCreating] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + // Set the moment we kick off the create call, never reset. There is a + // window between the create resolving and React Query's revalidate + // refetch finishing where `enterpriseConnection` is still `undefined`. + // Without this guard we'd refire the effect (since `useCardState()` + // returns a new object every render and `card.setError` was in the + // deps) and POST another connection on every render — causing a + // request storm and `too_many_requests` from FAPI + const hasAttemptedCreateRef = React.useRef(false); + + // Auto-create the enterprise connection when this step mounts so the + // user immediately sees the SP details (ACS URL / SP entity ID) they + // need to register on the IdP side. The connection is created with + // empty SAML params; the user fills them in before continuing + React.useEffect(() => { + if (enterpriseConnection || !selectedProvider || !emailDomain || hasAttemptedCreateRef.current) { + return; + } + + hasAttemptedCreateRef.current = true; + setIsCreating(true); + card.setError(undefined); + + createEnterpriseConnection({ + provider: selectedProvider, + name: emailDomain, + }) + .catch(err => handleError(err as Error, [], card.setError)) + .finally(() => setIsCreating(false)); + // `card` is intentionally omitted: `useCardState()` returns a new + // object every render, which would refire this effect indefinitely + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enterpriseConnection, selectedProvider, emailDomain, createEnterpriseConnection]); + + // Sync the local metadata URL state when the connection finishes loading + React.useEffect(() => { + const remote = enterpriseConnection?.samlConnection?.idpMetadataUrl; + if (remote && !metadataUrl) { + setMetadataUrl(remote); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enterpriseConnection?.samlConnection?.idpMetadataUrl]); + + const acsUrl = enterpriseConnection?.samlConnection?.acsUrl ?? ''; + const spEntityId = enterpriseConnection?.samlConnection?.spEntityId ?? ''; + const copy = getProviderCopy(selectedProvider ?? enterpriseConnection?.provider); + + const canSubmit = Boolean(enterpriseConnection) && isUrl(metadataUrl) && !isSubmitting && !isCreating; + + const submit = React.useCallback(async () => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + setIsSubmitting(true); + card.setError(undefined); + + try { + await updateEnterpriseConnection(enterpriseConnection.id, { + saml: { idpMetadataUrl: metadataUrl }, + }); + await goNext(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + setIsSubmitting(false); + } + }, [canSubmit, enterpriseConnection, updateEnterpriseConnection, metadataUrl, card, goNext]); useRegisterContinueAction({ - handler: () => goNext(), + handler: submit, + isDisabled: !canSubmit, + isLoading: isSubmitting, }); return ( - UI goes here + {isCreating || !enterpriseConnection ? ( + + + + ) : ( + ({ gap: t.space.$6, paddingBlockEnd: t.space.$4 })}> + ({ gap: t.space.$3 })}> + ({ gap: t.space.$1 })}> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + {copy.step1Title} + + ({ color: t.colors.$colorMutedForeground })} + > + {copy.step1Body} + + + + ({ gap: t.space.$2 })}> + + + + + + ({ gap: t.space.$3 })}> + ({ gap: t.space.$1 })}> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + {copy.step2Title} + + ({ color: t.colors.$colorMutedForeground })} + > + {copy.step2Body} + + + + ({ gap: t.space.$1x5 })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + > + {copy.metadataLabel} + + setMetadataUrl(e.currentTarget.value)} + hasError={Boolean(card.error)} + isDisabled={isSubmitting} + aria-label={copy.metadataLabel} + /> + {card.error ? ( + ({ color: t.colors.$danger500, fontSize: t.fontSizes.$sm })} + > + {card.error} + + ) : null} + + + + )} ); }; + +function getEmailDomain(emailAddress: string): string { + const at = emailAddress.lastIndexOf('@'); + if (at === -1) { + return ''; + } + return emailAddress.slice(at + 1).toLowerCase(); +} + +interface ProviderCopy { + stepTitle: string; + stepSubtitle: string; + step1Title: string; + step1Body: string; + step2Title: string; + step2Body: string; + metadataLabel: string; +} + +/** + * Provider-aware copy for the Configure step. Okta gets dashboard- + * specific phrasing; everything else (e.g. `saml_custom`) falls back + * to fully generic IdP language so the same UI can guide users + * through any SAML provider + */ +function getProviderCopy(provider: string | undefined): ProviderCopy { + if (provider === 'saml_okta') { + return { + stepTitle: 'Configure Okta Workforce', + stepSubtitle: 'Create a new enterprise application in your Okta dashboard.', + step1Title: '1. Create a SAML application in Okta', + step1Body: + 'In your Okta admin dashboard, create a new SAML 2.0 application and use the following service provider details:', + step2Title: '2. Provide your Okta metadata URL', + step2Body: + 'Once the SAML application is created, copy the metadata URL from your Okta application and paste it below.', + metadataLabel: 'Okta metadata URL', + }; + } + + return { + stepTitle: 'Configure your identity provider', + stepSubtitle: 'Register Clerk as a service provider in your IdP, then provide its metadata URL.', + step1Title: '1. Create a SAML application on your IdP', + step1Body: + "In your identity provider's admin dashboard, create a new SAML 2.0 application and use the following service provider details:", + step2Title: '2. Provide your IdP metadata URL', + step2Body: 'Once the SAML application is created, copy the metadata URL from your IdP and paste it below.', + metadataLabel: 'IdP metadata URL', + }; +} + +interface CopyableFieldProps { + label: string; + value: string; +} + +const CopyableField = ({ label, value }: CopyableFieldProps): JSX.Element => { + const { onCopy, hasCopied } = useClipboard(value); + + return ( + ({ gap: t.space.$1 })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + > + {label} + + ({ + gap: t.space.$2, + padding: `${t.space.$1x5} ${t.space.$2}`, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + backgroundColor: t.colors.$neutralAlpha25, + })} + > + ({ + flex: 1, + minWidth: 0, + fontFamily: t.fonts.$buttons, + fontSize: t.fontSizes.$sm, + color: t.colors.$colorForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + })} + > + {value || '\u2014'} + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index 0f6cbf2c49e..d6af72daca8 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -1,13 +1,377 @@ -import { Flow, Text } from '@/customizables'; +import { useReverification } from '@clerk/shared/react'; +import React from 'react'; +import { Box, Button, Col, descriptors, Flex, Flow, Spinner, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSOFlow } from '../ConfigureSSOContext'; +import { useConfigureSSOWizard } from '../wizard'; import { StepLayout } from './StepLayout'; export const ConfirmationStep = (): JSX.Element => { + const { goToStep } = useConfigureSSOWizard(); + const { enterpriseConnection, updateEnterpriseConnection, deleteEnterpriseConnection } = useConfigureSSOFlow(); + const card = useCardState(); + + const updateActive = useReverification((id: string, active: boolean) => updateEnterpriseConnection(id, { active })); + const deleteConnection = useReverification((id: string) => deleteEnterpriseConnection(id)); + + const [isToggling, setIsToggling] = React.useState(false); + const [isResetting, setIsResetting] = React.useState(false); + + // Defensive: this step is only mounted with an existing connection, + // but if the user lands here directly with no connection, send them + // back to the very first step + React.useEffect(() => { + if (!enterpriseConnection) { + void goToStep('select-provider'); + } + }, [enterpriseConnection, goToStep]); + + if (!enterpriseConnection) { + return ( + + + + + + + + ); + } + + const isActive = enterpriseConnection.active; + const domain = enterpriseConnection.domains[0] ?? ''; + const saml = enterpriseConnection.samlConnection; + + const handleToggle = async () => { + setIsToggling(true); + card.setError(undefined); + try { + await updateActive(enterpriseConnection.id, !isActive); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + setIsToggling(false); + } + }; + + const handleReset = async () => { + setIsResetting(true); + card.setError(undefined); + try { + await deleteConnection(enterpriseConnection.id); + // After deletion, the parent's React Query cache invalidates and + // the wizard rehydrates with `enterpriseConnection === undefined`, + // so we send the user back to the start of the flow + void goToStep('select-provider'); + } catch (err) { + handleError(err as Error, [], card.setError); + setIsResetting(false); + } + }; + return ( - UI goes here + ({ gap: t.space.$5, paddingBlockEnd: t.space.$4 })}> + + +
+ + } + /> +
+ +
+ ({ gap: t.space.$2x5 })}> + + + + + + } + /> +
+ +
+ { + void handleReset(); + }} + sx={t => ({ + alignSelf: 'flex-start', + padding: 0, + color: t.colors.$danger500, + fontSize: t.fontSizes.$sm, + fontWeight: t.fontWeights.$medium, + '&:hover': { textDecoration: 'underline' }, + })} + > + {isResetting ? 'Resetting…' : 'Reset connection'} + + } + /> +
+ + {card.error ? ( + ({ color: t.colors.$danger500, fontSize: t.fontSizes.$sm })} + > + {card.error} + + ) : null} +
); }; + +interface HeadingProps { + isActive: boolean; + domain: string; +} + +const Heading = ({ isActive, domain }: HeadingProps): JSX.Element => ( + ({ gap: t.space.$1 })}> + ({ + color: t.colors.$colorForeground, + fontSize: t.fontSizes.$lg, + fontWeight: t.fontWeights.$semibold, + })} + > + Your SSO is{' '} + ({ + color: isActive ? t.colors.$success500 : t.colors.$warning500, + fontWeight: t.fontWeights.$semibold, + })} + > + {isActive ? 'active' : 'inactive'} + {' '} + {domain ? ( + <> + on{' '} + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + {domain} + + + ) : null} + + {!isActive ? ( + ({ color: t.colors.$colorMutedForeground })} + > + Use the toggle below to enable SSO. + + ) : null} + +); + +const Section = ({ children }: { children: React.ReactNode }): JSX.Element => ( + ({ + paddingBlock: t.space.$3, + borderBottomWidth: t.borderWidths.$normal, + borderBottomStyle: t.borderStyles.$solid, + borderBottomColor: t.colors.$borderAlpha100, + '&:last-child': { borderBottomWidth: 0 }, + })} + > + {children} + +); + +interface SectionRowProps { + label: string; + value: React.ReactNode; +} + +const SectionRow = ({ label, value }: SectionRowProps): JSX.Element => ( + ({ + gap: t.space.$4, + })} + > + ({ + flex: '0 0 auto', + width: t.sizes.$48, + color: t.colors.$colorForeground, + fontSize: t.fontSizes.$sm, + fontWeight: t.fontWeights.$medium, + })} + > + {label} + + {value} + +); + +interface DetailRowProps { + label: string; + value: string | undefined | null; + isLink?: boolean; +} + +const DetailRow = ({ label, value, isLink }: DetailRowProps): JSX.Element => ( + ({ gap: t.space.$4 })} + > + ({ + flex: '0 0 auto', + width: t.sizes.$24, + color: t.colors.$colorMutedForeground, + fontSize: t.fontSizes.$sm, + })} + > + {label} + + ({ + flex: 1, + minWidth: 0, + fontSize: t.fontSizes.$sm, + color: isLink ? t.colors.$primary500 : t.colors.$colorForeground, + textDecoration: isLink && value ? 'underline' : 'none', + textUnderlineOffset: '2px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + })} + > + {value || '\u2014'} + + +); + +interface ToggleProps { + isActive: boolean; + isLoading: boolean; + onClick: () => void | Promise; +} + +const Toggle = ({ isActive, isLoading, onClick }: ToggleProps): JSX.Element => ( + +); + +/** + * Certificates can be huge PEM blobs. Show only the trailing fragment + * so the row doesn't blow out the layout + */ +function truncateCertificate(cert: string | null | undefined): string | undefined { + if (!cert) { + return undefined; + } + const cleaned = cert.replace(/-----BEGIN [A-Z ]+-----|-----END [A-Z ]+-----/g, '').trim(); + if (cleaned.length <= 32) { + return cleaned; + } + return `…${cleaned.slice(-24)}`; +} diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx new file mode 100644 index 00000000000..660cc231cf4 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -0,0 +1,205 @@ +import React from 'react'; + +import { Box, Button, Col, descriptors, Flex, Flow, Icon, Text } from '@/customizables'; +import { CheckCircle, CogFilled, ExclamationTriangle } from '@/icons'; + +import { type ConfigureSSOSupportedProvider, useConfigureSSOFlow } from '../ConfigureSSOContext'; +import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; +import { StepLayout } from './StepLayout'; + +interface ProviderOption { + id: ConfigureSSOSupportedProvider; + label: string; + icon: React.ComponentType; +} + +const SAML_PROVIDERS: ProviderOption[] = [ + { + id: 'saml_okta', + label: 'Okta Workforce', + icon: CogFilled, + }, + { + id: 'saml_custom', + label: 'Custom SAML Provider', + icon: CogFilled, + }, +]; + +export const SelectProviderStep = (): JSX.Element => { + const { goNext } = useConfigureSSOWizard(); + const { selectedProvider, setSelectedProvider, enterpriseConnection } = useConfigureSSOFlow(); + + // Once a connection exists the provider is locked-in for that user; + // re-entering the wizard should preselect it + const isLockedIn = Boolean(enterpriseConnection?.provider); + + useRegisterContinueAction({ + handler: () => goNext(), + isDisabled: !selectedProvider, + }); + + return ( + + + ({ gap: t.space.$5, paddingBlockEnd: t.space.$4 })}> + ({ gap: t.space.$3 })}> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$medium })} + > + Select your identity provider + + ({ color: t.colors.$colorMutedForeground })} + > + We'll guide you through the desired setup process next. + + + + + + ({ + gap: t.space.$2, + padding: `${t.space.$2x5} ${t.space.$3}`, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$warningAlpha100, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$warningAlpha200, + })} + > + ({ color: t.colors.$warning500, flexShrink: 0 })} + /> + ({ color: t.colors.$colorMutedForeground, fontSize: t.fontSizes.$sm })} + > + Once a provider is selected, you cannot change again until the configuration is over. + + + + + + ); +}; + +interface ProviderGroupProps { + heading: string; + options: ProviderOption[]; + selectedProvider: ConfigureSSOSupportedProvider | undefined; + isLockedIn: boolean; + onSelect: (provider: ConfigureSSOSupportedProvider | undefined) => void; +} + +const ProviderGroup = ({ heading, options, selectedProvider, isLockedIn, onSelect }: ProviderGroupProps) => ( + ({ gap: t.space.$2 })}> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + {heading} + + ({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: t.space.$3, + })} + > + {options.map(option => ( + + ))} + + +); + +interface ProviderCardProps { + option: ProviderOption; + isSelected: boolean; + isLockedIn: boolean; + onSelect: (provider: ConfigureSSOSupportedProvider | undefined) => void; +} + +const ProviderCard = ({ option, isSelected, isLockedIn, onSelect }: ProviderCardProps) => { + const isDisabled = isLockedIn && !isSelected; + + return ( + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 31c1ab907de..d11670aa130 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,16 +1,271 @@ -import { Flow, Text } from '@/customizables'; +import { useUser } from '@clerk/shared/react'; +import React from 'react'; +import { Box, Button, Col, descriptors, Flex, Flow, Icon, Spinner, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { useClipboard } from '@/hooks'; +import { Check, CheckCircle, Copy } from '@/icons'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSOFlow } from '../ConfigureSSOContext'; +import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; import { StepLayout } from './StepLayout'; +const POLL_INTERVAL_MS = 3_000; + export const TestConfigurationStep = (): JSX.Element => { + const { goNext } = useConfigureSSOWizard(); + const { enterpriseConnection } = useConfigureSSOFlow(); + const { user } = useUser(); + const card = useCardState(); + + const [testUrl, setTestUrl] = React.useState(''); + const [isCreatingTestRun, setIsCreatingTestRun] = React.useState(false); + const [hasSuccessfulRun, setHasSuccessfulRun] = React.useState(false); + const [isPolling, setIsPolling] = React.useState(false); + const initRef = React.useRef(false); + + // Create the test URL once on mount + React.useEffect(() => { + if (initRef.current || !user || !enterpriseConnection) { + return; + } + initRef.current = true; + setIsCreatingTestRun(true); + card.setError(undefined); + + user + .createEnterpriseConnectionTestRun(enterpriseConnection.id) + .then(({ url }) => setTestUrl(url)) + .catch(err => handleError(err as Error, [], card.setError)) + .finally(() => setIsCreatingTestRun(false)); + // `card` is intentionally omitted: `useCardState()` returns a new + // object every render, which would refire this effect needlessly. + // `initRef` already guards against duplicate test-run creation + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, enterpriseConnection]); + + // Poll for a successful test run while we're still on this step + React.useEffect(() => { + if (!user || !enterpriseConnection || !testUrl || hasSuccessfulRun) { + return; + } + + let cancelled = false; + setIsPolling(true); + + const poll = async () => { + try { + const result = await user.getEnterpriseConnectionTestRuns(enterpriseConnection.id, { + status: ['success'], + pageSize: 1, + }); + if (cancelled) { + return; + } + if (result.data.length > 0) { + setHasSuccessfulRun(true); + setIsPolling(false); + } + } catch { + // Network blips are expected while polling; surface only persistent + // errors via the card error from the initial create call + } + }; + + void poll(); + const id = window.setInterval(() => { + void poll(); + }, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(id); + setIsPolling(false); + }; + }, [user, enterpriseConnection, testUrl, hasSuccessfulRun]); + + useRegisterContinueAction({ + handler: () => goNext(), + isDisabled: !hasSuccessfulRun, + }); + return ( - UI goes here + ({ gap: t.space.$5, paddingBlockEnd: t.space.$4 })}> + ({ gap: t.space.$2 })}> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + Test your SSO + + ({ color: t.colors.$colorMutedForeground })} + > + Authenticate using the test URL below, in an incognito tab, to verify you configured the connection + correctly. + + + + + + ({ gap: t.space.$2 })}> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + Your test results + + + + ); }; + +interface CopyTestUrlButtonProps { + url: string; + isLoading: boolean; +} + +const CopyTestUrlButton = ({ url, isLoading }: CopyTestUrlButtonProps): JSX.Element => { + const { onCopy, hasCopied } = useClipboard(url); + + return ( + + ); +}; + +interface TestResultsBoxProps { + hasSuccessfulRun: boolean; + isPolling: boolean; + isCreatingTestRun: boolean; + hasError: boolean; + errorMessage?: string; +} + +const TestResultsBox = ({ + hasSuccessfulRun, + isPolling, + isCreatingTestRun, + hasError, + errorMessage, +}: TestResultsBoxProps): JSX.Element => { + return ( + ({ + minHeight: t.sizes.$36, + padding: t.space.$5, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + backgroundColor: t.colors.$neutralAlpha25, + })} + > + {hasSuccessfulRun ? ( + ({ gap: t.space.$2, textAlign: 'center' })} + > + ({ width: t.sizes.$8, height: t.sizes.$8, color: t.colors.$success500 })} + /> + ({ color: t.colors.$colorForeground, fontWeight: t.fontWeights.$semibold })} + > + Test successful + + ({ color: t.colors.$colorMutedForeground })} + > + Continue to enable the connection. + + + ) : hasError ? ( + ({ gap: t.space.$1, textAlign: 'center' })} + > + ({ color: t.colors.$danger500, fontWeight: t.fontWeights.$semibold })} + > + Could not start a test + + {errorMessage ? ( + ({ color: t.colors.$colorMutedForeground })} + > + {errorMessage} + + ) : null} + + ) : ( + ({ gap: t.space.$2, textAlign: 'center' })} + > + + + + ({ color: t.colors.$colorMutedForeground })} + > + {isCreatingTestRun + ? 'Preparing a test URL for you…' + : isPolling + ? 'Waiting for a successful sign-in via the test URL…' + : 'Waiting for the test to start…'} + + + )} + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts index 300535512e9..6ef5d36e210 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/index.ts +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -1,6 +1,7 @@ export { ConfigureCreateApp } from './ConfigureCreateAppStep'; export { ConfirmationStep } from './ConfirmationStep'; export { ProvideEmail } from './ProvideEmailStep'; +export { SelectProviderStep } from './SelectProviderStep'; export { StepLayout } from './StepLayout'; export { TestConfigurationStep } from './TestConfigurationStep'; export { VerifyDomainStep } from './VerifyDomainStep'; diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index e0c5eff48d0..11379652372 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -135,6 +135,7 @@ export type FlowMetadata = { | 'organizationCreationDisabled' | 'methodSelectionMFA' | 'provideEmail' + | 'selectProvider' | 'verifyDomain' | 'configureCreateApp' | 'configureMapAttributes' From e8dde74f74b01b14bb5d2639dfe584b7e0b4c496 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 7 May 2026 13:17:43 -0300 Subject: [PATCH 5/8] Add changeset --- .changeset/shaky-grapes-add.md | 8 ++++++++ .../ui/src/components/ConfigureSSO/ConfigureSSO.tsx | 2 +- packages/ui/src/elements/Navbar.tsx | 13 +++++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .changeset/shaky-grapes-add.md diff --git a/.changeset/shaky-grapes-add.md b/.changeset/shaky-grapes-add.md new file mode 100644 index 00000000000..1fea345e6f2 --- /dev/null +++ b/.changeset/shaky-grapes-add.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +`<__experimental_ConfigureSSO />` POC for internal testing, not meant for public release diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 22bb4919c57..f9de14cb1ed 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -58,7 +58,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { > ({ diff --git a/packages/ui/src/elements/Navbar.tsx b/packages/ui/src/elements/Navbar.tsx index 1871c0c5825..6f3583d2030 100644 --- a/packages/ui/src/elements/Navbar.tsx +++ b/packages/ui/src/elements/Navbar.tsx @@ -49,10 +49,15 @@ type NavBarProps = { contentRef: React.RefObject; routes: NavbarRoute[]; header?: React.ReactNode; + /** + * Content rendered above the navbar title. Useful when the sidebar + * needs to show context (app + org) before the page title + */ + headerAboveTitle?: React.ReactNode; }; export const NavBar = (props: NavBarProps) => { - const { contentRef, title, titleSx, description, routes, header } = props; + const { contentRef, title, titleSx, description, routes, header, headerAboveTitle } = props; const { close } = useNavbarContext(); const { navigate } = useRouter(); const { navigateToFlowStart } = useNavigateToFlowStart(); @@ -129,11 +134,13 @@ export const NavBar = (props: NavBarProps) => { title={title} titleSx={titleSx} description={description} + headerAboveTitle={headerAboveTitle} > {header} {items} + {headerAboveTitle} {header} {items} @@ -146,9 +153,10 @@ const NavbarContainer = ( title: LocalizationKey | string; titleSx?: ThemableCssProp; description?: LocalizationKey | string; + headerAboveTitle?: React.ReactNode; }>, ) => { - const { title, titleSx, description } = props; + const { title, titleSx, description, headerAboveTitle } = props; return ( ({ gap: t.space.$6, flex: `0 0 ${t.space.$60}` })}> + {headerAboveTitle} ({ gap: t.space.$0x5, From a7071b0d753420ff949cb3198c591309d2f44d41 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 7 May 2026 14:40:55 -0300 Subject: [PATCH 6/8] Show CTA when connection is already created --- .../components/ConfigureSSO/ConfigureSSO.tsx | 23 ++++--- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 66 +++++++++++++++---- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f9de14cb1ed..15383e47a05 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -11,7 +11,7 @@ import { ProfileCard } from '@/elements/ProfileCard'; import { BoxIcon } from '@/icons'; import { Route, Switch } from '@/router'; -import { ConfigureSSOFlowProvider } from './ConfigureSSOContext'; +import { ConfigureSSOFlowProvider, useConfigureSSOFlow } from './ConfigureSSOContext'; import { ConfigureCreateApp, ConfirmationStep, @@ -144,18 +144,25 @@ const AuthenticatedContent = withCoreUserGuard(() => { const ConfigureSSOSteps = () => { const { user } = useUser(); + const { enterpriseConnection } = useConfigureSSOFlow(); const hasEmailAddress = Boolean(user?.emailAddresses?.length); + // The provider can only be picked once; if a connection already + // exists for this user we drop the step from the wizard entirely + // so it never shows in the breadcrumb and is not routable + const hasEnterpriseConnection = Boolean(enterpriseConnection); return ( - - - + {!hasEnterpriseConnection && ( + + + + )} { const { goNext, goToStep } = useConfigureSSOWizard(); + const { enterpriseConnection } = useConfigureSSOFlow(); const card = useCardState(); const { t } = useLocalizations(); const { user } = useUser(); + const { organization } = useOrganization(); const emailToVerify = user?.primaryEmailAddress ?? user?.emailAddresses?.find(e => e.verification.status !== 'verified'); const isVerified = emailToVerify?.verification.status === 'verified'; const isAlreadyPrimary = Boolean(emailToVerify && emailToVerify.id === user?.primaryEmailAddressId); + // The user's domain is already wired to an enterprise connection that + // doesn't belong to the org they're currently configuring. They can't + // take it over from here — they need the existing app's owner to + // re-configure (or share) the connection + const isDomainTakenByOtherOrg = Boolean( + isVerified && enterpriseConnection && enterpriseConnection.organizationId !== (organization?.id ?? null), + ); + const prepareEmailVerification = useReverification(() => emailToVerify?.prepareVerification({ strategy: 'email_code' }), ); @@ -62,20 +73,27 @@ export const VerifyDomainStep = (): JSX.Element | null => { const { values, length } = otp.otpControl.otpInputProps; const isCodeComplete = values.filter(Boolean).length === length; - const showVerifiedView = isVerified && !codeSubmittedRef.current; + const showVerifiedView = isVerified && !codeSubmittedRef.current && !isDomainTakenByOtherOrg; useRegisterContinueAction( - showVerifiedView + isDomainTakenByOtherOrg ? { handler: () => { - void goNext(); + // No-op: there's no path forward from this state }, + isDisabled: true, } - : { - handler: otp.onFakeContinue, - isDisabled: !isCodeComplete, - isLoading: otp.isLoading, - }, + : showVerifiedView + ? { + handler: () => { + void goNext(); + }, + } + : { + handler: otp.onFakeContinue, + isDisabled: !isCodeComplete, + isLoading: otp.isLoading, + }, ); React.useEffect(() => { @@ -114,7 +132,33 @@ export const VerifyDomainStep = (): JSX.Element | null => { paddingBlock: t.space.$8, })} > - {showVerifiedView ? ( + {isDomainTakenByOtherOrg ? ( + <> + ({ + width: t.sizes.$8, + height: t.sizes.$8, + color: t.colors.$neutralAlpha600, + })} + /> + ({ gap: t.space.$1, textAlign: 'center', maxWidth: t.sizes.$66 })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + > + That domain is already used for a different enterprise connection. + + ({ color: t.colors.$colorMutedForeground })} + > + Contact the application owner to configure an SSO connection using the same domain. + + + + ) : showVerifiedView ? ( <> Date: Thu, 7 May 2026 14:54:46 -0300 Subject: [PATCH 7/8] Show CTA when user does not have manage permission --- .../components/ConfigureSSO/ConfigureSSO.tsx | 88 ++++++++++++++++--- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 6 +- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 15383e47a05..f603e7c7475 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -2,13 +2,25 @@ import { __internal_useUserEnterpriseConnections, useOrganization, useUser } fro import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; import React from 'react'; +import { useProtect } from '@/common'; import { useEnvironment, withCoreUserGuard } from '@/contexts'; -import { Box, Col, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; +import { + Box, + Col, + descriptors, + Flex, + Flow, + Heading, + Icon, + localizationKeys, + Text, + useAppearance, +} from '@/customizables'; import { ApplicationLogo } from '@/elements/ApplicationLogo'; import { withCardStateProvider } from '@/elements/contexts'; import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; import { ProfileCard } from '@/elements/ProfileCard'; -import { BoxIcon } from '@/icons'; +import { BoxIcon, ExclamationTriangle } from '@/icons'; import { Route, Switch } from '@/router'; import { ConfigureSSOFlowProvider, useConfigureSSOFlow } from './ConfigureSSOContext'; @@ -41,6 +53,14 @@ const AuthenticatedContent = withCoreUserGuard(() => { const { parsedOptions } = useAppearance(); const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); + // Gate the entire wizard behind the org-level permission. When the + // user can't manage enterprise connections, we still render the + // outer sidebar/title chrome but replace the wizard with a + // permissions-error message so the layout stays consistent + const canManageEnterpriseConnections = useProtect({ + permission: 'org:sys_enterprise_connections:manage', + }); + const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections, @@ -48,7 +68,9 @@ const AuthenticatedContent = withCoreUserGuard(() => { updateEnterpriseConnection, deleteEnterpriseConnection, revalidate: revalidateEnterpriseConnections, - } = __internal_useUserEnterpriseConnections({ enabled: true }); + } = __internal_useUserEnterpriseConnections({ + enabled: canManageEnterpriseConnections, + }); // Currently FAPI only supports one enterprise connection per user const enterpriseConnection = enterpriseConnections?.[0]; @@ -126,16 +148,20 @@ const AuthenticatedContent = withCoreUserGuard(() => { flex: 1, })} > - - - + {canManageEnterpriseConnections ? ( + + + + ) : ( + + )} @@ -228,5 +254,41 @@ const OrganizationSidebarSubtitle = () => { ); }; +/** + * Rendered in place of the wizard when the user lacks the + * `org:sys_enterprise_connections:manage` permission. The outer + * sidebar / title chrome stays the same; only the body changes + */ +const NoPermission = () => ( + ({ flex: 1, padding: t.space.$8 })} + > + ({ gap: t.space.$2, textAlign: 'center', maxWidth: t.sizes.$66 })} + > + ({ width: t.sizes.$8, height: t.sizes.$8, color: t.colors.$neutralAlpha600 })} + /> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + > + You do not have permission to manage enterprise connections + + ({ color: t.colors.$colorMutedForeground })} + > + Contact your organization administrator in order to have permissions to manage enterprise connections. + + + +); + export const ConfigureSSO: React.ComponentType<__experimental_ConfigureSSOProps> = withCardStateProvider(ConfigureSSOInternal); diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 269080384da..3c0f82e9635 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -145,16 +145,16 @@ export const VerifyDomainStep = (): JSX.Element | null => { ({ gap: t.space.$1, textAlign: 'center', maxWidth: t.sizes.$66 })}> ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + sx={t => ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$md })} > - That domain is already used for a different enterprise connection. + That domain already has an SSO connection ({ color: t.colors.$colorMutedForeground })} > - Contact the application owner to configure an SSO connection using the same domain. + Contact the application's administrator to get access through the existing connection. From e77e33f6541442beb3fa634538699e27d0a9eb75 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 7 May 2026 14:57:09 -0300 Subject: [PATCH 8/8] Create connection for organization --- .../components/ConfigureSSO/ConfigureSSO.tsx | 35 +++++++++++++------ .../steps/ConfigureCreateAppStep.tsx | 7 +++- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 24 ++++++++++--- .../wizard/ConfigureSSOWizard.tsx | 24 +++++++++---- .../components/ConfigureSSO/wizard/types.ts | 9 +++++ 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f603e7c7475..07d0490b0e0 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,4 +1,4 @@ -import { __internal_useUserEnterpriseConnections, useOrganization, useUser } from '@clerk/shared/react'; +import { __internal_useUserEnterpriseConnections, useSession, useUser } from '@clerk/shared/react'; import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; import React from 'react'; @@ -53,13 +53,16 @@ const AuthenticatedContent = withCoreUserGuard(() => { const { parsedOptions } = useAppearance(); const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); - // Gate the entire wizard behind the org-level permission. When the - // user can't manage enterprise connections, we still render the - // outer sidebar/title chrome but replace the wizard with a - // permissions-error message so the layout stays consistent - const canManageEnterpriseConnections = useProtect({ + const { session } = useSession(); + const hasActiveOrganization = Boolean(session?.lastActiveOrganizationId); + + // Gate the entire wizard behind the org-level permission. `useProtect` + // must be called unconditionally to satisfy the rules of hooks; we + // combine its result with `hasActiveOrganization` afterwards + const hasManagePermission = useProtect({ permission: 'org:sys_enterprise_connections:manage', }); + const canManageEnterpriseConnections = hasActiveOrganization && hasManagePermission; const { data: enterpriseConnections, @@ -69,7 +72,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { deleteEnterpriseConnection, revalidate: revalidateEnterpriseConnections, } = __internal_useUserEnterpriseConnections({ - enabled: canManageEnterpriseConnections, + enabled: true, }); // Currently FAPI only supports one enterprise connection per user const enterpriseConnection = enterpriseConnections?.[0]; @@ -148,7 +151,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { flex: 1, })} > - {canManageEnterpriseConnections ? ( + {canManageEnterpriseConnections || !hasActiveOrganization ? ( { id='select-provider' path='select-provider' label='Select provider' + hideFromBreadcrumb >
@@ -237,9 +241,18 @@ const ConfigureSSOSteps = () => { }; const OrganizationSidebarSubtitle = () => { - const { organization } = useOrganization(); + // Resolve the active org's name without `useOrganization()` (which + // would subscribe to the organization resource). The active id lives + // on the session, and the user already carries the matching + // membership eagerly, so we can join the two without an extra fetch + const { user } = useUser(); + const { session } = useSession(); + const activeOrganizationId = session?.lastActiveOrganizationId ?? null; + const activeOrganization = activeOrganizationId + ? user?.organizationMemberships.find(m => m.organization.id === activeOrganizationId)?.organization + : undefined; - if (!organization) { + if (!activeOrganization) { return null; } @@ -249,7 +262,7 @@ const OrganizationSidebarSubtitle = () => { truncate sx={t => ({ color: t.colors.$colorMutedForeground })} > - {organization?.name} + {activeOrganization.name} ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx index 83be26eb74c..72760262b01 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx @@ -1,4 +1,4 @@ -import { useUser } from '@clerk/shared/react'; +import { useSession, useUser } from '@clerk/shared/react'; import React from 'react'; import { Box, Button, Col, descriptors, Flex, Flow, Icon, Input, Spinner, Text } from '@/customizables'; @@ -19,6 +19,10 @@ export const ConfigureCreateApp = (): JSX.Element => { useConfigureSSOFlow(); const { user } = useUser(); const card = useCardState(); + // Active org id straight off the session — `useOrganization()` would + // subscribe to the organization resource and we only need the id + const { session } = useSession(); + const activeOrganizationId = session?.lastActiveOrganizationId ?? undefined; const primaryEmail = user?.primaryEmailAddress?.emailAddress ?? ''; const emailDomain = getEmailDomain(primaryEmail); @@ -51,6 +55,7 @@ export const ConfigureCreateApp = (): JSX.Element => { createEnterpriseConnection({ provider: selectedProvider, name: emailDomain, + organizationId: activeOrganizationId, }) .catch(err => handleError(err as Error, [], card.setError)) .finally(() => setIsCreating(false)); diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 3c0f82e9635..24f62291ab6 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -1,4 +1,4 @@ -import { useOrganization, useReverification, useUser } from '@clerk/shared/react'; +import { useReverification, useSession, useUser } from '@clerk/shared/react'; import React from 'react'; import { Col, Flow, Heading, Icon, Input, localizationKeys, Text, useLocalizations } from '@/customizables'; @@ -18,19 +18,23 @@ export const VerifyDomainStep = (): JSX.Element | null => { const card = useCardState(); const { t } = useLocalizations(); const { user } = useUser(); - const { organization } = useOrganization(); + const { session } = useSession(); + const activeOrganizationId = session?.lastActiveOrganizationId ?? null; const emailToVerify = user?.primaryEmailAddress ?? user?.emailAddresses?.find(e => e.verification.status !== 'verified'); const isVerified = emailToVerify?.verification.status === 'verified'; const isAlreadyPrimary = Boolean(emailToVerify && emailToVerify.id === user?.primaryEmailAddressId); + // Domain that the existing connection is registered for. + const conflictingDomain = enterpriseConnection?.domains?.[0] ?? getEmailDomain(emailToVerify?.emailAddress ?? ''); + // The user's domain is already wired to an enterprise connection that // doesn't belong to the org they're currently configuring. They can't // take it over from here — they need the existing app's owner to // re-configure (or share) the connection const isDomainTakenByOtherOrg = Boolean( - isVerified && enterpriseConnection && enterpriseConnection.organizationId !== (organization?.id ?? null), + isVerified && enterpriseConnection && enterpriseConnection.organizationId !== activeOrganizationId, ); const prepareEmailVerification = useReverification(() => @@ -147,14 +151,16 @@ export const VerifyDomainStep = (): JSX.Element | null => { textVariant='h1' sx={t => ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$md })} > - That domain already has an SSO connection + {conflictingDomain + ? `This domain (${conflictingDomain}) already has an SSO connection` + : 'This domain already has an SSO connection'} ({ color: t.colors.$colorMutedForeground })} > - Contact the application's administrator to get access through the existing connection. + Contact the application's administrator to get access through the existing connection. @@ -217,3 +223,11 @@ export const VerifyDomainStep = (): JSX.Element | null => {
); }; + +function getEmailDomain(emailAddress: string): string { + const at = emailAddress.lastIndexOf('@'); + if (at === -1) { + return ''; + } + return emailAddress.slice(at + 1).toLowerCase(); +} diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx index 0cbe9614367..3ebd35b2760 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx @@ -55,6 +55,7 @@ function extractSteps(children: React.ReactNode): ConfigureSSOWizardActiveStep[] path: props.path, label: props.label, isCompleted: props.isCompleted, + hideFromBreadcrumb: props.hideFromBreadcrumb, children: props.children, }); }); @@ -265,12 +266,16 @@ const StepBody = ({ step }: { step: ConfigureSSOWizardActiveStep }): JSX.Element /** * Numbered breadcrumb of the outermost wizard's active steps. * Completed and current steps are clickable for backwards navigation, - * future steps are disabled + * future steps are disabled. Steps marked with `hideFromBreadcrumb` + * are silently skipped over and the visible steps are renumbered, so + * dropping a hidden step from the wizard later doesn't shift numbers */ const Header = (): JSX.Element => { const { activeSteps, currentIndex, isLoading, goToStep } = useConfigureSSOWizard(); const { t } = useLocalizations(); + const visibleSteps = React.useMemo(() => activeSteps.filter(s => !s.hideFromBreadcrumb), [activeSteps]); + return ( { flexWrap: 'wrap', })} > - {activeSteps.map((step, index) => { - const isCurrent = index === currentIndex; - const isCompleted = step.isCompleted ?? index < currentIndex; - const isReachable = isCompleted || index <= currentIndex; + {visibleSteps.map((step, visibleIndex) => { + // `currentIndex` is computed against the full `activeSteps` + // list, so look the visible step back up there to keep + // current/completed/reachable consistent with the wizard's + // own routing state + const actualIndex = activeSteps.findIndex(s => s.id === step.id); + const isCurrent = actualIndex === currentIndex; + const isCompleted = step.isCompleted ?? actualIndex < currentIndex; + const isReachable = isCompleted || actualIndex <= currentIndex; const labelText = step.label ? (typeof step.label === 'string' ? step.label : t(step.label)) : ''; return ( @@ -342,7 +352,7 @@ const Header = (): JSX.Element => { size='xs' /> ) : ( - index + 1 + visibleIndex + 1 )} { )} - {index < activeSteps.length - 1 && ( + {visibleIndex < visibleSteps.length - 1 && ( ` for inner sub-steps @@ -74,6 +82,7 @@ export interface ConfigureSSOWizardActiveStep { path: string; label?: LocalizationKey | string; isCompleted?: boolean; + hideFromBreadcrumb?: boolean; children: React.ReactNode; }