diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md new file mode 100644 index 00000000000..9924a1e2fb4 --- /dev/null +++ b/.changeset/blue-owls-remember.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': patch +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including copyable authorized redirect URI, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose their callback, authorization, token, and user-info endpoints. 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 e7419100697..43816f07b6f 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -241,17 +241,88 @@ 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', + 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.', + scopes: { + addButton: 'Add', + label: 'Scopes', + optional: 'Optional', + placeholder: 'Paste scope here...', + }, }, 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:', + redirectUri: { + label: 'Authorized redirect URI', + }, }, }, samlCustom: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index b685d908db7..41ad3141a13 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,14 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'discoveryUrl' + | 'authUrl' + | 'tokenUrl' + | 'userInfoUrl' + | 'clientId' + | 'clientSecret' + | 'scopes' + | 'redirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index 35a3f8a336f..7944f483159 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 88df4c9b294..1a5ee6a6125 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1529,15 +1529,84 @@ export type __internal_LocalizationResource = { mainHeaderTitle: LocalizationValue; redirectUriStep: { headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; + redirectUri: { + label: LocalizationValue; + }; }; 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; + 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; + scopes: { + addButton: LocalizationValue; + label: LocalizationValue; + optional: LocalizationValue; + placeholder: LocalizationValue; + }; }; }; samlOkta: { 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..50c35d77166 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,28 @@ 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: { + redirectUri?: string; + discoveryUrl?: string; + authUrl?: string; + tokenUrl?: string; + userInfoUrl?: 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,13 +85,157 @@ describe('ConfigureProviderStep', () => { it('renders the OIDC configure steps for a derived provider key without throwing', async () => { contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { + redirectUri: 'https://instance.example/v1/oauth_callback', + }, + }; 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. - expect(await screen.findByText(/create a new oidc application/i)).toBeInTheDocument(); + expect(await screen.findAllByText(/create a new oidc application/i)).not.toHaveLength(0); + const [redirectUri] = screen.getAllByRole('textbox') as HTMLInputElement[]; + expect(redirectUri).toHaveAttribute('readonly'); + expect(redirectUri).toHaveValue('https://instance.example/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(); + + 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('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('saves credentials and lets the user manage optional OIDC scopes', 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(document.querySelector('input[name="clientId"]')).not.toBeNull(); + }); + + const clientId = document.querySelector('input[name="clientId"]') as HTMLInputElement; + const clientSecret = document.querySelector('input[name="clientSecret"]') as HTMLInputElement; + await userEvent.type(clientId, 'client_123'); + await userEvent.type(clientSecret, 'secret_456'); + + expect(screen.getByText('openid')).toBeInTheDocument(); + expect(screen.getByText('profile')).toBeInTheDocument(); + expect(screen.getByText('Optional')).toBeInTheDocument(); + + const scopeField = document.querySelector('input[name="scopes"]') as HTMLInputElement; + await userEvent.type(scopeField, 'email'); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(screen.getByText('email')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenLastCalledWith('ent_123', { + oidc: { clientId: 'client_123', clientSecret: 'secret_456' }, + }); + }); + }); + + 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' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + const [, manualMode] = await screen.findAllByRole('radio'); + await userEvent.click(manualMode); + + expect(document.querySelector('input[name="authUrl"]')).toHaveValue('https://idp.example/authorize'); + expect(document.querySelector('input[name="tokenUrl"]')).toHaveValue('https://idp.example/token'); + expect(document.querySelector('input[name="userInfoUrl"]')).toHaveValue('https://idp.example/userinfo'); }); 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..140b3666af9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,10 +1,10 @@ -import { type JSX } from 'react'; +import React, { 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'; +import { Wizard, type WizardStepConfig } from '../../../elements/Wizard'; +import { OidcClaimsStep } from './shared/OidcClaimsStep'; +import { OidcCredentialsStep } from './shared/OidcCredentialsStep'; +import { OidcEndpointsStep } from './shared/OidcEndpointsStep'; +import { OidcRedirectUriStep } from './shared/OidcRedirectUriStep'; const OIDC_STEPS: WizardStepConfig[] = [ { id: 'redirect-uri' }, @@ -15,7 +15,6 @@ const OIDC_STEPS: WizardStepConfig[] = [ 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/shared/IdentityProviderConfigurationModes.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/IdentityProviderConfigurationModes.tsx new file mode 100644 index 00000000000..6793bd8c7ec --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/IdentityProviderConfigurationModes.tsx @@ -0,0 +1,58 @@ +import { type JSX } from 'react'; + +import { type LocalizationKey, useLocalizations } from '@/customizables'; +import { SegmentedControl } from '@/elements/SegmentedControl'; + +/** + * The possible modes for the OIDC 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 = 'discoveryUrl' | '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/oidc/shared/OidcClaimsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcClaimsStep.tsx new file mode 100644 index 00000000000..8c4133efeb8 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcClaimsStep.tsx @@ -0,0 +1,130 @@ +import React, { type JSX } from 'react'; + +import { Badge, descriptors, Flex, localizationKeys, Table, Tbody, Td, Text, Th, Thead, Tr } from '@/customizables'; + +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; + +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 }, + })} + > + + + + ({ 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', + )} + /> + + + + + {OIDC_CLAIM_ROWS.map(row => ( + + + ({ gap: theme.space.$2 })} + > + + + + + + + + + ))} + + +); + +export const OidcClaimsStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + + + + ({ gap: theme.space.$3 })}> + + + + + + + + + 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..37fabe83ca8 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx @@ -0,0 +1,202 @@ +import React, { type JSX } from 'react'; + +import { Badge, Box, Button, Col, Flex, Icon, localizationKeys, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { Field } from '@/elements/FieldControl'; +import { Form } from '@/elements/Form'; +import { Close } from '@/icons'; +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('clientId', 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('clientSecret', '', { + type: 'password', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.placeholder'), + isRequired: true, + }); + const scopeField = useFormControl('scopes', '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.placeholder'), + isRequired: false, + }); + const [scopes, setScopes] = React.useState(['openid', 'profile']); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const canSubmit = clientIdField.value.trim().length > 0 && clientSecretField.value.trim().length > 0 && !isSubmitting; + + const addScope = (): void => { + const scope = scopeField.value.trim(); + if (!scope || scopes.includes(scope)) { + return; + } + + setScopes(previousScopes => [...previousScopes, scope]); + scopeField.setValue(''); + }; + + 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 ( + <> + + + + + + + + + + + + + + + + + + + ({ flex: 1, gap: theme.space.$2 })}> + + ({ gap: theme.space.$2 })} + > + + + + + ({ gap: theme.space.$2 })} + > + + + + + + + + + + ({ gap: theme.space.$2 })} + > + {scopes.map(scope => ( + + ({ gap: theme.space.$1 })} + > + + {scope} + + setScopes(previousScopes => previousScopes.filter(value => value !== scope))} + sx={theme => ({ minHeight: 'auto', minWidth: 'auto', padding: theme.space.$0x5 })} + > + + + + + ))} + + + + + + + + + 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..d4865a84831 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx @@ -0,0 +1,75 @@ +import type { FieldId } from '@clerk/shared/types'; +import React, { type JSX } from 'react'; + +import { type LocalizationKey, Text } from '@/customizables'; +import { Form } from '@/elements/Form'; +import type { FormControlState } from '@/ui/utils/useFormControl'; + +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; + +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 => { + switch (props.mode) { + case 'discoveryUrl': + return ( + <> + + + + + + > + ); + case 'manual': + return ( + <> + + + + + + + + + + + + > + ); + default: { + const unhandledMode: never = props; + throw new Error(`Unhandled OIDC endpoints configuration mode: ${(unhandledMode as { mode: string }).mode}`); + } + } +}; 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..312a705a6cd --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx @@ -0,0 +1,162 @@ +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 { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from '../shared/IdentityProviderConfigurationModes'; +import { + OidcEndpointsConfigurationForm, + type OidcEndpointsConfigurationFormProps, +} from './OidcEndpointsConfigurationForm'; + +const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + +export const OidcEndpointsStep = (): JSX.Element => { + const card = useCardState(); + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + 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', 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'), + 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 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..ad53c3e2923 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx @@ -0,0 +1,69 @@ +import React, { type JSX } from 'react'; + +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 { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; + +export const OidcRedirectUriStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; + const redirectUriField = useFormControl('redirectUri', redirectUri, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), + isRequired: false, + }); + + return ( + <> + + + + + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + > + ); +}; 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..52fcd44e5a9 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,7 +21,7 @@ import { ArrowUpTray, Close } from '@/icons'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; -import { ActiveConnectionAlert } from './ActiveConnectionAlert'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; import type { IdpConfigurationMode } from './IdentityProviderConfigurationModes'; type CardState = ReturnType; 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();