diff --git a/.changeset/public-parts-chew.md b/.changeset/public-parts-chew.md new file mode 100644 index 00000000000..761ec1d47c6 --- /dev/null +++ b/.changeset/public-parts-chew.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Add support for custom SAML provider in `` diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 2c53f0070d1..2435fe52c8a 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -379,9 +379,9 @@ export const enUS: LocalizationResource = { }, }, samlOkta: { - title: 'Configure Okta Workforce', - subtitle: 'Create a new enterprise application in your Okta Dashboard', + headerTitle: 'Configure Okta Workforce', createApp: { + headerSubtitle: 'Create a new enterprise application in your Okta Dashboard', title: 'Create a new enterprise application in Okta', step1: 'Sign in to Okta and go to Admin → Applications.', step2: 'Click Create App Integration.', @@ -402,6 +402,7 @@ export const enUS: LocalizationResource = { step2: 'Complete the form with any comments and select Finish.', }, configureAttributes: { + headerSubtitle: 'Map users attributes from Okta to Clerk', step1: 'In the Okta dashboard, find the Attribute Statements section.', step2: 'Select Add Expression for each attribute, and enter the following name and expression pairs:', @@ -422,6 +423,7 @@ export const enUS: LocalizationResource = { }, }, assignUsers: { + headerSubtitle: 'Assign users to the enterprise app', title: 'Assign selected user or group in Okta', paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', step1: 'In the Okta dashboard, select the Assignments tab.', @@ -432,6 +434,7 @@ export const enUS: LocalizationResource = { step5: 'Select the Done button to complete the assignment.', }, metadataUrl: { + headerSubtitle: 'Configure identity provider metadata', label: 'Metadata URL', placeholder: 'Paste URL here...', description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.', @@ -463,6 +466,57 @@ export const enUS: LocalizationResource = { }, }, }, + samlCustom: { + headerTitle: 'Configure your identity provider (IdP)', + createApp: { + headerSubtitle: + 'Register Clerk as a service provider in your IdP, then add your identity provider configuration.', + title: 'Create a SAML application on your identity provider', + subtitle: + 'In your identity provider’s admin dashboard, create a new SAML 2.0 application and use the following service provider details:', + }, + configureAttributes: { + headerSubtitle: 'Map user attributes from your identity provider to Clerk.', + title: 'We expect your SAML responses to have the following specific attributes:', + }, + assignUsers: { + headerSubtitle: 'Assign users to the enterprise app', + title: 'Assign selected user or group', + paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.', + }, + metadataUrl: { + headerSubtitle: 'Configure identity provider metadata', + label: 'Metadata URL', + placeholder: 'Paste URL here...', + description: 'In your enterprise app, retrieve the metadata URL. Paste it below.', + }, + modes: { + ariaLabel: 'Configuration mode', + metadataUrl: 'Add via metadata', + manual: 'Configure manually', + }, + submitSamlConfig: { + title: 'Fill in your SAML application details', + }, + manual: { + description: 'In your SAML app, retrieve these values.', + signOnUrl: { + label: 'Sign on URL', + placeholder: 'Paste URL here...', + }, + issuer: { + label: 'Issuer', + placeholder: 'Paste URL here...', + }, + signingCertificate: { + label: 'Signing certificate', + uploadFile: 'Upload file', + replaceFile: 'Replace file', + removeFile: 'Remove file', + fileUploaded: 'File uploaded', + }, + }, + }, }, }, createOrganization: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 05d9a743102..6c0352c4a68 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1436,9 +1436,9 @@ export type __internal_LocalizationResource = { }; }; samlOkta: { - title: LocalizationValue; - subtitle: LocalizationValue; + headerTitle: LocalizationValue; createApp: { + headerSubtitle: LocalizationValue; title: LocalizationValue; step1: LocalizationValue; step2: LocalizationValue; @@ -1457,6 +1457,7 @@ export type __internal_LocalizationResource = { step2: LocalizationValue; }; configureAttributes: { + headerSubtitle: LocalizationValue; step1: LocalizationValue; step2: LocalizationValue; pairs: { @@ -1476,6 +1477,7 @@ export type __internal_LocalizationResource = { }; }; assignUsers: { + headerSubtitle: LocalizationValue; title: LocalizationValue; paragraph: LocalizationValue; step1: LocalizationValue; @@ -1485,6 +1487,56 @@ export type __internal_LocalizationResource = { step5: LocalizationValue; }; metadataUrl: { + headerSubtitle: LocalizationValue; + label: LocalizationValue; + placeholder: LocalizationValue; + description: LocalizationValue; + }; + modes: { + ariaLabel: LocalizationValue; + metadataUrl: LocalizationValue; + manual: LocalizationValue; + }; + submitSamlConfig: { + title: LocalizationValue; + }; + manual: { + description: LocalizationValue; + signOnUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + issuer: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + signingCertificate: { + label: LocalizationValue; + uploadFile: LocalizationValue; + replaceFile: LocalizationValue; + removeFile: LocalizationValue; + fileUploaded: LocalizationValue; + }; + }; + }; + samlCustom: { + headerTitle: LocalizationValue; + createApp: { + headerSubtitle: LocalizationValue; + title: LocalizationValue; + subtitle: LocalizationValue; + }; + configureAttributes: { + headerSubtitle: LocalizationValue; + title: LocalizationValue; + }; + assignUsers: { + headerSubtitle: LocalizationValue; + title: LocalizationValue; + paragraph: LocalizationValue; + }; + metadataUrl: { + headerSubtitle: LocalizationValue; label: LocalizationValue; placeholder: LocalizationValue; description: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index e366df1078f..7a0afd6c7b7 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,11 +1,15 @@ -import { __internal_useUserEnterpriseConnections, useSession } from '@clerk/shared/react'; -import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; +import { + __internal_useEnterpriseConnectionTestRuns, + __internal_useUserEnterpriseConnections, + useSession, +} from '@clerk/shared/react'; +import type { __experimental_ConfigureSSOProps, EnterpriseConnectionResource } from '@clerk/shared/types'; import React from 'react'; import { useProtect } from '@/common'; import { withCoreUserGuard } from '@/contexts'; import { Col, descriptors, Flex, Flow, Heading, Icon, localizationKeys, Text } from '@/customizables'; -import { withCardStateProvider } from '@/elements/contexts'; +import { useCardState, withCardStateProvider } from '@/elements/contexts'; import { ProfileCard } from '@/elements/ProfileCard'; import { ExclamationTriangle } from '@/icons'; import { Route, Switch } from '@/router'; @@ -16,7 +20,7 @@ import { ConfigureSSONavbar } from './ConfigureSSONavbar'; import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton'; import { ProfileCardFooter, ProfileCardHeader } from './elements/ProfileCard'; import { Step } from './elements/Step'; -import { Wizard } from './elements/Wizard'; +import { useWizard, Wizard } from './elements/Wizard'; import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps'; const ConfigureSSOInternal = () => { @@ -64,19 +68,31 @@ const AuthenticatedContent = withCoreUserGuard(() => { }); const ConfigureSSOCardContent = ({ contentRef }: { contentRef: React.RefObject }) => { - const { data: enterpriseConnections, isLoading } = __internal_useUserEnterpriseConnections({ enabled: true }); - + const { + data: enterpriseConnections, + isLoading: isLoadingEnterpriseConnections, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + } = __internal_useUserEnterpriseConnections({ enabled: true }); // Currently FAPI only supports one enterprise connection per user const enterpriseConnection = enterpriseConnections?.[0]; - if (isLoading && !enterpriseConnection) { + const { hasSuccessfulTestRun, isLoading: isLoadingTestRuns } = useHasSuccessfulTestRun(enterpriseConnection); + + const isLoading = isLoadingEnterpriseConnections || isLoadingTestRuns; + if (isLoading) { return ; } return ( @@ -88,6 +104,7 @@ const ConfigureSSOSteps = () => { return ( + @@ -183,5 +200,45 @@ const MissingManageEnterpriseConnectionsPermission = () => ( ); +/** + * Sentinel component rendered inside `` + * + * Clears any card-level error whenever the active step transitions, so a stale failure from one step + * doesn't leak into the next + */ +const ResetCardErrorOnStepChange = (): null => { + const { currentStep } = useWizard(); + const card = useCardState(); + const previousStepIdRef = React.useRef(currentStep?.id); + + React.useEffect(() => { + if (previousStepIdRef.current === currentStep?.id) { + return; + } + + previousStepIdRef.current = currentStep?.id; + card.setError(undefined); + }, [currentStep?.id, card]); + + return null; +}; + +/** + * Fetches a single successful test run for the given connection on mount + */ +const useHasSuccessfulTestRun = ( + enterpriseConnection: EnterpriseConnectionResource | undefined, +): { hasSuccessfulTestRun: boolean; isLoading: boolean } => { + const { data: successfulTestRuns, isLoading } = __internal_useEnterpriseConnectionTestRuns({ + enterpriseConnectionId: enterpriseConnection?.id ?? null, + params: { initialPage: 1, pageSize: 1, status: ['success'] }, + }); + + return { + hasSuccessfulTestRun: (successfulTestRuns?.length ?? 0) > 0, + isLoading, + }; +}; + export const ConfigureSSO: React.ComponentType<__experimental_ConfigureSSOProps> = withCardStateProvider(ConfigureSSOInternal); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index db6ac7dd9e9..51c217f17a3 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -1,5 +1,14 @@ -import type { EnterpriseConnectionResource } from '@clerk/shared/types'; -import React, { type PropsWithChildren } from 'react'; +import type { UseUserEnterpriseConnectionsReturn } from '@clerk/shared/react/index'; +import { useSession, useUser } from '@clerk/shared/react/index'; +import type { + EmailAddressResource, + EnterpriseConnectionResource, + SignedInSessionResource, + UserResource, +} from '@clerk/shared/types'; +import React, { type PropsWithChildren, useCallback } from 'react'; + +import { useCardState } from '@/elements/contexts'; import { deriveInitialStep } from './deriveInitialStep'; import type { ProviderType, WizardStepId } from './types'; @@ -28,11 +37,32 @@ export interface ConfigureSSOData { * Ref to the scrollable content container of the wizard. */ contentRef: React.RefObject; + /** + * Creates a new enterprise connection + */ + createEnterpriseConnection: (provider: ProviderType, primaryEmailAddress?: EmailAddressResource) => Promise; + /** + * Updates an existing enterprise connection + */ + updateEnterpriseConnection: UseUserEnterpriseConnectionsReturn['updateEnterpriseConnection']; + /** + * Deletes an enterprise connection + */ + deleteEnterpriseConnection: UseUserEnterpriseConnectionsReturn['deleteEnterpriseConnection']; + /** + * Determines if the user's domain is already wired to an enterprise connection that + * doesn't belong to the org they're currently configuring + */ + isDomainTakenByOtherOrg: boolean; } interface ConfigureSSOProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; + hasSuccessfulTestRun: boolean; contentRef: React.RefObject; + createEnterpriseConnection: UseUserEnterpriseConnectionsReturn['createEnterpriseConnection']; + updateEnterpriseConnection: UseUserEnterpriseConnectionsReturn['updateEnterpriseConnection']; + deleteEnterpriseConnection: UseUserEnterpriseConnectionsReturn['deleteEnterpriseConnection']; } const ConfigureSSOContext = React.createContext(null); @@ -40,24 +70,69 @@ ConfigureSSOContext.displayName = 'ConfigureSSOContext'; export const ConfigureSSOProvider = ({ enterpriseConnection, + hasSuccessfulTestRun, contentRef, + createEnterpriseConnection: createEnterpriseConnectionApi, + updateEnterpriseConnection, + deleteEnterpriseConnection, children, }: PropsWithChildren): JSX.Element => { const [provider, setProvider] = React.useState( enterpriseConnection?.provider as ProviderType, ); + const { session } = useSession(); + const { user } = useUser(); + const card = useCardState(); + + const isDomainTakenByOtherOrg = checkDomainTakenByOtherOrg(user, session, enterpriseConnection); + const initialStepId = deriveInitialStep(enterpriseConnection, { isDomainTakenByOtherOrg, hasSuccessfulTestRun }); + + const createEnterpriseConnection = useCallback( + async (provider: ProviderType, primaryEmailAddress?: EmailAddressResource): Promise => { + const emailDomain = primaryEmailAddress?.emailAddress.split('@')[1]; + const organizationId = session?.lastActiveOrganizationId ?? null; - const initialStepId = deriveInitialStep(enterpriseConnection); + if (!emailDomain) { + return; + } + + card.setLoading(); + + try { + await createEnterpriseConnectionApi({ + provider, + name: emailDomain, + organizationId, + }); + } finally { + card.setIdle(); + } + }, + [card, session, createEnterpriseConnectionApi], + ); const value = React.useMemo( () => ({ + provider, + contentRef, + setProvider, initialStepId, enterpriseConnection, + isDomainTakenByOtherOrg, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + }), + [ provider, - setProvider, contentRef, - }), - [initialStepId, enterpriseConnection, provider, contentRef], + initialStepId, + enterpriseConnection, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + isDomainTakenByOtherOrg, + ], ); return {children}; @@ -70,3 +145,20 @@ export const useConfigureSSO = (): ConfigureSSOData => { } return ctx; }; + +/** + * Determines if the user's domain is already wired to an enterprise connection that + * doesn't belong to the org they're currently configuring + */ +const checkDomainTakenByOtherOrg = ( + user: UserResource | null | undefined, + session: SignedInSessionResource | null | undefined, + enterpriseConnection: EnterpriseConnectionResource | undefined, +): boolean => { + const emailToVerify = + user?.primaryEmailAddress ?? user?.emailAddresses?.find(e => e.verification.status !== 'verified'); + const isVerified = emailToVerify?.verification.status === 'verified'; + const activeOrganizationId = session?.lastActiveOrganizationId ?? null; + + return Boolean(isVerified && enterpriseConnection && enterpriseConnection.organizationId !== activeOrganizationId); +}; diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/deriveInitialStep.test.ts b/packages/ui/src/components/ConfigureSSO/__tests__/deriveInitialStep.test.ts index 2f67bbcc603..c077e3395d7 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/deriveInitialStep.test.ts +++ b/packages/ui/src/components/ConfigureSSO/__tests__/deriveInitialStep.test.ts @@ -1,4 +1,4 @@ -import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import type { EnterpriseConnectionResource, SamlAccountConnectionResource } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; import { deriveInitialStep } from '../deriveInitialStep'; @@ -25,65 +25,83 @@ const makeConnection = (overrides: Partial = {}): ...overrides, }) as EnterpriseConnectionResource; +const makeSamlConnection = (overrides: Partial = {}): SamlAccountConnectionResource => + ({ + id: 'saml_1', + name: 'acme.com', + active: false, + idpEntityId: '', + idpSsoUrl: '', + idpCertificate: '', + idpMetadataUrl: '', + idpMetadata: '', + acsUrl: 'https://clerk.example.com/acs', + spEntityId: 'https://clerk.example.com', + spMetadataUrl: 'https://clerk.example.com/sp-metadata', + allowSubdomains: false, + allowIdpInitiated: false, + forceAuthn: false, + ...overrides, + }) as SamlAccountConnectionResource; + +const defaultOptions = { isDomainTakenByOtherOrg: false, hasSuccessfulTestRun: false }; + describe('deriveInitialStep', () => { - const cases: Array<{ name: string; input: EnterpriseConnectionResource | undefined; expected: WizardStepId }> = [ + const cases: Array<{ + name: string; + input: EnterpriseConnectionResource | undefined; + options?: Partial; + expected: WizardStepId; + }> = [ + { + name: 'domain taken by other org → verify-domain', + input: makeConnection(), + options: { isDomainTakenByOtherOrg: true }, + expected: 'verify-domain', + }, { name: 'no connection → select-provider', input: undefined, expected: 'select-provider', }, { - name: 'connection without samlConnection → configure', - input: makeConnection({ samlConnection: null }), + name: 'active connection → confirmation', + input: makeConnection({ active: true, samlConnection: makeSamlConnection() }), + expected: 'confirmation', + }, + { + name: 'connection with empty SAML IdP config → configure', + input: makeConnection({ samlConnection: makeSamlConnection() }), expected: 'configure', }, { - name: 'connection with empty samlConnection.idpSsoUrl → configure', + name: 'configured connection without successful test run → test', input: makeConnection({ - samlConnection: { - id: 'saml_1', - name: 'acme.com', - active: false, - idpEntityId: '', - idpSsoUrl: '', - idpCertificate: '', - idpMetadataUrl: '', - idpMetadata: '', - acsUrl: 'https://clerk.example.com/acs', - spEntityId: 'https://clerk.example.com', - spMetadataUrl: 'https://clerk.example.com/sp-metadata', - allowSubdomains: false, - allowIdpInitiated: false, - forceAuthn: false, - }, + samlConnection: makeSamlConnection({ + idpEntityId: 'https://idp.example.com/entity', + idpSsoUrl: 'https://idp.example.com/sso', + idpCertificate: 'CERT', + idpMetadataUrl: 'https://idp.example.com/metadata', + }), }), - expected: 'configure', + expected: 'test', }, { - name: 'connection with samlConnection.idpSsoUrl populated → confirmation', + name: 'configured connection with successful test run → confirmation', input: makeConnection({ - samlConnection: { - id: 'saml_1', - name: 'acme.com', - active: true, + samlConnection: makeSamlConnection({ idpEntityId: 'https://idp.example.com/entity', idpSsoUrl: 'https://idp.example.com/sso', idpCertificate: 'CERT', idpMetadataUrl: 'https://idp.example.com/metadata', - idpMetadata: '', - acsUrl: 'https://clerk.example.com/acs', - spEntityId: 'https://clerk.example.com', - spMetadataUrl: 'https://clerk.example.com/sp-metadata', - allowSubdomains: false, - allowIdpInitiated: false, - forceAuthn: false, - }, + }), }), + options: { hasSuccessfulTestRun: true }, expected: 'confirmation', }, ]; - it.each(cases)('$name', ({ input, expected }) => { - expect(deriveInitialStep(input)).toBe(expected); + it.each(cases)('$name', ({ input, options, expected }) => { + expect(deriveInitialStep(input, { ...defaultOptions, ...options })).toBe(expected); }); }); diff --git a/packages/ui/src/components/ConfigureSSO/deriveInitialStep.ts b/packages/ui/src/components/ConfigureSSO/deriveInitialStep.ts index fb19fee3138..4a403486be4 100644 --- a/packages/ui/src/components/ConfigureSSO/deriveInitialStep.ts +++ b/packages/ui/src/components/ConfigureSSO/deriveInitialStep.ts @@ -5,21 +5,47 @@ import type { WizardStepId } from './types'; /** * Decides where the ConfigureSSO wizard should mount on (re)load based on * the current state of the user's enterprise connection. - * - * No connection → `select-provider` - * Connection without SAML IdP metadata → `configure` - * Connection with SAML IdP metadata → `confirmation` - * - * The `test` step is intentionally absent — we can't derive a "last test - * passed" signal synchronously from the resource. Users can re-test from - * Confirmation. */ -export const deriveInitialStep = (connection: EnterpriseConnectionResource | undefined): WizardStepId => { - if (!connection) { +export const deriveInitialStep = ( + enterpriseConnection: EnterpriseConnectionResource | undefined, + options: { isDomainTakenByOtherOrg: boolean; hasSuccessfulTestRun: boolean }, +): WizardStepId => { + const { isDomainTakenByOtherOrg, hasSuccessfulTestRun } = options; + + // Go to the verify domain step in order to display warning + if (isDomainTakenByOtherOrg) { + return 'verify-domain'; + } + + // If no initial connection, go to the select provider step + if (!enterpriseConnection) { return 'select-provider'; } - if (!connection.samlConnection?.idpSsoUrl) { + + // Connection is enabled, go to the confirmation step + const isEnabled = enterpriseConnection?.active; + if (isEnabled) { + return 'confirmation'; + } + + const hasMinimumIdPConfiguration = checkHasMinimumIdPConfiguration(enterpriseConnection); + + // If the connection hasn't finished configuring it, go to the configure step + // Connection exists, but is not enabled and hasn't finished configuring it + if (!hasMinimumIdPConfiguration) { return 'configure'; } + + // If the connection hasn't been tested, go to the test step + if (!hasSuccessfulTestRun) { + return 'test'; + } + + // Connection is disabled but has been tested and configured return 'confirmation'; }; + +// TODO - Update to support OpenID Connect +const checkHasMinimumIdPConfiguration = (connection: EnterpriseConnectionResource): boolean => { + return Boolean(connection.samlConnection?.idpSsoUrl && connection.samlConnection?.idpEntityId); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx index c9c93571978..d5c126febec 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx @@ -1,6 +1,7 @@ -import { __internal_useUserEnterpriseConnections, useReverification } from '@clerk/shared/react'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { useReverification } from '@clerk/shared/react'; import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; -import React from 'react'; +import React, { type JSX } from 'react'; import { Badge, @@ -35,8 +36,11 @@ import { handleError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; import { useWizard, Wizard } from '../elements/Wizard'; +import { useConfigureStepTranslations } from './configureStepTranslations'; export const ConfigureStep = (): JSX.Element => { + const { key } = useConfigureStepTranslations(); + return ( { elementId={descriptors.configureSSOStep.setId('configure')} > - - - - + + + + + + + + + + + + + + + + @@ -123,7 +148,8 @@ const ATTRIBUTE_PAIRS = [ export const CreateAppSubStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { enterpriseConnection } = useConfigureSSO(); + const { enterpriseConnection, provider } = useConfigureSSO(); + const { key } = useConfigureStepTranslations(); const acsUrl = enterpriseConnection?.samlConnection?.acsUrl ?? ''; const spEntityId = enterpriseConnection?.samlConnection?.spEntityId ?? ''; @@ -147,62 +173,21 @@ export const CreateAppSubStep = (): JSX.Element => { - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'disc', - })} - > - - - - + + {provider === 'saml_okta' && } + + {provider === 'saml_custom' && ( - + )} - ({ gap: theme.space.$1x5 })}> - - - - + {provider === 'saml_okta' && } @@ -226,33 +211,7 @@ export const CreateAppSubStep = (): JSX.Element => { - ({ gap: theme.space.$1x5 })}> - - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'disc', - })} - > - - - - + {provider === 'saml_okta' && } @@ -270,18 +229,117 @@ export const CreateAppSubStep = (): JSX.Element => { ); }; +// TODO - Move IdP specific content to separate modules +const OktaServiceProviderStepContent = (): JSX.Element => { + return ( + ({ gap: theme.space.$1x5 })}> + + + + + ); +}; + +const OktaCreateAppStepContent = (): JSX.Element => { + return ( + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + + ); +}; + +const OktaCompleteSamlIntegrationStepContent = (): JSX.Element => { + return ( + ({ gap: theme.space.$1x5 })}> + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + ); +}; + export const ConfigureAttributesSubStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { provider } = useConfigureSSO(); + return ( <> ({ gap: theme.space.$3 })}> - + {provider === 'saml_custom' && ( + + )} ({ @@ -345,70 +403,7 @@ export const ConfigureAttributesSubStep = (): JSX.Element => {
- - - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'decimal', - })} - > - - - - ({ - gap: theme.space.$1x5, - margin: 0, - marginTop: theme.space.$1x5, - paddingInlineStart: theme.space.$5, - listStyleType: '"- "', - })} - > - {ATTRIBUTE_PAIRS.map(pair => ( - - - - - - - - ))} - - - + {provider === 'saml_okta' && }
@@ -426,8 +421,112 @@ export const ConfigureAttributesSubStep = (): JSX.Element => { ); }; +const OktaConfigureAttributesStepContent = (): JSX.Element => { + return ( + <> + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > + + + + ({ + gap: theme.space.$1x5, + margin: 0, + marginTop: theme.space.$1x5, + paddingInlineStart: theme.space.$5, + listStyleType: '"- "', + })} + > + {ATTRIBUTE_PAIRS.map(pair => ( + + + + + + + + ))} + + + + + ); +}; + export const AssignUsersSubStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { provider } = useConfigureSSO(); + + if (provider === 'saml_custom') { + return ( + <> + + ({ gap: theme.space.$3 })}> + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); + } return ( <> @@ -500,8 +599,8 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { const card = useCardState(); const { t } = useLocalizations(); const { goNext, goPrev, isFirstStep } = useWizard(); - const { enterpriseConnection } = useConfigureSSO(); - const { updateEnterpriseConnection } = __internal_useUserEnterpriseConnections(); + const { enterpriseConnection, updateEnterpriseConnection } = useConfigureSSO(); + const { key } = useConfigureStepTranslations(); const samlConnection = enterpriseConnection?.samlConnection; const hasExistingConfig = Boolean( @@ -530,28 +629,28 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { const metadataUrlField = useFormControl('idpMetadataUrl', samlConnection?.idpMetadataUrl ?? '', { type: 'text', - label: localizationKeys('configureSSO.configureStep.samlOkta.metadataUrl.label'), - placeholder: localizationKeys('configureSSO.configureStep.samlOkta.metadataUrl.placeholder'), + label: localizationKeys(key('metadataUrl.label')), + placeholder: localizationKeys(key('metadataUrl.placeholder')), isRequired: true, }); const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { type: 'text', - label: localizationKeys('configureSSO.configureStep.samlOkta.manual.signOnUrl.label'), - placeholder: localizationKeys('configureSSO.configureStep.samlOkta.manual.signOnUrl.placeholder'), + label: localizationKeys(key('manual.signOnUrl.label')), + placeholder: localizationKeys(key('manual.signOnUrl.placeholder')), isRequired: true, }); const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { type: 'text', - label: localizationKeys('configureSSO.configureStep.samlOkta.manual.issuer.label'), - placeholder: localizationKeys('configureSSO.configureStep.samlOkta.manual.issuer.placeholder'), + label: localizationKeys(key('manual.issuer.label')), + placeholder: localizationKeys(key('manual.issuer.placeholder')), isRequired: true, }); const certFileField = useFormControl('idpCertificate', '', { type: 'text', - label: localizationKeys('configureSSO.configureStep.samlOkta.manual.signingCertificate.label'), + label: localizationKeys(key('manual.signingCertificate.label')), isRequired: true, }); @@ -588,12 +687,21 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { await updateConnection({ saml: samlPayload }); } + void goNext(); } catch (err) { - if (mode === 'metadataUrl') { - handleError(err as Error, [metadataUrlField], card.setError); - } else { - handleError(err as Error, [signOnUrlField, issuerField, certFileField], card.setError); + const fields = mode === 'metadataUrl' ? [metadataUrlField] : [signOnUrlField, issuerField, certFileField]; + + handleError(err as Error, fields, card.setError); + + if (isClerkAPIResponseError(err)) { + const unscopedSamlError = err.errors.find(e => e.code?.startsWith('saml_') && !e.meta?.paramName); + + if (unscopedSamlError) { + const primaryField = mode === 'metadataUrl' ? metadataUrlField : signOnUrlField; + primaryField.setError(unscopedSamlError); + card.setError(undefined); + } } } finally { card.setIdle(); @@ -610,21 +718,24 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { setMode(value as 'metadataUrl' | 'manual')} + onChange={value => { + card.setError(undefined); + setMode(value as 'metadataUrl' | 'manual'); + }} fullWidth > @@ -674,12 +785,14 @@ type ManualEntryPanelProps = { }; const MetadataUrlPanel = ({ field }: MetadataUrlPanelProps): JSX.Element => { + const { key } = useConfigureStepTranslations(); + return ( <> @@ -698,13 +811,14 @@ const ManualEntryPanel = ({ }: ManualEntryPanelProps): JSX.Element => { const { t } = useLocalizations(); const certInputRef = React.useRef(null); + const { key } = useConfigureStepTranslations(); return ( <> @@ -748,7 +862,7 @@ const ManualEntryPanel = ({ /> )}