From 2a067a675058f5b70887979f8d47c5190afd61a9 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 26 May 2026 15:36:27 -0300 Subject: [PATCH 1/8] Refactor localization per provider --- packages/localizations/src/en-US.ts | 272 +++++++++++----------- packages/shared/src/types/localization.ts | 252 ++++++++++---------- 2 files changed, 255 insertions(+), 269 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index b0b2d822312..5343347b7ca 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -367,180 +367,174 @@ export const enUS: LocalizationResource = { }, }, samlOkta: { - headerTitle: 'Configure Okta Workforce', - attributeMapping: { - columns: { - name: 'Name', - value: 'Value', - }, - rows: { - email: { name: 'mail', value: 'user.profile.mail' }, - firstName: { name: 'firstName', value: 'user.profile.firstName' }, - lastName: { name: 'lastName', value: 'user.profile.lastName' }, + mainHeaderTitle: 'Configure Okta Workforce', + createAppStep: { + headerSubtitle: 'Create a new enterprise application in your Okta Dashboard', + createAppInstructions: { + title: 'Create a new enterprise application in Okta', + step1: 'Sign in to Okta and go to Admin → Applications.', + step2: 'Click Create App Integration.', + step3: 'Select SAML 2.0.', + step4: 'Fill in the General Settings (App name is required).', + step5: 'Click Next to complete creating the application.', }, - }, - spFields: { - acsUrl: { - label: 'Single sign-on URL', + serviceProviderInstructions: { + title: 'Configure service provider', + paragraph1: + 'Once you have moved forward from the General Settings instructions, you will be presented with the Configure SAML page.', + paragraph2: + 'To configure your service provider (Clerk), you must add these two fields to your Okta application:', + serviceProviderFields: { + acsUrl: { + label: 'Single sign-on URL', + }, + spEntityId: { + label: 'Audience URI (SP Entity ID)', + }, + }, }, - spEntityId: { - label: 'Audience URI (SP Entity ID)', + completeSamlIntegrationInstructions: { + title: 'Complete SAML integration', + step1: 'Select This is an internal app that we have created from the options menu.', + step2: 'Complete the form with any comments and select "Finish".', }, }, - createApp: { - headerSubtitle: 'Create a new enterprise application in your Okta Dashboard', - title: 'Create a new enterprise application in Okta', - step1: 'Sign in to Okta and go to Admin → Applications.', - step2: 'Click Create App Integration.', - step3: 'Select SAML 2.0.', - step4: 'Fill in the General Settings (App name is required).', - step5: 'Click Next to complete creating the application.', - }, - serviceProvider: { - title: 'Configure service provider', - paragraph1: - 'Once you have moved forward from the General Settings instructions, you will be presented with the Configure SAML page.', - paragraph2: - 'To configure your service provider (Clerk), you must add these two fields to your Okta application:', - }, - completeSamlIntegration: { - title: 'Complete SAML integration', - step1: 'Select This is an internal app that we have created from the options menu.', - step2: 'Complete the form with any comments and select Finish.', - }, - configureAttributes: { + attributeMappingStep: { + // TODO - Not mention Clerk headerSubtitle: 'Map user attributes from Okta to Clerk', + paragraph: 'We expect your SAML responses to have the following specific attributes:', step1: 'Open the Sign On tab of your Okta application and locate the Attribute Statements section. If you don’t see it, click Show legacy configuration, then Edit.', step2: 'Select Add Expression for each row below, then enter the matching name and value:', - pairs: { - conjunction: ' and ', - email: { - name: 'mail', - expression: 'user.profile.mail', - }, - firstName: { - name: 'firstName', - expression: 'user.profile.firstName', + attributeMappingTable: { + columns: { + name: 'Name', + expression: 'Expression', }, - lastName: { - name: 'lastName', - expression: 'user.profile.lastName', + rows: { + email: { name: 'mail', expression: 'user.mail' }, + firstName: { name: 'firstName', expression: 'user.firstName' }, + lastName: { name: 'lastName', expression: 'user.lastName' }, }, }, }, - assignUsers: { + assignUsersStep: { headerSubtitle: 'Assign users to the enterprise app', - title: 'Assign selected user or group in Okta', - paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', - step1: 'In the Okta dashboard, select the Assignments tab.', - step2: - 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.', - step3: 'In the search field, enter the user or group of users that you want to assign to the application.', - step4: 'Select the Assign button next to the user or group that you want to assign.', - step5: 'Select the Done button to complete the assignment.', + assignUsersInstructions: { + title: 'Assign selected user or group in Okta', + paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', + step1: 'In the Okta dashboard, select the Assignments tab.', + step2: + 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.', + step3: 'In the search field, enter the user or group of users that you want to assign to the application.', + step4: 'Select the Assign button next to the user or group that you want to assign.', + step5: 'Select the Done button to complete the assignment.', + }, }, - metadataUrl: { + identityProviderMetadataStep: { headerSubtitle: 'Configure identity provider metadata', - label: 'Metadata URL', - placeholder: 'Paste URL here...', - description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.', - }, - modes: { - ariaLabel: 'Configuration mode', - metadataUrl: 'Add via metadata', - manual: 'Configure manually', - }, - submitSamlConfig: { - title: 'Fill in your Okta SAML application details', - }, - manual: { - description: 'In your Okta SAML app, go to the Sign On tab and retrieve these values.', - signOnUrl: { - label: 'Sign on URL', - placeholder: 'Paste URL here...', + label: '', + placeholder: '', + description: '', + modes: { + title: 'Fill in your Okta SAML application details', + ariaLabel: 'Configuration ', + metadataUrl: 'Add via metadata', + manual: 'Configure manually', }, - issuer: { - label: 'Issuer', + metadataUrl: { + label: 'Metadata URL', placeholder: 'Paste URL here...', + description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.', }, - signingCertificate: { - label: 'Signing certificate', - uploadFile: 'Upload file', - replaceFile: 'Replace file', - removeFile: 'Remove file', - fileUploaded: 'File uploaded', + manual: { + description: 'In your Okta SAML app, go to the Sign On tab and retrieve these values.', + signOnUrl: { + label: 'Single Sign-On URL', + placeholder: 'Paste URL here...', + }, + issuer: { + label: 'Issuer', + placeholder: 'Paste URL here...', + }, + signingCertificate: { + label: 'X.509 certificate', + uploadFile: 'Upload file', + replaceFile: 'Replace file', + removeFile: 'Remove file', + fileUploaded: 'File uploaded', + }, }, }, }, samlCustom: { - headerTitle: 'Configure your identity provider (IdP)', - attributeMapping: { - columns: { - userProfile: 'Identity Provider User Profile', - attributeName: 'Attribute Name', + mainHeaderTitle: 'Configure your identity provider (IdP)', + createAppStep: { + headerSubtitle: 'Create a new enterprise application in your identity provider’s admin dashboard', + createAppInstructions: { + title: 'Create a SAML application on your identity provider', + paragraph: + 'In your identity provider’s admin dashboard, create a new SAML 2.0 application and use the following service provider details:', }, - rows: { - id: { userProfile: 'Unique identifier representing a user', attributeName: 'id' }, - email: { userProfile: 'User’s email address', attributeName: 'email' }, - firstName: { userProfile: 'User’s first name', attributeName: 'firstName' }, - lastName: { userProfile: 'User’s last name', attributeName: 'lastName' }, + serviceProviderFields: { + acsUrl: { + label: 'Assertion consumer service (ACS) URL', + }, + spEntityId: { + label: 'Service provider entity ID', + }, }, }, - spFields: { - acsUrl: { - label: 'Assertion consumer service (ACS) URL', - }, - spEntityId: { - label: 'Service provider entity ID', + attributeMappingStep: { + // TODO - Implement header subtitle copy + headerSubtitle: 'Map user attributes from your identity provider.', + attributeMappingTable: { + columns: { + userProfile: 'Identity Provider User Profile', + attributeName: 'Attribute Name', + }, + rows: { + email: { userProfile: 'User’s email address', attributeName: 'email' }, + firstName: { userProfile: 'User’s first name', attributeName: 'firstName' }, + lastName: { userProfile: 'User’s last name', attributeName: 'lastName' }, + }, }, }, - createApp: { - headerSubtitle: - 'Register Clerk as a service provider in your IdP, then add your identity provider configuration.', - title: 'Create a SAML application on your identity provider', - subtitle: - 'In your identity provider’s admin dashboard, create a new SAML 2.0 application and use the following service provider details:', - }, - configureAttributes: { - headerSubtitle: 'Map user attributes from your identity provider to Clerk.', - title: 'We expect your SAML responses to have the following specific attributes:', - }, - assignUsers: { + assignUsersStep: { headerSubtitle: 'Assign users to the enterprise app', title: 'Assign selected user or group', paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', }, - metadataUrl: { + identityProviderMetadataStep: { headerSubtitle: 'Configure identity provider metadata', - label: 'Metadata URL', - placeholder: 'Paste URL here...', - description: 'In your enterprise app, retrieve the metadata URL. Paste it below.', - }, - modes: { - ariaLabel: 'Configuration mode', - metadataUrl: 'Add via metadata', - manual: 'Configure manually', - }, - submitSamlConfig: { - title: 'Fill in your SAML application details', - }, - manual: { - description: 'In your SAML app, retrieve these values.', - signOnUrl: { - label: 'Sign on URL', - placeholder: 'Paste URL here...', + modes: { + title: 'Fill in your SAML application details', + ariaLabel: 'Configuration ', + metadataUrl: 'Add via metadata', + manual: 'Configure manually', }, - issuer: { - label: 'Issuer', + metadataUrl: { + label: 'Metadata URL', placeholder: 'Paste URL here...', + description: 'In your enterprise app, retrieve the metadata URL. Paste it below.', }, - signingCertificate: { - label: 'Signing certificate', - uploadFile: 'Upload file', - replaceFile: 'Replace file', - removeFile: 'Remove file', - fileUploaded: 'File uploaded', + manual: { + description: 'In your SAML app, retrieve these values.', + signOnUrl: { + label: 'Single Sign-On URL', + placeholder: 'Paste URL here...', + }, + issuer: { + label: 'Issuer', + placeholder: 'Paste URL here...', + }, + signingCertificate: { + label: 'X.509 certificate', + uploadFile: 'Upload file', + replaceFile: 'Replace file', + removeFile: 'Remove file', + fileUploaded: 'File uploaded', + }, }, }, }, diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index e6c2a17cf4c..d70a8cc1653 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1400,6 +1400,7 @@ export type __internal_LocalizationResource = { }; }; configureStep: { + // TODO -> Is it worth to maintain a separate generic list of attribute mapping attributeMapping: { title: LocalizationValue; paragraph: LocalizationValue; @@ -1428,174 +1429,165 @@ export type __internal_LocalizationResource = { }; }; samlOkta: { - headerTitle: LocalizationValue; - attributeMapping: { - columns: { - name: LocalizationValue; - value: LocalizationValue; - }; - rows: { - email: { name: LocalizationValue; value: LocalizationValue }; - firstName: { name: LocalizationValue; value: LocalizationValue }; - lastName: { name: LocalizationValue; value: LocalizationValue }; + mainHeaderTitle: LocalizationValue; + createAppStep: { + headerSubtitle: LocalizationValue; + createAppInstructions: { + title: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + step5: LocalizationValue; }; - }; - spFields: { - acsUrl: { - label: LocalizationValue; + serviceProviderInstructions: { + title: LocalizationValue; + paragraph1: LocalizationValue; + paragraph2: LocalizationValue; + serviceProviderFields: { + acsUrl: { + label: LocalizationValue; + }; + spEntityId: { + label: LocalizationValue; + }; + }; }; - spEntityId: { - label: LocalizationValue; + completeSamlIntegrationInstructions: { + title: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; }; }; - createApp: { - headerSubtitle: LocalizationValue; - title: LocalizationValue; - step1: LocalizationValue; - step2: LocalizationValue; - step3: LocalizationValue; - step4: LocalizationValue; - step5: LocalizationValue; - }; - serviceProvider: { - title: LocalizationValue; - paragraph1: LocalizationValue; - paragraph2: LocalizationValue; - }; - completeSamlIntegration: { - title: LocalizationValue; - step1: LocalizationValue; - step2: LocalizationValue; - }; - configureAttributes: { + attributeMappingStep: { headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; step1: LocalizationValue; step2: LocalizationValue; - pairs: { - conjunction: LocalizationValue; - email: { - name: LocalizationValue; - expression: LocalizationValue; - }; - firstName: { + attributeMappingTable: { + columns: { name: LocalizationValue; expression: LocalizationValue; }; - lastName: { - name: LocalizationValue; - expression: LocalizationValue; + rows: { + email: { name: LocalizationValue; expression: LocalizationValue }; + firstName: { name: LocalizationValue; expression: LocalizationValue }; + lastName: { name: LocalizationValue; expression: LocalizationValue }; }; }; }; - assignUsers: { + assignUsersStep: { headerSubtitle: LocalizationValue; - title: LocalizationValue; - paragraph: LocalizationValue; - step1: LocalizationValue; - step2: LocalizationValue; - step3: LocalizationValue; - step4: LocalizationValue; - step5: LocalizationValue; + assignUsersInstructions: { + title: LocalizationValue; + paragraph: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + step5: LocalizationValue; + }; }; - metadataUrl: { + identityProviderMetadataStep: { headerSubtitle: LocalizationValue; - label: LocalizationValue; - placeholder: LocalizationValue; - description: LocalizationValue; - }; - modes: { - ariaLabel: LocalizationValue; - metadataUrl: LocalizationValue; - manual: LocalizationValue; - }; - submitSamlConfig: { - title: LocalizationValue; - }; - manual: { - description: LocalizationValue; - signOnUrl: { - label: LocalizationValue; - placeholder: LocalizationValue; + modes: { + title: LocalizationValue; + ariaLabel: LocalizationValue; + metadataUrl: LocalizationValue; + manual: LocalizationValue; }; - issuer: { + metadataUrl: { label: LocalizationValue; placeholder: LocalizationValue; + description: LocalizationValue; }; - signingCertificate: { - label: LocalizationValue; - uploadFile: LocalizationValue; - replaceFile: LocalizationValue; - removeFile: LocalizationValue; - fileUploaded: LocalizationValue; + manual: { + description: LocalizationValue; + signOnUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + issuer: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + signingCertificate: { + label: LocalizationValue; + uploadFile: LocalizationValue; + replaceFile: LocalizationValue; + removeFile: LocalizationValue; + fileUploaded: LocalizationValue; + }; }; }; }; samlCustom: { - headerTitle: LocalizationValue; - attributeMapping: { - columns: { - userProfile: LocalizationValue; - attributeName: LocalizationValue; - }; - rows: { - id: { userProfile: LocalizationValue; attributeName: LocalizationValue }; - email: { userProfile: LocalizationValue; attributeName: LocalizationValue }; - firstName: { userProfile: LocalizationValue; attributeName: LocalizationValue }; - lastName: { userProfile: LocalizationValue; attributeName: LocalizationValue }; - }; - }; - spFields: { - acsUrl: { - label: LocalizationValue; + mainHeaderTitle: LocalizationValue; + createAppStep: { + headerSubtitle: LocalizationValue; + createAppInstructions: { + title: LocalizationValue; + paragraph: LocalizationValue; }; - spEntityId: { - label: LocalizationValue; + serviceProviderFields: { + acsUrl: { + label: LocalizationValue; + }; + spEntityId: { + label: LocalizationValue; + }; }; }; - createApp: { - headerSubtitle: LocalizationValue; - title: LocalizationValue; - subtitle: LocalizationValue; - }; - configureAttributes: { + attributeMappingStep: { headerSubtitle: LocalizationValue; - title: LocalizationValue; + attributeMappingTable: { + title: LocalizationValue; + columns: { + userProfile: LocalizationValue; + attributeName: LocalizationValue; + }; + rows: { + email: { userProfile: LocalizationValue; attributeName: LocalizationValue }; + firstName: { userProfile: LocalizationValue; attributeName: LocalizationValue }; + lastName: { userProfile: LocalizationValue; attributeName: LocalizationValue }; + }; + }; }; - assignUsers: { + assignUsersStep: { headerSubtitle: LocalizationValue; title: LocalizationValue; paragraph: LocalizationValue; }; - metadataUrl: { + identityProviderMetadataStep: { headerSubtitle: LocalizationValue; - label: LocalizationValue; - placeholder: LocalizationValue; - description: LocalizationValue; - }; - modes: { - ariaLabel: LocalizationValue; - metadataUrl: LocalizationValue; - manual: LocalizationValue; - }; - submitSamlConfig: { - title: LocalizationValue; - }; - manual: { - description: LocalizationValue; - signOnUrl: { - label: LocalizationValue; - placeholder: LocalizationValue; + modes: { + title: LocalizationValue; + ariaLabel: LocalizationValue; + metadataUrl: LocalizationValue; + manual: LocalizationValue; }; - issuer: { + metadataUrl: { label: LocalizationValue; placeholder: LocalizationValue; + description: LocalizationValue; }; - signingCertificate: { - label: LocalizationValue; - uploadFile: LocalizationValue; - replaceFile: LocalizationValue; - removeFile: LocalizationValue; - fileUploaded: LocalizationValue; + manual: { + description: LocalizationValue; + signOnUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + issuer: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + signingCertificate: { + label: LocalizationValue; + uploadFile: LocalizationValue; + replaceFile: LocalizationValue; + removeFile: LocalizationValue; + fileUploaded: LocalizationValue; + }; }; }; }; From cd7332b39d190d87ec4497550fb7d7b2e9aab383 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 26 May 2026 15:50:22 -0300 Subject: [PATCH 2/8] Move `InnerStepCounter` to separate module --- packages/localizations/src/en-US.ts | 3 --- .../elements/Wizard/InnerStepCounter.tsx | 12 ++++++++++++ .../ConfigureSSO/steps/VerifyDomainStep.tsx | 11 +---------- 3 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Wizard/InnerStepCounter.tsx diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 5343347b7ca..d5ae9373ce5 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -433,9 +433,6 @@ export const enUS: LocalizationResource = { }, identityProviderMetadataStep: { headerSubtitle: 'Configure identity provider metadata', - label: '', - placeholder: '', - description: '', modes: { title: 'Fill in your Okta SAML application details', ariaLabel: 'Configuration ', diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/InnerStepCounter.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/InnerStepCounter.tsx new file mode 100644 index 00000000000..a49e00a4dcb --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/InnerStepCounter.tsx @@ -0,0 +1,12 @@ +import { Step } from '../Step'; +import { useWizard } from './index'; + +export const InnerStepCounter = (): JSX.Element => { + const { currentIndex, totalSteps } = useWizard(); + return ( + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 6c97e674f6d..32854089282 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -22,6 +22,7 @@ import { handleError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; import { useWizard, Wizard } from '../elements/Wizard'; +import { InnerStepCounter } from '../elements/Wizard/InnerStepCounter'; export const VerifyDomainStep = (): JSX.Element => { const { user } = useUser(); @@ -157,16 +158,6 @@ export const VerifyDomainStep = (): JSX.Element => { ); }; -const InnerStepCounter = (): JSX.Element => { - const { currentIndex, totalSteps } = useWizard(); - return ( - - ); -}; - const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); type ProvideEmailStepProps = { From 74c061ee766150986e38514e0e3d116fc9ba7ea1 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 26 May 2026 15:50:44 -0300 Subject: [PATCH 3/8] Refactor for IdP configure steps per provider type --- .../ConfigureSSO/steps/ConfigureStep.tsx | 968 ------------------ .../steps/ConfigureStep/index.tsx | 44 + .../saml/SamlCustomConfigureSteps.tsx | 24 + .../saml/SamlOktaConfigureSteps.tsx | 25 + .../steps/configureStepTranslations.ts | 35 - 5 files changed, 93 insertions(+), 1003 deletions(-) delete mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx delete mode 100644 packages/ui/src/components/ConfigureSSO/steps/configureStepTranslations.ts diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx deleted file mode 100644 index f0d842a4b1e..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx +++ /dev/null @@ -1,968 +0,0 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; -import { useReverification } from '@clerk/shared/react'; -import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; -import React, { type JSX } from 'react'; - -import { - Badge, - Box, - Button, - Col, - descriptors, - Flex, - Flow, - Heading, - Icon, - type LocalizationKey, - localizationKeys, - Table, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - useLocalizations, -} from '@/customizables'; -import { ClipboardInput } from '@/elements/ClipboardInput'; -import { useCardState } from '@/elements/contexts'; -import { Field } from '@/elements/FieldControl'; -import { Form } from '@/elements/Form'; -import { SegmentedControl } from '@/elements/SegmentedControl'; -import { Checkmark, Clipboard, Close, ArrowUpTray } from '@/icons'; -import type { FormControlState } from '@/ui/utils/useFormControl'; -import { useFormControl } from '@/ui/utils/useFormControl'; -import { handleError } from '@/utils/errorHandler'; - -import { useConfigureSSO } from '../ConfigureSSOContext'; -import { Step } from '../elements/Step'; -import { useWizard, Wizard } from '../elements/Wizard'; -import type { ProviderType } from '../types'; -import { useConfigureStepTranslations } from './configureStepTranslations'; - -export const ConfigureStep = (): JSX.Element => { - const { key } = useConfigureStepTranslations(); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const InnerStepCounter = (): JSX.Element => { - const { currentIndex, totalSteps } = useWizard(); - return ( - - ); -}; - -/** - * Per-provider attribute mapping table descriptor - */ -type AttributeMappingTableConfig = { - columns: { first: LocalizationKey; second: LocalizationKey }; - monoFirst?: boolean; - rows: ReadonlyArray<{ - id: string; - isRequired: boolean; - first: LocalizationKey; - second: LocalizationKey; - }>; -}; - -const SAML_OKTA_ATTRIBUTE_MAPPING: AttributeMappingTableConfig = { - columns: { - first: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.columns.name'), - second: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.columns.value'), - }, - monoFirst: true, - rows: [ - { - id: 'email', - isRequired: true, - first: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.rows.email.name'), - second: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.rows.email.value'), - }, - { - id: 'firstName', - isRequired: false, - first: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.rows.firstName.name'), - second: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.rows.firstName.value'), - }, - { - id: 'lastName', - isRequired: false, - first: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.rows.lastName.name'), - second: localizationKeys('configureSSO.configureStep.samlOkta.attributeMapping.rows.lastName.value'), - }, - ], -}; - -const SAML_CUSTOM_ATTRIBUTE_MAPPING: AttributeMappingTableConfig = { - columns: { - first: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.columns.userProfile'), - second: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.columns.attributeName'), - }, - rows: [ - { - id: 'id', - isRequired: true, - first: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.id.userProfile'), - second: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.id.attributeName'), - }, - { - id: 'email', - isRequired: true, - first: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.email.userProfile'), - second: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.email.attributeName'), - }, - { - id: 'firstName', - isRequired: false, - first: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.firstName.userProfile'), - second: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.firstName.attributeName'), - }, - { - id: 'lastName', - isRequired: false, - first: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.lastName.userProfile'), - second: localizationKeys('configureSSO.configureStep.samlCustom.attributeMapping.rows.lastName.attributeName'), - }, - ], -}; - -const ATTRIBUTE_MAPPING_BY_PROVIDER: Partial> = { - saml_okta: SAML_OKTA_ATTRIBUTE_MAPPING, - saml_custom: SAML_CUSTOM_ATTRIBUTE_MAPPING, -}; - -export const CreateAppSubStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { enterpriseConnection, provider } = useConfigureSSO(); - const { key } = useConfigureStepTranslations(); - - const acsUrl = enterpriseConnection?.samlConnection?.acsUrl ?? ''; - const spEntityId = enterpriseConnection?.samlConnection?.spEntityId ?? ''; - - const acsUrlField = useFormControl('acsUrl', acsUrl, { - type: 'text', - label: localizationKeys(key('spFields.acsUrl.label')), - isRequired: false, - }); - const spEntityIdField = useFormControl('spEntityId', spEntityId, { - type: 'text', - label: localizationKeys(key('spFields.spEntityId.label')), - isRequired: false, - }); - - return ( - <> - - ({ gap: theme.space.$5 })}> - ({ gap: theme.space.$1x5 })}> - - - {provider === 'saml_okta' && } - - {provider === 'saml_custom' && ( - - )} - - - {provider === 'saml_okta' && } - - - - - - - - - - - - - - {provider === 'saml_okta' && } - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -// TODO - Move IdP specific content to separate modules -const OktaServiceProviderStepContent = (): JSX.Element => { - return ( - ({ gap: theme.space.$1x5 })}> - - - - - ); -}; - -const OktaCreateAppStepContent = (): JSX.Element => { - return ( - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'disc', - })} - > - - - - - - - ); -}; - -const OktaCompleteSamlIntegrationStepContent = (): JSX.Element => { - return ( - ({ gap: theme.space.$1x5 })}> - - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'disc', - })} - > - - - - - ); -}; - -export const ConfigureAttributesSubStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - - const { provider } = useConfigureSSO(); - const mappingConfig = provider ? ATTRIBUTE_MAPPING_BY_PROVIDER[provider] : undefined; - - return ( - <> - - ({ gap: theme.space.$3 })}> - {provider === 'saml_custom' && ( - - )} - - {provider === 'saml_okta' && } - - {mappingConfig && } - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -const OktaConfigureAttributesStepContent = (): JSX.Element => ( - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'decimal', - })} - > - - {/* - * The actual name/expression pairs that step 2 refers to are rendered - * by the `AttributeMappingTable` immediately below this component — - * keeping them in a single tabular surface instead of an inline badge - * list matches the design (see Okta screenshot: "Create the following - * attribute mapping statements:" + table). - */} - - -); - -const AttributeMappingTable = ({ config }: { config: AttributeMappingTableConfig }): JSX.Element => ( - ({ - 'tr > th:first-of-type': { - paddingInlineStart: theme.space.$4, - }, - })} - > - - - - - - - - - {config.rows.map(row => ( - - - - - ))} - -
- ({ fontSize: theme.fontSizes.$xs })} - localizationKey={config.columns.first} - /> - - ({ fontSize: theme.fontSizes.$xs })} - localizationKey={config.columns.second} - /> -
- ({ gap: theme.space.$2 })} - > - - - - - -
-); - -export const AssignUsersSubStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { provider } = useConfigureSSO(); - - if (provider === 'saml_custom') { - return ( - <> - - ({ gap: theme.space.$3 })}> - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); - } - - return ( - <> - - ({ gap: theme.space.$3 })}> - - - - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'decimal', - })} - > - - - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -export const SubmitSamlConfigSubStep = (): JSX.Element => { - const card = useCardState(); - const { t } = useLocalizations(); - const { goNext, goPrev, isFirstStep } = useWizard(); - const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); - const { key } = useConfigureStepTranslations(); - - const samlConnection = enterpriseConnection?.samlConnection; - const hasExistingConfig = Boolean( - samlConnection?.idpSsoUrl || - samlConnection?.idpEntityId || - samlConnection?.idpCertificate || - samlConnection?.idpMetadataUrl, - ); - const existingCertPresent = Boolean(samlConnection?.idpCertificate); - - const [mode, setMode] = React.useState<'metadataUrl' | 'manual'>(hasExistingConfig ? 'manual' : 'metadataUrl'); - const [certFile, setCertFile] = React.useState(null); - - const updateConnection = useReverification( - React.useCallback( - async (params: UpdateMeEnterpriseConnectionParams) => { - if (!enterpriseConnection) { - throw new Error('Enterprise connection required'); - } - - return updateEnterpriseConnection(enterpriseConnection.id, params); - }, - [enterpriseConnection, updateEnterpriseConnection], - ), - ); - - const metadataUrlField = useFormControl('idpMetadataUrl', samlConnection?.idpMetadataUrl ?? '', { - type: 'text', - label: localizationKeys(key('metadataUrl.label')), - placeholder: localizationKeys(key('metadataUrl.placeholder')), - isRequired: true, - }); - - const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { - type: 'text', - label: localizationKeys(key('manual.signOnUrl.label')), - placeholder: localizationKeys(key('manual.signOnUrl.placeholder')), - isRequired: true, - }); - - const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { - type: 'text', - label: localizationKeys(key('manual.issuer.label')), - placeholder: localizationKeys(key('manual.issuer.placeholder')), - isRequired: true, - }); - - const certFileField = useFormControl('idpCertificate', '', { - type: 'text', - label: localizationKeys(key('manual.signingCertificate.label')), - isRequired: true, - }); - - const trimmedMetadataUrl = metadataUrlField.value.trim(); - const trimmedSignOnUrl = signOnUrlField.value.trim(); - const trimmedIssuer = issuerField.value.trim(); - - const hasCert = certFile !== null || existingCertPresent; - const canSubmit = - !card.isLoading && - ((mode === 'metadataUrl' && trimmedMetadataUrl.length > 0) || - (mode === 'manual' && trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert)); - - const handleContinue = async () => { - if (!enterpriseConnection || !canSubmit) { - return; - } - - card.setError(undefined); - card.setLoading(); - - try { - if (mode === 'metadataUrl') { - await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); - } else { - const samlPayload: NonNullable = { - idpSsoUrl: trimmedSignOnUrl, - idpEntityId: trimmedIssuer, - }; - - if (certFile !== null) { - samlPayload.idpCertificate = await certFile.text(); - } - - await updateConnection({ saml: samlPayload }); - } - - void goNext(); - } catch (err) { - const fields = mode === 'metadataUrl' ? [metadataUrlField] : [signOnUrlField, issuerField, certFileField]; - - handleError(err as Error, fields, card.setError); - - if (isClerkAPIResponseError(err)) { - const unscopedSamlError = err.errors.find(e => e.code?.startsWith('saml_') && !e.meta?.paramName); - - if (unscopedSamlError) { - const primaryField = mode === 'metadataUrl' ? metadataUrlField : signOnUrlField; - primaryField.setError(unscopedSamlError); - card.setError(undefined); - } - } - } finally { - card.setIdle(); - } - }; - - return ( - <> - - - - { - card.setError(undefined); - setMode(value as 'metadataUrl' | 'manual'); - }} - fullWidth - > - - - - - {mode === 'metadataUrl' ? ( - - ) : ( - - )} - - - - - goPrev()} - isDisabled={isFirstStep || card.isLoading} - /> - - - - ); -}; - -type FormControl = FormControlState; - -type MetadataUrlPanelProps = { - field: FormControl; -}; - -type ManualEntryPanelProps = { - signOnUrlField: FormControl; - issuerField: FormControl; - certFileField: FormControl; - certFile: File | null; - setCertFile: React.Dispatch>; - existingCertPresent: boolean; -}; - -const MetadataUrlPanel = ({ field }: MetadataUrlPanelProps): JSX.Element => { - const { key } = useConfigureStepTranslations(); - - return ( - <> - - - - - - ); -}; - -const ManualEntryPanel = ({ - signOnUrlField, - issuerField, - certFileField, - certFile, - setCertFile, - existingCertPresent, -}: ManualEntryPanelProps): JSX.Element => { - const { t } = useLocalizations(); - const certInputRef = React.useRef(null); - const { key } = useConfigureStepTranslations(); - - return ( - <> - - - - - - - - - - - - - - - - - - { - setCertFile(e.target.files?.[0] ?? null); - certFileField.clearFeedback(); - }} - /> - - {certFile === null ? ( - - {existingCertPresent && ( - - )} - - - ) : ( - ({ paddingTop: theme.space.$1, paddingBottom: theme.space.$1 })} - > - - {certFile.name} - - - - - )} - - - - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx new file mode 100644 index 00000000000..d8a518d4fd9 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx @@ -0,0 +1,44 @@ +import { type JSX } from 'react'; + +import { descriptors, Flow } from '@/customizables'; + +import { useConfigureSSO } from '../../ConfigureSSOContext'; +import { Step } from '../../elements/Step'; +import { Wizard } from '../../elements/Wizard'; +import type { ProviderType } from '../../types'; +import { SamlCustomConfigureSteps } from './saml/SamlCustomConfigureSteps'; +import { SamlOktaConfigureSteps } from './saml/SamlOktaConfigureSteps'; + +const STEPS_BY_PROVIDER: Record JSX.Element> = { + saml_custom: SamlCustomConfigureSteps, + saml_okta: SamlOktaConfigureSteps, +}; + +export const ConfigureStep = (): JSX.Element | null => { + const { provider } = useConfigureSSO(); + + // Type guard, at this point the provider should have been defined + if (!provider) { + return null; + } + + // Type guard to ensure steps are provided + // If a new provider is added to the select step, then build will fail ahead of runtime + const StepsByProvider = STEPS_BY_PROVIDER[provider]; + if (!StepsByProvider) { + throw new Error(`No steps found for provider: ${provider}`); + } + + return ( + + + + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx new file mode 100644 index 00000000000..b6738b70923 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -0,0 +1,24 @@ +import { Step } from '@/components/ConfigureSSO/elements/Step'; +import { Wizard } from '@/components/ConfigureSSO/elements/Wizard'; +import { InnerStepCounter } from '@/components/ConfigureSSO/elements/Wizard/InnerStepCounter'; +import { localizationKeys } from '@/localization'; + +export const SamlCustomConfigureSteps = () => { + return ( + + + + + + + + + ); +}; + +const SamlCustomCreateAppStep = () => { + return
SamlCustomCreateAppStep
; +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx new file mode 100644 index 00000000000..f431260639b --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -0,0 +1,25 @@ +import { Step } from '@/components/ConfigureSSO/elements/Step'; +import { Wizard } from '@/components/ConfigureSSO/elements/Wizard'; +import { InnerStepCounter } from '@/components/ConfigureSSO/elements/Wizard/InnerStepCounter'; +import { localizationKeys } from '@/localization'; + +export const SamlOktaConfigureSteps = () => { + return ( + + + + + + + + + + ); +}; + +const SamlOktaCreateAppStep = () => { + return
SamlOktaCreateAppStep
; +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/configureStepTranslations.ts b/packages/ui/src/components/ConfigureSSO/steps/configureStepTranslations.ts deleted file mode 100644 index b38efd6921f..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/configureStepTranslations.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { __internal_LocalizationResource } from '@clerk/shared/types'; - -import { useConfigureSSO } from '../ConfigureSSOContext'; -import type { ProviderType } from '../types'; - -type ConfigureStepProviderKey = Extract< - keyof __internal_LocalizationResource['configureSSO']['configureStep'], - `saml${string}` ->; - -export type TranslationKeyPrefix = `configureSSO.configureStep.${ConfigureStepProviderKey}`; - -export const PROVIDER_TRANSLATION_KEY_PREFIX = { - saml_okta: 'configureSSO.configureStep.samlOkta', - saml_custom: 'configureSSO.configureStep.samlCustom', -} as const satisfies Record; - -export function getConfigureStepKey

( - provider: P, - suffix: S, -): `${(typeof PROVIDER_TRANSLATION_KEY_PREFIX)[P]}.${S}` { - return `${PROVIDER_TRANSLATION_KEY_PREFIX[provider]}.${suffix}`; -} - -export function useConfigureStepTranslations() { - const { provider } = useConfigureSSO(); - if (!provider) { - throw new Error('useConfigureStepTranslations called without a selected provider.'); - } - - return { - provider, - key: (suffix: S) => getConfigureStepKey(provider, suffix), - } as const; -} From 7a09d18571e9b58885b1275ccc94d3e0205dbada Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 26 May 2026 16:19:14 -0300 Subject: [PATCH 4/8] Create root steps per provider --- packages/localizations/src/en-US.ts | 6 +- .../saml/SamlCustomConfigureSteps.tsx | 147 ++++++++++++++++- .../saml/SamlOktaConfigureSteps.tsx | 148 +++++++++++++++++- 3 files changed, 286 insertions(+), 15 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d5ae9373ce5..1c268950c56 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -400,8 +400,7 @@ export const enUS: LocalizationResource = { }, }, attributeMappingStep: { - // TODO - Not mention Clerk - headerSubtitle: 'Map user attributes from Okta to Clerk', + headerSubtitle: 'Map user attributes from Okta to your application', paragraph: 'We expect your SAML responses to have the following specific attributes:', step1: 'Open the Sign On tab of your Okta application and locate the Attribute Statements section. If you don’t see it, click Show legacy configuration, then Edit.', @@ -483,8 +482,7 @@ export const enUS: LocalizationResource = { }, }, attributeMappingStep: { - // TODO - Implement header subtitle copy - headerSubtitle: 'Map user attributes from your identity provider.', + headerSubtitle: 'Map user attributes from your identity provider to your application.', attributeMappingTable: { columns: { userProfile: 'Identity Provider User Profile', diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx index b6738b70923..a790225d960 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -1,11 +1,11 @@ import { Step } from '@/components/ConfigureSSO/elements/Step'; -import { Wizard } from '@/components/ConfigureSSO/elements/Wizard'; +import { useWizard, Wizard } from '@/components/ConfigureSSO/elements/Wizard'; import { InnerStepCounter } from '@/components/ConfigureSSO/elements/Wizard/InnerStepCounter'; -import { localizationKeys } from '@/localization'; +import { Col, descriptors, Heading, localizationKeys } from '@/customizables'; export const SamlCustomConfigureSteps = () => { return ( - + <> { - + + + + + + + + + + + + + + + + + + + + + + ); }; const SamlCustomCreateAppStep = () => { - return

SamlCustomCreateAppStep
; + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlCustomAttributeMappingStep = () => { + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> +

add table here

+
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlCustomAssignUsersStep = () => { + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> +

add content here

+
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlCustomIdentityProviderMetadataStep = () => { + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> +

add content here

+
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx index f431260639b..cf6d37277d8 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -1,11 +1,11 @@ import { Step } from '@/components/ConfigureSSO/elements/Step'; -import { Wizard } from '@/components/ConfigureSSO/elements/Wizard'; +import { useWizard, Wizard } from '@/components/ConfigureSSO/elements/Wizard'; import { InnerStepCounter } from '@/components/ConfigureSSO/elements/Wizard/InnerStepCounter'; -import { localizationKeys } from '@/localization'; +import { Col, descriptors, Heading, localizationKeys } from '@/customizables'; export const SamlOktaConfigureSteps = () => { return ( - + <> { > - - + + + + + + + + + + + + + + + + + + + + + + ); }; const SamlOktaCreateAppStep = () => { - return
SamlOktaCreateAppStep
; + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlOktaAttributeMappingStep = () => { + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> +

add table here

+
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlOktaAssignUsersStep = () => { + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> +

add content here

+
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlOktaIdentityProviderMetadataStep = () => { + const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> +

add content here

+
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); }; From 7acabcf85fc3eb64481a9db003900ca7904f01f0 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 26 May 2026 16:45:10 -0300 Subject: [PATCH 5/8] Implement steps for Okta and Custom SAML --- packages/localizations/src/en-US.ts | 29 +- packages/shared/src/types/localization.ts | 25 +- .../saml/SamlCustomConfigureSteps.tsx | 226 +++++++-- .../saml/SamlOktaConfigureSteps.tsx | 420 +++++++++++++++-- .../saml/shared/AttributeMappingTable.tsx | 99 ++++ .../shared/IdentityProviderMetadataForm.tsx | 427 ++++++++++++++++++ 6 files changed, 1104 insertions(+), 122 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/AttributeMappingTable.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 1c268950c56..13bb4037d1b 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -340,31 +340,11 @@ export const enUS: LocalizationResource = { }, }, configureStep: { - attributeMapping: { - title: 'We expect your SAML responses to have the following specific attributes:', - columns: { - attribute: 'Attribute', - claimName: 'Claim Name', - claimValue: 'Claim Value', - }, + attributeMappingTable: { badges: { required: 'Required', optional: 'Optional', }, - rows: { - email: { - attribute: 'Email address', - claim: 'mail', - }, - firstName: { - attribute: 'First Name', - claim: 'firstName', - }, - lastName: { - attribute: 'Last Name', - claim: 'lastName', - }, - }, }, samlOkta: { mainHeaderTitle: 'Configure Okta Workforce', @@ -483,15 +463,16 @@ export const enUS: LocalizationResource = { }, attributeMappingStep: { headerSubtitle: 'Map user attributes from your identity provider to your application.', + paragraph: 'We expect your SAML response to return the user’s email, first name and last name.', attributeMappingTable: { columns: { userProfile: 'Identity Provider User Profile', attributeName: 'Attribute Name', }, rows: { - email: { userProfile: 'User’s email address', attributeName: 'email' }, - firstName: { userProfile: 'User’s first name', attributeName: 'firstName' }, - lastName: { userProfile: 'User’s last name', attributeName: 'lastName' }, + email: { userProfile: 'Email address', attributeName: 'email' }, + firstName: { userProfile: 'First name', attributeName: 'firstName' }, + lastName: { userProfile: 'Last name', attributeName: 'lastName' }, }, }, }, diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index d70a8cc1653..3b3bca7ad40 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1400,33 +1400,11 @@ export type __internal_LocalizationResource = { }; }; configureStep: { - // TODO -> Is it worth to maintain a separate generic list of attribute mapping - attributeMapping: { - title: LocalizationValue; - paragraph: LocalizationValue; - columns: { - attribute: LocalizationValue; - claimName: LocalizationValue; - claimValue: LocalizationValue; - }; + attributeMappingTable: { badges: { required: LocalizationValue; optional: LocalizationValue; }; - rows: { - email: { - attribute: LocalizationValue; - claim: LocalizationValue; - }; - firstName: { - attribute: LocalizationValue; - claim: LocalizationValue; - }; - lastName: { - attribute: LocalizationValue; - claim: LocalizationValue; - }; - }; }; samlOkta: { mainHeaderTitle: LocalizationValue; @@ -1540,6 +1518,7 @@ export type __internal_LocalizationResource = { }; attributeMappingStep: { headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; attributeMappingTable: { title: LocalizationValue; columns: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx index a790225d960..0629f5a6b5e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -1,9 +1,19 @@ -import { Step } from '@/components/ConfigureSSO/elements/Step'; -import { useWizard, Wizard } from '@/components/ConfigureSSO/elements/Wizard'; -import { InnerStepCounter } from '@/components/ConfigureSSO/elements/Wizard/InnerStepCounter'; -import { Col, descriptors, Heading, localizationKeys } from '@/customizables'; +import { type JSX } from 'react'; -export const SamlCustomConfigureSteps = () => { +import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { Form } from '@/elements/Form'; +import { Check, ClipboardOutline } from '@/icons'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { useConfigureSSO } from '../../../ConfigureSSOContext'; +import { Step } from '../../../elements/Step'; +import { useWizard, Wizard } from '../../../elements/Wizard'; +import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { AttributeMappingTable, type AttributeMappingTableConfig } from './shared/AttributeMappingTable'; +import { IdentityProviderMetadataForm } from './shared/IdentityProviderMetadataForm'; + +export const SamlCustomConfigureSteps = (): JSX.Element => { return ( <> @@ -51,8 +61,25 @@ export const SamlCustomConfigureSteps = () => { ); }; -const SamlCustomCreateAppStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); +const SamlCustomCreateAppStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + + const acsUrl = enterpriseConnection?.samlConnection?.acsUrl ?? ''; + const spEntityId = enterpriseConnection?.samlConnection?.spEntityId ?? ''; + + const acsUrlField = useFormControl('acsUrl', acsUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlCustom.createAppStep.serviceProviderFields.acsUrl.label'), + isRequired: false, + }); + const spEntityIdField = useFormControl('spEntityId', spEntityId, { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.createAppStep.serviceProviderFields.spEntityId.label', + ), + isRequired: false, + }); return ( <> @@ -67,32 +94,36 @@ const SamlCustomCreateAppStep = () => { 'configureSSO.configureStep.samlCustom.createAppStep.createAppInstructions.title', )} /> + - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -const SamlCustomAttributeMappingStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + + + + - return ( - <> - - ({ gap: theme.space.$5 })}> -

add table here

+ + + + +
@@ -110,14 +141,63 @@ const SamlCustomAttributeMappingStep = () => { ); }; -const SamlCustomAssignUsersStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); +const CUSTOM_ATTRIBUTE_MAPPING: AttributeMappingTableConfig = { + columns: { + first: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.columns.userProfile', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.columns.attributeName', + ), + }, + rows: [ + { + id: 'email', + isRequired: true, + first: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.rows.email.userProfile', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.rows.email.attributeName', + ), + }, + { + id: 'firstName', + isRequired: false, + first: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.rows.firstName.userProfile', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.rows.firstName.attributeName', + ), + }, + { + id: 'lastName', + isRequired: false, + first: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.rows.lastName.userProfile', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlCustom.attributeMappingStep.attributeMappingTable.rows.lastName.attributeName', + ), + }, + ], +}; + +const SamlCustomAttributeMappingStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); return ( <> - ({ gap: theme.space.$5 })}> -

add content here

+ ({ gap: theme.space.$3 })}> + + +
@@ -135,14 +215,24 @@ const SamlCustomAssignUsersStep = () => { ); }; -const SamlCustomIdentityProviderMetadataStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); +const SamlCustomAssignUsersStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); return ( <> - ({ gap: theme.space.$5 })}> -

