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';