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 diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 0179215908d..d4282640539 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', }, @@ -359,11 +360,11 @@ 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: - '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', @@ -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: { @@ -516,6 +517,104 @@ 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 ', + metadataFile: 'Add via metadata', + manual: 'Configure manually', + }, + metadataFile: { + label: 'IdP metadata', + 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.', + 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: { + headerSubtitle: 'Configure service provider', + title: 'Configure service provider', + paragraph: + 'To configure your service provider, 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: { googleAttribute: 'Primary email', appAttribute: 'email' }, + firstName: { googleAttribute: 'First name', appAttribute: 'firstName' }, + lastName: { googleAttribute: 'Last name', appAttribute: 'lastName' }, + }, + }, + }, + configureUserAccess: { + 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.', + 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/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/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/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3b3bca7ad40..66bd2d6574a 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; }; @@ -1570,6 +1571,99 @@ 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; + metadataFile: LocalizationValue; + manual: LocalizationValue; + }; + metadataFile: { + label: LocalizationValue; + description: 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; + }; + }; + }; + serviceProviderStep: { + headerSubtitle: LocalizationValue; + 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; + }; + rows: { + email: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue }; + firstName: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue }; + lastName: { googleAttribute: LocalizationValue; appAttribute: LocalizationValue }; + }; + }; + }; + configureUserAccess: { + headerSubtitle: LocalizationValue; + assignUsersInstructions: { + paragraph1: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + paragraph2: LocalizationValue; + }; + }; + }; }; confirmation: { statusSection: { 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} 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/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/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx new file mode 100644 index 00000000000..721df013bdf --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -0,0 +1,648 @@ +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'; + +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, + IdentityProviderConfigurationForm, + type IdentityProviderConfigurationFormProps, +} from './shared/IdentityProviderConfigurationForm'; +import { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from './shared/IdentityProviderConfigurationModes'; + +export const SamlGoogleConfigureSteps = (): JSX.Element => { + 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 GOOGLE_IDP_MODES = ['metadataFile', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + +const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { + 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} + /> + + + + ); +}; + +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} + /> + + + ); +}; + +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} + /> + + + ); +}; + +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} + /> + + + ); +}; 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/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/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx new file mode 100644 index 00000000000..73854aa4aad --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx @@ -0,0 +1,352 @@ +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, - }; -}; 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/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 () => { 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';