add content here

+ ({ gap: theme.space.$3 })}> + +
@@ -159,3 +249,63 @@ const SamlCustomIdentityProviderMetadataStep = () => { ); }; + +const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => ( + +); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx index cf6d37277d8..00d9bb48ffb 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -1,9 +1,19 @@ -import { Step } from '@/components/ConfigureSSO/elements/Step'; -import { useWizard, Wizard } from '@/components/ConfigureSSO/elements/Wizard'; -import { InnerStepCounter } from '@/components/ConfigureSSO/elements/Wizard/InnerStepCounter'; -import { Col, descriptors, Heading, localizationKeys } from '@/customizables'; +import { type JSX } from 'react'; -export const SamlOktaConfigureSteps = () => { +import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { Form } from '@/elements/Form'; +import { Check, ClipboardOutline } from '@/icons'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { useConfigureSSO } from '../../../ConfigureSSOContext'; +import { Step } from '../../../elements/Step'; +import { useWizard, Wizard } from '../../../elements/Wizard'; +import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { AttributeMappingTable, type AttributeMappingTableConfig } from './shared/AttributeMappingTable'; +import { IdentityProviderMetadataForm } from './shared/IdentityProviderMetadataForm'; + +export const SamlOktaConfigureSteps = (): JSX.Element => { return ( <> @@ -51,8 +61,27 @@ export const SamlOktaConfigureSteps = () => { ); }; -const SamlOktaCreateAppStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); +const SamlOktaCreateAppStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + + const acsUrl = enterpriseConnection?.samlConnection?.acsUrl ?? ''; + const spEntityId = enterpriseConnection?.samlConnection?.spEntityId ?? ''; + + const acsUrlField = useFormControl('acsUrl', acsUrl, { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlOkta.createAppStep.serviceProviderInstructions.serviceProviderFields.acsUrl.label', + ), + isRequired: false, + }); + const spEntityIdField = useFormControl('spEntityId', spEntityId, { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlOkta.createAppStep.serviceProviderInstructions.serviceProviderFields.spEntityId.label', + ), + isRequired: false, + }); return ( <> @@ -67,32 +96,144 @@ const SamlOktaCreateAppStep = () => { 'configureSSO.configureStep.samlOkta.createAppStep.createAppInstructions.title', )} /> + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; + ({ gap: theme.space.$1x5 })}> + + + + -const SamlOktaAttributeMappingStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); + + + + + - return ( - <> - - ({ gap: theme.space.$5 })}> -

