diff --git a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts index 109e7775ce1..f50213ded64 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts @@ -59,7 +59,11 @@ function oauthConfigFromJSON(data: EnterpriseOAuthConfigJSON): EnterpriseOAuthCo name: data.name, clientId: data.client_id, providerKey: data.provider_key, + redirectUri: data.redirect_uri, discoveryUrl: data.discovery_url, + authUrl: data.auth_url, + tokenUrl: data.token_url, + userInfoUrl: data.user_info_url, logoPublicUrl: data.logo_public_url, requiresPkce: data.requires_pkce, createdAt: unixEpochToDate(data.created_at), @@ -73,7 +77,11 @@ function oauthConfigToJSON(data: EnterpriseOAuthConfigResource): EnterpriseOAuth name: data.name, client_id: data.clientId, provider_key: data.providerKey, + redirect_uri: data.redirectUri, discovery_url: data.discoveryUrl, + auth_url: data.authUrl, + token_url: data.tokenUrl, + user_info_url: data.userInfoUrl, logo_public_url: data.logoPublicUrl, requires_pkce: data.requiresPkce, created_at: data.createdAt?.getTime() ?? 0, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 183b6572af9..a8002b86972 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -240,18 +240,70 @@ export const enUS: LocalizationResource = { }, }, oidcCustom: { - claimsStep: { - headerSubtitle: 'Set the claims your identity provider includes in the ID token', - }, credentialsStep: { headerSubtitle: 'Add your application credentials', + clientId: { + label: 'Client ID', + placeholder: 'Paste client ID here...', + }, + clientSecret: { + label: 'Client secret', + placeholder: 'Paste client secret here...', + }, + paragraph: 'In your identity provider’s OIDC application, retrieve these values.', }, 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: { 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:', + claims: { + description: 'Ensure your ID token includes the following claims:', + table: { + columns: { + attribute: 'Clerk attribute', + claim: 'ID token claim', + }, + rows: { + email: { attribute: 'Primary email' }, + firstName: { attribute: 'First name' }, + lastName: { attribute: 'Last name' }, + subject: { attribute: 'External user ID' }, + }, + }, + }, + redirectUri: { + label: 'Authorized redirect URI', + }, }, }, samlCustom: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index b685d908db7..32b62ccb70e 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,16 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'discoveryUrl' + | 'authUrl' + | 'tokenUrl' + | 'userInfoUrl' + | 'clientId' + | 'clientSecret' + | 'redirectUri' + | 'oidcClientId' + | 'oidcClientSecret' + | 'oidcRedirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index f3e17fc9367..ed5c59eaa4d 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -100,7 +100,11 @@ export interface EnterpriseOAuthConfigJSON { name: string; provider_key?: string; client_id: string; + redirect_uri?: string; discovery_url?: string; + auth_url?: string; + token_url?: string; + user_info_url?: string; logo_public_url?: string | null; requires_pkce?: boolean; created_at: number; @@ -112,7 +116,11 @@ export interface EnterpriseOAuthConfigResource { name: string; clientId: string; providerKey?: string; + redirectUri?: string; discoveryUrl?: string; + authUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; logoPublicUrl?: string | null; requiresPkce?: boolean; createdAt: Date | null; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index bd986c8da97..ca7cb69b383 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1529,15 +1529,65 @@ export type __internal_LocalizationResource = { mainHeaderTitle: LocalizationValue; redirectUriStep: { headerSubtitle: LocalizationValue; - }; - claimsStep: { - headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; + claims: { + description: LocalizationValue; + table: { + columns: { + attribute: LocalizationValue; + claim: LocalizationValue; + }; + rows: { + email: { attribute: LocalizationValue }; + firstName: { attribute: LocalizationValue }; + lastName: { attribute: LocalizationValue }; + subject: { attribute: LocalizationValue }; + }; + }; + }; + redirectUri: { + label: LocalizationValue; + }; }; 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; + clientId: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + clientSecret: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + paragraph: LocalizationValue; }; }; unsupportedProvider: { 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 5e9f58b678f..3051c64fdc7 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -12,7 +12,6 @@ import { areAllOrganizationDomainsVerified, connectionBackingEmail, isEnterpriseConnectionConfigured, - isOidcProvider, organizationEnterpriseConnection, } from '../organizationEnterpriseConnection'; @@ -62,7 +61,7 @@ const fullyConfiguredSaml = makeSamlConnection({ const configuredOidc = makeOauthConfig({ clientId: 'client_abc' }); const makeOidcConnection = (overrides: Partial = {}): EnterpriseConnectionResource => - makeConnection({ provider: 'oidc_custom', samlConnection: null, oauthConfig: configuredOidc, ...overrides }); + makeConnection({ provider: 'oauth_custom_acme', samlConnection: null, oauthConfig: configuredOidc, ...overrides }); // Builds the entity with sensible defaults; each test overrides what it cares // about. @@ -75,18 +74,6 @@ const derive = (overrides: Partial { - describe('isOidcProvider', () => { - it('recognizes OIDC provider keys with the oidc_ prefix', () => { - expect(isOidcProvider('oidc_custom')).toBe(true); - expect(isOidcProvider('oidc_clerk_dev')).toBe(true); - }); - - it('rejects malformed OIDC provider keys', () => { - expect(isOidcProvider('oidc')).toBe(false); - expect(isOidcProvider('oidcfoo')).toBe(false); - }); - }); - describe('hasConnection', () => { it('undefined connection → false', () => { expect(derive({ connection: undefined }).hasConnection).toBe(false); @@ -106,8 +93,10 @@ describe('organizationEnterpriseConnection', () => { it('connection → its provider', () => { expect(derive({ connection: makeConnection({ provider: 'saml_custom' }) }).provider).toBe('saml_custom'); }); - it('carries a derived OIDC key verbatim', () => { - expect(derive({ connection: makeConnection({ provider: 'oidc_clerk_dev' }) }).provider).toBe('oidc_clerk_dev'); + it('carries a custom OIDC key verbatim', () => { + expect(derive({ connection: makeConnection({ provider: 'oauth_custom_clerk_dev' }) }).provider).toBe( + 'oauth_custom_clerk_dev', + ); }); }); diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index 743e38bf60f..eb6937f63ed 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -7,7 +7,8 @@ import type { import type { EnterpriseConnectionProviderType, OidcProviderType } from '../types'; -export const isOidcProvider = (provider: string): provider is OidcProviderType => provider.startsWith('oidc_'); +export const isOidcProvider = (provider: string): provider is OidcProviderType => + provider.startsWith('oidc_') || provider.startsWith('oauth_custom_'); /** * The email whose domain backs the connection: the user's primary address if @@ -56,7 +57,6 @@ export const isEnterpriseConnectionConfigured = ( if (!connection) { return false; } - // OIDC exposes only the client ID on the resource; the secret and manual endpoints are write-only. if (isOidcProvider(connection.provider)) { return Boolean(connection.oauthConfig?.clientId); } 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 88b1ffb7c5a..c0d83f54193 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 @@ -1,7 +1,45 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, 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'; -import { resolveConfigureSteps } from '../index'; + +// 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, + enterpriseConnection: undefined as + | { + id: string; + oauthConfig: { + redirectUri?: string; + discoveryUrl?: string; + authUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + } | null; + } + | undefined, +})); +const updateConnection = vi.hoisted(() => vi.fn()); + +vi.mock('../../../ConfigureSSOContext', () => ({ + useConfigureSSO: () => ({ + enterpriseConnection: contextState.enterpriseConnection, + contentRef: { current: null }, + enterpriseConnectionMutations: { updateConnection }, + organizationEnterpriseConnection: { + provider: contextState.provider, + hasConnection: true, + }, + }), +})); + +import { ConfigureProviderStep, resolveConfigureSteps } from '../index'; import { OidcCustomConfigureSteps } from '../oidc'; import { SamlCustomConfigureSteps, @@ -10,8 +48,11 @@ import { SamlOktaConfigureSteps, } from '../saml'; +const { createFixtures } = bindCreateFixtures('ConfigureSSO'); + describe('resolveConfigureSteps', () => { - it('dispatches OIDC provider keys to the OIDC sub-flow', () => { + it('dispatches custom and legacy OIDC provider keys to the OIDC sub-flow', () => { + expect(resolveConfigureSteps('oauth_custom_clerk_dev')).toBe(OidcCustomConfigureSteps); expect(resolveConfigureSteps('oidc_clerk_dev')).toBe(OidcCustomConfigureSteps); expect(resolveConfigureSteps('oidc_ghe_acme')).toBe(OidcCustomConfigureSteps); expect(resolveConfigureSteps('oidc_gitlab_ent_acme')).toBe(OidcCustomConfigureSteps); @@ -29,3 +70,258 @@ describe('resolveConfigureSteps', () => { expect(resolveConfigureSteps('ldap_enterprise' as EnterpriseConnectionProviderType)).toBeUndefined(); }); }); + +describe('ConfigureProviderStep', () => { + beforeEach(() => { + contextState.provider = undefined; + contextState.enterpriseConnection = undefined; + updateConnection.mockReset(); + }); + + const renderStep = (wrapper: React.ComponentType<{ children?: React.ReactNode }>) => + render( + + + + + , + { wrapper }, + ); + + it('renders the OIDC configure steps for a custom OAuth provider key without throwing', async () => { + contextState.provider = 'oauth_custom_clerk_dev'; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { + redirectUri: 'https://instance.example/v1/oauth_callback', + }, + }; + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + expect(await screen.findAllByText(/create a new oidc application/i)).not.toHaveLength(0); + const redirectUri = screen.getByRole('textbox', { name: 'Authorized redirect URI' }); + expect(redirectUri).toHaveAttribute('readonly'); + expect(redirectUri).toHaveValue('https://instance.example/v1/oauth_callback'); + expect(screen.getByText('Ensure your ID token includes the following claims:')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(5); + expect(screen.getByText('sub')).toBeInTheDocument(); + expect(screen.getByText('email')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'ID token claim' })).toBeInTheDocument(); + expect(screen.getByText('given_name')).toBeInTheDocument(); + expect(screen.getByText('family_name')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + const discoveryMode = await screen.findByRole('radio', { name: 'Add via discovery endpoint' }); + const manualMode = screen.getByRole('radio', { name: 'Configure manually' }); + 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('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.type( + screen.getByRole('textbox', { name: 'Discovery endpoint' }), + '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' })); + const manualMode = await screen.findByRole('radio', { name: 'Configure manually' }); + await userEvent.click(manualMode); + + const authUrl = screen.getByRole('textbox', { name: 'Authorization URL' }); + const tokenUrl = screen.getByRole('textbox', { name: 'Token URL' }); + const userInfoUrl = screen.getByRole('textbox', { name: 'User Info URL' }); + 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('saves manual endpoints without a user info URL', 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' })); + const manualMode = await screen.findByRole('radio', { name: 'Configure manually' }); + await userEvent.click(manualMode); + + const authUrl = screen.getByRole('textbox', { name: 'Authorization URL' }); + const tokenUrl = screen.getByRole('textbox', { name: 'Token URL' }); + await userEvent.type(authUrl, 'https://idp.example/authorize'); + await userEvent.type(tokenUrl, 'https://idp.example/token'); + 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: '', + }, + }); + }); + }); + + it('saves credentials before advancing', 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.type( + screen.getByRole('textbox', { name: 'Discovery endpoint' }), + 'https://idp.example/.well-known/openid-configuration', + ); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + const clientId = await screen.findByRole('textbox', { name: 'Client ID' }); + const clientSecret = screen.getByLabelText('Client secret'); + await userEvent.type(clientId, 'client_123'); + await userEvent.type(clientSecret, 'secret_456'); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenLastCalledWith('ent_123', { + oidc: { clientId: 'client_123', clientSecret: 'secret_456' }, + }); + }); + }); + + it('selects manual mode when an existing connection has manual endpoints without a discovery URL', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { + authUrl: 'https://idp.example/authorize', + tokenUrl: 'https://idp.example/token', + userInfoUrl: 'https://idp.example/userinfo', + }, + }; + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + + const discoveryMode = await screen.findByRole('radio', { name: 'Add via discovery endpoint' }); + const manualMode = screen.getByRole('radio', { name: 'Configure manually' }); + expect(discoveryMode).not.toBeChecked(); + expect(manualMode).toBeChecked(); + expect(screen.getByRole('textbox', { name: 'Authorization URL' })).toHaveValue('https://idp.example/authorize'); + }); + + it('populates manual endpoints resolved from discovery', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { + discoveryUrl: 'https://idp.example/.well-known/openid-configuration', + authUrl: 'https://idp.example/authorize', + tokenUrl: 'https://idp.example/token', + userInfoUrl: 'https://idp.example/userinfo', + }, + }; + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + const manualMode = await screen.findByRole('radio', { name: 'Configure manually' }); + await userEvent.click(manualMode); + + expect(screen.getByRole('textbox', { name: 'Authorization URL' })).toHaveValue('https://idp.example/authorize'); + expect(screen.getByRole('textbox', { name: 'Token URL' })).toHaveValue('https://idp.example/token'); + expect(screen.getByRole('textbox', { name: 'User Info URL' })).toHaveValue('https://idp.example/userinfo'); + }); + + it('retains manual mode when returning from credentials with a stale discovery URL', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { + discoveryUrl: 'https://idp.example/.well-known/openid-configuration', + authUrl: 'https://idp.example/authorize', + tokenUrl: 'https://idp.example/token', + userInfoUrl: 'https://idp.example/userinfo', + }, + }; + updateConnection.mockResolvedValue({}); + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + const manualMode = await screen.findByRole('radio', { name: 'Configure manually' }); + await userEvent.click(manualMode); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await screen.findByRole('textbox', { name: 'Client ID' }); + await userEvent.click(screen.getByRole('button', { name: 'Previous' })); + + expect(await screen.findByRole('radio', { name: 'Configure manually' })).toBeChecked(); + expect(screen.getByRole('textbox', { name: 'Authorization URL' })).toHaveValue('https://idp.example/authorize'); + }); + + 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 1b965f20e19..c687f4c7d3b 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx @@ -62,10 +62,9 @@ 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. if (!c.provider) { return null; } 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 ce7866c38c0..b7c27de5168 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,17 +1,21 @@ -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' }, -]; +import { type JSX, useState } from 'react'; + +import { useConfigureSSO } from '../../../ConfigureSSOContext'; +import { Wizard, type WizardStepConfig } from '../../../elements/Wizard'; +import type { OidcIdpConfigurationMode } from '../shared/IdentityProviderConfigurationModes'; +import { OidcCredentialsStep } from './shared/OidcCredentialsStep'; +import { OidcEndpointsStep } from './shared/OidcEndpointsStep'; +import { OidcRedirectUriStep } from './shared/OidcRedirectUriStep'; + +const OIDC_STEPS: WizardStepConfig[] = [{ id: 'redirect-uri' }, { id: 'endpoints' }, { id: 'credentials' }]; + +export const OidcCustomConfigureSteps = (): JSX.Element => { + const { enterpriseConnection } = useConfigureSSO(); + const oauthConfig = enterpriseConnection?.oauthConfig; + const [endpointMode, setEndpointMode] = useState( + oauthConfig?.authUrl && !oauthConfig.discoveryUrl ? 'manual' : 'discoveryUrl', + ); -export const OidcCustomConfigureSteps = () => { return ( { - - - - - + @@ -35,119 +38,3 @@ export const OidcCustomConfigureSteps = () => { ); }; - -const OidcRedirectUriStep = () => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - - return ( - <> - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -const OidcClaimsStep = () => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - - return ( - <> - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -const OidcEndpointsStep = () => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - - return ( - <> - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -const OidcCredentialsStep = () => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - - return ( - <> - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx new file mode 100644 index 00000000000..ed736081cfe --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx @@ -0,0 +1,106 @@ +import React, { type JSX } from 'react'; + +import { localizationKeys, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; + +export const OidcCredentialsStep = (): JSX.Element => { + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const clientIdField = useFormControl('oidcClientId', enterpriseConnection?.oauthConfig?.clientId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.placeholder'), + isRequired: true, + }); + const clientSecretField = useFormControl('oidcClientSecret', '', { + type: 'password', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.placeholder'), + isRequired: true, + }); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const canSubmit = clientIdField.value.trim().length > 0 && clientSecretField.value.trim().length > 0 && !isSubmitting; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + setIsSubmitting(true); + + try { + await updateConnection(enterpriseConnection.id, { + oidc: { + clientId: clientIdField.value.trim(), + clientSecret: clientSecretField.value.trim(), + }, + }); + void goNext(); + } catch (err) { + handleError(err as Error, [clientIdField, clientSecretField], card.setError); + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep || isSubmitting} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx new file mode 100644 index 00000000000..ff6016fcead --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx @@ -0,0 +1,66 @@ +import type { FieldId } from '@clerk/shared/types'; +import { type JSX } from 'react'; + +import { type LocalizationKey, Text } from '@/customizables'; +import { Form } from '@/elements/Form'; +import type { FormControlState } from '@/ui/utils/useFormControl'; + +type FormControl = FormControlState; + +type DiscoveryUrlForm = { + discoveryUrlField: FormControl; +}; + +type DiscoveryUrlLabels = { + description: LocalizationKey; +}; + +type ManualEndpointsForm = { + authUrlField: FormControl; + tokenUrlField: FormControl; + userInfoUrlField: FormControl; +}; + +type ManualEndpointsLabels = { + description: LocalizationKey; +}; + +export type OidcEndpointsConfigurationFormProps = + | { mode: 'discoveryUrl'; form: DiscoveryUrlForm; labels: DiscoveryUrlLabels } + | { mode: 'manual'; form: ManualEndpointsForm; labels: ManualEndpointsLabels }; + +export const OidcEndpointsConfigurationForm = (props: OidcEndpointsConfigurationFormProps): JSX.Element => { + if (props.mode === 'discoveryUrl') { + return ( + <> + + + + + + ); + } + + return ( + <> + + + + + + + + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx new file mode 100644 index 00000000000..cfc98ba99e1 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx @@ -0,0 +1,165 @@ +import React, { type JSX } from 'react'; + +import { localizationKeys } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; +import { + IdentityProviderConfigurationModes, + type OidcIdpConfigurationMode, +} from '../../shared/IdentityProviderConfigurationModes'; +import { + OidcEndpointsConfigurationForm, + type OidcEndpointsConfigurationFormProps, +} from './OidcEndpointsConfigurationForm'; + +const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly OidcIdpConfigurationMode[]; + +interface OidcEndpointsStepProps { + mode: OidcIdpConfigurationMode; + onModeChange: (mode: OidcIdpConfigurationMode) => void; +} + +export const OidcEndpointsStep = ({ mode, onModeChange }: OidcEndpointsStepProps): JSX.Element => { + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const oauthConfig = enterpriseConnection?.oauthConfig; + 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', oauthConfig?.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', oauthConfig?.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', oauthConfig?.userInfoUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), + }); + + const isValid = + mode === 'discoveryUrl' + ? discoveryUrlField.value.trim().length > 0 + : authUrlField.value.trim().length > 0 && tokenUrlField.value.trim().length > 0; + + const canSubmit = isValid && !isSubmitting; + + const formProps: OidcEndpointsConfigurationFormProps = + mode === 'discoveryUrl' + ? { + mode: 'discoveryUrl', + form: { discoveryUrlField }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.description', + ), + }, + } + : { + mode: 'manual', + form: { authUrlField, tokenUrlField, userInfoUrlField }, + labels: { + description: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.description'), + }, + }; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + 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 ( + <> + + + + + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep || isSubmitting} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx new file mode 100644 index 00000000000..36abd73b105 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx @@ -0,0 +1,177 @@ +import { type JSX } from 'react'; + +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'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; + +type OidcClaimRow = { + id: 'subject' | 'email' | 'firstName' | 'lastName'; + claim: 'sub' | 'email' | 'given_name' | 'family_name'; + isRequired: boolean; +}; + +const OIDC_CLAIM_ROWS: ReadonlyArray = [ + { id: 'subject', claim: 'sub', isRequired: true }, + { id: 'email', claim: 'email', isRequired: true }, + { id: 'firstName', claim: 'given_name', isRequired: false }, + { id: 'lastName', claim: 'family_name', 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.redirectUriStep.claims.table.columns.claim', + )} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.redirectUriStep.claims.table.columns.attribute', + )} + /> +
+ ({ gap: theme.space.$2 })} + > + + {row.claim} + + + + + +
+); + +export const OidcRedirectUriStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; + const redirectUriField = useFormControl('oidcRedirectUri', redirectUri, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), + isRequired: false, + }); + + return ( + <> + + + + + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + ({ gap: theme.space.$3 })}> + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx index 315ef2b0718..ded9cbeafa4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -24,16 +24,16 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { + IdentityProviderConfigurationModes, + type SamlIdpConfigurationMode, +} from '../shared/IdentityProviderConfigurationModes'; import { applySamlSubmitError, buildSamlConfigurationPayload, IdentityProviderConfigurationForm, type IdentityProviderConfigurationFormProps, } from './shared/IdentityProviderConfigurationForm'; -import { - IdentityProviderConfigurationModes, - type IdpConfigurationMode, -} from './shared/IdentityProviderConfigurationModes'; const CUSTOM_STEPS: WizardStepConfig[] = [ { id: 'create-app' }, @@ -308,7 +308,7 @@ const SamlCustomAssignUsersStep = (): JSX.Element => { ); }; -const CUSTOM_SAML_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; +const CUSTOM_SAML_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly SamlIdpConfigurationMode[]; const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => { const card = useCardState(); @@ -327,7 +327,7 @@ const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => { ); const existingCertPresent = Boolean(samlConnection?.idpCertificate); - const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); + const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); const [certFile, setCertFile] = React.useState(null); // Step-LOCAL submit state for the Continue button. `goNext` bubbles to the // parent (this is the terminal nested step) and the parent DEFERS the diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx index 1431d6f3325..dff36e9feaf 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -12,16 +12,16 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { + IdentityProviderConfigurationModes, + type SamlIdpConfigurationMode, +} from '../shared/IdentityProviderConfigurationModes'; import { applySamlSubmitError, buildSamlConfigurationPayload, IdentityProviderConfigurationForm, type IdentityProviderConfigurationFormProps, } from './shared/IdentityProviderConfigurationForm'; -import { - IdentityProviderConfigurationModes, - type IdpConfigurationMode, -} from './shared/IdentityProviderConfigurationModes'; const GOOGLE_STEPS: WizardStepConfig[] = [ { id: 'create-app' }, @@ -143,7 +143,7 @@ const SamlGoogleCreateAppStep = (): JSX.Element => { ); }; -const GOOGLE_IDP_MODES = ['metadataFile', 'manual'] as const satisfies readonly IdpConfigurationMode[]; +const GOOGLE_IDP_MODES = ['metadataFile', 'manual'] as const satisfies readonly SamlIdpConfigurationMode[]; const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { const card = useCardState(); @@ -160,7 +160,7 @@ const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { const existingCertPresent = Boolean(samlConnection?.idpCertificate); const existingMetadataPresent = Boolean(samlConnection?.idpMetadata); - const [mode, setMode] = React.useState(hasExistingManualConfig ? 'manual' : 'metadataFile'); + const [mode, setMode] = React.useState(hasExistingManualConfig ? 'manual' : 'metadataFile'); const [metadataFile, setMetadataFile] = React.useState(null); const [certFile, setCertFile] = React.useState(null); // Step-LOCAL submit state for the Continue button. `goNext` bubbles to the diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlMicrosoftConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlMicrosoftConfigureSteps.tsx index b9b27e58ce1..c7c937028dd 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlMicrosoftConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlMicrosoftConfigureSteps.tsx @@ -33,16 +33,16 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { + IdentityProviderConfigurationModes, + type SamlIdpConfigurationMode, +} from '../shared/IdentityProviderConfigurationModes'; import { applySamlSubmitError, buildSamlConfigurationPayload, IdentityProviderConfigurationForm, type IdentityProviderConfigurationFormProps, } from './shared/IdentityProviderConfigurationForm'; -import { - IdentityProviderConfigurationModes, - type IdpConfigurationMode, -} from './shared/IdentityProviderConfigurationModes'; const MICROSOFT_STEPS: WizardStepConfig[] = [ { id: 'create-app' }, @@ -636,7 +636,7 @@ const SamlMicrosoftAttributeMappingStep = (): JSX.Element => { ); }; -const MICROSOFT_SAML_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; +const MICROSOFT_SAML_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly SamlIdpConfigurationMode[]; const SamlMicrosoftIdentityProviderMetadataStep = (): JSX.Element => { const card = useCardState(); @@ -655,7 +655,7 @@ const SamlMicrosoftIdentityProviderMetadataStep = (): JSX.Element => { ); const existingCertPresent = Boolean(samlConnection?.idpCertificate); - const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); + const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); const [certFile, setCertFile] = React.useState(null); const [isSubmitting, setIsSubmitting] = React.useState(false); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx index 812c357e1b1..ac4e0739ee4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -25,16 +25,16 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { + IdentityProviderConfigurationModes, + type SamlIdpConfigurationMode, +} from '../shared/IdentityProviderConfigurationModes'; import { applySamlSubmitError, buildSamlConfigurationPayload, IdentityProviderConfigurationForm, type IdentityProviderConfigurationFormProps, } from './shared/IdentityProviderConfigurationForm'; -import { - IdentityProviderConfigurationModes, - type IdpConfigurationMode, -} from './shared/IdentityProviderConfigurationModes'; const OKTA_STEPS: WizardStepConfig[] = [ { id: 'create-app' }, @@ -494,7 +494,7 @@ const SamlOktaAssignUsersStep = (): JSX.Element => { ); }; -const OKTA_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; +const OKTA_IDP_MODES = ['metadataUrl', 'manual'] as const satisfies readonly SamlIdpConfigurationMode[]; const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => { const card = useCardState(); @@ -513,7 +513,7 @@ const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => { ); const existingCertPresent = Boolean(samlConnection?.idpCertificate); - const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); + const [mode, setMode] = React.useState(hasExistingConfig ? 'manual' : 'metadataUrl'); const [certFile, setCertFile] = React.useState(null); // Step-LOCAL submit state for the Continue button. `goNext` bubbles to the // parent (this is the terminal nested step) and the parent DEFERS the diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx index ed8ac096250..14fad7e2586 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx @@ -21,8 +21,8 @@ import { ArrowUpTray, Close } from '@/icons'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; -import { ActiveConnectionAlert } from './ActiveConnectionAlert'; -import type { IdpConfigurationMode } from './IdentityProviderConfigurationModes'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; +import type { SamlIdpConfigurationMode } from '../../shared/IdentityProviderConfigurationModes'; type CardState = ReturnType; type FormControl = FormControlState; @@ -180,7 +180,7 @@ const ManualPanel = ({ form, labels }: ManualPanelProps): JSX.Element => ( ); type BuildSamlPayloadParams = { - mode: IdpConfigurationMode; + mode: SamlIdpConfigurationMode; metadataUrl?: { value: string }; metadataFile?: { file: File | null }; manual?: { 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 deleted file mode 100644 index 1b75ea36aee..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { type JSX } from 'react'; - -import { type LocalizationKey, useLocalizations } from '@/customizables'; -import { SegmentedControl } from '@/elements/SegmentedControl'; - -/** - * The possible modes for the identity provider configuration - * - * metadataUrl: Fetch IdP configuration via metadata URL - * metadataFile: Upload IdP configuration via metadata file - * manual: Configure manually each field, such as sign on URL, issuer, and signing certificate - */ -export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'manual'; - -type ModeLocalizationKeys = Partial>; - -type IdentityProviderConfigurationModesProps = { - modes: readonly IdpConfigurationMode[]; - value: IdpConfigurationMode; - onChange: (mode: IdpConfigurationMode) => void; - labels: { - ariaLabel: LocalizationKey; - } & ModeLocalizationKeys; -}; - -export const IdentityProviderConfigurationModes = ({ - modes, - value, - onChange, - labels, -}: IdentityProviderConfigurationModesProps): JSX.Element => { - const { t } = useLocalizations(); - - return ( - onChange(next as IdpConfigurationMode)} - fullWidth - size='lg' - > - {modes.map(mode => { - const label = labels[mode]; - if (!label) { - return null; - } - - return ( - - ); - })} - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/ActiveConnectionAlert.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/ActiveConnectionAlert.tsx similarity index 91% rename from packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/ActiveConnectionAlert.tsx rename to packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/ActiveConnectionAlert.tsx index 36fd518e72b..281cc069a5b 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/ActiveConnectionAlert.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/ActiveConnectionAlert.tsx @@ -3,7 +3,7 @@ import React, { type JSX } from 'react'; import { localizationKeys } from '@/customizables'; import { Alert } from '@/elements/Alert'; -import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { useConfigureSSO } from '../../../ConfigureSSOContext'; export const ActiveConnectionAlert = (): JSX.Element | null => { const { enterpriseConnection } = useConfigureSSO(); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/IdentityProviderConfigurationModes.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/IdentityProviderConfigurationModes.tsx new file mode 100644 index 00000000000..f472cb0bf7d --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/IdentityProviderConfigurationModes.tsx @@ -0,0 +1,50 @@ +import { type JSX } from 'react'; + +import { type LocalizationKey, useLocalizations } from '@/customizables'; +import { SegmentedControl } from '@/elements/SegmentedControl'; + +export type SamlIdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'manual'; +export type OidcIdpConfigurationMode = 'discoveryUrl' | 'manual'; + +type IdentityProviderConfigurationModesProps = { + modes: readonly Mode[]; + value: Mode; + onChange: (mode: Mode) => void; + labels: { + ariaLabel: LocalizationKey; + } & Partial>; +}; + +export const IdentityProviderConfigurationModes = ({ + modes, + value, + onChange, + labels, +}: IdentityProviderConfigurationModesProps): JSX.Element => { + const { t } = useLocalizations(); + + return ( + onChange(next as Mode)} + fullWidth + size='lg' + > + {modes.map(mode => { + const label = labels[mode]; + if (!label) { + return null; + } + + return ( + + ); + })} + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/types.ts b/packages/ui/src/components/ConfigureSSO/types.ts index 6f502b168d9..57056616d18 100644 --- a/packages/ui/src/components/ConfigureSSO/types.ts +++ b/packages/ui/src/components/ConfigureSSO/types.ts @@ -1,6 +1,6 @@ export type SamlProviderType = 'saml_okta' | 'saml_custom' | 'saml_google' | 'saml_microsoft'; -export type OidcProviderType = `oidc_${string}`; +export type OidcProviderType = `oidc_${string}` | `oauth_custom_${string}`; export type ProviderType = SamlProviderType | 'oidc_custom';