diff --git a/.changeset/configure-sso-configure-step-metadata-url.md b/.changeset/configure-sso-configure-step-metadata-url.md new file mode 100644 index 00000000000..0fa7758492b --- /dev/null +++ b/.changeset/configure-sso-configure-step-metadata-url.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Implement the Okta SAML metadata URL submission path in the Configure step of `<__experimental_ConfigureSSO />`. Adds a single text input for the IdP metadata URL; Continue posts `{ saml: { idpMetadataUrl } }` via `user.updateEnterpriseConnection` wrapped in `useReverification`, with `useCardState` driving the loading state and `handleError` routing backend errors inline to the field or to the card-level error surface. Locale keys added under `configureSSO.configureStep` in `en-US`. Manual entry, file upload, SP-side copy rows, and the Okta admin-console walkthrough ship in follow-up PRs. diff --git a/.changeset/fix-enterprise-connection-flat-body.md b/.changeset/fix-enterprise-connection-flat-body.md new file mode 100644 index 00000000000..f9eefece296 --- /dev/null +++ b/.changeset/fix-enterprise-connection-flat-body.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix `toMeEnterpriseConnectionBody` to produce the flat snake_case body shape the backend expects for `user.createEnterpriseConnection` and `user.updateEnterpriseConnection`. SAML and OIDC fields are now top-level prefixed (e.g., `saml_idp_metadata_url`) rather than nested under `saml` / `oidc` objects. Without this fix, IdP metadata submission in `<__experimental_ConfigureSSO />` silently fails on the backend. diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index af80f6704bb..d8bc28b2507 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -43,7 +43,6 @@ import type { VerifyTOTPParams, Web3WalletResource, } from '@clerk/shared/types'; -import { deepCamelToSnake } from '@clerk/shared/underscore'; import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; @@ -551,25 +550,64 @@ export class User extends BaseResource implements UserResource { * Serializes `CreateMeEnterpriseConnectionParams` / `UpdateMeEnterpriseConnectionParams` * for the `/me/enterprise_connections` FAPI endpoints. * - * Uses `deepCamelToSnake` but preserves `saml.attributeMapping` and `customAttributes` as-is. Their keys are + * The handler expects a flat form body where SAML and OIDC fields are + * prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather + * than nested under `saml`/`oidc` objects. `attribute_mapping` and + * `custom_attributes` stay as object values and are JSON-stringified + * by the form serializer downstream — their inner keys are * user-supplied data and must not be camel→snake transformed. */ function toMeEnterpriseConnectionBody( params: CreateMeEnterpriseConnectionParams | UpdateMeEnterpriseConnectionParams, ): Record { - const originalAttributeMapping = - params.saml && typeof params.saml === 'object' ? params.saml.attributeMapping : undefined; - const originalCustomAttributes = 'customAttributes' in params ? params.customAttributes : undefined; - - const body = deepCamelToSnake(params) as Record; - - if (originalAttributeMapping !== undefined && body.saml && typeof body.saml === 'object') { - body.saml.attribute_mapping = originalAttributeMapping; + const body: Record = {}; + + // Top-level fields. `provider` is only on Create, the rest are shared + setIfDefined(body, 'provider', (params as CreateMeEnterpriseConnectionParams).provider); + setIfDefined(body, 'name', params.name); + setIfDefined(body, 'organization_id', params.organizationId); + setIfDefined(body, 'active', (params as UpdateMeEnterpriseConnectionParams).active); + setIfDefined(body, 'sync_user_attributes', (params as UpdateMeEnterpriseConnectionParams).syncUserAttributes); + setIfDefined( + body, + 'disable_additional_identifications', + (params as UpdateMeEnterpriseConnectionParams).disableAdditionalIdentifications, + ); + setIfDefined(body, 'custom_attributes', (params as UpdateMeEnterpriseConnectionParams).customAttributes); + + if (params.saml) { + setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId); + setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl); + setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate); + setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl); + setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata); + setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping); + setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains); + setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated); + setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn); } - if (originalCustomAttributes !== undefined) { - body.custom_attributes = originalCustomAttributes; + if (params.oidc) { + setIfDefined(body, 'oidc_client_id', params.oidc.clientId); + setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret); + setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl); + setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl); + setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl); + setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl); + setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce); } return body; } + +/** + * Adds `value` under `key` only when the caller actually provided it. + * Mirrors the SDK's existing semantics: `undefined` means "don't send + * this field"; `null` is forwarded so users can explicitly clear a + * value via the form-encoded body + */ +function setIfDefined(target: Record, key: string, value: unknown): void { + if (value !== undefined) { + target[key] = value; + } +} diff --git a/packages/clerk-js/src/core/resources/__tests__/User.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.test.ts index 0dad85bc27e..0f43cf341bd 100644 --- a/packages/clerk-js/src/core/resources/__tests__/User.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/User.test.ts @@ -184,7 +184,7 @@ describe('User', () => { provider: 'saml_okta', name: 'New SSO', organization_id: 'org_1', - saml: { idp_entity_id: 'https://idp.example.com' }, + saml_idp_entity_id: 'https://idp.example.com', }, }); @@ -291,13 +291,11 @@ describe('User', () => { body: { provider: 'saml_okta', name: 'New SSO', - saml: { - idp_entity_id: 'https://idp.example.com', - attribute_mapping: { - emailAddress: 'mail', - firstName: 'givenName', - 'custom:role': 'role', - }, + saml_idp_entity_id: 'https://idp.example.com', + saml_attribute_mapping: { + emailAddress: 'mail', + firstName: 'givenName', + 'custom:role': 'role', }, }, }); @@ -359,11 +357,9 @@ describe('User', () => { CustomValue: 'y', nestedCamelKey: { innerCamelKey: 'z' }, }, - saml: { - attribute_mapping: { - emailAddress: 'mail', - firstName: 'givenName', - }, + saml_attribute_mapping: { + emailAddress: 'mail', + firstName: 'givenName', }, }, }); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 407aa6f5634..608b0e537a7 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -223,6 +223,82 @@ export const enUS: LocalizationResource = { }, warning: 'Once a provider is selected you cannot change again until the configuration is over', }, + configureStep: { + spFields: { + acsUrl: { + label: 'Single sign-on URL', + }, + spEntityId: { + label: 'Audience URI', + }, + }, + attributeMapping: { + title: 'We expect your SAML responses to have the following specific attributes:', + paragraph: + "These are the defaults and probably won't need you to change them. However, many SAML configuration errors are due to incorrect attribute mappings, so it's worth double-checking. Here's how:", + columns: { + attribute: 'Attribute', + claimName: 'Claim Name', + }, + badges: { + required: 'Required', + optional: 'Optional', + }, + rows: { + email: { + attribute: 'Email address', + claim: 'user.email', + }, + firstName: { + attribute: 'First Name', + claim: 'user.firstName', + }, + lastName: { + attribute: 'Last Name', + claim: 'user.lastName', + }, + }, + }, + samlOkta: { + title: 'Configure Okta Workforce', + subtitle: 'Create a new enterprise application in your Okta Dashboard', + createApp: { + title: 'Create a new enterprise application in Okta', + step1: 'Sign in to Okta and go to Admin → Applications.', + step2: 'Click Create App Integration.', + step3: 'Select SAML 2.0.', + step4: 'Fill in the General Settings (App name is required).', + step5: 'Click Next to complete creating the application.', + }, + serviceProvider: { + title: 'Configure service provider', + paragraph1: + 'Once you have moved forward from the General Settings instructions, you will be presented with the Configure SAML page.', + paragraph2: + 'To configure your service provider (Clerk), you must add these two fields to your Okta application:', + }, + completeSamlIntegration: { + title: 'Complete SAML integration', + step1: 'Select This is an internal app that we have created from the options menu.', + step2: 'Complete the form with any comments and select "Finish".', + }, + configureAttributes: { + 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:', + pairs: { + email: 'mail and user.profile.mail', + firstName: 'firstName and user.profile.firstName', + lastName: 'lastName and user.profile.lastName', + }, + }, + metadataUrl: { + label: 'Metadata URL', + placeholder: 'Paste URL here...', + description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.', + }, + }, + }, }, createOrganization: { formButtonSubmit: 'Create organization', diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 77f71404daa..bbd791603cb 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -26,6 +26,8 @@ export type FieldId = | 'apiKeyExpirationDate' | 'apiKeyRevokeConfirmation' | 'apiKeySecret' + | 'idpMetadataUrl' + | 'acsUrl' | 'web3WalletName'; export type ProfileSectionId = | 'profile' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 1123b6e88fb..cab20a199d1 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1314,6 +1314,78 @@ export type __internal_LocalizationResource = { }; warning: LocalizationValue; }; + configureStep: { + spFields: { + acsUrl: { + label: LocalizationValue; + }; + spEntityId: { + label: LocalizationValue; + }; + }; + attributeMapping: { + title: LocalizationValue; + paragraph: LocalizationValue; + columns: { + attribute: LocalizationValue; + claimName: LocalizationValue; + }; + badges: { + required: LocalizationValue; + optional: LocalizationValue; + }; + rows: { + email: { + attribute: LocalizationValue; + claim: LocalizationValue; + }; + firstName: { + attribute: LocalizationValue; + claim: LocalizationValue; + }; + lastName: { + attribute: LocalizationValue; + claim: LocalizationValue; + }; + }; + }; + samlOkta: { + title: LocalizationValue; + subtitle: LocalizationValue; + createApp: { + title: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + step4: LocalizationValue; + step5: LocalizationValue; + }; + serviceProvider: { + title: LocalizationValue; + paragraph1: LocalizationValue; + paragraph2: LocalizationValue; + }; + completeSamlIntegration: { + title: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + }; + configureAttributes: { + step1: LocalizationValue; + step2: LocalizationValue; + pairs: { + email: LocalizationValue; + firstName: LocalizationValue; + lastName: LocalizationValue; + }; + }; + metadataUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + description: LocalizationValue; + }; + }; + }; }; apiKeys: { formTitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 9be9db0a9ea..56ee537c026 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -68,6 +68,7 @@ const ConfigureSSOCardContent = () => { data: enterpriseConnections, isLoading, createEnterpriseConnection, + updateEnterpriseConnection, } = __internal_useUserEnterpriseConnections({ enabled: true }); // Currently FAPI only supports one enterprise connection per user @@ -81,6 +82,7 @@ const ConfigureSSOCardContent = () => { diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 42a3b2f67c7..c6ee00b8f95 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -1,5 +1,9 @@ import { useReverification, useSession, useUser } from '@clerk/shared/react'; -import type { CreateMeEnterpriseConnectionParams, EnterpriseConnectionResource } from '@clerk/shared/types'; +import type { + CreateMeEnterpriseConnectionParams, + EnterpriseConnectionResource, + UpdateMeEnterpriseConnectionParams, +} from '@clerk/shared/types'; import React, { type PropsWithChildren } from 'react'; import { deriveInitialStep } from './deriveInitialStep'; @@ -31,6 +35,13 @@ export interface ConfigureSSOData { * an enterprise connection already exists so callers can safely re-trigger. */ createConnection: (provider: ProviderType) => Promise; + /** + * Updates the current enterprise connection with the supplied params. The id + * is taken implicitly from `enterpriseConnection` in context, so callers do + * not need to thread it through. Throws when no enterprise connection is + * loaded yet. + */ + updateConnection: (params: UpdateMeEnterpriseConnectionParams) => Promise; } interface ConfigureSSOProviderProps { @@ -38,6 +49,10 @@ interface ConfigureSSOProviderProps { createEnterpriseConnection: ( params: CreateMeEnterpriseConnectionParams, ) => Promise; + updateEnterpriseConnection: ( + enterpriseConnectionId: string, + params: UpdateMeEnterpriseConnectionParams, + ) => Promise; } const ConfigureSSOContext = React.createContext(null); @@ -46,6 +61,7 @@ ConfigureSSOContext.displayName = 'ConfigureSSOContext'; export const ConfigureSSOProvider = ({ enterpriseConnection, createEnterpriseConnection, + updateEnterpriseConnection, children, }: PropsWithChildren): JSX.Element => { const { user } = useUser(); @@ -79,6 +95,19 @@ export const ConfigureSSOProvider = ({ const createConnection = useReverification(createConnectionFetcher); + const updateConnectionFetcher = React.useCallback( + async (params: UpdateMeEnterpriseConnectionParams) => { + if (!enterpriseConnection) { + throw new Error('Enterprise connection required'); + } + + return updateEnterpriseConnection(enterpriseConnection.id, params); + }, + [enterpriseConnection, updateEnterpriseConnection], + ); + + const updateConnection = useReverification(updateConnectionFetcher); + const value = React.useMemo( () => ({ initialStepId, @@ -86,8 +115,9 @@ export const ConfigureSSOProvider = ({ provider, setProvider, createConnection, + updateConnection, }), - [initialStepId, enterpriseConnection, provider, createConnection], + [initialStepId, enterpriseConnection, provider, createConnection, updateConnection], ); return {children}; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx index 10d39a377d1..b5d58e422a7 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx @@ -26,12 +26,20 @@ const Layout = ({ sx, ...props }: StepLayoutProps): JSX.Element => ( /> ); -type StepSectionProps = PropsOfComponent; +type StepSectionProps = PropsOfComponent & { + /** + * When true, the section grows to fill its parent's remaining vertical + * space (flex: 1). Defaults to false so the section sizes to its content + * — required for Step.Header, multi-section sub-steps, and other places + * where a section should stay natural-height. + */ + fill?: boolean; +}; -const Section = ({ sx, ...props }: StepSectionProps): JSX.Element => ( +const Section = ({ fill, sx, ...props }: StepSectionProps): JSX.Element => ( ({ padding: theme.space.$5 }), sx]} + sx={[theme => ({ padding: theme.space.$5 }), fill && { flex: 1 }, sx]} /> ); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx index af420302c57..7a36d636a3a 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx @@ -1,39 +1,579 @@ -import { descriptors, Flow } from '@/customizables'; +import { + Badge, + Col, + descriptors, + Flex, + Flow, + Heading, + type LocalizationKey, + localizationKeys, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useLocalizations, +} from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { Check, ClipboardOutline } from '@/icons'; +import { handleError } from '@/utils/errorHandler'; +import { useFormControl } from '@/utils/useFormControl'; import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; -import { useWizard } from '../elements/Wizard'; +import { useWizard, Wizard } from '../elements/Wizard'; export const ConfigureStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { enterpriseConnection } = useConfigureSSO(); - return ( - + + + + - - Single sign-on URL: {enterpriseConnection?.samlConnection?.acsUrl} - + + + - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - + + + + + + + + + + + + ); }; + +const InnerStepCounter = (): JSX.Element => { + const { currentIndex, totalSteps } = useWizard(); + return ( + + ); +}; + +const RICH_TEXT_PATTERN = /<(strong|code)>(.*?)<\/\1>/g; + +type RichTextSegment = + | { type: 'text'; value: string } + | { type: 'strong'; value: string } + | { type: 'code'; value: string }; + +const parseRichText = (input: string): RichTextSegment[] => { + const segments: RichTextSegment[] = []; + let lastIndex = 0; + RICH_TEXT_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = RICH_TEXT_PATTERN.exec(input)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: 'text', value: input.slice(lastIndex, match.index) }); + } + segments.push({ type: match[1] as 'strong' | 'code', value: match[2] }); + lastIndex = RICH_TEXT_PATTERN.lastIndex; + } + if (lastIndex < input.length) { + segments.push({ type: 'text', value: input.slice(lastIndex) }); + } + return segments; +}; + +const RichText = ({ localizationKey }: { localizationKey: LocalizationKey }): JSX.Element => { + const { t } = useLocalizations(); + const text = t(localizationKey); + if (!text) { + return <>; + } + return ( + <> + {parseRichText(text).map((segment, index) => { + if (segment.type === 'strong') { + return ( + ({ fontWeight: theme.fontWeights.$medium })} + > + {segment.value} + + ); + } + if (segment.type === 'code') { + return ( + + {segment.value} + + ); + } + return segment.value; + })} + + ); +}; + +const ATTRIBUTE_ROWS = [ + { + id: 'email', + isRequired: true, + attribute: localizationKeys('configureSSO.configureStep.attributeMapping.rows.email.attribute'), + claim: localizationKeys('configureSSO.configureStep.attributeMapping.rows.email.claim'), + }, + { + id: 'firstName', + isRequired: false, + attribute: localizationKeys('configureSSO.configureStep.attributeMapping.rows.firstName.attribute'), + claim: localizationKeys('configureSSO.configureStep.attributeMapping.rows.firstName.claim'), + }, + { + id: 'lastName', + isRequired: false, + attribute: localizationKeys('configureSSO.configureStep.attributeMapping.rows.lastName.attribute'), + claim: localizationKeys('configureSSO.configureStep.attributeMapping.rows.lastName.claim'), + }, +] as const; + +const ATTRIBUTE_PAIRS = [ + { + id: 'email', + value: localizationKeys('configureSSO.configureStep.samlOkta.configureAttributes.pairs.email'), + }, + { + id: 'firstName', + value: localizationKeys('configureSSO.configureStep.samlOkta.configureAttributes.pairs.firstName'), + }, + { + id: 'lastName', + value: localizationKeys('configureSSO.configureStep.samlOkta.configureAttributes.pairs.lastName'), + }, +] as const; + +export const CreateAppSubStep = (): 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.spFields.acsUrl.label'), + isRequired: false, + }); + const spEntityIdField = useFormControl('acsUrl', spEntityId, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.spFields.spEntityId.label'), + isRequired: false, + }); + + return ( + <> + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$3 })}> + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + + + + + + + + + + + + + + ({ gap: theme.space.$3 })}> + + + + + + + + + + + + + + + + ({ gap: theme.space.$3 })}> + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +export const ConfigureAttributesSubStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$3 })}> + + + ({ + 'tr > th:first-of-type': { + paddingInlineStart: theme.space.$4, + }, + })} + > + + + + + + + + + + {ATTRIBUTE_ROWS.map(row => ( + + + + + + ))} + +
+ ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.attributeMapping.columns.attribute', + )} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.attributeMapping.columns.claimName', + )} + /> +
+ ({ gap: theme.space.$2 })} + > + + + + + + +
+ + + ({ gap: theme.space.$3 })}> + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > + + + + + + ({ + gap: theme.space.$1, + margin: 0, + marginTop: theme.space.$1, + paddingInlineStart: theme.space.$5, + listStyleType: 'disc', + })} + > + {ATTRIBUTE_PAIRS.map(pair => ( + + + + ))} + + + + +
+
+ + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +export const AssignUsersSubStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + UI goes here + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +export const SubmitSamlConfigSubStep = (): JSX.Element => { + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { enterpriseConnection, updateConnection } = useConfigureSSO(); + + const metadataUrlField = useFormControl('idpMetadataUrl', '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlOkta.metadataUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.samlOkta.metadataUrl.placeholder'), + isRequired: true, + }); + + const trimmedMetadataUrl = metadataUrlField.value.trim(); + const canSubmit = trimmedMetadataUrl.length > 0 && !card.isLoading; + + const handleContinue = async () => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + card.setLoading(); + + try { + await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); + void goNext(); + } catch (err) { + handleError(err as Error, [metadataUrlField], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + <> + + ({ gap: theme.space.$5 })} + > + + + + + + + + + goPrev()} + isDisabled={isFirstStep || card.isLoading} + /> + + + + ); +};