add table here

+ + + + + + + ({ gap: theme.space.$1x5 })}> + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + +
@@ -110,14 +251,95 @@ const SamlOktaAttributeMappingStep = () => { ); }; -const SamlOktaAssignUsersStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); +const OKTA_ATTRIBUTE_MAPPING: AttributeMappingTableConfig = { + columns: { + first: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.columns.name', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.columns.expression', + ), + }, + monoFirst: true, + rows: [ + { + id: 'email', + isRequired: true, + first: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.rows.email.name', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.rows.email.expression', + ), + }, + { + id: 'firstName', + isRequired: false, + first: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.rows.firstName.name', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.rows.firstName.expression', + ), + }, + { + id: 'lastName', + isRequired: false, + first: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.rows.lastName.name', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlOkta.attributeMappingStep.attributeMappingTable.rows.lastName.expression', + ), + }, + ], +}; + +const SamlOktaAttributeMappingStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); return ( <> - ({ gap: theme.space.$5 })}> -

add content here

+ ({ gap: theme.space.$3 })}> + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > + + {/* + * The actual name/expression pairs that step 2 refers to are + * rendered by the `AttributeMappingTable` immediately below this + * list — keeping them in a single tabular surface instead of an + * inline badge list matches the design (see Okta screenshot: + * "Create the following attribute mapping statements:" + table). + */} + + + +
@@ -135,14 +357,80 @@ const SamlOktaAssignUsersStep = () => { ); }; -const SamlOktaIdentityProviderMetadataStep = () => { - const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); +const SamlOktaAssignUsersStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); return ( <> - ({ gap: theme.space.$5 })}> -

