From 7424ae8bcf70ac247892896bfa28f898ac798720 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 27 May 2026 20:22:03 -0300 Subject: [PATCH 01/11] Add provider --- packages/localizations/src/en-US.ts | 3 ++- packages/shared/src/types/localization.ts | 1 + .../components/ConfigureSSO/steps/ConfigureStep/index.tsx | 4 ++-- .../steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx | 5 +++++ .../ConfigureSSO/steps/ConfigureStep/saml/index.tsx | 3 +++ .../src/components/ConfigureSSO/steps/SelectProviderStep.tsx | 5 +++++ packages/ui/src/components/ConfigureSSO/types.ts | 2 +- 7 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/index.tsx diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 0179215908d..09ba4e50c66 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -242,6 +242,7 @@ export const enUS: LocalizationResource = { groupLabel: 'SAML', okta: 'Okta Workforce', customSaml: 'Custom SAML Provider', + google: 'Google Workspace', }, warning: 'Once a provider is selected you cannot change again until the configuration is over', }, @@ -387,7 +388,7 @@ export const enUS: LocalizationResource = { step2: 'Select Add Expression for each row below, then enter the matching name and value:', attributeMappingTable: { columns: { - name: 'Name', + name: 'Attribute name', expression: 'Expression', }, rows: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3b3bca7ad40..3af195d29e8 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1308,6 +1308,7 @@ export type __internal_LocalizationResource = { groupLabel: LocalizationValue; okta: LocalizationValue; customSaml: LocalizationValue; + google: LocalizationValue; }; warning: LocalizationValue; }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx index d8a518d4fd9..04a9b36240c 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx @@ -6,12 +6,12 @@ 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'; +import { SamlCustomConfigureSteps, SamlGoogleConfigureSteps, SamlOktaConfigureSteps } from './saml'; const STEPS_BY_PROVIDER: Record JSX.Element> = { saml_custom: SamlCustomConfigureSteps, saml_okta: SamlOktaConfigureSteps, + saml_google: SamlGoogleConfigureSteps, }; export const ConfigureStep = (): JSX.Element | null => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx new file mode 100644 index 00000000000..e0ca31857a3 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'react'; + +export const SamlGoogleConfigureSteps = (): JSX.Element => { + return
SamlGoogleConfigureSteps
; +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/index.tsx new file mode 100644 index 00000000000..19dd8ec8f64 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/index.tsx @@ -0,0 +1,3 @@ +export { SamlCustomConfigureSteps } from './SamlCustomConfigureSteps'; +export { SamlGoogleConfigureSteps } from './SamlGoogleConfigureSteps'; +export { SamlOktaConfigureSteps } from './SamlOktaConfigureSteps'; diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx index 28818a04546..19578c1c163 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -47,6 +47,11 @@ const PROVIDER_GROUPS: ReadonlyArray<{ label: localizationKeys('configureSSO.selectProviderStep.saml.customSaml'), iconId: 'saml', }, + { + id: 'saml_google', + label: localizationKeys('configureSSO.selectProviderStep.saml.google'), + iconId: 'google', + }, ], }, ]; diff --git a/packages/ui/src/components/ConfigureSSO/types.ts b/packages/ui/src/components/ConfigureSSO/types.ts index f138ef38387..6cf1bdb63cc 100644 --- a/packages/ui/src/components/ConfigureSSO/types.ts +++ b/packages/ui/src/components/ConfigureSSO/types.ts @@ -1,3 +1,3 @@ -export type ProviderType = 'saml_okta' | 'saml_custom'; +export type ProviderType = 'saml_okta' | 'saml_custom' | 'saml_google'; export type WizardStepId = 'select-provider' | 'verify-domain' | 'configure' | 'test' | 'confirmation'; From 46491d2876acec7be3f897ebbbce9e7744b57127 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 27 May 2026 20:58:34 -0300 Subject: [PATCH 02/11] Add localization keys --- packages/localizations/src/en-US.ts | 96 ++++++++++++++++++++++- packages/shared/src/types/localization.ts | 91 +++++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 09ba4e50c66..8a12c7248fb 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -360,7 +360,7 @@ export const enUS: LocalizationResource = { step5: 'Click Next to complete creating the application.', }, serviceProviderInstructions: { - title: 'Configure service provider', + title: 'Add service provider configuration to Okta', paragraph1: 'Once you have moved forward from the General Settings instructions, you will be presented with the Configure SAML page.', paragraph2: @@ -517,6 +517,100 @@ export const enUS: LocalizationResource = { }, }, }, + samlGoogle: { + mainHeaderTitle: 'Configure Google Workspace', + createAppStep: { + headerSubtitle: 'Create a new enterprise application in your Google Workspace', + createAppInstructions: { + title: 'Create a new enterprise application in Google Workspace', + step1: 'Sign in to Google Admin Portal', + step2: 'In the side navigation, under Apps, select Web and mobile apps', + step3: 'Click on the Add app button, and select Add custom SAML app', + step4: 'In the App details section, fill out the required App name.', + step5: 'Select the Continue button', + }, + }, + identityProviderMetadataStep: { + headerSubtitle: 'Configure identity provider metadata', + modes: { + title: 'Fill in your Google Workspace application details', + ariaLabel: 'Configuration ', + metadataUpload: 'Add via metadata', + manual: 'Configure manually', + }, + metadataUpload: { + label: 'IdP metadata', + uploadFile: 'Upload file', + description: 'In your Google Workspace app, download the IdP metadata and upload it below.', + }, + manual: { + description: 'In your Google Workspace app, retrieve these values.', + signOnUrl: { + label: 'SSO URL', + placeholder: 'Paste URL here...', + }, + issuer: { + label: 'Entity ID', + placeholder: 'Paste URL here...', + }, + signingCertificate: { + label: 'Signing certificate', + uploadFile: 'Upload file', + replaceFile: 'Replace file', + removeFile: 'Remove file', + fileUploaded: 'File uploaded', + }, + }, + }, + serviceProviderStep: { + title: 'Add service provider configuration to Google Workspace', + paragraph: + 'To configure your service provider (Clerk), you must add these two fields to your Google Workspace SAML application:', + serviceProviderFields: { + acsUrl: { + label: 'ACS URL', + }, + spEntityId: { + label: 'Entity ID', + }, + }, + nameIdInstructions: { + step1: + 'Under the Name ID section, select the Name ID format dropdown and select Email.', + step2: 'Select Continue', + }, + }, + attributeMappingStep: { + headerSubtitle: 'Map user attributes from Google Workspace to your application', + paragraph: 'We expect your SAML response to return the user’s email, first name and last name.', + step1: 'In the Google Admin Console, find the Attributes section.', + step2: + 'Select Add mapping for each attribute, and enter the following Google and app attribute:', + attributeMappingTable: { + columns: { + googleAttribute: 'Google attribute', + appAttribute: 'App attribute', + }, + rows: { + email: { name: 'mail' }, + firstName: { name: 'firstName' }, + lastName: { name: 'lastName' }, + }, + }, + }, + configureUserAccess: { + headerSubtitle: 'Configure users access to the enterprise application', + assignUsersInstructions: { + paragraph1: + "Once the configuration is complete in Google, you'll be redirected to the app's overview page.", + step1: 'Open the User access section', + step2: 'Select ON for everyone', + step3: 'Select Save', + paragraph2: + 'Google may take up to 24 hours to propagate these changes. The connection will remain inactive until they take effect.', + }, + }, + }, }, }, createOrganization: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3af195d29e8..c56718c94ff 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1571,6 +1571,97 @@ export type __internal_LocalizationResource = { }; }; }; + samlGoogle: { + mainHeaderTitle: LocalizationValue; + createAppStep: { + headerSubtitle: LocalizationValue; + createAppInstructions: { + title: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + step5: LocalizationValue; + }; + }; + identityProviderMetadataStep: { + headerSubtitle: LocalizationValue; + modes: { + title: LocalizationValue; + ariaLabel: LocalizationValue; + metadataUpload: LocalizationValue; + manual: LocalizationValue; + }; + metadataUpload: { + label: LocalizationValue; + uploadFile: LocalizationValue; + description: 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; + }; + }; + }; + serviceProviderStep: { + title: LocalizationValue; + paragraph: LocalizationValue; + serviceProviderFields: { + acsUrl: { + label: LocalizationValue; + }; + spEntityId: { + label: LocalizationValue; + }; + }; + nameIdInstructions: { + step1: LocalizationValue; + step2: LocalizationValue; + }; + }; + attributeMappingStep: { + headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + attributeMappingTable: { + columns: { + googleAttribute: LocalizationValue; + appAttribute: LocalizationValue; + expression: LocalizationValue; + }; + rows: { + email: { name: LocalizationValue }; + firstName: { name: LocalizationValue }; + lastName: { name: LocalizationValue }; + }; + }; + }; + configureUserAccess: { + headerSubtitle: LocalizationValue; + assignUsersInstructions: { + title: LocalizationValue; + paragraph1: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + paragraph2: LocalizationValue; + }; + }; + }; }; confirmation: { statusSection: { From 0724470921997a4ac36f29ec6cf8c3f2f03bdb82 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 27 May 2026 21:12:00 -0300 Subject: [PATCH 03/11] Introduce create app step --- .../saml/SamlGoogleConfigureSteps.tsx | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx index e0ca31857a3..2a8de18f40e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -1,5 +1,126 @@ import { type JSX } from 'react'; +import { Col, descriptors, Heading, Text } from '@/customizables'; +import { localizationKeys } from '@/localization'; + +import { Step } from '../../../elements/Step'; +import { useWizard, Wizard } from '../../../elements/Wizard'; +import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; + export const SamlGoogleConfigureSteps = (): JSX.Element => { - return
SamlGoogleConfigureSteps
; + return ( + <> + + + + + + + + + + + + + + + ); +}; + +const SamlGoogleCreateAppStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { + return
SamlGoogleIdentityProviderMetadataStep
; }; From 0ca876f997a645843db69b4697469c1051166f8e Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 12:39:51 -0300 Subject: [PATCH 04/11] Refactor identity provider form to declarative approach --- packages/localizations/src/en-US.ts | 9 +- packages/shared/src/types/localization.ts | 9 +- .../saml/SamlCustomConfigureSteps.tsx | 199 ++++++---- .../saml/SamlOktaConfigureSteps.tsx | 196 ++++++---- .../IdentityProviderConfigurationForm.tsx | 351 ++++++++++++++++++ .../IdentityProviderConfigurationModes.tsx | 69 ++++ .../shared/IdentityProviderMetadataForm.tsx | 259 ------------- .../shared/useIdentityProviderMetadataForm.ts | 149 -------- 8 files changed, 695 insertions(+), 546 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx delete mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx delete mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/useIdentityProviderMetadataForm.ts diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 8a12c7248fb..2055f436f80 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -535,13 +535,16 @@ export const enUS: LocalizationResource = { modes: { title: 'Fill in your Google Workspace application details', ariaLabel: 'Configuration ', - metadataUpload: 'Add via metadata', + metadataFile: 'Add via metadata', manual: 'Configure manually', }, - metadataUpload: { + metadataFile: { label: 'IdP metadata', - uploadFile: 'Upload file', description: 'In your Google Workspace app, download the IdP metadata and upload it below.', + uploadFile: 'Upload file', + replaceFile: 'Replace file', + removeFile: 'Remove file', + fileUploaded: 'File uploaded', }, manual: { description: 'In your Google Workspace app, retrieve these values.', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index c56718c94ff..96fd95e3d53 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1589,13 +1589,16 @@ export type __internal_LocalizationResource = { modes: { title: LocalizationValue; ariaLabel: LocalizationValue; - metadataUpload: LocalizationValue; + metadataFile: LocalizationValue; manual: LocalizationValue; }; - metadataUpload: { + metadataFile: { label: LocalizationValue; - uploadFile: LocalizationValue; description: LocalizationValue; + uploadFile: LocalizationValue; + replaceFile: LocalizationValue; + removeFile: LocalizationValue; + fileUploaded: LocalizationValue; }; manual: { description: LocalizationValue; 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 da367429243..7af3d0f4fed 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -1,4 +1,4 @@ -import { type JSX } from 'react'; +import React, { type JSX } from 'react'; import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; @@ -12,8 +12,16 @@ 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'; -import { useIdentityProviderMetadataForm } from './shared/useIdentityProviderMetadataForm'; +import { + applySamlSubmitError, + buildSamlConfigurationPayload, + IdentityProviderConfigurationForm, + type IdentityProviderConfigurationFormProps, +} from './shared/IdentityProviderConfigurationForm'; +import { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from './shared/IdentityProviderConfigurationModes'; export const SamlCustomConfigureSteps = (): JSX.Element => { return ( @@ -252,42 +260,113 @@ const SamlCustomAssignUsersStep = (): JSX.Element => { ); }; +const CUSTOM_SAML_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + 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', - ), - }, - manual: { - signOnUrl: { - label: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', - ), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.placeholder', - ), - }, - issuer: { - label: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.label', - ), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.placeholder', - ), - }, - signingCertificateLabel: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.label', - ), - }, + 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: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.placeholder', + ), + isRequired: true, + }); + + const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', + ), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.placeholder', + ), + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.placeholder', + ), + isRequired: true, }); - const canSubmit = !card.isLoading && controller.isValid; + const certificateField = useFormControl('idpCertificate', '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.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 isValid = + mode === 'metadataUrl' + ? trimmedMetadataUrl.length > 0 + : trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert; + + const canSubmit = isValid && !card.isLoading; + + const formProps: IdentityProviderConfigurationFormProps = + mode === 'metadataUrl' + ? { + mode: 'metadataUrl', + form: { field: metadataUrlField }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.description', + ), + }, + } + : { + mode: 'manual', + form: { + signOnUrlField, + issuerField, + certificateField, + certFile, + onCertFileChange: setCertFile, + existingCertPresent, + }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.description', + ), + 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', + ), + }, + }; const handleContinue = async (): Promise => { if (!enterpriseConnection || !canSubmit) { @@ -298,11 +377,20 @@ const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => { card.setLoading(); try { - const saml = await controller.buildSamlPayload(); + const saml = await buildSamlConfigurationPayload({ + mode, + metadataUrl: { value: metadataUrlField.value }, + manual: { signOnUrl: signOnUrlField.value, issuer: issuerField.value, certFile }, + }); + await updateEnterpriseConnection(enterpriseConnection.id, { saml }); void goNext(); } catch (err) { - controller.applySubmitError(err, card); + if (mode === 'metadataUrl') { + applySamlSubmitError(err, card, metadataUrlField); + } else { + applySamlSubmitError(err, card, signOnUrlField, [issuerField, certificateField]); + } } finally { card.setIdle(); } @@ -323,47 +411,26 @@ const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => { 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.title', )} /> - { + card.setError(undefined); + setMode(next); + }} + labels={{ ariaLabel: localizationKeys( 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.ariaLabel', ), - metadataUrlLabel: localizationKeys( + metadataUrl: localizationKeys( 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.metadataUrl', ), - manualLabel: localizationKeys( + manual: localizationKeys( 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.manual', ), }} - metadataUrl={{ - description: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.description', - ), - }} - manual={{ - description: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.description', - ), - 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', - ), - }, - }} /> + 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 265cb2a5c84..99b55402ac9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -1,4 +1,4 @@ -import { type JSX } from 'react'; +import React, { type JSX } from 'react'; import { Col, descriptors, Heading, localizationKeys, Text } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; @@ -12,8 +12,16 @@ 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'; -import { useIdentityProviderMetadataForm } from './shared/useIdentityProviderMetadataForm'; +import { + applySamlSubmitError, + buildSamlConfigurationPayload, + IdentityProviderConfigurationForm, + type IdentityProviderConfigurationFormProps, +} from './shared/IdentityProviderConfigurationForm'; +import { + type IdpConfigurationMode, + IdentityProviderConfigurationModes, +} from './shared/IdentityProviderConfigurationModes'; export const SamlOktaConfigureSteps = (): JSX.Element => { return ( @@ -450,40 +458,111 @@ const SamlOktaAssignUsersStep = (): JSX.Element => { ); }; +const OKTA_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + 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', - ), - }, - manual: { - signOnUrl: { - label: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signOnUrl.label', - ), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signOnUrl.placeholder', - ), - }, - issuer: { - label: localizationKeys('configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.issuer.label'), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.issuer.placeholder', - ), - }, - signingCertificateLabel: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signingCertificate.label', - ), - }, + 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: localizationKeys('configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.placeholder', + ), + isRequired: true, }); - const canSubmit = !card.isLoading && controller.isValid; + const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signOnUrl.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.signOnUrl.placeholder', + ), + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.issuer.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.issuer.placeholder', + ), + isRequired: true, + }); + + const certificateField = useFormControl('idpCertificate', '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.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 isValid = + mode === 'metadataUrl' + ? trimmedMetadataUrl.length > 0 + : trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert; + + const canSubmit = isValid && !card.isLoading; + + const formProps: IdentityProviderConfigurationFormProps = + mode === 'metadataUrl' + ? { + mode: 'metadataUrl', + form: { field: metadataUrlField }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.description', + ), + }, + } + : { + mode: 'manual', + form: { + signOnUrlField, + issuerField, + certificateField, + certFile, + onCertFileChange: setCertFile, + existingCertPresent, + }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.description', + ), + 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', + ), + }, + }; const handleContinue = async (): Promise => { if (!enterpriseConnection || !canSubmit) { @@ -494,11 +573,19 @@ const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => { card.setLoading(); try { - const saml = await controller.buildSamlPayload(); + const saml = await buildSamlConfigurationPayload({ + mode, + metadataUrl: { value: metadataUrlField.value }, + manual: { signOnUrl: signOnUrlField.value, issuer: issuerField.value, certFile }, + }); await updateEnterpriseConnection(enterpriseConnection.id, { saml }); void goNext(); } catch (err) { - controller.applySubmitError(err, card); + if (mode === 'metadataUrl') { + applySamlSubmitError(err, card, metadataUrlField); + } else { + applySamlSubmitError(err, card, signOnUrlField, [issuerField, certificateField]); + } } finally { card.setIdle(); } @@ -519,47 +606,24 @@ const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => { 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.modes.title', )} /> - { + card.setError(undefined); + setMode(next); + }} + labels={{ ariaLabel: localizationKeys( 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.modes.ariaLabel', ), - metadataUrlLabel: localizationKeys( + metadataUrl: localizationKeys( 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.modes.metadataUrl', ), - manualLabel: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.modes.manual', - ), - }} - metadataUrl={{ - description: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.metadataUrl.description', - ), - }} - manual={{ - description: localizationKeys( - 'configureSSO.configureStep.samlOkta.identityProviderMetadataStep.manual.description', - ), - 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', - ), - }, + manual: localizationKeys('configureSSO.configureStep.samlOkta.identityProviderMetadataStep.modes.manual'), }} /> + diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx new file mode 100644 index 00000000000..e86a49e9c96 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx @@ -0,0 +1,351 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; +import React, { type JSX } from 'react'; + +import { + Badge, + Box, + Button, + Col, + descriptors, + Flex, + Icon, + type LocalizationKey, + Text, + useLocalizations, +} from '@/customizables'; +import type { useCardState } from '@/elements/contexts'; +import { Field } from '@/elements/FieldControl'; +import { Form } from '@/elements/Form'; +import { ArrowUpTray, Close } from '@/icons'; +import type { FormControlState } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import type { IdpConfigurationMode } from './IdentityProviderConfigurationModes'; + +type CardState = ReturnType; +type FormControl = FormControlState; + +type FileUploadLabels = { + uploadFile: LocalizationKey; + replaceFile: LocalizationKey; + removeFile: LocalizationKey; + fileUploaded: LocalizationKey; +}; + +type MetadataUrlForm = { + field: FormControl; +}; + +type MetadataUrlLabels = { + description: LocalizationKey; +}; + +type MetadataFileForm = { + field: FormControl; + file: File | null; + onFileChange: (file: File | null) => void; + existingFilePresent?: boolean; +}; + +type MetadataFileLabels = { + description: LocalizationKey; +} & FileUploadLabels; + +type ManualConfigurationForm = { + signOnUrlField: FormControl; + issuerField: FormControl; + certificateField: FormControl; + certFile: File | null; + onCertFileChange: (file: File | null) => void; + existingCertPresent?: boolean; +}; + +type ManualConfigurationLabels = { + description: LocalizationKey; +} & FileUploadLabels; + +export type IdentityProviderConfigurationFormProps = + | { mode: 'metadataUrl'; form: MetadataUrlForm; labels: MetadataUrlLabels } + | { mode: 'metadataFile'; form: MetadataFileForm; labels: MetadataFileLabels } + | { mode: 'manual'; form: ManualConfigurationForm; labels: ManualConfigurationLabels }; + +export const IdentityProviderConfigurationForm = (config: IdentityProviderConfigurationFormProps): JSX.Element => { + switch (config.mode) { + case 'metadataUrl': + return ( + + ); + case 'metadataFile': + return ( + + ); + case 'manual': + return ( + + ); + } +}; + +type MetadataUrlPanelProps = { + form: MetadataUrlForm; + labels: MetadataUrlLabels; +}; + +const MetadataUrlPanel = ({ form, labels }: MetadataUrlPanelProps): JSX.Element => ( + <> + + + + + +); + +type MetadataFilePanelProps = { + form: MetadataFileForm; + labels: MetadataFileLabels; +}; + +const MetadataFilePanel = ({ form, labels }: MetadataFilePanelProps): JSX.Element => ( + <> + + + +); + +type ManualPanelProps = { + form: ManualConfigurationForm; + labels: ManualConfigurationLabels; +}; + +const ManualPanel = ({ form, labels }: ManualPanelProps): JSX.Element => ( + <> + + + + + + + + + + + + +); + +type BuildSamlPayloadParams = { + mode: IdpConfigurationMode; + metadataUrl?: { value: string }; + metadataFile?: { file: File | null }; + manual?: { + signOnUrl: string; + issuer: string; + certFile: File | null; + }; +}; + +type SamlConfigurationPayload = NonNullable; + +export const buildSamlConfigurationPayload = async ({ + mode, + metadataUrl, + metadataFile, + manual, +}: BuildSamlPayloadParams): Promise => { + if (mode === 'metadataUrl') { + if (!metadataUrl) { + throw new Error('metadataUrl values missing for mode "metadataUrl"'); + } + + return { idpMetadataUrl: metadataUrl.value.trim() }; + } + + if (mode === 'metadataFile') { + if (!metadataFile?.file) { + throw new Error('metadataFile is missing for mode "metadataFile"'); + } + + return { idpMetadata: await metadataFile.file.text() }; + } + + if (!manual) { + throw new Error('manual values missing for mode "manual"'); + } + + const payload: SamlConfigurationPayload = { + idpSsoUrl: manual.signOnUrl.trim(), + idpEntityId: manual.issuer.trim(), + }; + + if (manual.certFile !== null) { + payload.idpCertificate = await manual.certFile.text(); + } + + return payload; +}; + +export const applySamlSubmitError = ( + err: unknown, + card: CardState, + primaryField: FormControl, + additionalFields: FormControl[] = [], +): void => { + handleError(err as Error, [primaryField, ...additionalFields], card.setError); + + if (isClerkAPIResponseError(err)) { + const unscopedSamlError = err.errors.find(e => e.code?.startsWith('saml_') && !e.meta?.paramName); + + if (unscopedSamlError) { + primaryField.setError(unscopedSamlError); + card.setError(undefined); + } + } +}; + +type FileUploadFieldProps = { + field: FormControl; + file: File | null; + onFileChange: (file: File | null) => void; + existingFilePresent: boolean; + accept?: string; + labels: FileUploadLabels; +}; + +const FileUploadField = ({ + field, + file, + onFileChange, + existingFilePresent, + labels, + accept, +}: FileUploadFieldProps): JSX.Element => { + const { t } = useLocalizations(); + const inputRef = React.useRef(null); + + return ( + + + + + + + + { + onFileChange(e.target.files?.[0] ?? null); + field.clearFeedback(); + }} + /> + + {file === null ? ( + + {existingFilePresent && ( + + )} + + + ) : ( + ({ paddingTop: theme.space.$1, paddingBottom: theme.space.$1 })} + > + + {file.name} + + + + + )} + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx new file mode 100644 index 00000000000..728ee02cd4c --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx @@ -0,0 +1,69 @@ +import { type JSX } from 'react'; + +import { type LocalizationKey, useLocalizations } from '@/customizables'; +import { SegmentedControl } from '@/elements/SegmentedControl'; + +/** + * The possible modes for the identity provider configuration + * + * metadataUrl: Fetch IdP configuration via metadata URL + * metadataFile: Upload IdP configuration via metadata file + * manual: Configure manually each field, such as sign on URL, issuer, and signing certificate + */ +export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'manual'; + +type ModeLocalizationKeys = Partial>; + +type IdentityProviderConfigurationModesProps = { + /** + * The set of modes to render as segments, in display order + */ + modes: readonly IdpConfigurationMode[]; + /** + * The currently controlled selected mode state + */ + value: IdpConfigurationMode; + /** + * Called when the user selects a different mode + */ + onChange: (mode: IdpConfigurationMode) => void; + /** + * Localization keys for the aria label and each mode button + */ + labels: { + ariaLabel: LocalizationKey; + } & ModeLocalizationKeys; +}; + +export const IdentityProviderConfigurationModes = ({ + modes, + value, + onChange, + labels, +}: IdentityProviderConfigurationModesProps): JSX.Element => { + const { t } = useLocalizations(); + + return ( + onChange(next as IdpConfigurationMode)} + fullWidth + > + {modes.map(mode => { + const label = labels[mode]; + if (!label) { + return null; + } + + return ( + + ); + })} + + ); +}; 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 deleted file mode 100644 index 5b108d9b2ac..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderMetadataForm.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import type { FieldId } from '@clerk/shared/types'; -import React, { type JSX } from 'react'; - -import { - Badge, - Box, - Button, - Col, - descriptors, - Flex, - 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 { ArrowUpTray, Close } from '@/icons'; -import type { FormControlState } from '@/ui/utils/useFormControl'; - -import type { IdentityProviderMetadataFormController, IdpMetadataMode } from './useIdentityProviderMetadataForm'; - -type SigningCertificateLocalization = { - label: LocalizationKey; - uploadFile: LocalizationKey; - replaceFile: LocalizationKey; - removeFile: LocalizationKey; - fileUploaded: LocalizationKey; -}; - -type IdentityProviderMetadataFormProps = { - controller: IdentityProviderMetadataFormController; - modes: { - ariaLabel: LocalizationKey; - metadataUrlLabel: LocalizationKey; - manualLabel: LocalizationKey; - }; - /** - * Copy for the metadata URL panel - */ - metadataUrl: { - description: LocalizationKey; - }; - /** - * Copy for the manual metadata panel - */ - manual: { - description: LocalizationKey; - signingCertificate: SigningCertificateLocalization; - }; -}; - -export const IdentityProviderMetadataForm = ({ - controller, - modes, - metadataUrl, - manual, -}: IdentityProviderMetadataFormProps): JSX.Element => { - const card = useCardState(); - const { t } = useLocalizations(); - - return ( - <> - { - card.setError(undefined); - controller.setMode(value as IdpMetadataMode); - }} - fullWidth - > - - - - - {controller.mode === 'metadataUrl' ? ( - - ) : ( - - )} - - ); -}; - -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} - - - - - )} - - - - - - ); -}; 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 deleted file mode 100644 index 4bdb6effb4d..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/useIdentityProviderMetadataForm.ts +++ /dev/null @@ -1,149 +0,0 @@ -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, - }; -}; From 64aa7cb71e78dcfd4d4e7368ae144f43be155bd8 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 13:26:45 -0300 Subject: [PATCH 05/11] Implement identity provider config for Google --- packages/shared/src/types/elementIds.ts | 1 + .../saml/SamlGoogleConfigureSteps.tsx | 213 +++++++++++++++++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 0500034745e..336279a9a07 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -28,6 +28,7 @@ export type FieldId = | 'apiKeySecret' | 'idpCertificate' | 'idpEntityId' + | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' | 'acsUrl' diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx index 2a8de18f40e..2d7dc8b0bca 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -1,11 +1,24 @@ -import { type JSX } from 'react'; +import React, { type JSX } from 'react'; import { Col, descriptors, Heading, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; import { localizationKeys } from '@/localization'; +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 { + applySamlSubmitError, + buildSamlConfigurationPayload, + IdentityProviderConfigurationForm, + type IdentityProviderConfigurationFormProps, +} from './shared/IdentityProviderConfigurationForm'; +import { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from './shared/IdentityProviderConfigurationModes'; export const SamlGoogleConfigureSteps = (): JSX.Element => { return ( @@ -121,6 +134,202 @@ const SamlGoogleCreateAppStep = (): JSX.Element => { ); }; +const GOOGLE_IDP_MODES = ['metadataFile', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { - return
SamlGoogleIdentityProviderMetadataStep
; + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); + + const samlConnection = enterpriseConnection?.samlConnection; + const hasExistingManualConfig = Boolean( + samlConnection?.idpSsoUrl || samlConnection?.idpEntityId || samlConnection?.idpCertificate, + ); + const existingCertPresent = Boolean(samlConnection?.idpCertificate); + const existingMetadataPresent = Boolean(samlConnection?.idpMetadata); + + const [mode, setMode] = React.useState(hasExistingManualConfig ? 'manual' : 'metadataFile'); + const [metadataFile, setMetadataFile] = React.useState(null); + const [certFile, setCertFile] = React.useState(null); + + const metadataFileField = useFormControl('idpMetadata', '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.metadataFile.label'), + isRequired: true, + }); + + const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signOnUrl.label', + ), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signOnUrl.placeholder', + ), + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.issuer.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.issuer.placeholder', + ), + isRequired: true, + }); + + const certificateField = useFormControl('idpCertificate', '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signingCertificate.label', + ), + isRequired: true, + }); + + const trimmedSignOnUrl = signOnUrlField.value.trim(); + const trimmedIssuer = issuerField.value.trim(); + const hasCert = certFile !== null || existingCertPresent; + const hasMetadataFile = metadataFile !== null || existingMetadataPresent; + + const isValid = + mode === 'metadataFile' ? hasMetadataFile : trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert; + + const canSubmit = isValid && !card.isLoading; + + const formProps: IdentityProviderConfigurationFormProps = + mode === 'metadataFile' + ? { + mode: 'metadataFile', + form: { + field: metadataFileField, + file: metadataFile, + onFileChange: setMetadataFile, + existingFilePresent: existingMetadataPresent, + }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.metadataFile.description', + ), + uploadFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.metadataFile.uploadFile', + ), + replaceFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.metadataFile.replaceFile', + ), + removeFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.metadataFile.removeFile', + ), + fileUploaded: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.metadataFile.fileUploaded', + ), + }, + } + : { + mode: 'manual', + form: { + signOnUrlField, + issuerField, + certificateField, + certFile, + onCertFileChange: setCertFile, + existingCertPresent, + }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.description', + ), + uploadFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signingCertificate.uploadFile', + ), + replaceFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signingCertificate.replaceFile', + ), + removeFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signingCertificate.removeFile', + ), + fileUploaded: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.manual.signingCertificate.fileUploaded', + ), + }, + }; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + card.setLoading(); + + try { + const saml = await buildSamlConfigurationPayload({ + mode, + metadataFile: { file: metadataFile }, + manual: { signOnUrl: signOnUrlField.value, issuer: issuerField.value, certFile }, + }); + + await updateEnterpriseConnection(enterpriseConnection.id, { saml }); + void goNext(); + } catch (err) { + if (mode === 'metadataFile') { + applySamlSubmitError(err, card, metadataFileField); + } else { + applySamlSubmitError(err, card, signOnUrlField, [issuerField, certificateField]); + } + } finally { + card.setIdle(); + } + }; + + return ( + <> + + + + { + card.setError(undefined); + setMode(next); + }} + labels={{ + ariaLabel: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.modes.ariaLabel', + ), + metadataFile: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.modes.metadataFile', + ), + manual: localizationKeys( + 'configureSSO.configureStep.samlGoogle.identityProviderMetadataStep.modes.manual', + ), + }} + /> + + + + + + goPrev()} + isDisabled={isFirstStep || card.isLoading} + /> + + + + ); }; From bb49131aa07fcb6daebcd480f49bc3f404beaf47 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 13:52:38 -0300 Subject: [PATCH 06/11] Implement service provider step for Google --- packages/localizations/src/en-US.ts | 7 +- packages/shared/src/types/localization.ts | 1 + .../saml/SamlGoogleConfigureSteps.tsx | 119 ++++++++++++++++++ .../IdentityProviderConfigurationForm.tsx | 1 + 4 files changed, 125 insertions(+), 3 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 2055f436f80..625728f6213 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -364,7 +364,7 @@ export const enUS: LocalizationResource = { 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:', + 'To configure your service provider, you must add these two fields to your Okta SAML application:', serviceProviderFields: { acsUrl: { label: 'Single sign-on URL', @@ -566,9 +566,10 @@ export const enUS: LocalizationResource = { }, }, serviceProviderStep: { - title: 'Add service provider configuration to Google Workspace', + headerSubtitle: 'Configure service provider', + title: 'Configure service provider', paragraph: - 'To configure your service provider (Clerk), you must add these two fields to your Google Workspace SAML application:', + 'To configure your service provider, you must add these two fields to your Google Workspace SAML application:', serviceProviderFields: { acsUrl: { label: 'ACS URL', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 96fd95e3d53..966eff345c5 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1620,6 +1620,7 @@ export type __internal_LocalizationResource = { }; }; serviceProviderStep: { + headerSubtitle: LocalizationValue; title: LocalizationValue; paragraph: LocalizationValue; serviceProviderFields: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx index 2d7dc8b0bca..d789884699b 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -1,7 +1,10 @@ import React, { type JSX } from 'react'; import { Col, descriptors, Heading, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { Checkmark, Clipboard } from '@/icons'; import { localizationKeys } from '@/localization'; import { useFormControl } from '@/ui/utils/useFormControl'; @@ -44,6 +47,16 @@ export const SamlGoogleConfigureSteps = (): JSX.Element => { + + + + + + + ); }; @@ -333,3 +346,109 @@ const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { ); }; + +const SamlGoogleServiceProviderStep = (): 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.samlGoogle.serviceProviderStep.serviceProviderFields.acsUrl.label', + ), + isRequired: false, + }); + const spEntityIdField = useFormControl('spEntityId', spEntityId, { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlGoogle.serviceProviderStep.serviceProviderFields.spEntityId.label', + ), + isRequired: false, + }); + + return ( + <> + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + + + + + + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx index e86a49e9c96..73854aa4aad 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx @@ -132,6 +132,7 @@ const MetadataFilePanel = ({ form, labels }: MetadataFilePanelProps): JSX.Elemen onFileChange={form.onFileChange} existingFilePresent={Boolean(form.existingFilePresent)} labels={labels} + accept='.xml' /> ); From 3c9bf53aaff410856639eb368d4e54a2952dfee8 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 13:59:23 -0300 Subject: [PATCH 07/11] Implement attribute mapping for Google --- packages/localizations/src/en-US.ts | 6 +- packages/shared/src/types/localization.ts | 7 +- .../saml/SamlGoogleConfigureSteps.tsx | 109 ++++++++++++++++++ 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 625728f6213..66db83a0c9b 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -596,9 +596,9 @@ export const enUS: LocalizationResource = { appAttribute: 'App attribute', }, rows: { - email: { name: 'mail' }, - firstName: { name: 'firstName' }, - lastName: { name: 'lastName' }, + email: { googleAttribute: 'Primary email', appAttribute: 'email' }, + firstName: { googleAttribute: 'First name', appAttribute: 'firstName' }, + lastName: { googleAttribute: 'Last name', appAttribute: 'lastName' }, }, }, }, diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 966eff345c5..85069985be2 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1645,12 +1645,11 @@ export type __internal_LocalizationResource = { columns: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue; - expression: LocalizationValue; }; rows: { - email: { name: LocalizationValue }; - firstName: { name: LocalizationValue }; - lastName: { name: LocalizationValue }; + email: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue }; + firstName: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue }; + lastName: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue }; }; }; }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx index d789884699b..1c6b97633d2 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -12,6 +12,7 @@ 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 { applySamlSubmitError, buildSamlConfigurationPayload, @@ -57,6 +58,16 @@ export const SamlGoogleConfigureSteps = (): JSX.Element => { + + + + + + + ); }; @@ -452,3 +463,101 @@ const SamlGoogleServiceProviderStep = (): JSX.Element => { ); }; + +const GOOGLE_ATTRIBUTE_MAPPING: AttributeMappingTableConfig = { + columns: { + first: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.columns.googleAttribute', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.columns.appAttribute', + ), + }, + rows: [ + { + id: 'email', + isRequired: true, + first: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.rows.email.googleAttribute', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.rows.email.appAttribute', + ), + }, + { + id: 'firstName', + isRequired: false, + first: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.rows.firstName.googleAttribute', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.rows.firstName.appAttribute', + ), + }, + { + id: 'lastName', + isRequired: false, + first: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.rows.lastName.googleAttribute', + ), + second: localizationKeys( + 'configureSSO.configureStep.samlGoogle.attributeMappingStep.attributeMappingTable.rows.lastName.appAttribute', + ), + }, + ], +}; + +const SamlGoogleAttributeMappingStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$3 })}> + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > + + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; From 10ce4d767e4e45e39507876f205eaad65d2377a8 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 14:02:40 -0300 Subject: [PATCH 08/11] Add configure user access step for Google --- packages/localizations/src/en-US.ts | 8 +- packages/shared/src/types/localization.ts | 1 - .../saml/SamlGoogleConfigureSteps.tsx | 85 +++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 66db83a0c9b..d4282640539 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -603,13 +603,13 @@ export const enUS: LocalizationResource = { }, }, configureUserAccess: { - headerSubtitle: 'Configure users access to the enterprise application', + headerSubtitle: 'Enable your Google Workspace SAML workspace', assignUsersInstructions: { paragraph1: "Once the configuration is complete in Google, you'll be redirected to the app's overview page.", - step1: 'Open the User access section', - step2: 'Select ON for everyone', - step3: 'Select Save', + step1: 'Open the User access section.', + step2: 'Select ON for everyone.', + step3: 'Select Save.', paragraph2: 'Google may take up to 24 hours to propagate these changes. The connection will remain inactive until they take effect.', }, diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 85069985be2..66bd2d6574a 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1656,7 +1656,6 @@ export type __internal_LocalizationResource = { configureUserAccess: { headerSubtitle: LocalizationValue; assignUsersInstructions: { - title: LocalizationValue; paragraph1: LocalizationValue; step1: LocalizationValue; step2: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx index 1c6b97633d2..721df013bdf 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -68,6 +68,16 @@ export const SamlGoogleConfigureSteps = (): JSX.Element => { + + + + + + + ); }; @@ -561,3 +571,78 @@ const SamlGoogleAttributeMappingStep = (): JSX.Element => { ); }; + +const SamlGoogleConfigureUserAccessStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$3 })}> + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; From fd154debea032412a4e0de21414105e75a9784e7 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 14:04:39 -0300 Subject: [PATCH 09/11] Fix stepper line height issue --- .../src/components/ConfigureSSO/elements/Stepper/Stepper.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx b/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx index fa2013aaf24..5449584d036 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, descriptors, Flex, Icon, SimpleButton, Text } from '@/customizables'; -import { ChevronRight, Checkmark } from '@/icons'; +import { Checkmark, ChevronRight } from '@/icons'; import type { StepperItemProps, StepperProps } from './types'; @@ -88,6 +88,7 @@ const Item = ({ fontSize: theme.fontSizes.$xs, fontWeight: theme.fontWeights.$medium, color: theme.colors.$colorBackground, + lineHeight: '1rem', })} > {bullet} From 95bcd4cb02a8964a45d7ab751349dccfb6ca9253 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 14:08:54 -0300 Subject: [PATCH 10/11] Add changeset --- .changeset/beige-breads-bathe.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/beige-breads-bathe.md diff --git a/.changeset/beige-breads-bathe.md b/.changeset/beige-breads-bathe.md new file mode 100644 index 00000000000..827079a0824 --- /dev/null +++ b/.changeset/beige-breads-bathe.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Add support for Google Workspace SAML provider to self-serve SSO From a1018c205768dee7538e2fe762475903582f325e Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 28 May 2026 14:26:36 -0300 Subject: [PATCH 11/11] Disable `refetchOnWindowFocus` for test runs query Previously, the 'Refresh logs' button was triggering a loading state on mount, since the step gets focused and the query gets triggered. We don't need to refetch on window focus, in order to rely on the refresh logs button only + the current internal polling mechanism. --- .../src/react/hooks/useEnterpriseConnectionTestRuns.tsx | 1 + .../steps/__tests__/SelectProviderStep.test.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx index 7f7338f36d2..6787312e9fe 100644 --- a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx @@ -104,6 +104,7 @@ function useEnterpriseConnectionTestRuns( }, enabled: queryEnabled, refetchIntervalInBackground: false, + refetchOnWindowFocus: false, }); const hasRows = (query.data?.data?.length ?? 0) > 0; diff --git a/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx index 33f6e0e725a..7c40ca6c699 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx @@ -102,13 +102,14 @@ describe('SelectProviderStep', () => { expect(screen.getByText(/We.*ll guide you through the detailed setup process next\./)).toBeInTheDocument(); }); - it('renders both SAML provider radios with their labels', async () => { + it('renders all SAML provider radios with their labels', async () => { resetMocks(); const { wrapper } = await createFixtures(); renderStep(wrapper); expect(screen.getByRole('radio', { name: 'Okta Workforce' })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: 'Custom SAML Provider' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Google Workspace' })).toBeInTheDocument(); }); it('loads each tile icon from img.clerk.com', async () => { @@ -118,7 +119,7 @@ describe('SelectProviderStep', () => { // Emotion serializes sx into stylesheets, so we check both inline + the document's collected styles const iconSpans = Array.from(container.querySelectorAll('label span[aria-hidden]')); - expect(iconSpans).toHaveLength(2); + expect(iconSpans).toHaveLength(3); const collectedStyles = [ ...Array.from(document.head.querySelectorAll('style')).map(s => s.textContent ?? ''), @@ -127,6 +128,7 @@ describe('SelectProviderStep', () => { expect(collectedStyles).toMatch(/img\.clerk\.com\/static\/okta\.svg/); expect(collectedStyles).toMatch(/img\.clerk\.com\/static\/saml\.svg/); + expect(collectedStyles).toMatch(/img\.clerk\.com\/static\/google\.svg/); }); it('disables Continue when no provider is selected', async () => {