From 1ac718be9195d4afc6e7c5b6561fed812a952225 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Tue, 7 Jul 2026 16:15:52 -0300 Subject: [PATCH 1/2] feat(ui): add OIDC provider path and step skeletons to ConfigureSSO --- .changeset/cool-dragons-march.md | 2 + packages/localizations/src/en-US.ts | 19 +++ packages/shared/src/types/localization.ts | 19 +++ .../organizationEnterpriseConnection.test.ts | 63 +++++++ .../organizationEnterpriseConnection.ts | 12 +- ...eOrganizationEnterpriseConnection.test.tsx | 10 +- .../steps/ConfigureStep/index.tsx | 2 + .../oidc/OidcCustomConfigureSteps.tsx | 157 ++++++++++++++++++ .../steps/ConfigureStep/oidc/index.tsx | 1 + .../ConfigureSSO/steps/SelectProviderStep.tsx | 22 ++- .../__tests__/SelectProviderStep.test.tsx | 27 +++ .../ui/src/components/ConfigureSSO/types.ts | 2 +- 12 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 .changeset/cool-dragons-march.md create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/index.tsx diff --git a/.changeset/cool-dragons-march.md b/.changeset/cool-dragons-march.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/cool-dragons-march.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 6f4c008a9ae..e9effed60e4 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -239,6 +239,21 @@ export const enUS: LocalizationResource = { required: 'Required', }, }, + oidcCustom: { + claimsStep: { + headerSubtitle: 'Set the claims your identity provider includes in the ID token', + }, + credentialsStep: { + headerSubtitle: 'Add your application credentials', + }, + endpointsStep: { + headerSubtitle: 'Add your identity provider’s endpoints', + }, + mainHeaderTitle: 'Configure your identity provider', + redirectUriStep: { + headerSubtitle: 'Create a new OIDC application in your identity provider’s dashboard', + }, + }, samlCustom: { assignUsersStep: { headerSubtitle: 'Assign users or groups to your SAML application', @@ -694,6 +709,10 @@ export const enUS: LocalizationResource = { title: 'Reset connection', }, selectProviderStep: { + oidc: { + groupLabel: 'OpenID Connect (OIDC)', + oidcProvider: 'OIDC Provider', + }, saml: { customSaml: 'Custom SAML Provider', google: 'Google Workspace', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 8c8f12fb467..f10deb3bdca 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1399,6 +1399,10 @@ export type __internal_LocalizationResource = { google: LocalizationValue; microsoft: LocalizationValue; }; + oidc: { + groupLabel: LocalizationValue; + oidcProvider: LocalizationValue; + }; warning: LocalizationValue; }; changeProviderDialog: { @@ -1517,6 +1521,21 @@ export type __internal_LocalizationResource = { title: LocalizationValue; dismiss: LocalizationValue; }; + oidcCustom: { + mainHeaderTitle: LocalizationValue; + redirectUriStep: { + headerSubtitle: LocalizationValue; + }; + claimsStep: { + headerSubtitle: LocalizationValue; + }; + endpointsStep: { + headerSubtitle: LocalizationValue; + }; + credentialsStep: { + headerSubtitle: LocalizationValue; + }; + }; samlOkta: { mainHeaderTitle: LocalizationValue; createAppStep: { diff --git a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts index 23dbbb2841a..33d5eeb0995 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -1,6 +1,7 @@ import type { EmailAddressResource, EnterpriseConnectionResource, + EnterpriseOAuthConfigResource, OrganizationDomainResource, SamlAccountConnectionResource, UserResource, @@ -24,6 +25,14 @@ const makeSamlConnection = (overrides: Partial = ...overrides, }) as SamlAccountConnectionResource; +const makeOauthConfig = (overrides: Partial = {}): EnterpriseOAuthConfigResource => + ({ + id: 'oauth_1', + name: 'oidc', + clientId: '', + ...overrides, + }) as EnterpriseOAuthConfigResource; + const makeConnection = (overrides: Partial = {}): EnterpriseConnectionResource => ({ id: 'enc_1', @@ -49,6 +58,11 @@ const fullyConfiguredSaml = makeSamlConnection({ idpMetadataUrl: 'https://idp.example.com/metadata', }); +const configuredOidc = makeOauthConfig({ clientId: 'client_abc' }); + +const makeOidcConnection = (overrides: Partial = {}): EnterpriseConnectionResource => + makeConnection({ provider: 'oidc_custom', samlConnection: null, oauthConfig: configuredOidc, ...overrides }); + // Builds the entity with sensible defaults; each test overrides what it cares // about. const derive = (overrides: Partial[0]> = {}) => @@ -122,6 +136,17 @@ describe('organizationEnterpriseConnection', () => { }).hasMinimumConfiguration, ).toBe(false); }); + it('oidc client id present → true', () => { + expect(derive({ connection: makeOidcConnection() }).hasMinimumConfiguration).toBe(true); + }); + it('oidc without oauth config → false', () => { + expect(derive({ connection: makeOidcConnection({ oauthConfig: null }) }).hasMinimumConfiguration).toBe(false); + }); + it('oidc with empty client id → false', () => { + expect( + derive({ connection: makeOidcConnection({ oauthConfig: makeOauthConfig() }) }).hasMinimumConfiguration, + ).toBe(false); + }); }); describe('hasSuccessfulTestRun', () => { @@ -197,6 +222,17 @@ describe('organizationEnterpriseConnection', () => { }).status, ).toBe('active'); }); + it('oidc configured + successfully tested + not active → inactive', () => { + expect( + derive({ + connection: makeOidcConnection({ active: false }), + hasSuccessfulTestRun: true, + }).status, + ).toBe('inactive'); + }); + it('active oidc connection → active', () => { + expect(derive({ connection: makeOidcConnection({ active: true }) }).status).toBe('active'); + }); }); it('is pure: identical inputs produce a deep-equal entity', () => { @@ -247,10 +283,37 @@ describe('isEnterpriseConnectionConfigured', () => { it('idpSsoUrl + idpEntityId present → true', () => { expect(isEnterpriseConnectionConfigured(makeConnection({ samlConnection: fullyConfiguredSaml }))).toBe(true); }); + it('oidc with no oauth config → false', () => { + expect(isEnterpriseConnectionConfigured(makeOidcConnection({ oauthConfig: null }))).toBe(false); + }); + it('oidc with empty client id → false', () => { + expect(isEnterpriseConnectionConfigured(makeOidcConnection({ oauthConfig: makeOauthConfig() }))).toBe(false); + }); + it('oidc with client id present → true', () => { + expect(isEnterpriseConnectionConfigured(makeOidcConnection())).toBe(true); + }); + it('branches on provider: an oidc connection is not satisfied by saml fields', () => { + expect( + isEnterpriseConnectionConfigured( + makeOidcConnection({ oauthConfig: makeOauthConfig(), samlConnection: fullyConfiguredSaml }), + ), + ).toBe(false); + }); + it('branches on provider: a saml connection is not satisfied by oauth fields', () => { + expect( + isEnterpriseConnectionConfigured( + makeConnection({ provider: 'saml_okta', samlConnection: null, oauthConfig: configuredOidc }), + ), + ).toBe(false); + }); it('matches the derived `hasMinimumConfiguration` field', () => { const connection = makeConnection({ samlConnection: fullyConfiguredSaml }); expect(isEnterpriseConnectionConfigured(connection)).toBe(derive({ connection }).hasMinimumConfiguration); }); + it('matches the derived `hasMinimumConfiguration` field for oidc', () => { + const connection = makeOidcConnection(); + expect(isEnterpriseConnectionConfigured(connection)).toBe(derive({ connection }).hasMinimumConfiguration); + }); }); describe('areAllOrganizationDomainsVerified', () => { diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index fd9f7c7bd56..005c8d6dc41 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -48,10 +48,18 @@ export interface OrganizationEnterpriseConnection { readonly status: OrganizationEnterpriseConnectionStatus; } -// TODO - Update to support OpenID Connect export const isEnterpriseConnectionConfigured = ( connection: EnterpriseConnectionResource | null | undefined, -): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId); +): boolean => { + if (!connection) { + return false; + } + // OIDC exposes only the client ID on the resource; the secret and manual endpoints are write-only. + if (connection.provider.startsWith('oidc')) { + return Boolean(connection.oauthConfig?.clientId); + } + return Boolean(connection.samlConnection?.idpSsoUrl && connection.samlConnection?.idpEntityId); +}; export const areAllOrganizationDomainsVerified = (domains: OrganizationDomainResource[] | null | undefined): boolean => !!domains?.length && domains.every(domain => domain.ownershipVerification?.status === 'verified'); diff --git a/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx b/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx index bb9579ad215..5288dd01549 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx @@ -7,20 +7,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // test-runs query's `enabled` and folds its loading into the global gate. // A connection shaped enough for the umbrella's gating: the derived -// "configured" predicate reads `samlConnection.idpSsoUrl` + `idpEntityId`, and -// the active path reads `active`. +// "configured" predicate branches on `provider`, then reads +// `samlConnection.idpSsoUrl` + `idpEntityId`, and the active path reads `active`. type MockConnection = { id: string; + provider: string; active?: boolean; samlConnection?: { idpSsoUrl?: string; idpEntityId?: string } | null; }; const configuredConnection = (id: string): MockConnection => ({ id, + provider: 'saml_okta', samlConnection: { idpSsoUrl: 'https://idp.example.com/sso', idpEntityId: 'https://idp.example.com/entity' }, }); -const unconfiguredConnection = (id: string): MockConnection => ({ id, samlConnection: null }); +const unconfiguredConnection = (id: string): MockConnection => ({ id, provider: 'saml_okta', samlConnection: null }); // Mutable state the connection-source mock reads from, so a test can flip from // "no connection at load" to "connection created mid-flow" between renders. @@ -119,7 +121,7 @@ describe('useOrganizationEnterpriseConnection — test-runs gating', () => { }); it('(a2) active (but unconfigured) connection at load → test-runs active via the isActive path', () => { - connectionsState.data = [{ id: 'ent_active', active: true, samlConnection: null }]; + connectionsState.data = [{ id: 'ent_active', provider: 'saml_okta', active: true, samlConnection: null }]; const { result } = renderHook(() => useOrganizationEnterpriseConnection()); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx index b5f442e7bd5..9e5ceb9fa0e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx @@ -8,6 +8,7 @@ import { Step } from '../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../elements/Wizard'; import type { ProviderType } from '../../types'; import { SelectProviderStep } from '../SelectProviderStep'; +import { OidcCustomConfigureSteps } from './oidc'; import { SamlCustomConfigureSteps, SamlGoogleConfigureSteps, @@ -20,6 +21,7 @@ const STEPS_BY_PROVIDER: Record JSX.Element> = { saml_okta: SamlOktaConfigureSteps, saml_google: SamlGoogleConfigureSteps, saml_microsoft: SamlMicrosoftConfigureSteps, + oidc_custom: OidcCustomConfigureSteps, }; export const ConfigureStep = (): JSX.Element => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx new file mode 100644 index 00000000000..d5100e7c2ca --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -0,0 +1,157 @@ +import { type JSX } from 'react'; + +import { localizationKeys } from '@/customizables'; + +import { Step } from '../../../elements/Step'; +import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; +import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; + +const OIDC_STEPS: WizardStepConfig[] = [ + { id: 'redirect-uri' }, + { id: 'claims' }, + { id: 'endpoints' }, + { id: 'credentials' }, +]; + +export const OidcCustomConfigureSteps = (): JSX.Element => { + return ( + // Linear, guard-less sub-flow: mount on the first step, mirroring the SAML custom inner wizard. + + + + + + + + + + + + + + + + + + ); +}; + +const OidcRedirectUriStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const OidcClaimsStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const OidcEndpointsStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +const OidcCredentialsStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + {/* Terminal step: the connection submit lands with the credentials step ticket; disabled as a placeholder. */} + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/index.tsx new file mode 100644 index 00000000000..cd97ad4a843 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/index.tsx @@ -0,0 +1 @@ +export { OidcCustomConfigureSteps } from './OidcCustomConfigureSteps'; diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx index 7ba827671b5..939016faf02 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -27,7 +27,7 @@ import type { ProviderType } from '../types'; const MONOCHROMATIC_PROVIDER_ICONS: ReadonlySet = new Set(['okta']); const PROVIDER_GROUPS: ReadonlyArray<{ - id: 'saml'; + id: 'saml' | 'oidc'; label: LocalizationKey; options: ReadonlyArray<{ id: ProviderType; label: LocalizationKey; iconId: string }>; }> = [ @@ -53,6 +53,17 @@ const PROVIDER_GROUPS: ReadonlyArray<{ }, ], }, + { + id: 'oidc', + label: localizationKeys('configureSSO.selectProviderStep.oidc.groupLabel'), + options: [ + { + id: 'oidc_custom', + label: localizationKeys('configureSSO.selectProviderStep.oidc.oidcProvider'), + iconId: 'oidc', + }, + ], + }, ]; const providerLabel = (provider: ProviderType): LocalizationKey | undefined => @@ -63,10 +74,17 @@ export const SelectProviderStep = (): JSX.Element => { organizationEnterpriseConnection: c, enterpriseConnectionMutations: { createConnection, changeProvider }, contentRef, + isOIDCFlowEnabled, } = useConfigureSSO(); const { goNext, goPrev, isFirstStep } = useWizard(); const { t } = useLocalizations(); + // OIDC is gated behind the experimental self-serve flag; SAML always shows. + const providerGroups = React.useMemo( + () => PROVIDER_GROUPS.filter(group => group.id !== 'oidc' || isOIDCFlowEnabled), + [isOIDCFlowEnabled], + ); + const [selected, setSelected] = React.useState(c.provider ?? null); const card = useCardState(); @@ -143,7 +161,7 @@ export const SelectProviderStep = (): JSX.Element => { ({ gap: theme.space.$5 })}> - {PROVIDER_GROUPS.map(group => ( + {providerGroups.map(group => ( ({ provider: undefined as 'saml_okta' | 'saml_custom' | 'saml_google' | undefined, hasConnection: false, + isOIDCFlowEnabled: false, })); vi.mock('../../ConfigureSSOContext', () => ({ @@ -39,6 +40,7 @@ vi.mock('../../ConfigureSSOContext', () => ({ provider: contextState.provider, hasConnection: contextState.hasConnection, }, + isOIDCFlowEnabled: contextState.isOIDCFlowEnabled, }), })); @@ -62,6 +64,7 @@ const resetMocks = () => { changeProvider.mockResolvedValue(undefined); contextState.provider = undefined; contextState.hasConnection = false; + contextState.isOIDCFlowEnabled = false; }; describe('SelectProviderStep', () => { @@ -105,6 +108,30 @@ describe('SelectProviderStep', () => { expect(collectedStyles).toMatch(/img\.clerk\.com\/static\/google\.svg/); }); + describe('OIDC provider (experimental flag)', () => { + it('hides the OIDC provider tile when the flag is off', async () => { + resetMocks(); + const { wrapper } = await createFixtures(); + renderStep(wrapper); + + // SAML is unaffected by the gate; only OIDC is hidden. + expect(screen.getByRole('radio', { name: 'Okta Workforce' })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: 'OIDC Provider' })).not.toBeInTheDocument(); + }); + + it('shows the OIDC provider tile when the flag is on', async () => { + resetMocks(); + contextState.isOIDCFlowEnabled = true; + const { wrapper } = await createFixtures(); + const { container } = renderStep(wrapper); + + expect(screen.getByRole('radio', { name: 'OIDC Provider' })).toBeInTheDocument(); + // Four SAML tiles plus the single OIDC tile. + const iconSpans = Array.from(container.querySelectorAll('label span[aria-hidden]')); + expect(iconSpans).toHaveLength(5); + }); + }); + it('disables Continue when no provider is selected', async () => { resetMocks(); const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/ConfigureSSO/types.ts b/packages/ui/src/components/ConfigureSSO/types.ts index 190fa6cd26e..06112ae2528 100644 --- a/packages/ui/src/components/ConfigureSSO/types.ts +++ b/packages/ui/src/components/ConfigureSSO/types.ts @@ -1,4 +1,4 @@ -export type ProviderType = 'saml_okta' | 'saml_custom' | 'saml_google' | 'saml_microsoft'; +export type ProviderType = 'saml_okta' | 'saml_custom' | 'saml_google' | 'saml_microsoft' | 'oidc_custom'; export type WizardStepId = | 'verify-domain' From 4bbf894f98e804c0829fb5eafe7fac2fcf49ddd5 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Tue, 7 Jul 2026 17:23:52 -0300 Subject: [PATCH 2/2] fix(ui): dispatch OIDC enterprise connections by protocol, not provider literal --- .changeset/spotty-items-dream.md | 2 + .../organizationEnterpriseConnection.test.ts | 5 ++ .../organizationEnterpriseConnection.ts | 19 +++- .../__tests__/ConfigureStep.test.tsx | 90 +++++++++++++++++++ .../steps/ConfigureStep/index.tsx | 42 ++++++--- .../ConfigureSSO/steps/SelectProviderStep.tsx | 21 +++-- .../ui/src/components/ConfigureSSO/types.ts | 23 ++++- 7 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 .changeset/spotty-items-dream.md create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx diff --git a/.changeset/spotty-items-dream.md b/.changeset/spotty-items-dream.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/spotty-items-dream.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts index 33d5eeb0995..847acd860b1 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -93,6 +93,11 @@ describe('organizationEnterpriseConnection', () => { it('connection → its provider', () => { expect(derive({ connection: makeConnection({ provider: 'saml_custom' }) }).provider).toBe('saml_custom'); }); + it('carries a derived OIDC key verbatim — the open family, not the oidc_custom alias', () => { + // The backend derives `oidc_` from the connection name; the entity must + // expose that real value so dispatch can prefix-match it. + expect(derive({ connection: makeConnection({ provider: 'oidc_clerk_dev' }) }).provider).toBe('oidc_clerk_dev'); + }); }); describe('isActive', () => { diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index 005c8d6dc41..cf285e5b4cf 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -5,7 +5,15 @@ import type { UserResource, } from '@clerk/shared/types'; -import type { ProviderType } from '../types'; +import type { EnterpriseConnectionProviderType, OidcProviderType } from '../types'; + +/** + * OIDC providers are recognized by protocol prefix, never by literal: the backend + * stores a derived `oidc_` key (open family), so the read-back provider is + * not a fixed enum. SAML providers stay exact literals. Single source of the + * prefix notion — dispatch and configuration checks both read it from here. + */ +export const isOidcProvider = (provider: string): provider is OidcProviderType => provider.startsWith('oidc'); /** * The email whose domain backs the connection: the user's primary address if @@ -40,7 +48,7 @@ export type OrganizationEnterpriseConnectionStatus = 'unconfigured' | 'in_progre * object the wizard makes every flow decision from. A snapshot of flattened booleans/values. */ export interface OrganizationEnterpriseConnection { - readonly provider: ProviderType | undefined; + readonly provider: EnterpriseConnectionProviderType | undefined; readonly hasConnection: boolean; readonly isActive: boolean; readonly hasMinimumConfiguration: boolean; @@ -55,7 +63,7 @@ export const isEnterpriseConnectionConfigured = ( return false; } // OIDC exposes only the client ID on the resource; the secret and manual endpoints are write-only. - if (connection.provider.startsWith('oidc')) { + if (isOidcProvider(connection.provider)) { return Boolean(connection.oauthConfig?.clientId); } return Boolean(connection.samlConnection?.idpSsoUrl && connection.samlConnection?.idpEntityId); @@ -94,7 +102,10 @@ export const organizationEnterpriseConnection = ({ const hasMinimumConfiguration = isEnterpriseConnectionConfigured(connection); return { - provider: connection?.provider as ProviderType | undefined, + // Boundary cast at the FAPI edge: SAML returns exact literals, OIDC an open + // `oidc_` family. An unrecognized value degrades downstream (dispatch + // falls back), so the honest open type — not the `oidc_custom` input alias — holds here. + provider: connection?.provider as EnterpriseConnectionProviderType | undefined, hasConnection, isActive, hasMinimumConfiguration, 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 new file mode 100644 index 00000000000..f4b4c5c0d8c --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Flow } from '@/customizables'; +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +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 })); + +vi.mock('../../../ConfigureSSOContext', () => ({ + useConfigureSSO: () => ({ + enterpriseConnection: undefined, + contentRef: { current: null }, + enterpriseConnectionMutations: {}, + organizationEnterpriseConnection: { + provider: contextState.provider, + hasConnection: true, + }, + }), +})); + +import { ConfigureProviderStep, resolveConfigureSteps } from '../index'; +import { OidcCustomConfigureSteps } from '../oidc'; +import { + SamlCustomConfigureSteps, + SamlGoogleConfigureSteps, + SamlMicrosoftConfigureSteps, + SamlOktaConfigureSteps, +} from '../saml'; + +const { createFixtures } = bindCreateFixtures('ConfigureSSO'); + +describe('resolveConfigureSteps', () => { + it('dispatches a derived OIDC provider key to the OIDC sub-flow by prefix (not the oidc_custom literal)', () => { + // The regression: the backend returns `oidc_` derived from the connection + // name (e.g. `clerk.dev` → `oidc_clerk_dev`), never the `oidc_custom` input alias. + expect(resolveConfigureSteps('oidc_clerk_dev')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_ghe_acme')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_gitlab_ent_acme')).toBe(OidcCustomConfigureSteps); + expect(resolveConfigureSteps('oidc_custom')).toBe(OidcCustomConfigureSteps); + }); + + it('dispatches SAML providers by exact literal', () => { + expect(resolveConfigureSteps('saml_okta')).toBe(SamlOktaConfigureSteps); + expect(resolveConfigureSteps('saml_custom')).toBe(SamlCustomConfigureSteps); + expect(resolveConfigureSteps('saml_google')).toBe(SamlGoogleConfigureSteps); + expect(resolveConfigureSteps('saml_microsoft')).toBe(SamlMicrosoftConfigureSteps); + }); + + it('returns undefined for an unrecognized provider so the caller can degrade', () => { + expect(resolveConfigureSteps('ldap_enterprise' as EnterpriseConnectionProviderType)).toBeUndefined(); + }); +}); + +describe('ConfigureProviderStep', () => { + const renderStep = (wrapper: React.ComponentType<{ children?: React.ReactNode }>) => + render( + + + + + , + { wrapper }, + ); + + it('renders the OIDC configure steps for a derived provider key without throwing', async () => { + contextState.provider = 'oidc_clerk_dev'; + const { wrapper } = await createFixtures(); + + 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. + expect(await screen.findByText(/create a new oidc application/i)).toBeInTheDocument(); + }); + + it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { + contextState.provider = 'ldap_enterprise'; + const { wrapper } = await createFixtures(); + + renderStep(wrapper); + + expect(await screen.findByText(/unsupported provider/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx index 9e5ceb9fa0e..0afc876b794 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx @@ -4,9 +4,10 @@ import { descriptors, Flow } from '@/customizables'; import { CardStateProvider } from '@/elements/contexts'; import { useConfigureSSO } from '../../ConfigureSSOContext'; +import { isOidcProvider } from '../../domain/organizationEnterpriseConnection'; import { Step } from '../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../elements/Wizard'; -import type { ProviderType } from '../../types'; +import type { EnterpriseConnectionProviderType, SamlProviderType } from '../../types'; import { SelectProviderStep } from '../SelectProviderStep'; import { OidcCustomConfigureSteps } from './oidc'; import { @@ -16,14 +17,26 @@ import { SamlOktaConfigureSteps, } from './saml'; -const STEPS_BY_PROVIDER: Record JSX.Element> = { +type ConfigureStepsComponent = () => JSX.Element; + +const STEPS_BY_SAML_PROVIDER: Record = { saml_custom: SamlCustomConfigureSteps, saml_okta: SamlOktaConfigureSteps, saml_google: SamlGoogleConfigureSteps, saml_microsoft: SamlMicrosoftConfigureSteps, - oidc_custom: OidcCustomConfigureSteps, }; +/** + * Resolves the configure sub-flow for a created connection's provider. OIDC is an + * open, backend-derived family (`oidc_`), so every OIDC provider shares one + * sub-flow and is matched by protocol prefix; SAML stays an exact-literal lookup. + * Returns `undefined` for an unrecognized provider so the caller can degrade. + */ +export const resolveConfigureSteps = ( + provider: EnterpriseConnectionProviderType, +): ConfigureStepsComponent | undefined => + isOidcProvider(provider) ? OidcCustomConfigureSteps : STEPS_BY_SAML_PROVIDER[provider]; + export const ConfigureStep = (): JSX.Element => { const { organizationEnterpriseConnection: c } = useConfigureSSO(); const { direction } = useWizard(); @@ -55,7 +68,7 @@ export const ConfigureStep = (): JSX.Element => { ); }; -const ConfigureProviderStep = (): JSX.Element | null => { +export const ConfigureProviderStep = (): JSX.Element | null => { const { organizationEnterpriseConnection: c } = useConfigureSSO(); // Type guard: the provider should be defined by the time we reach configure. @@ -63,12 +76,7 @@ const ConfigureProviderStep = (): JSX.Element | null => { return null; } - // Adding a provider to the select step without a mapping here fails the build. - const StepsByProvider = STEPS_BY_PROVIDER[c.provider]; - - if (!StepsByProvider) { - throw new Error(`No steps found for provider: ${c.provider}`); - } + const ConfigureSteps = resolveConfigureSteps(c.provider); return ( @@ -76,7 +84,19 @@ const ConfigureProviderStep = (): JSX.Element | null => { elementDescriptor={descriptors.configureSSOStep} elementId={descriptors.configureSSOStep.setId('configure')} > - + {ConfigureSteps ? ( + + ) : ( + // A provider the SDK doesn't recognize (e.g. a newer backend family) + // degrades to a terminal state instead of white-screening the wizard. + <> + + + + )} ); diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx index 939016faf02..877f99c1572 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -21,9 +21,10 @@ import { handleError } from '@/utils/errorHandler'; import { ChangeProviderDialog } from '../ChangeProviderDialog'; import { useConfigureSSO } from '../ConfigureSSOContext'; +import { isOidcProvider } from '../domain/organizationEnterpriseConnection'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; -import type { ProviderType } from '../types'; +import type { EnterpriseConnectionProviderType, ProviderType } from '../types'; const MONOCHROMATIC_PROVIDER_ICONS: ReadonlySet = new Set(['okta']); const PROVIDER_GROUPS: ReadonlyArray<{ @@ -69,6 +70,14 @@ const PROVIDER_GROUPS: ReadonlyArray<{ const providerLabel = (provider: ProviderType): LocalizationKey | undefined => PROVIDER_GROUPS.flatMap(group => group.options).find(option => option.id === provider)?.label; +/** + * The picker works in input aliases; an existing connection reports back its real + * provider (OIDC as an open `oidc_` family), so collapse it onto the card + * that represents it — every OIDC family maps to the single `oidc_custom` card. + */ +const toProviderCard = (provider: EnterpriseConnectionProviderType): ProviderType => + isOidcProvider(provider) ? 'oidc_custom' : provider; + export const SelectProviderStep = (): JSX.Element => { const { organizationEnterpriseConnection: c, @@ -85,7 +94,9 @@ export const SelectProviderStep = (): JSX.Element => { [isOIDCFlowEnabled], ); - const [selected, setSelected] = React.useState(c.provider ?? null); + const currentCard = c.provider ? toProviderCard(c.provider) : null; + + const [selected, setSelected] = React.useState(currentCard); const card = useCardState(); const [isSubmitting, setIsSubmitting] = React.useState(false); @@ -96,20 +107,20 @@ export const SelectProviderStep = (): JSX.Element => { setSelected(next); }; - const isChangingProvider = c.hasConnection && selected !== null && selected !== c.provider; + const isChangingProvider = c.hasConnection && selected !== null && selected !== currentCard; const handleContinue = async (): Promise => { if (!selected) { return; } - if (c.hasConnection && selected === c.provider) { + if (c.hasConnection && selected === currentCard) { void goNext(); return; } if (isChangingProvider) { - setChangeFromProvider(c.provider ?? null); + setChangeFromProvider(currentCard); setIsChangeDialogOpen(true); return; } diff --git a/packages/ui/src/components/ConfigureSSO/types.ts b/packages/ui/src/components/ConfigureSSO/types.ts index 06112ae2528..fac467ce444 100644 --- a/packages/ui/src/components/ConfigureSSO/types.ts +++ b/packages/ui/src/components/ConfigureSSO/types.ts @@ -1,4 +1,25 @@ -export type ProviderType = 'saml_okta' | 'saml_custom' | 'saml_google' | 'saml_microsoft' | 'oidc_custom'; +/** SAML providers are exact backend literals — the read-back key matches the input alias. */ +export type SamlProviderType = 'saml_okta' | 'saml_custom' | 'saml_google' | 'saml_microsoft'; + +/** + * The OIDC provider family a created connection reports back. The backend derives + * the key from the connection name (`oidc_`, plus `oidc_ghe_` and + * `oidc_gitlab_ent_`), so it is an open string, never the `oidc_custom` + * input alias the picker sends. + */ +export type OidcProviderType = `oidc_${string}`; + +/** + * The provider the SELECT step SENDS on create. OIDC always creates through the + * single `oidc_custom` input alias; the backend then derives the real key. + */ +export type ProviderType = SamlProviderType | 'oidc_custom'; + +/** + * The provider a created connection READS BACK. SAML narrows to the exact literals; + * OIDC is the open, backend-derived family — dispatch on this by protocol, not literal. + */ +export type EnterpriseConnectionProviderType = SamlProviderType | OidcProviderType; export type WizardStepId = | 'verify-domain'