add content here

+ ({ gap: theme.space.$3 })}> + + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > + + + + + +
@@ -159,3 +447,61 @@ const SamlOktaIdentityProviderMetadataStep = () => { ); }; + +const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => ( + +); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/AttributeMappingTable.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/AttributeMappingTable.tsx new file mode 100644 index 00000000000..162adde1772 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/AttributeMappingTable.tsx @@ -0,0 +1,99 @@ +import { type JSX } from 'react'; + +import { + Badge, + descriptors, + Flex, + type LocalizationKey, + localizationKeys, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@/customizables'; + +export type AttributeMappingTableConfig = { + columns: { first: LocalizationKey; second: LocalizationKey }; + rows: ReadonlyArray<{ + id: string; + isRequired: boolean; + first: LocalizationKey; + second: LocalizationKey; + }>; + /** + * When true, the first column renders in a monospace font and the badge + * sits inline with it + */ + monoFirst?: boolean; +}; + +export const AttributeMappingTable = ({ config }: { config: AttributeMappingTableConfig }): JSX.Element => ( + ({ + 'tr > th:first-of-type': { + paddingInlineStart: theme.space.$4, + }, + })} + > + + + + + + + + + {config.rows.map(row => ( + + + + + ))} + +
+ ({ fontSize: theme.fontSizes.$xs })} + localizationKey={config.columns.first} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={config.columns.second} + /> +
+ ({ gap: theme.space.$2 })} + > + + + + + +
+); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx new file mode 100644 index 00000000000..1d0506efd08 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx @@ -0,0 +1,427 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { useReverification } from '@clerk/shared/react'; +import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; +import React, { type JSX } from 'react'; + +import { + Badge, + Box, + Button, + Col, + descriptors, + Flex, + Heading, + Icon, + type LocalizationKey, + Text, + useLocalizations, +} from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { Field } from '@/elements/FieldControl'; +import { Form } from '@/elements/Form'; +import { SegmentedControl } from '@/elements/SegmentedControl'; +import { Close, Upload } from '@/icons'; +import type { FormControlState } from '@/ui/utils/useFormControl'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; + +type FieldLocalization = { + label: LocalizationKey; + placeholder: LocalizationKey; +}; + +type SigningCertificateLocalization = { + label: LocalizationKey; + uploadFile: LocalizationKey; + replaceFile: LocalizationKey; + removeFile: LocalizationKey; + fileUploaded: LocalizationKey; +}; + +type IdentityProviderMetadataFormProps = { + /** + * Segmented control copy: section title, the toggle's `aria-label`, and + * the two segment labels (`metadataUrl` / `manual`). + */ + modes: { + title: LocalizationKey; + ariaLabel: LocalizationKey; + metadataUrlLabel: LocalizationKey; + manualLabel: LocalizationKey; + }; + /** + * Copy for the `metadataUrl` panel: the single-URL input and its + * accompanying description paragraph. + */ + metadataUrl: FieldLocalization & { + description: LocalizationKey; + }; + /** + * Copy for the `manual` panel: description paragraph, the two text + * inputs (signOn URL + issuer), and the signing-certificate uploader's + * label + state-dependent button copy. + */ + manual: { + description: LocalizationKey; + signOnUrl: FieldLocalization; + issuer: FieldLocalization; + signingCertificate: SigningCertificateLocalization; + }; +}; + +/** + * Submit form for the IdP-metadata wizard step. + * + * Behavior is fully shared across SAML providers: + * - Segmented control toggles between `metadataUrl` (single URL) and + * `manual` (sign-on URL + issuer + signing cert) modes. + * - Mode defaults to `manual` if the connection already has any IdP + * config persisted, otherwise `metadataUrl`. + * - On submit, the connection is updated via `useReverification` and + * the wizard advances; field/card errors are surfaced from the API. + * + * All copy is provided by the caller as resolved `LocalizationKey`s so + * this primitive stays decoupled from any specific localization + * namespace. + */ +export const IdentityProviderMetadataForm = ({ + modes, + metadataUrl, + manual, +}: IdentityProviderMetadataFormProps): JSX.Element => { + const card = useCardState(); + const { t } = useLocalizations(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); + + const samlConnection = enterpriseConnection?.samlConnection; + const hasExistingConfig = Boolean( + samlConnection?.idpSsoUrl || + samlConnection?.idpEntityId || + samlConnection?.idpCertificate || + samlConnection?.idpMetadataUrl, + ); + const existingCertPresent = Boolean(samlConnection?.idpCertificate); + + const [mode, setMode] = React.useState<'metadataUrl' | 'manual'>(hasExistingConfig ? 'manual' : 'metadataUrl'); + const [certFile, setCertFile] = React.useState(null); + + const updateConnection = useReverification( + React.useCallback( + async (params: UpdateMeEnterpriseConnectionParams) => { + if (!enterpriseConnection) { + throw new Error('Enterprise connection required'); + } + return updateEnterpriseConnection(enterpriseConnection.id, params); + }, + [enterpriseConnection, updateEnterpriseConnection], + ), + ); + + const metadataUrlField = useFormControl('idpMetadataUrl', samlConnection?.idpMetadataUrl ?? '', { + type: 'text', + label: metadataUrl.label, + placeholder: metadataUrl.placeholder, + isRequired: true, + }); + + const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { + type: 'text', + label: manual.signOnUrl.label, + placeholder: manual.signOnUrl.placeholder, + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { + type: 'text', + label: manual.issuer.label, + placeholder: manual.issuer.placeholder, + isRequired: true, + }); + + const certFileField = useFormControl('idpCertificate', '', { + type: 'text', + label: manual.signingCertificate.label, + isRequired: true, + }); + + const trimmedMetadataUrl = metadataUrlField.value.trim(); + const trimmedSignOnUrl = signOnUrlField.value.trim(); + const trimmedIssuer = issuerField.value.trim(); + + const hasCert = certFile !== null || existingCertPresent; + const canSubmit = + !card.isLoading && + ((mode === 'metadataUrl' && trimmedMetadataUrl.length > 0) || + (mode === 'manual' && trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert)); + + const handleContinue = async () => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + card.setLoading(); + + try { + if (mode === 'metadataUrl') { + await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); + } else { + const samlPayload: NonNullable = { + idpSsoUrl: trimmedSignOnUrl, + idpEntityId: trimmedIssuer, + }; + + if (certFile !== null) { + samlPayload.idpCertificate = await certFile.text(); + } + + await updateConnection({ saml: samlPayload }); + } + + void goNext(); + } catch (err) { + const fields = mode === 'metadataUrl' ? [metadataUrlField] : [signOnUrlField, issuerField, certFileField]; + handleError(err as Error, fields, card.setError); + + if (isClerkAPIResponseError(err)) { + // Some SAML failures come back without a `paramName`, so they wouldn't + // attach to any field via `handleError`. Pin them to the visible + // primary input for the active mode and clear the card-level dupe. + const unscopedSamlError = err.errors.find(e => e.code?.startsWith('saml_') && !e.meta?.paramName); + if (unscopedSamlError) { + const primaryField = mode === 'metadataUrl' ? metadataUrlField : signOnUrlField; + primaryField.setError(unscopedSamlError); + card.setError(undefined); + } + } + } finally { + card.setIdle(); + } + }; + + return ( + <> + + + + { + card.setError(undefined); + setMode(value as 'metadataUrl' | 'manual'); + }} + fullWidth + > + + + + + {mode === 'metadataUrl' ? ( + + ) : ( + + )} + + + + + goPrev()} + isDisabled={isFirstStep || card.isLoading} + /> + + + + ); +}; + +type FormControl = FormControlState; + +type MetadataUrlPanelProps = { + field: FormControl; + description: LocalizationKey; +}; + +const MetadataUrlPanel = ({ field, description }: MetadataUrlPanelProps): JSX.Element => ( + <> + + + + + +); + +type ManualEntryPanelProps = { + description: LocalizationKey; + signingCertificate: SigningCertificateLocalization; + signOnUrlField: FormControl; + issuerField: FormControl; + certFileField: FormControl; + certFile: File | null; + setCertFile: React.Dispatch>; + existingCertPresent: boolean; +}; + +const ManualEntryPanel = ({ + description, + signingCertificate, + signOnUrlField, + issuerField, + certFileField, + certFile, + setCertFile, + existingCertPresent, +}: ManualEntryPanelProps): JSX.Element => { + const { t } = useLocalizations(); + const certInputRef = React.useRef(null); + + return ( + <> + + + + + + + + + + + + + + + + + + { + setCertFile(e.target.files?.[0] ?? null); + certFileField.clearFeedback(); + }} + /> + + {certFile === null ? ( + + {existingCertPresent && ( + + )} + + + ) : ( + ({ paddingTop: theme.space.$1, paddingBottom: theme.space.$1 })} + > + + {certFile.name} + + + + + )} + + + + + + ); +}; From 5392acaf5e8fe9923370c8b7d6baf0bcab918605 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 27 May 2026 12:14:54 -0300 Subject: [PATCH 6/8] Use new icon set --- .../ConfigureStep/saml/SamlCustomConfigureSteps.tsx | 10 +++++----- .../ConfigureStep/saml/SamlOktaConfigureSteps.tsx | 10 +++++----- .../saml/shared/IdentityProviderMetadataForm.tsx | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx index 0629f5a6b5e..4e67814f5fb 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -3,7 +3,7 @@ import { type JSX } from 'react'; import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { Form } from '@/elements/Form'; -import { Check, ClipboardOutline } from '@/icons'; +import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; import { useConfigureSSO } from '../../../ConfigureSSOContext'; @@ -108,8 +108,8 @@ const SamlCustomCreateAppStep = (): JSX.Element => { @@ -119,8 +119,8 @@ const SamlCustomCreateAppStep = (): JSX.Element => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx index 00d9bb48ffb..1b8ca585ece 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -3,7 +3,7 @@ import { type JSX } from 'react'; import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { Form } from '@/elements/Form'; -import { Check, ClipboardOutline } from '@/icons'; +import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; import { useConfigureSSO } from '../../../ConfigureSSOContext'; @@ -180,8 +180,8 @@ const SamlOktaCreateAppStep = (): JSX.Element => { @@ -191,8 +191,8 @@ const SamlOktaCreateAppStep = (): JSX.Element => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx index 1d0506efd08..fdabec38e31 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx @@ -20,7 +20,7 @@ import { useCardState } from '@/elements/contexts'; import { Field } from '@/elements/FieldControl'; import { Form } from '@/elements/Form'; import { SegmentedControl } from '@/elements/SegmentedControl'; -import { Close, Upload } from '@/icons'; +import { ArrowUpTray, Close } from '@/icons'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { useFormControl } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; @@ -369,7 +369,7 @@ const ManualEntryPanel = ({ onClick={() => certInputRef.current?.click()} > ({ marginInlineEnd: theme.space.$1 })} From 3d8617f9a600ac7c653c44da684157f1ed98d8dd Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 27 May 2026 12:29:20 -0300 Subject: [PATCH 7/8] Add changeset --- .changeset/brown-singers-fall.md | 7 +++++++ packages/localizations/src/en-US.ts | 16 +++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 .changeset/brown-singers-fall.md diff --git a/.changeset/brown-singers-fall.md b/.changeset/brown-singers-fall.md new file mode 100644 index 00000000000..aa039d45201 --- /dev/null +++ b/.changeset/brown-singers-fall.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Layer architecture for configure steps per IdP and protocol on `` diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 13bb4037d1b..0179215908d 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -398,10 +398,11 @@ export const enUS: LocalizationResource = { }, }, assignUsersStep: { - headerSubtitle: 'Assign users to the enterprise app', + headerSubtitle: 'Assign users to the enterprise application', assignUsersInstructions: { title: 'Assign selected user or group in Okta', - paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', + paragraph: + 'You need to assign users or groups to your enterprise application before they can use it to sign in.', step1: 'In the Okta dashboard, select the Assignments tab.', step2: 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.', @@ -470,16 +471,17 @@ export const enUS: LocalizationResource = { attributeName: 'Attribute Name', }, rows: { - email: { userProfile: 'Email address', attributeName: 'email' }, + email: { userProfile: 'Primary email', attributeName: 'email' }, firstName: { userProfile: 'First name', attributeName: 'firstName' }, lastName: { userProfile: 'Last name', attributeName: 'lastName' }, }, }, }, assignUsersStep: { - headerSubtitle: 'Assign users to the enterprise app', + headerSubtitle: 'Assign users to the enterprise application', title: 'Assign selected user or group', - paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', + paragraph: + 'You need to assign users or groups to your enterprise application before they can use it to sign in.', }, identityProviderMetadataStep: { headerSubtitle: 'Configure identity provider metadata', @@ -492,10 +494,10 @@ export const enUS: LocalizationResource = { metadataUrl: { label: 'Metadata URL', placeholder: 'Paste URL here...', - description: 'In your enterprise app, retrieve the metadata URL. Paste it below.', + description: 'In your enterprise application, retrieve the metadata URL. Paste it below.', }, manual: { - description: 'In your SAML app, retrieve these values.', + description: 'In your SAML application, retrieve these values.', signOnUrl: { label: 'Single Sign-On URL', placeholder: 'Paste URL here...', From 8be9b230cc860008a2dbc98623bda68bc6d589b7 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 27 May 2026 19:13:35 -0300 Subject: [PATCH 8/8] Refactor `IdentityProviderMetadataForm` --- .../saml/SamlCustomConfigureSteps.tsx | 150 ++++++++--- .../saml/SamlOktaConfigureSteps.tsx | 150 ++++++++--- .../shared/IdentityProviderMetadataForm.tsx | 248 +++--------------- .../shared/useIdentityProviderMetadataForm.ts | 149 +++++++++++ 4 files changed, 411 insertions(+), 286 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/useIdentityProviderMetadataForm.ts diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx index 4e67814f5fb..da367429243 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -2,6 +2,7 @@ import { type JSX } from 'react'; import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; +import { useCardState } from '@/elements/contexts'; import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; @@ -12,6 +13,7 @@ import { useWizard, Wizard } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; import { AttributeMappingTable, type AttributeMappingTableConfig } from './shared/AttributeMappingTable'; import { IdentityProviderMetadataForm } from './shared/IdentityProviderMetadataForm'; +import { useIdentityProviderMetadataForm } from './shared/useIdentityProviderMetadataForm'; export const SamlCustomConfigureSteps = (): JSX.Element => { return ( @@ -250,29 +252,19 @@ const SamlCustomAssignUsersStep = (): JSX.Element => { ); }; -const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => ( - { + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); + + const controller = useIdentityProviderMetadataForm({ + metadataUrl: { label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.label'), placeholder: localizationKeys( 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.placeholder', ), - description: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.description', - ), - }} - manual={{ - description: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.description', - ), + }, + manual: { signOnUrl: { label: localizationKeys( 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', @@ -289,23 +281,103 @@ const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => ( 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.placeholder', ), }, - signingCertificate: { - label: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.label', - ), - uploadFile: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.uploadFile', - ), - replaceFile: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.replaceFile', - ), - removeFile: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.removeFile', - ), - fileUploaded: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.fileUploaded', - ), - }, - }} - /> -); + signingCertificateLabel: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.label', + ), + }, + }); + + const canSubmit = !card.isLoading && controller.isValid; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + card.setLoading(); + + try { + const saml = await controller.buildSamlPayload(); + await updateEnterpriseConnection(enterpriseConnection.id, { saml }); + void goNext(); + } catch (err) { + controller.applySubmitError(err, card); + } finally { + card.setIdle(); + } + }; + + return ( + <> + + + + + + + + + goPrev()} + isDisabled={isFirstStep || card.isLoading} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx index 1b8ca585ece..265cb2a5c84 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -2,6 +2,7 @@ import { type JSX } from 'react'; import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; +import { useCardState } from '@/elements/contexts'; import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; @@ -12,6 +13,7 @@ import { useWizard, Wizard } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; import { AttributeMappingTable, type AttributeMappingTableConfig } from './shared/AttributeMappingTable'; import { IdentityProviderMetadataForm } from './shared/IdentityProviderMetadataForm'; +import { useIdentityProviderMetadataForm } from './shared/useIdentityProviderMetadataForm'; export const SamlOktaConfigureSteps = (): JSX.Element => { return ( @@ -448,29 +450,19 @@ const SamlOktaAssignUsersStep = (): JSX.Element => { ); }; -const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => ( - { + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); + + const controller = useIdentityProviderMetadataForm({ + metadataUrl: { label: localizationKeys('configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.label'), placeholder: localizationKeys( 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.placeholder', ), - description: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.description', - ), - }} - manual={{ - description: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.description', - ), + }, + manual: { signOnUrl: { label: localizationKeys( 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signOnUrl.label', @@ -485,23 +477,103 @@ const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => ( 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.issuer.placeholder', ), }, - signingCertificate: { - label: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.label', - ), - uploadFile: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.uploadFile', - ), - replaceFile: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.replaceFile', - ), - removeFile: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.removeFile', - ), - fileUploaded: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.fileUploaded', - ), - }, - }} - /> -); + signingCertificateLabel: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.label', + ), + }, + }); + + const canSubmit = !card.isLoading && controller.isValid; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + card.setLoading(); + + try { + const saml = await controller.buildSamlPayload(); + await updateEnterpriseConnection(enterpriseConnection.id, { saml }); + void goNext(); + } catch (err) { + controller.applySubmitError(err, card); + } finally { + card.setIdle(); + } + }; + + return ( + <> + + + + + + + + + goPrev()} + isDisabled={isFirstStep || card.isLoading} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx index fdabec38e31..5b108d9b2ac 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx @@ -1,6 +1,4 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; -import { useReverification } from '@clerk/shared/react'; -import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; +import type { FieldId } from '@clerk/shared/types'; import React, { type JSX } from 'react'; import { @@ -10,7 +8,6 @@ import { Col, descriptors, Flex, - Heading, Icon, type LocalizationKey, Text, @@ -22,17 +19,8 @@ import { Form } from '@/elements/Form'; import { SegmentedControl } from '@/elements/SegmentedControl'; import { ArrowUpTray, Close } from '@/icons'; import type { FormControlState } from '@/ui/utils/useFormControl'; -import { useFormControl } from '@/ui/utils/useFormControl'; -import { handleError } from '@/utils/errorHandler'; -import { useConfigureSSO } from '../../../../ConfigureSSOContext'; -import { Step } from '../../../../elements/Step'; -import { useWizard } from '../../../../elements/Wizard'; - -type FieldLocalization = { - label: LocalizationKey; - placeholder: LocalizationKey; -}; +import type { IdentityProviderMetadataFormController, IdpMetadataMode } from './useIdentityProviderMetadataForm'; type SigningCertificateLocalization = { label: LocalizationKey; @@ -43,230 +31,74 @@ type SigningCertificateLocalization = { }; type IdentityProviderMetadataFormProps = { - /** - * Segmented control copy: section title, the toggle's `aria-label`, and - * the two segment labels (`metadataUrl` / `manual`). - */ + controller: IdentityProviderMetadataFormController; modes: { - title: LocalizationKey; ariaLabel: LocalizationKey; metadataUrlLabel: LocalizationKey; manualLabel: LocalizationKey; }; /** - * Copy for the `metadataUrl` panel: the single-URL input and its - * accompanying description paragraph. + * Copy for the metadata URL panel */ - metadataUrl: FieldLocalization & { + metadataUrl: { description: LocalizationKey; }; /** - * Copy for the `manual` panel: description paragraph, the two text - * inputs (signOn URL + issuer), and the signing-certificate uploader's - * label + state-dependent button copy. + * Copy for the manual metadata panel */ manual: { description: LocalizationKey; - signOnUrl: FieldLocalization; - issuer: FieldLocalization; signingCertificate: SigningCertificateLocalization; }; }; -/** - * Submit form for the IdP-metadata wizard step. - * - * Behavior is fully shared across SAML providers: - * - Segmented control toggles between `metadataUrl` (single URL) and - * `manual` (sign-on URL + issuer + signing cert) modes. - * - Mode defaults to `manual` if the connection already has any IdP - * config persisted, otherwise `metadataUrl`. - * - On submit, the connection is updated via `useReverification` and - * the wizard advances; field/card errors are surfaced from the API. - * - * All copy is provided by the caller as resolved `LocalizationKey`s so - * this primitive stays decoupled from any specific localization - * namespace. - */ export const IdentityProviderMetadataForm = ({ + controller, modes, metadataUrl, manual, }: IdentityProviderMetadataFormProps): JSX.Element => { const card = useCardState(); const { t } = useLocalizations(); - const { goNext, goPrev, isFirstStep } = useWizard(); - const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); - - const samlConnection = enterpriseConnection?.samlConnection; - const hasExistingConfig = Boolean( - samlConnection?.idpSsoUrl || - samlConnection?.idpEntityId || - samlConnection?.idpCertificate || - samlConnection?.idpMetadataUrl, - ); - const existingCertPresent = Boolean(samlConnection?.idpCertificate); - - const [mode, setMode] = React.useState<'metadataUrl' | 'manual'>(hasExistingConfig ? 'manual' : 'metadataUrl'); - const [certFile, setCertFile] = React.useState(null); - - const updateConnection = useReverification( - React.useCallback( - async (params: UpdateMeEnterpriseConnectionParams) => { - if (!enterpriseConnection) { - throw new Error('Enterprise connection required'); - } - return updateEnterpriseConnection(enterpriseConnection.id, params); - }, - [enterpriseConnection, updateEnterpriseConnection], - ), - ); - - const metadataUrlField = useFormControl('idpMetadataUrl', samlConnection?.idpMetadataUrl ?? '', { - type: 'text', - label: metadataUrl.label, - placeholder: metadataUrl.placeholder, - isRequired: true, - }); - - const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { - type: 'text', - label: manual.signOnUrl.label, - placeholder: manual.signOnUrl.placeholder, - isRequired: true, - }); - - const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { - type: 'text', - label: manual.issuer.label, - placeholder: manual.issuer.placeholder, - isRequired: true, - }); - - const certFileField = useFormControl('idpCertificate', '', { - type: 'text', - label: manual.signingCertificate.label, - isRequired: true, - }); - - const trimmedMetadataUrl = metadataUrlField.value.trim(); - const trimmedSignOnUrl = signOnUrlField.value.trim(); - const trimmedIssuer = issuerField.value.trim(); - - const hasCert = certFile !== null || existingCertPresent; - const canSubmit = - !card.isLoading && - ((mode === 'metadataUrl' && trimmedMetadataUrl.length > 0) || - (mode === 'manual' && trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert)); - - const handleContinue = async () => { - if (!enterpriseConnection || !canSubmit) { - return; - } - - card.setError(undefined); - card.setLoading(); - - try { - if (mode === 'metadataUrl') { - await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); - } else { - const samlPayload: NonNullable = { - idpSsoUrl: trimmedSignOnUrl, - idpEntityId: trimmedIssuer, - }; - - if (certFile !== null) { - samlPayload.idpCertificate = await certFile.text(); - } - - await updateConnection({ saml: samlPayload }); - } - - void goNext(); - } catch (err) { - const fields = mode === 'metadataUrl' ? [metadataUrlField] : [signOnUrlField, issuerField, certFileField]; - handleError(err as Error, fields, card.setError); - - if (isClerkAPIResponseError(err)) { - // Some SAML failures come back without a `paramName`, so they wouldn't - // attach to any field via `handleError`. Pin them to the visible - // primary input for the active mode and clear the card-level dupe. - const unscopedSamlError = err.errors.find(e => e.code?.startsWith('saml_') && !e.meta?.paramName); - if (unscopedSamlError) { - const primaryField = mode === 'metadataUrl' ? metadataUrlField : signOnUrlField; - primaryField.setError(unscopedSamlError); - card.setError(undefined); - } - } - } finally { - card.setIdle(); - } - }; return ( <> - - - - { - card.setError(undefined); - setMode(value as 'metadataUrl' | 'manual'); - }} - fullWidth - > - - - - - {mode === 'metadataUrl' ? ( - - ) : ( - - )} - - + { + card.setError(undefined); + controller.setMode(value as IdpMetadataMode); + }} + fullWidth + > + + + - - goPrev()} - isDisabled={isFirstStep || card.isLoading} + {controller.mode === 'metadataUrl' ? ( + - - + )} ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/useIdentityProviderMetadataForm.ts b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/useIdentityProviderMetadataForm.ts new file mode 100644 index 00000000000..4bdb6effb4d --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/useIdentityProviderMetadataForm.ts @@ -0,0 +1,149 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; +import React from 'react'; + +import { type LocalizationKey } from '@/customizables'; +import type { useCardState } from '@/elements/contexts'; +import type { FormControlState } from '@/ui/utils/useFormControl'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; + +export type IdpMetadataMode = 'metadataUrl' | 'manual'; + +type FieldLocalization = { + label: LocalizationKey; + placeholder: LocalizationKey; +}; + +type UseIdentityProviderMetadataFormParams = { + metadataUrl: FieldLocalization; + manual: { + signOnUrl: FieldLocalization; + issuer: FieldLocalization; + /** Label for the signing-certificate uploader field. */ + signingCertificateLabel: LocalizationKey; + }; +}; + +type CardState = ReturnType; + +type SamlPayload = NonNullable; + +export type IdentityProviderMetadataFormController = { + mode: IdpMetadataMode; + setMode: React.Dispatch>; + certFile: File | null; + setCertFile: React.Dispatch>; + existingCertPresent: boolean; + metadataUrlField: FormControlState; + signOnUrlField: FormControlState; + issuerField: FormControlState; + certFileField: FormControlState; + isValid: boolean; + buildSamlPayload: () => Promise; + applySubmitError: (err: unknown, card: CardState) => void; +}; + +export const useIdentityProviderMetadataForm = ({ + metadataUrl, + manual, +}: UseIdentityProviderMetadataFormParams): IdentityProviderMetadataFormController => { + const { enterpriseConnection } = useConfigureSSO(); + + const samlConnection = enterpriseConnection?.samlConnection; + const hasExistingConfig = Boolean( + samlConnection?.idpSsoUrl || + samlConnection?.idpEntityId || + samlConnection?.idpCertificate || + samlConnection?.idpMetadataUrl, + ); + const existingCertPresent = Boolean(samlConnection?.idpCertificate); + + const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); + const [certFile, setCertFile] = React.useState(null); + + const metadataUrlField = useFormControl('idpMetadataUrl', samlConnection?.idpMetadataUrl ?? '', { + type: 'text', + label: metadataUrl.label, + placeholder: metadataUrl.placeholder, + isRequired: true, + }); + + const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { + type: 'text', + label: manual.signOnUrl.label, + placeholder: manual.signOnUrl.placeholder, + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { + type: 'text', + label: manual.issuer.label, + placeholder: manual.issuer.placeholder, + isRequired: true, + }); + + const certFileField = useFormControl('idpCertificate', '', { + type: 'text', + label: manual.signingCertificateLabel, + isRequired: true, + }); + + const trimmedMetadataUrl = metadataUrlField.value.trim(); + const trimmedSignOnUrl = signOnUrlField.value.trim(); + const trimmedIssuer = issuerField.value.trim(); + const hasCert = certFile !== null || existingCertPresent; + + const isValid = + (mode === 'metadataUrl' && trimmedMetadataUrl.length > 0) || + (mode === 'manual' && trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert); + + const buildSamlPayload = async (): Promise => { + if (mode === 'metadataUrl') { + return { idpMetadataUrl: trimmedMetadataUrl }; + } + + const payload: SamlPayload = { + idpSsoUrl: trimmedSignOnUrl, + idpEntityId: trimmedIssuer, + }; + + if (certFile !== null) { + payload.idpCertificate = await certFile.text(); + } + + return payload; + }; + + const applySubmitError = (err: unknown, card: CardState): void => { + const activeFields = mode === 'metadataUrl' ? [metadataUrlField] : [signOnUrlField, issuerField, certFileField]; + + handleError(err as Error, activeFields, card.setError); + + if (isClerkAPIResponseError(err)) { + const unscopedSamlError = err.errors.find(e => e.code?.startsWith('saml_') && !e.meta?.paramName); + if (unscopedSamlError) { + const primaryField = mode === 'metadataUrl' ? metadataUrlField : signOnUrlField; + primaryField.setError(unscopedSamlError); + card.setError(undefined); + } + } + }; + + return { + mode, + setMode, + certFile, + setCertFile, + existingCertPresent, + metadataUrlField, + signOnUrlField, + issuerField, + certFileField, + isValid, + buildSamlPayload, + applySubmitError, + }; +};