From 674669ec207a26795bdd7d6174d8855d8c2de580 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 15:19:01 -0300 Subject: [PATCH 1/4] feat(ui): display OIDC authorized redirect URI --- .changeset/blue-owls-remember.md | 7 ++++ packages/localizations/src/en-US.ts | 5 +++ packages/shared/src/types/elementIds.ts | 1 + packages/shared/src/types/localization.ts | 4 ++ .../__tests__/ConfigureStep.test.tsx | 3 ++ .../oidc/OidcCustomConfigureSteps.tsx | 37 ++++++++++++++++++- 6 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .changeset/blue-owls-remember.md diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md new file mode 100644 index 00000000000..c225243addf --- /dev/null +++ b/.changeset/blue-owls-remember.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Display a copyable authorized redirect URI in the first step of the experimental OIDC self-serve SSO setup flow. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e7419100697..fed9c32e9e8 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -252,6 +252,11 @@ export const enUS: LocalizationResource = { mainHeaderTitle: 'Configure your identity provider', redirectUriStep: { headerSubtitle: 'Create a new OIDC application in your identity provider’s dashboard', + paragraph: + 'In your identity provider’s dashboard, create a new OIDC application that supports the authorization code grant type, and use the following redirect URI:', + redirectUri: { + label: 'Authorized redirect URI', + }, }, }, samlCustom: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index b685d908db7..2ba342ba43c 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,7 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'redirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 88df4c9b294..b391aae00f0 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1529,6 +1529,10 @@ export type __internal_LocalizationResource = { mainHeaderTitle: LocalizationValue; redirectUriStep: { headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; + redirectUri: { + label: LocalizationValue; + }; }; claimsStep: { headerSubtitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index f4b4c5c0d8c..05101fe30c9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -77,6 +77,9 @@ describe('ConfigureProviderStep', () => { // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. expect(await screen.findByText(/create a new oidc application/i)).toBeInTheDocument(); + const redirectUri = screen.getByRole('textbox') as HTMLInputElement; + expect(redirectUri).toHaveAttribute('readonly'); + expect(redirectUri.value).toMatch(/^https:\/\/.+\/v1\/oauth_callback$/); }); it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index d5100e7c2ca..336850787a2 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,6 +1,11 @@ +import { useClerk } from '@clerk/shared/react'; import { type JSX } from 'react'; -import { localizationKeys } from '@/customizables'; +import { Col, localizationKeys, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { Form } from '@/elements/Form'; +import { Checkmark, Clipboard } from '@/icons'; +import { useFormControl } from '@/ui/utils/useFormControl'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; @@ -41,6 +46,13 @@ export const OidcCustomConfigureSteps = (): JSX.Element => { const OidcRedirectUriStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { frontendApi } = useClerk(); + const redirectUri = `https://${frontendApi}/v1/oauth_callback`; + const redirectUriField = useFormControl('redirectUri', redirectUri, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), + isRequired: false, + }); return ( <> @@ -51,7 +63,28 @@ const OidcRedirectUriStep = (): JSX.Element => { - + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + From 7b8a08843dfddd6b87c6350ea57ab2eeebcef1ac Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 15:42:47 -0300 Subject: [PATCH 2/4] feat(ui): add OIDC claims configuration step --- .changeset/blue-owls-remember.md | 2 +- packages/localizations/src/en-US.ts | 25 ++++ packages/shared/src/types/localization.ts | 25 ++++ .../__tests__/ConfigureStep.test.tsx | 8 +- .../oidc/OidcCustomConfigureSteps.tsx | 111 +++++++++++++++++- 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index c225243addf..147a14f263f 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Display a copyable authorized redirect URI in the first step of the experimental OIDC self-serve SSO setup flow. +Add the first two setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI and required ID-token claims. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index fed9c32e9e8..88a63c9164c 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -241,7 +241,32 @@ export const enUS: LocalizationResource = { }, oidcCustom: { claimsStep: { + attributeMappingTable: { + columns: { + attributeName: 'Attribute Name', + userAttribute: 'User Attribute', + }, + rows: { + subject: { + attributeName: 'External user ID', + userAttribute: 'sub', + }, + email: { + attributeName: 'Primary email', + userAttribute: 'email', + }, + firstName: { + attributeName: 'First name', + userAttribute: 'given_name', + }, + lastName: { + attributeName: 'Last name', + userAttribute: 'family_name', + }, + }, + }, headerSubtitle: 'Set the claims your identity provider includes in the ID token', + paragraph: 'Your user ID token must include the following claims:', }, credentialsStep: { headerSubtitle: 'Add your application credentials', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index b391aae00f0..21da71ecbe9 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1535,7 +1535,32 @@ export type __internal_LocalizationResource = { }; }; claimsStep: { + attributeMappingTable: { + columns: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + rows: { + subject: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + email: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + firstName: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + lastName: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + }; + }; headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; }; endpointsStep: { headerSubtitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 05101fe30c9..e2b0852e56e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -72,7 +72,7 @@ describe('ConfigureProviderStep', () => { contextState.provider = 'oidc_clerk_dev'; const { wrapper } = await createFixtures(); - renderStep(wrapper); + const { userEvent } = renderStep(wrapper); // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. @@ -80,6 +80,12 @@ describe('ConfigureProviderStep', () => { const redirectUri = screen.getByRole('textbox') as HTMLInputElement; expect(redirectUri).toHaveAttribute('readonly'); expect(redirectUri.value).toMatch(/^https:\/\/.+\/v1\/oauth_callback$/); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(5); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 336850787a2..0d4b70535e1 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,7 +1,20 @@ import { useClerk } from '@clerk/shared/react'; import { type JSX } from 'react'; -import { Col, localizationKeys, Text } from '@/customizables'; +import { + Badge, + Col, + descriptors, + Flex, + localizationKeys, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; @@ -113,7 +126,17 @@ const OidcClaimsStep = (): JSX.Element => { - + + ({ gap: theme.space.$3 })}> + + + + + @@ -130,6 +153,90 @@ const OidcClaimsStep = (): JSX.Element => { ); }; +type OidcClaimRow = { + id: 'subject' | 'email' | 'firstName' | 'lastName'; + isRequired: boolean; +}; + +const OIDC_CLAIM_ROWS: ReadonlyArray = [ + { id: 'subject', isRequired: true }, + { id: 'email', isRequired: true }, + { id: 'firstName', isRequired: false }, + { id: 'lastName', isRequired: false }, +]; + +const OidcClaimsTable = (): JSX.Element => ( + ({ + 'tr > th:first-of-type': { paddingInlineStart: theme.space.$4 }, + })} + > + + + + + + + + {OIDC_CLAIM_ROWS.map(row => ( + + + + + ))} + +
+ ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.attributeName', + )} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.userAttribute', + )} + /> +
+ ({ gap: theme.space.$2 })} + > + + + + + +
+); + const OidcEndpointsStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); From 069f908fbf69b8307ff41b817fcf056e71ad4d2b Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 15:52:11 -0300 Subject: [PATCH 3/4] feat(ui): add OIDC endpoint configuration step --- .changeset/blue-owls-remember.md | 2 +- packages/localizations/src/en-US.ts | 26 ++++ packages/shared/src/types/elementIds.ts | 4 + packages/shared/src/types/localization.ts | 25 +++ .../__tests__/ConfigureStep.test.tsx | 11 ++ .../oidc/OidcCustomConfigureSteps.tsx | 144 +++++++++++++++++- .../IdentityProviderConfigurationModes.tsx | 3 +- 7 files changed, 207 insertions(+), 8 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index 147a14f263f..13002dccbfe 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Add the first two setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI and required ID-token claims. +Add the first three setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, required ID-token claims, and identity provider endpoint configuration. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 88a63c9164c..e27648ddc57 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -273,6 +273,32 @@ export const enUS: LocalizationResource = { }, endpointsStep: { headerSubtitle: 'Add your identity provider’s endpoints', + discoveryUrl: { + description: + 'In your identity provider’s OIDC application, retrieve the discovery endpoint. Paste it below.', + label: 'Discovery endpoint', + placeholder: 'Paste URL here...', + }, + manual: { + authUrl: { + label: 'Authorization URL', + placeholder: 'Paste URL here...', + }, + description: 'In your identity provider’s OIDC application, retrieve these values.', + tokenUrl: { + label: 'Token URL', + placeholder: 'Paste URL here...', + }, + userInfoUrl: { + label: 'User Info URL', + placeholder: 'Paste URL here...', + }, + }, + modes: { + ariaLabel: 'OIDC endpoint configuration method', + discoveryUrl: 'Add via discovery endpoint', + manual: 'Configure manually', + }, }, mainHeaderTitle: 'Configure your identity provider', redirectUriStep: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 2ba342ba43c..82128ffaaf1 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,10 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'discoveryUrl' + | 'authUrl' + | 'tokenUrl' + | 'userInfoUrl' | 'redirectUri' | 'acsUrl' | 'spEntityId' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 21da71ecbe9..a62701fb8b1 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1564,6 +1564,31 @@ export type __internal_LocalizationResource = { }; endpointsStep: { headerSubtitle: LocalizationValue; + discoveryUrl: { + description: LocalizationValue; + label: LocalizationValue; + placeholder: LocalizationValue; + }; + manual: { + authUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + description: LocalizationValue; + tokenUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + userInfoUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + }; + modes: { + ariaLabel: LocalizationValue; + discoveryUrl: LocalizationValue; + manual: LocalizationValue; + }; }; credentialsStep: { headerSubtitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index e2b0852e56e..300aec9aca4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -86,6 +86,17 @@ describe('ConfigureProviderStep', () => { expect(await screen.findByRole('table')).toBeInTheDocument(); expect(screen.getAllByRole('row')).toHaveLength(5); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + const [discoveryMode, manualMode] = await screen.findAllByRole('radio'); + expect(discoveryMode).toBeChecked(); + expect(screen.getAllByRole('textbox')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + + await userEvent.click(manualMode); + + expect(screen.getAllByRole('textbox')).toHaveLength(3); }); it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 0d4b70535e1..c5c837268ed 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,5 +1,5 @@ import { useClerk } from '@clerk/shared/react'; -import { type JSX } from 'react'; +import React, { type JSX } from 'react'; import { Badge, @@ -20,9 +20,15 @@ import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../saml/shared/ActiveConnectionAlert'; +import { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from '../saml/shared/IdentityProviderConfigurationModes'; const OIDC_STEPS: WizardStepConfig[] = [ { id: 'redirect-uri' }, @@ -32,6 +38,14 @@ const OIDC_STEPS: WizardStepConfig[] = [ ]; export const OidcCustomConfigureSteps = (): JSX.Element => { + const { enterpriseConnection } = useConfigureSSO(); + const [endpoints, setEndpoints] = React.useState(() => ({ + discoveryUrl: enterpriseConnection?.oauthConfig?.discoveryUrl ?? '', + authUrl: '', + tokenUrl: '', + userInfoUrl: '', + })); + return ( // Linear, guard-less sub-flow: mount on the first step, mirroring the SAML custom inner wizard. { - + @@ -237,8 +254,71 @@ const OidcClaimsTable = (): JSX.Element => ( ); -const OidcEndpointsStep = (): JSX.Element => { +type OidcEndpointConfiguration = { + discoveryUrl: string; + authUrl: string; + tokenUrl: string; + userInfoUrl: string; +}; + +type OidcEndpointsStepProps = { + endpoints: OidcEndpointConfiguration; + onEndpointsChange: React.Dispatch>; +}; + +const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + +const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepProps): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const [mode, setMode] = React.useState( + endpoints.authUrl || endpoints.tokenUrl || endpoints.userInfoUrl ? 'manual' : 'discoveryUrl', + ); + + const discoveryUrlField = useFormControl('discoveryUrl', endpoints.discoveryUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), + isRequired: true, + }); + const authUrlField = useFormControl('authUrl', endpoints.authUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), + isRequired: true, + }); + const tokenUrlField = useFormControl('tokenUrl', endpoints.tokenUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), + isRequired: true, + }); + const userInfoUrlField = useFormControl('userInfoUrl', endpoints.userInfoUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), + isRequired: true, + }); + + const isValid = + mode === 'discoveryUrl' + ? discoveryUrlField.value.trim().length > 0 + : authUrlField.value.trim().length > 0 && + tokenUrlField.value.trim().length > 0 && + userInfoUrlField.value.trim().length > 0; + + const handleContinue = (): void => { + if (!isValid) { + return; + } + + onEndpointsChange({ + discoveryUrl: discoveryUrlField.value.trim(), + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }); + void goNext(); + }; return ( <> @@ -249,7 +329,59 @@ const OidcEndpointsStep = (): JSX.Element => { - + + + + + {mode === 'discoveryUrl' ? ( + <> + + + + + + ) : ( + <> + + + + + + + + + + + + )} + + + + @@ -258,8 +390,8 @@ const OidcEndpointsStep = (): JSX.Element => { isDisabled={isFirstStep} /> goNext()} - isDisabled={isLastStep} + onClick={handleContinue} + isDisabled={!isValid || isLastStep} /> 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 index 1b75ea36aee..b34a3a597a1 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx @@ -8,9 +8,10 @@ import { SegmentedControl } from '@/elements/SegmentedControl'; * * metadataUrl: Fetch IdP configuration via metadata URL * metadataFile: Upload IdP configuration via metadata file + * discoveryUrl: Fetch IdP configuration via OIDC discovery URL * manual: Configure manually each field, such as sign on URL, issuer, and signing certificate */ -export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'manual'; +export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'discoveryUrl' | 'manual'; type ModeLocalizationKeys = Partial>; From ae23318e7b9b418108161133b351034ba4f6ffaf Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 17:00:42 -0300 Subject: [PATCH 4/4] feat(ui): save OIDC endpoints before credentials --- .../__tests__/ConfigureStep.test.tsx | 63 ++++++++++++- .../oidc/OidcCustomConfigureSteps.tsx | 93 ++++++++++--------- 2 files changed, 109 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 300aec9aca4..116b2c1bc54 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -10,13 +10,17 @@ import type { EnterpriseConnectionProviderType } from '../../../types'; // The dispatch reads `organizationEnterpriseConnection.provider`. The nested // sub-flows also read `enterpriseConnection` (via `Step.Footer.Reset`), which is // left undefined so that footer self-hides in this isolated render. -const contextState = vi.hoisted(() => ({ provider: undefined as string | undefined })); +const contextState = vi.hoisted(() => ({ + provider: undefined as string | undefined, + enterpriseConnection: undefined as { id: string; oauthConfig: { discoveryUrl?: string } | null } | undefined, +})); +const updateConnection = vi.hoisted(() => vi.fn()); vi.mock('../../../ConfigureSSOContext', () => ({ useConfigureSSO: () => ({ - enterpriseConnection: undefined, + enterpriseConnection: contextState.enterpriseConnection, contentRef: { current: null }, - enterpriseConnectionMutations: {}, + enterpriseConnectionMutations: { updateConnection }, organizationEnterpriseConnection: { provider: contextState.provider, hasConnection: true, @@ -70,6 +74,7 @@ describe('ConfigureProviderStep', () => { it('renders the OIDC configure steps for a derived provider key without throwing', async () => { contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = undefined; const { wrapper } = await createFixtures(); const { userEvent } = renderStep(wrapper); @@ -99,6 +104,58 @@ describe('ConfigureProviderStep', () => { expect(screen.getAllByRole('textbox')).toHaveLength(3); }); + it('saves the discovery endpoint before advancing to credentials', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { id: 'ent_123', oauthConfig: null }; + updateConnection.mockReset(); + updateConnection.mockResolvedValue({}); + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + await userEvent.type(screen.getByRole('textbox'), 'https://idp.example/.well-known/openid-configuration'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenCalledWith('ent_123', { + oidc: { discoveryUrl: 'https://idp.example/.well-known/openid-configuration' }, + }); + }); + }); + + it('saves manual endpoints before advancing to credentials', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { id: 'ent_123', oauthConfig: null }; + updateConnection.mockReset(); + updateConnection.mockResolvedValue({}); + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + const [, manualMode] = await screen.findAllByRole('radio'); + await userEvent.click(manualMode); + + const [authUrl, tokenUrl, userInfoUrl] = screen.getAllByRole('textbox'); + await userEvent.type(authUrl, 'https://idp.example/authorize'); + await userEvent.type(tokenUrl, 'https://idp.example/token'); + await userEvent.type(userInfoUrl, 'https://idp.example/userinfo'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenCalledWith('ent_123', { + oidc: { + authUrl: 'https://idp.example/authorize', + tokenUrl: 'https://idp.example/token', + userInfoUrl: 'https://idp.example/userinfo', + }, + }); + }); + }); + it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { contextState.provider = 'ldap_enterprise'; const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index c5c837268ed..0e15f91100a 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -16,9 +16,11 @@ import { Tr, } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; +import { useCardState } from '@/elements/contexts'; import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; @@ -38,14 +40,6 @@ const OIDC_STEPS: WizardStepConfig[] = [ ]; export const OidcCustomConfigureSteps = (): JSX.Element => { - const { enterpriseConnection } = useConfigureSSO(); - const [endpoints, setEndpoints] = React.useState(() => ({ - discoveryUrl: enterpriseConnection?.oauthConfig?.discoveryUrl ?? '', - authUrl: '', - tokenUrl: '', - userInfoUrl: '', - })); - return ( // Linear, guard-less sub-flow: mount on the first step, mirroring the SAML custom inner wizard. { - + @@ -254,45 +245,38 @@ const OidcClaimsTable = (): JSX.Element => ( ); -type OidcEndpointConfiguration = { - discoveryUrl: string; - authUrl: string; - tokenUrl: string; - userInfoUrl: string; -}; - -type OidcEndpointsStepProps = { - endpoints: OidcEndpointConfiguration; - onEndpointsChange: React.Dispatch>; -}; - const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; -const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepProps): JSX.Element => { +const OidcEndpointsStep = (): JSX.Element => { + const card = useCardState(); const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const [mode, setMode] = React.useState( - endpoints.authUrl || endpoints.tokenUrl || endpoints.userInfoUrl ? 'manual' : 'discoveryUrl', - ); - - const discoveryUrlField = useFormControl('discoveryUrl', endpoints.discoveryUrl, { + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const oauthConfig = enterpriseConnection?.oauthConfig; + const [mode, setMode] = React.useState('discoveryUrl'); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const discoveryUrlField = useFormControl('discoveryUrl', oauthConfig?.discoveryUrl ?? '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), isRequired: true, }); - const authUrlField = useFormControl('authUrl', endpoints.authUrl, { + const authUrlField = useFormControl('authUrl', '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), isRequired: true, }); - const tokenUrlField = useFormControl('tokenUrl', endpoints.tokenUrl, { + const tokenUrlField = useFormControl('tokenUrl', '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), isRequired: true, }); - const userInfoUrlField = useFormControl('userInfoUrl', endpoints.userInfoUrl, { + const userInfoUrlField = useFormControl('userInfoUrl', '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), @@ -306,18 +290,38 @@ const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepPr tokenUrlField.value.trim().length > 0 && userInfoUrlField.value.trim().length > 0; - const handleContinue = (): void => { - if (!isValid) { + const canSubmit = isValid && !isSubmitting; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { return; } - onEndpointsChange({ - discoveryUrl: discoveryUrlField.value.trim(), - authUrl: authUrlField.value.trim(), - tokenUrl: tokenUrlField.value.trim(), - userInfoUrl: userInfoUrlField.value.trim(), - }); - void goNext(); + card.setError(undefined); + setIsSubmitting(true); + + try { + await updateConnection( + enterpriseConnection.id, + mode === 'discoveryUrl' + ? { oidc: { discoveryUrl: discoveryUrlField.value.trim() } } + : { + oidc: { + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }, + }, + ); + void goNext(); + } catch (err) { + handleError( + err as Error, + mode === 'discoveryUrl' ? [discoveryUrlField] : [authUrlField, tokenUrlField, userInfoUrlField], + card.setError, + ); + setIsSubmitting(false); + } }; return ( @@ -387,11 +391,12 @@ const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepPr goPrev()} - isDisabled={isFirstStep} + isDisabled={isFirstStep || isSubmitting} />