From 79fdd3a70b7e0bf4aab2919db94c04a1a1ed4eec Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 14 Sep 2026 10:53:11 -0300 Subject: [PATCH 01/12] refactor(ui): render ActiveConnectionAlert from the SAML wizard steps The identity provider form rendered the alert itself, which tied the form to the wizard context. The four SAML steps now render it, matching the OIDC steps, so the form can be reused outside the wizard. --- .../ConfigureStep/saml/SamlCustomConfigureSteps.tsx | 2 ++ .../ConfigureStep/saml/SamlGoogleConfigureSteps.tsx | 2 ++ .../saml/SamlMicrosoftConfigureSteps.tsx | 2 ++ .../ConfigureStep/saml/SamlOktaConfigureSteps.tsx | 2 ++ .../shared/IdentityProviderConfigurationForm.tsx | 12 +----------- 5 files changed, 9 insertions(+), 11 deletions(-) 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 ded9cbeafa4..b42c6be20e3 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlCustomConfigureSteps.tsx @@ -24,6 +24,7 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../shared/ActiveConnectionAlert'; import { IdentityProviderConfigurationModes, type SamlIdpConfigurationMode, @@ -493,6 +494,7 @@ const SamlCustomIdentityProviderMetadataStep = (): JSX.Element => { }} /> + 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 dff36e9feaf..901884d9238 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlGoogleConfigureSteps.tsx @@ -12,6 +12,7 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../shared/ActiveConnectionAlert'; import { IdentityProviderConfigurationModes, type SamlIdpConfigurationMode, @@ -339,6 +340,7 @@ const SamlGoogleIdentityProviderMetadataStep = (): JSX.Element => { }} /> + 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 c7c937028dd..a4e1bc94983 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlMicrosoftConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlMicrosoftConfigureSteps.tsx @@ -33,6 +33,7 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../shared/ActiveConnectionAlert'; import { IdentityProviderConfigurationModes, type SamlIdpConfigurationMode, @@ -817,6 +818,7 @@ const SamlMicrosoftIdentityProviderMetadataStep = (): JSX.Element => { }} /> + 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 ac4e0739ee4..39ca8214704 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/SamlOktaConfigureSteps.tsx @@ -25,6 +25,7 @@ import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../shared/ActiveConnectionAlert'; import { IdentityProviderConfigurationModes, type SamlIdpConfigurationMode, @@ -674,6 +675,7 @@ const SamlOktaIdentityProviderMetadataStep = (): JSX.Element => { }} /> + 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 14fad7e2586..0237bc7df53 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,6 @@ import { ArrowUpTray, Close } from '@/icons'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; -import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; import type { SamlIdpConfigurationMode } from '../../shared/IdentityProviderConfigurationModes'; type CardState = ReturnType; @@ -71,7 +70,7 @@ export type IdentityProviderConfigurationFormProps = | { mode: 'metadataFile'; form: MetadataFileForm; labels: MetadataFileLabels } | { mode: 'manual'; form: ManualConfigurationForm; labels: ManualConfigurationLabels }; -const ConfigurationPanel = (config: IdentityProviderConfigurationFormProps): JSX.Element => { +export const IdentityProviderConfigurationForm = (config: IdentityProviderConfigurationFormProps): JSX.Element => { switch (config.mode) { case 'metadataUrl': return ( @@ -97,15 +96,6 @@ const ConfigurationPanel = (config: IdentityProviderConfigurationFormProps): JSX } }; -export const IdentityProviderConfigurationForm = (config: IdentityProviderConfigurationFormProps): JSX.Element => { - return ( - <> - - - - ); -}; - type MetadataUrlPanelProps = { form: MetadataUrlForm; labels: MetadataUrlLabels; From 790ebb0782bd5e0614f678d6a3d411a3f595e3ed Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 14 Sep 2026 10:53:12 -0300 Subject: [PATCH 02/12] feat(ui): open a page for each enterprise connection Clicking a connection row on the organization Security page opens a page for that connection instead of the setup wizard. The page shows the service provider values to copy, an editable identity provider form, settings as switches, an inline rename, and the activate, deactivate, open wizard, and remove actions as buttons. The row menu is gone. The Security page tracks its views as one union, and the back control is shared by the wizard, the directory sync wizard, and the page. --- packages/localizations/src/en-US.ts | 59 +++ packages/shared/src/types/localization.ts | 59 +++ .../GeneralSection.tsx | 173 +++++++++ .../IdentityProviderSection.tsx | 359 ++++++++++++++++++ .../ServiceProviderSection.tsx | 98 +++++ .../SettingsSection.tsx | 126 ++++++ .../EnterpriseConnectionPage/index.tsx | 225 +++++++++++ .../OrganizationSecurityPage.tsx | 116 +++--- .../SecurityBackControl.tsx | 23 ++ .../SecuritySsoSection.tsx | 181 ++------- .../EnterpriseConnectionPage.test.tsx | 271 +++++++++++++ .../OrganizationSecurityPage.test.tsx | 272 +++---------- ...nizationSecurityPageWizardLoading.test.tsx | 7 +- .../enterpriseConnectionStatusBadges.ts | 29 ++ .../src/customizables/elementDescriptors.ts | 2 + packages/ui/src/internal/appearance.ts | 2 + 16 files changed, 1582 insertions(+), 420 deletions(-) create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/SettingsSection.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/SecurityBackControl.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/enterpriseConnectionStatusBadges.ts diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index f8a3fe28875..f71ce691428 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1320,6 +1320,65 @@ export const enUS: LocalizationResource = { title: 'Remove domain', }, securityPage: { + connectionPage: { + actions: { + activate: 'Activate', + deactivate: 'Deactivate', + openWizard: 'Open setup wizard', + remove: 'Remove', + }, + general: { + createdLabel: 'Created', + domainsLabel: 'Domains', + editNameButton: 'Edit', + nameForm: { + title: 'Rename connection', + }, + nameLabel: 'Name', + providerLabel: 'Provider', + title: 'General', + }, + identityProvider: { + certificateExpires: 'Certificate expires {{date}}', + clientSecret: { + placeholder: 'Leave empty to keep the current secret', + }, + description: 'Retrieve these values from your identity provider.', + title: 'Identity provider', + }, + saveButton: 'Save', + serviceProvider: { + acsUrl: 'Assertion consumer service (ACS) URL', + description: 'Enter these values into your identity provider.', + entityId: 'Entity ID', + metadataUrl: 'Metadata URL', + redirectUri: 'Redirect URI', + title: 'Service provider', + }, + settings: { + allowAdditionalIdentifiers: { + description: 'Let users keep other sign-in methods alongside this connection.', + label: 'Allow additional identifiers', + }, + allowIdpInitiated: { + description: 'Let users start sign-in from the identity provider.', + label: 'Allow IdP-initiated flow', + }, + allowSubdomains: { + description: "Accept email addresses on subdomains of the connection's domains.", + label: 'Allow subdomains', + }, + forceAuthn: { + description: 'Ask the identity provider to re-authenticate the user on every sign-in.', + label: 'Force authentication', + }, + syncUserAttributes: { + description: "Update the user's attributes from the identity provider on every sign-in.", + label: 'Sync user attributes', + }, + title: 'Settings', + }, + }, directorySyncSection: { badge__active: 'Active', badge__inactive: 'Inactive', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index a7758f5425a..90373c1cc33 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1220,6 +1220,65 @@ export type __internal_LocalizationResource = { tooltip__noRole: LocalizationValue; tooltipLabel: LocalizationValue; }; + connectionPage: { + saveButton: LocalizationValue; + actions: { + activate: LocalizationValue; + deactivate: LocalizationValue; + openWizard: LocalizationValue; + remove: LocalizationValue; + }; + general: { + title: LocalizationValue; + nameLabel: LocalizationValue; + domainsLabel: LocalizationValue; + providerLabel: LocalizationValue; + createdLabel: LocalizationValue; + editNameButton: LocalizationValue; + nameForm: { + title: LocalizationValue; + }; + }; + serviceProvider: { + title: LocalizationValue; + description: LocalizationValue; + acsUrl: LocalizationValue; + entityId: LocalizationValue; + metadataUrl: LocalizationValue; + redirectUri: LocalizationValue; + }; + identityProvider: { + title: LocalizationValue; + description: LocalizationValue; + certificateExpires: LocalizationValue<'date'>; + clientSecret: { + placeholder: LocalizationValue; + }; + }; + settings: { + title: LocalizationValue; + syncUserAttributes: { + label: LocalizationValue; + description: LocalizationValue; + }; + allowAdditionalIdentifiers: { + label: LocalizationValue; + description: LocalizationValue; + }; + allowSubdomains: { + label: LocalizationValue; + description: LocalizationValue; + }; + allowIdpInitiated: { + label: LocalizationValue; + description: LocalizationValue; + }; + forceAuthn: { + label: LocalizationValue; + description: LocalizationValue; + }; + }; + }; directorySyncSection: { title: LocalizationValue; badge__unconfigured: LocalizationValue; diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx new file mode 100644 index 00000000000..ffc3d5058f0 --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx @@ -0,0 +1,173 @@ +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import type React from 'react'; +import type { ReactNode } from 'react'; + +import { Action } from '@/elements/Action'; +import { useActionContext } from '@/elements/Action/ActionRoot'; +import { useCardState, withCardStateProvider } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { FormButtons } from '@/elements/FormButtons'; +import { FormContainer } from '@/elements/FormContainer'; +import { ProfileSection } from '@/elements/Section'; +import { formatDate } from '@/ui/utils/formatDate'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import type { LocalizationKey } from '../../../customizables'; +import { Badge, descriptors, Flex, localizationKeys, Text } from '../../../customizables'; +import { providerLabel, toProviderCard } from '../../ConfigureSSO/domain/providers'; +import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import type { EnterpriseConnectionProviderType } from '../../ConfigureSSO/types'; + +type GeneralSectionProps = { + connection: EnterpriseConnectionResource; + updateConnection: EnterpriseConnectionMutations['updateConnection']; +}; + +export const GeneralSection = ({ connection, updateConnection }: GeneralSectionProps): JSX.Element => { + const label = providerLabel(toProviderCard(connection.provider as EnterpriseConnectionProviderType)); + + return ( + + + + + + {connection.domains.map(domain => ( + + {domain} + + ))} + + + + {label && } + + + + {connection.createdAt && {formatDate(connection.createdAt)}} + + + + ); +}; + +const DetailRow = ({ label, children }: { label: LocalizationKey; children: ReactNode }): JSX.Element => ( + + + ({ minWidth: 0, gap: t.space.$1x5 })} + > + {children} + + +); + +const NameRow = ({ connection, updateConnection }: GeneralSectionProps): JSX.Element => ( + + + + {connection.name} + + + + + + + + + + + + + +); + +const NameScreen = (props: GeneralSectionProps): JSX.Element => { + const { close } = useActionContext(); + + return ( + + ); +}; + +const NameForm = withCardStateProvider( + ({ + connection, + updateConnection, + onSuccess, + onReset, + }: GeneralSectionProps & { onSuccess: () => void; onReset: () => void }): JSX.Element => { + const card = useCardState(); + const nameField = useFormControl('name', connection.name, { + type: 'text', + label: localizationKeys('organizationProfile.securityPage.connectionPage.general.nameLabel'), + isRequired: true, + }); + + const name = nameField.value.trim(); + const canSubmit = name.length > 0 && name !== connection.name; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!canSubmit) { + return; + } + + try { + await updateConnection(connection.id, { name }); + onSuccess(); + } catch (err) { + handleError(err as Error, [nameField], card.setError); + } + }; + + return ( + + + + + + + + + ); + }, +); diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx new file mode 100644 index 00000000000..cfb68cb80c4 --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx @@ -0,0 +1,359 @@ +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import type React from 'react'; +import { useState } from 'react'; + +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { FormButtonContainer } from '@/elements/FormButtons'; +import { ProfileSection } from '@/elements/Section'; +import { formatDate } from '@/ui/utils/formatDate'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { Col, localizationKeys, Text } from '../../../customizables'; +import { isOidcProvider } from '../../ConfigureSSO/domain/organizationEnterpriseConnection'; +import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { + OidcEndpointsConfigurationForm, + type OidcEndpointsConfigurationFormProps, +} from '../../ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm'; +import { + applySamlSubmitError, + buildSamlConfigurationPayload, + IdentityProviderConfigurationForm, + type IdentityProviderConfigurationFormProps, +} from '../../ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm'; +import { + IdentityProviderConfigurationModes, + type OidcIdpConfigurationMode, + type SamlIdpConfigurationMode, +} from '../../ConfigureSSO/steps/ConfigureStep/shared/IdentityProviderConfigurationModes'; + +type IdentityProviderSectionProps = { + connection: EnterpriseConnectionResource; + updateConnection: EnterpriseConnectionMutations['updateConnection']; +}; + +const SAML_MODES = ['metadataUrl', 'manual'] as const satisfies readonly SamlIdpConfigurationMode[]; +const OIDC_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly OidcIdpConfigurationMode[]; + +export const IdentityProviderSection = (props: IdentityProviderSectionProps): JSX.Element => ( + + + + {isOidcProvider(props.connection.provider) ? : } + + +); + +const SaveButton = ({ isDisabled }: { isDisabled: boolean }): JSX.Element => ( + + + +); + +const SamlForm = ({ connection, updateConnection }: IdentityProviderSectionProps): JSX.Element => { + const card = useCardState(); + const saml = connection.samlConnection; + const existingCertPresent = Boolean(saml?.idpCertificate); + + const [mode, setMode] = useState( + saml?.idpSsoUrl || saml?.idpEntityId || saml?.idpCertificate ? 'manual' : 'metadataUrl', + ); + const [certFile, setCertFile] = useState(null); + + const metadataUrlField = useFormControl('idpMetadataUrl', saml?.idpMetadataUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.placeholder', + ), + isRequired: true, + }); + + const signOnUrlField = useFormControl('idpSsoUrl', saml?.idpSsoUrl ?? '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', + ), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.placeholder', + ), + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', saml?.idpEntityId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.placeholder', + ), + isRequired: true, + }); + + const certificateField = useFormControl('idpCertificate', '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.label', + ), + isRequired: true, + }); + + const isValid = + mode === 'metadataUrl' + ? metadataUrlField.value.trim().length > 0 + : signOnUrlField.value.trim().length > 0 && + issuerField.value.trim().length > 0 && + (certFile !== null || existingCertPresent); + + const formProps: IdentityProviderConfigurationFormProps = + mode === 'metadataUrl' + ? { + mode: 'metadataUrl', + form: { field: metadataUrlField }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.description', + ), + }, + } + : { + mode: 'manual', + form: { + signOnUrlField, + issuerField, + certificateField, + certFile, + onCertFileChange: setCertFile, + existingCertPresent, + }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.description', + ), + uploadFile: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.uploadFile', + ), + replaceFile: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.replaceFile', + ), + removeFile: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.removeFile', + ), + fileUploaded: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.fileUploaded', + ), + }, + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValid) { + return; + } + + card.setError(undefined); + + try { + const payload = await buildSamlConfigurationPayload({ + mode, + metadataUrl: { value: metadataUrlField.value }, + manual: { signOnUrl: signOnUrlField.value, issuer: issuerField.value, certFile }, + }); + + await updateConnection(connection.id, { saml: payload }); + } catch (err) { + if (mode === 'metadataUrl') { + applySamlSubmitError(err, card, metadataUrlField); + } else { + applySamlSubmitError(err, card, signOnUrlField, [issuerField, certificateField]); + } + } + }; + + return ( + + { + card.setError(undefined); + setMode(next); + }} + labels={{ + ariaLabel: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.ariaLabel', + ), + metadataUrl: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.metadataUrl', + ), + manual: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.manual'), + }} + /> + + + + {saml && saml.idpCertificateExpiresAt > 0 && ( + + )} + + + + ); +}; + +const OidcForm = ({ connection, updateConnection }: IdentityProviderSectionProps): JSX.Element => { + const card = useCardState(); + const oauthConfig = connection.oauthConfig; + + const [mode, setMode] = useState( + oauthConfig?.authUrl || oauthConfig?.tokenUrl ? 'manual' : 'discoveryUrl', + ); + + const clientIdField = useFormControl('clientId', 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( + 'organizationProfile.securityPage.connectionPage.identityProvider.clientSecret.placeholder', + ), + }); + + 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 = + clientIdField.value.trim().length > 0 && + (mode === 'discoveryUrl' + ? discoveryUrlField.value.trim().length > 0 + : authUrlField.value.trim().length > 0 && tokenUrlField.value.trim().length > 0); + + const endpointsProps: 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 onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValid) { + return; + } + + card.setError(undefined); + + try { + await updateConnection(connection.id, { + oidc: { + clientId: clientIdField.value.trim(), + clientSecret: clientSecretField.value.trim() || undefined, + ...(mode === 'discoveryUrl' + ? { discoveryUrl: discoveryUrlField.value.trim() } + : { + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }), + }, + }); + } catch (err) { + handleError(err as Error, [clientIdField, clientSecretField], card.setError); + } + }; + + return ( + + + + + + + + + + { + card.setError(undefined); + setMode(next); + }} + labels={{ + ariaLabel: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.ariaLabel'), + discoveryUrl: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.discoveryUrl'), + manual: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.manual'), + }} + /> + + + + + + ); +}; diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx new file mode 100644 index 00000000000..8078d1239de --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx @@ -0,0 +1,98 @@ +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import type { ReactNode } from 'react'; + +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { ProfileSection } from '@/elements/Section'; +import { Checkmark, Clipboard } from '@/icons'; + +import type { LocalizationKey } from '../../../customizables'; +import { Col, localizationKeys, Text, useLocalizations } from '../../../customizables'; + +export const SamlServiceProviderSection = ({ + connection, +}: { + connection: EnterpriseConnectionResource; +}): JSX.Element | null => { + const saml = connection.samlConnection; + + if (!saml) { + return null; + } + + return ( + + + + + + ); +}; + +export const OidcServiceProviderSection = ({ + connection, +}: { + connection: EnterpriseConnectionResource; +}): JSX.Element | null => { + const redirectUri = connection.oauthConfig?.redirectUri; + + if (!redirectUri) { + return null; + } + + return ( + + + + ); +}; + +const ServiceProviderSectionRoot = ({ children }: { children: ReactNode }): JSX.Element => ( + + + + {children} + + +); + +const CopyableValue = ({ label, value }: { label: LocalizationKey; value: string }): JSX.Element => { + const { t } = useLocalizations(); + + return ( + + + + + ); +}; diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/SettingsSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/SettingsSection.tsx new file mode 100644 index 00000000000..ee58bdbcd8f --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/SettingsSection.tsx @@ -0,0 +1,126 @@ +import type { EnterpriseConnectionResource, UpdateOrganizationEnterpriseConnectionParams } from '@clerk/shared/types'; + +import { useCardState } from '@/elements/contexts'; +import { ProfileSection } from '@/elements/Section'; +import { Switch } from '@/elements/Switch'; +import { handleError } from '@/utils/errorHandler'; + +import { Col, localizationKeys, Text } from '../../../customizables'; +import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; + +export type ProviderFamily = 'saml' | 'oidc'; + +type SettingId = + | 'syncUserAttributes' + | 'allowAdditionalIdentifiers' + | 'allowSubdomains' + | 'allowIdpInitiated' + | 'forceAuthn'; + +type Setting = { + id: SettingId; + appliesTo: 'all' | ProviderFamily; + isChecked: (connection: EnterpriseConnectionResource) => boolean; + toParams: (checked: boolean) => UpdateOrganizationEnterpriseConnectionParams; +}; + +const SETTINGS: ReadonlyArray = [ + { + id: 'syncUserAttributes', + appliesTo: 'all', + isChecked: connection => connection.syncUserAttributes, + toParams: syncUserAttributes => ({ syncUserAttributes }), + }, + { + id: 'allowAdditionalIdentifiers', + appliesTo: 'all', + isChecked: connection => !connection.disableAdditionalIdentifications, + toParams: checked => ({ disableAdditionalIdentifications: !checked }), + }, + { + id: 'allowSubdomains', + appliesTo: 'saml', + isChecked: connection => Boolean(connection.samlConnection?.allowSubdomains), + toParams: allowSubdomains => ({ saml: { allowSubdomains } }), + }, + { + id: 'allowIdpInitiated', + appliesTo: 'saml', + isChecked: connection => Boolean(connection.samlConnection?.allowIdpInitiated), + toParams: allowIdpInitiated => ({ saml: { allowIdpInitiated } }), + }, + { + id: 'forceAuthn', + appliesTo: 'saml', + isChecked: connection => Boolean(connection.samlConnection?.forceAuthn), + toParams: forceAuthn => ({ saml: { forceAuthn } }), + }, +]; + +type SettingsSectionProps = { + connection: EnterpriseConnectionResource; + family: ProviderFamily; + updateConnection: EnterpriseConnectionMutations['updateConnection']; +}; + +export const SettingsSection = ({ connection, family, updateConnection }: SettingsSectionProps): JSX.Element => ( + + + {SETTINGS.filter(setting => setting.appliesTo === 'all' || setting.appliesTo === family).map(setting => ( + + ))} + + +); + +const SettingRow = ({ + setting, + connection, + updateConnection, +}: { + setting: Setting; + connection: EnterpriseConnectionResource; + updateConnection: EnterpriseConnectionMutations['updateConnection']; +}): JSX.Element => { + const card = useCardState(); + + const onChange = async (checked: boolean) => { + card.setError(undefined); + card.setLoading(); + + try { + await updateConnection(connection.id, setting.toParams(checked)); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + + void onChange(checked)} + label={localizationKeys(`organizationProfile.securityPage.connectionPage.settings.${setting.id}.label`)} + /> + + + ); +}; diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx new file mode 100644 index 00000000000..ba182578e14 --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx @@ -0,0 +1,225 @@ +import type { EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types'; +import { useState } from 'react'; + +import { Card } from '@/elements/Card'; +import { useCardState, withCardStateProvider } from '@/elements/contexts'; +import { Header } from '@/elements/Header'; +import { ProfileCard } from '@/elements/ProfileCard'; +import { handleError } from '@/utils/errorHandler'; + +import { ProviderIcon } from '../../../common'; +import { Badge, Button, Col, descriptors, Flex, localizationKeys, Text } from '../../../customizables'; +import { isOidcProvider } from '../../ConfigureSSO/domain/organizationEnterpriseConnection'; +import { providerLabel, toProviderCard } from '../../ConfigureSSO/domain/providers'; +import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { useOrganizationEnterpriseConnectionStatus } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnectionStatus'; +import { ResetConnectionDialog } from '../../ConfigureSSO/ResetConnectionDialog'; +import type { EnterpriseConnectionProviderType } from '../../ConfigureSSO/types'; +import { STATUS_BADGES } from '../enterpriseConnectionStatusBadges'; +import { SecurityBackControl } from '../SecurityBackControl'; +import { GeneralSection } from './GeneralSection'; +import { IdentityProviderSection } from './IdentityProviderSection'; +import { OidcServiceProviderSection, SamlServiceProviderSection } from './ServiceProviderSection'; +import { SettingsSection } from './SettingsSection'; + +export type EnterpriseConnectionPageProps = { + connection: EnterpriseConnectionResource; + enterpriseConnectionMutations: EnterpriseConnectionMutations; + organizationName: string; + contentRef: React.RefObject; + onBack: () => void; + /** Opens the wizard scoped to this connection, resuming at its furthest-reachable step. */ + onOpenWizard: () => void; +}; + +export const EnterpriseConnectionPage = withCardStateProvider( + ({ + connection, + enterpriseConnectionMutations, + organizationName, + contentRef, + onBack, + onOpenWizard, + }: EnterpriseConnectionPageProps): JSX.Element => { + const { updateConnection } = enterpriseConnectionMutations; + const isOidc = isOidcProvider(connection.provider); + + return ( + + ({ gap: t.space.$8 })} + > + + + + + + {isOidc ? ( + + ) : ( + + )} + + + + + + + + ); + }, +); + +const ConnectionHeader = ({ + connection, + enterpriseConnectionMutations: { setConnectionActive, deleteConnection }, + organizationName, + contentRef, + onBack, + onOpenWizard, +}: EnterpriseConnectionPageProps): JSX.Element => { + const card = useCardState(); + const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false); + const { status } = useOrganizationEnterpriseConnectionStatus(connection); + + const badge = STATUS_BADGES[status]; + const label = providerLabel(toProviderCard(connection.provider as EnterpriseConnectionProviderType)); + + const onSetActive = async (active: boolean) => { + if (card.isLoading) { + return; + } + + card.setError(undefined); + card.setLoading(); + + try { + // The mutation revalidates before resolving, so the refreshed entity drives the settled UI. + await setConnectionActive(connection.id, active); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + ({ gap: t.space.$4, marginBottom: t.space.$4 })}> + + + ({ gap: t.space.$2 })} + > + + + + {connection.name} + {label && ( + + )} + + + + + + ({ gap: t.space.$2 })} + > + {status === 'active' && ( + ); }; diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx new file mode 100644 index 00000000000..01b77dfd9b7 --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx @@ -0,0 +1,271 @@ +import { within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { EnterpriseConnectionPage } from '../EnterpriseConnectionPage'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +const withPageFixtures = (f: Parameters[0]>[0]) => { + f.withEnterpriseSso({ selfServeSSO: true }); + f.withEmailAddress(); + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }], + }); +}; + +const samlConnection = (overrides: Record = {}) => + ({ + id: 'ent_1', + name: 'clerk.com', + provider: 'saml_okta', + active: false, + organizationId: 'Org1', + domains: ['clerk.com'], + logoPublicUrl: null, + syncUserAttributes: false, + disableAdditionalIdentifications: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + samlConnection: { + idpSsoUrl: 'https://idp.example.com/sso', + idpEntityId: 'https://idp.example.com/entity', + idpCertificate: 'CERT', + idpCertificateExpiresAt: 0, + idpMetadataUrl: '', + acsUrl: 'https://accounts.clerk.com/v1/acs', + spEntityId: 'https://accounts.clerk.com/saml/ent_1', + spMetadataUrl: 'https://accounts.clerk.com/saml/ent_1/metadata', + allowSubdomains: false, + allowIdpInitiated: false, + forceAuthn: false, + }, + ...overrides, + }) as any; + +const oidcConnection = (overrides: Record = {}) => + ({ + id: 'ent_2', + name: 'oidc.com', + provider: 'oidc_custom', + active: false, + organizationId: 'Org1', + domains: ['oidc.com'], + logoPublicUrl: null, + syncUserAttributes: false, + disableAdditionalIdentifications: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + samlConnection: null, + oauthConfig: { + clientId: 'client-abc', + redirectUri: 'https://accounts.clerk.com/v1/oauth_callback', + discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration', + }, + ...overrides, + }) as any; + +/** Mirrors how the umbrella hook maps the mutation surface onto the organization resource. */ +const mutationsFor = (fixtures: any): EnterpriseConnectionMutations => ({ + createConnection: vi.fn(), + changeProvider: vi.fn(), + updateConnection: (id, params) => fixtures.clerk.organization.updateEnterpriseConnection(id, params), + setConnectionActive: (id, active) => fixtures.clerk.organization.updateEnterpriseConnection(id, { active }), + deleteConnection: id => fixtures.clerk.organization.deleteEnterpriseConnection(id), + createTestRun: vi.fn(), +}); + +const renderPage = ( + wrapper: React.ComponentType<{ children?: React.ReactNode }>, + fixtures: any, + connection: any, + onBack = vi.fn(), + onOpenWizard = vi.fn(), +) => ({ + onBack, + onOpenWizard, + ...render( + , + { wrapper }, + ), +}); + +const withNoTestRuns = (fixtures: any) => { + fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ + data: [], + total_count: 0, + } as any); +}; + +describe('EnterpriseConnectionPage', () => { + describe('sections', () => { + it('renders every section for a SAML connection', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + + renderPage(wrapper, fixtures, samlConnection()); + + expect(await screen.findByRole('heading', { name: 'clerk.com' })).toBeInTheDocument(); + // Once beside the title, once as the General section's Provider row. + expect(screen.getAllByText('Okta Workforce')).toHaveLength(2); + + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByText('Service provider')).toBeInTheDocument(); + expect(screen.getByText('Identity provider')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + + expect(screen.getByDisplayValue('https://accounts.clerk.com/v1/acs')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://accounts.clerk.com/saml/ent_1')).toBeInTheDocument(); + + expect(screen.getByDisplayValue('https://idp.example.com/sso')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://idp.example.com/entity')).toBeInTheDocument(); + + expect(screen.getAllByRole('switch')).toHaveLength(5); + }); + + it('renders the OIDC variant of the service provider section and only the shared switches', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + + renderPage(wrapper, fixtures, oidcConnection()); + + expect(await screen.findByRole('heading', { name: 'oidc.com' })).toBeInTheDocument(); + + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByText('Service provider')).toBeInTheDocument(); + expect(screen.getByText('Identity provider')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + + expect(screen.getByDisplayValue('https://accounts.clerk.com/v1/oauth_callback')).toBeInTheDocument(); + expect(screen.getByDisplayValue('client-abc')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('https://accounts.clerk.com/v1/acs')).not.toBeInTheDocument(); + + expect(screen.getAllByRole('switch')).toHaveLength(2); + }); + }); + + describe('header actions', () => { + it('deactivates an active connection', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({ active: false } as any); + + const { userEvent } = renderPage(wrapper, fixtures, samlConnection({ active: true })); + + await userEvent.click(await screen.findByRole('button', { name: 'Deactivate' })); + + expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { active: false }); + expect(screen.queryByRole('button', { name: 'Activate' })).not.toBeInTheDocument(); + }); + + it('offers the wizard for a connection that is still mid-setup', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + + const { userEvent, onOpenWizard } = renderPage( + wrapper, + fixtures, + samlConnection({ samlConnection: null, oauthConfig: null }), + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open setup wizard' })); + + expect(onOpenWizard).toHaveBeenCalled(); + }); + + it('removes the connection through the type-to-confirm dialog and returns to the overview', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.deleteEnterpriseConnection.mockResolvedValue({} as any); + + const { userEvent, onBack } = renderPage(wrapper, fixtures, samlConnection({ active: true })); + + await userEvent.click(await screen.findByRole('button', { name: 'Remove' })); + + expect(await screen.findByRole('heading', { name: 'Remove SSO connection' })).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to remove the connection "clerk.com"\?/i)).toBeInTheDocument(); + + await userEvent.type(screen.getByLabelText(/below to continue/i), 'Org1'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Remove connection' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Remove connection' })); + + await waitFor(() => { + expect(fixtures.clerk.organization?.deleteEnterpriseConnection).toHaveBeenCalledWith('ent_1'); + }); + expect(onBack).toHaveBeenCalled(); + }); + }); + + describe('editing', () => { + it('renames the connection from the General section', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue(samlConnection({ name: 'Renamed' })); + + const { userEvent, container } = renderPage(wrapper, fixtures, samlConnection()); + + await userEvent.click(await screen.findByRole('button', { name: 'Edit' })); + + const form = container.querySelector('.cl-actionCard') as HTMLElement; + expect(within(form).getByRole('heading', { name: 'Rename connection' })).toBeInTheDocument(); + + const nameInput = within(form).getByLabelText('Name'); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'Renamed'); + await userEvent.click(within(form).getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { + name: 'Renamed', + }); + }); + }); + + it('saves the manual SAML identity-provider configuration', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue(samlConnection()); + + const { userEvent } = renderPage(wrapper, fixtures, samlConnection()); + + await userEvent.click(await screen.findByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { + saml: { + idpSsoUrl: 'https://idp.example.com/sso', + idpEntityId: 'https://idp.example.com/entity', + }, + }); + }); + }); + + it('toggles a setting through the connection update', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue( + samlConnection({ syncUserAttributes: true }), + ); + + const { userEvent } = renderPage(wrapper, fixtures, samlConnection()); + + await userEvent.click(await screen.findByRole('switch', { name: 'Sync user attributes' })); + + await waitFor(() => { + expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { + syncUserAttributes: true, + }); + }); + }); + }); +}); diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index 6af6137395a..cfcbfde1d43 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -70,7 +70,7 @@ describe('OrganizationSecurityPage', () => { expect(screen.queryByText(/select your identity provider/i)).not.toBeInTheDocument(); }); - it('renders the in-progress state with a Continue configuration action', async () => { + it('renders the in-progress state as a clickable row', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); // A connection without SAML configuration is mid-setup. @@ -82,19 +82,18 @@ describe('OrganizationSecurityPage', () => { total_count: 0, } as any); - const { userEvent } = renderPage(wrapper); + renderPage(wrapper); expect(await screen.findByText('In Progress')).toBeInTheDocument(); expect(screen.getByText(DESCRIPTION_LINE_1)).toBeInTheDocument(); expect(screen.queryByText(/you have started a configuration/i)).not.toBeInTheDocument(); expect(screen.queryByRole('switch')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Continue configuration' })).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: /open menu/i })); - expect(screen.getByRole('menuitem', { name: 'Continue configuration' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'clerk.com' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); }); - it('renders the active state as a condensed overview with the domains and actions menu', async () => { + it('renders the active state as a condensed overview with the domains', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); @@ -113,7 +112,8 @@ describe('OrganizationSecurityPage', () => { expect(screen.queryByText(/^Domains:?$/)).not.toBeInTheDocument(); expect(screen.getAllByText('clerk.com').length).toBeGreaterThan(0); - expect(screen.getByRole('button', { name: /open menu/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'clerk.com' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Start configuration' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Continue configuration' })).not.toBeInTheDocument(); }); @@ -134,7 +134,7 @@ describe('OrganizationSecurityPage', () => { expect(await screen.findByText('Inactive')).toBeInTheDocument(); expect(screen.queryByRole('switch')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /open menu/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); for (const domain of ['github.com', 'gmail.com', 'maps.com', 'another.com']) { expect(screen.getByText(domain)).toBeInTheDocument(); @@ -221,7 +221,7 @@ describe('OrganizationSecurityPage', () => { expect(screen.queryByRole('button', { name: 'Start configuration' })).not.toBeInTheDocument(); }); - it('resumes the wizard at the reachable step when Continue configuration is clicked', async () => { + it("resumes the wizard at the reachable step from the connection page's Open setup wizard", async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); // A connection without SAML configuration is mid-setup (in_progress). @@ -236,10 +236,10 @@ describe('OrganizationSecurityPage', () => { const { userEvent } = renderPage(wrapper); - await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Continue configuration' })); + await userEvent.click(await screen.findByRole('button', { name: 'clerk.com' })); + await userEvent.click(await screen.findByRole('button', { name: 'Open setup wizard' })); - // Continue passes no forced step, so the wizard resumes at the furthest- + // The connection page forces no step, so the wizard resumes at the furthest- // reachable step for this connection (configure, since a provider connection // exists and the domain is verified) rather than the forced first step. // Resuming into `configure` (direction 0) falls through to its furthest- @@ -249,28 +249,6 @@ describe('OrganizationSecurityPage', () => { expect(screen.queryByRole('heading', { name: /select your identity provider/i })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument(); }); - - it('opens the wizard at the first step when Edit is selected from the actions menu', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); - - fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); - fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ - data: [{ id: 'run_1', status: 'success' }], - total_count: 1, - } as any); - fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [verifiedDomain], total_count: 1 } as any); - - const { userEvent } = renderPage(wrapper); - - await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' })); - - // Edit forces the first step rather than the connection's furthest-reachable - // step (confirmation, for an active connection). - expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument(); - expect(screen.queryByText(/configuration details/i)).not.toBeInTheDocument(); - expect(screen.queryByText(DESCRIPTION_LINE_1)).not.toBeInTheDocument(); - }); }); describe('wizard back control', () => { @@ -295,31 +273,11 @@ describe('OrganizationSecurityPage', () => { }); }); - describe('actions menu', () => { - it('lists Edit, Deactivate, and Remove for an active connection', async () => { + describe('connection page', () => { + it('opens the connection page for the clicked row', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); - fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ - data: [], - total_count: 0, - } as any); - - const { userEvent } = renderPage(wrapper); - - await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); - - expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Deactivate' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Activate' })).not.toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument(); - }); - - it('lists Edit, Activate, and Remove for an inactive connection', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); - - fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection()]); fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ data: [{ id: 'run_1', status: 'success' }], total_count: 1, @@ -327,160 +285,53 @@ describe('OrganizationSecurityPage', () => { const { userEvent } = renderPage(wrapper); - await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); + await userEvent.click(await screen.findByRole('button', { name: 'clerk.com' })); - expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Activate' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Deactivate' })).not.toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'clerk.com' })).toBeInTheDocument(); + expect(screen.getAllByText('Okta Workforce').length).toBeGreaterThan(0); + expect(screen.getByText('Identity provider')).toBeInTheDocument(); + expect(screen.queryByText(DESCRIPTION_LINE_1)).not.toBeInTheDocument(); }); - it('opens the type-to-confirm removal dialog with Remove-oriented copy from Remove', async () => { + it('returns to the overview from the back control', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); - fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ - data: [], - total_count: 0, - } as any); - fixtures.clerk.organization?.deleteEnterpriseConnection.mockResolvedValue({} as any); - - const { userEvent } = renderPage(wrapper); - - await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Remove' })); - - // The shared dialog renders the Remove copy here, not the wizard's Reset copy. - expect(await screen.findByRole('heading', { name: 'Remove SSO connection' })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: 'Reset connection' })).not.toBeInTheDocument(); - expect(screen.getByText(/Are you sure you want to remove the connection "clerk.com"\?/i)).toBeInTheDocument(); - - // Type-to-confirm uses the organization name. - await userEvent.type(screen.getByLabelText(/below to continue/i), 'Org1'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Remove connection' })).toBeEnabled()); - await userEvent.click(screen.getByRole('button', { name: 'Remove connection' })); - - await waitFor(() => { - expect(fixtures.clerk.organization?.deleteEnterpriseConnection).toHaveBeenCalledWith('ent_1'); - }); - }); - - it('deactivates directly from the menu, settling on the revalidated connection', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); - - // The revalidation that follows the update returns the deactivated connection. - fixtures.clerk.organization?.getEnterpriseConnections - .mockResolvedValueOnce([configuredConnection({ active: true })]) - .mockResolvedValue([configuredConnection()]); fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ data: [{ id: 'run_1', status: 'success' }], total_count: 1, } as any); - fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({ active: false } as any); const { userEvent } = renderPage(wrapper); - expect(await screen.findByText('Active')).toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Deactivate' })); - - expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { - active: false, - }); + await userEvent.click(await screen.findByRole('button', { name: 'clerk.com' })); + await userEvent.click(await screen.findByRole('button', { name: 'Security' })); - // The badge follows the revalidated entity, not an optimistic flip. - await waitFor(() => expect(screen.getByText('Inactive')).toBeInTheDocument()); - expect(screen.queryByText('Active')).not.toBeInTheDocument(); + expect(await screen.findByText(DESCRIPTION_LINE_1)).toBeInTheDocument(); + expect(screen.queryByText('Identity provider')).not.toBeInTheDocument(); }); - it('activates directly from the menu, settling on the revalidated connection', async () => { + it('falls back to the overview when the open connection leaves the list', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); + // The revalidation that follows the settings write no longer returns the + // connection — the same state a removal in another tab leaves behind. fixtures.clerk.organization?.getEnterpriseConnections - .mockResolvedValueOnce([configuredConnection()]) - .mockResolvedValue([configuredConnection({ active: true })]); - fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ - data: [{ id: 'run_1', status: 'success' }], - total_count: 1, - } as any); - fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({ active: true } as any); - - const { userEvent } = renderPage(wrapper); - - expect(await screen.findByText('Inactive')).toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Activate' })); - - expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { - active: true, - }); - - await waitFor(() => expect(screen.getByText('Active')).toBeInTheDocument()); - expect(screen.queryByText('Inactive')).not.toBeInTheDocument(); - }); - - it('disables the activation action while the update is in flight', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); - - fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); + .mockResolvedValueOnce([configuredConnection({ active: true })]) + .mockResolvedValue([]); fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ data: [{ id: 'run_1', status: 'success' }], total_count: 1, } as any); - - let resolveUpdate!: (value: unknown) => void; - fixtures.clerk.organization?.updateEnterpriseConnection.mockImplementation( - () => new Promise(resolve => (resolveUpdate = resolve)) as any, - ); - - const { userEvent } = renderPage(wrapper); - - expect(await screen.findByText('Active')).toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Deactivate' })); - - // The update is still pending; the acting item is disabled when the menu is reopened. - await userEvent.click(screen.getByRole('button', { name: /open menu/i })); - await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Deactivate' })).toBeDisabled()); - - resolveUpdate({ active: true }); - }); - - it('surfaces the error and keeps the entity state when the update fails', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); - - fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); - fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ - data: [], - total_count: 0, - } as any); - fixtures.clerk.organization?.updateEnterpriseConnection.mockRejectedValue( - new ClerkAPIResponseError('Error', { - data: [ - { - code: 'connection_update_failed', - long_message: 'The connection could not be updated', - message: 'update failed', - }, - ], - status: 400, - }), - ); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({} as any); const { userEvent } = renderPage(wrapper); - expect(await screen.findByText('Active')).toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', { name: /open menu/i })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Deactivate' })); + await userEvent.click(await screen.findByRole('button', { name: 'clerk.com' })); + await userEvent.click(await screen.findByRole('switch', { name: 'Sync user attributes' })); - expect(await screen.findByText('The connection could not be updated')).toBeInTheDocument(); - // The badge reads from the (unchanged) entity — no optimistic flip to roll back. - expect(screen.getByText('Active')).toBeInTheDocument(); - expect(screen.queryByText('Inactive')).not.toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Start configuration' })).toBeInTheDocument(); + expect(screen.queryByText('Identity provider')).not.toBeInTheDocument(); }); }); @@ -528,42 +379,16 @@ describe('OrganizationSecurityPage', () => { expect(screen.queryByText('Unconfigured')).not.toBeInTheDocument(); }); - it('opens the wizard scoped to the row whose Edit was selected', async () => { + it('opens the connection page of the clicked row', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); withTwoConnections(fixtures); const { userEvent } = renderPage(wrapper); - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); - await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' })); + await userEvent.click(await screen.findByRole('button', { name: 'second.com' })); - expect(await screen.findByText('Editing "second.com"')).toBeInTheDocument(); - expect(screen.queryByText('Editing "first.com"')).not.toBeInTheDocument(); - }); - - it('removes the connection of the row whose Remove was selected', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); - withTwoConnections(fixtures); - fixtures.clerk.organization?.deleteEnterpriseConnection.mockResolvedValue({} as any); - - const { userEvent } = renderPage(wrapper); - - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); - await userEvent.click(screen.getByRole('menuitem', { name: 'Remove' })); - - expect( - await screen.findByText(/Are you sure you want to remove the connection "second.com"/i), - ).toBeInTheDocument(); - - await userEvent.type(screen.getByLabelText(/below to continue/i), 'Org1'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Remove connection' })).toBeEnabled()); - await userEvent.click(screen.getByRole('button', { name: 'Remove connection' })); - - await waitFor(() => { - expect(fixtures.clerk.organization?.deleteEnterpriseConnection).toHaveBeenCalledWith('ent_2'); - }); + expect(await screen.findByRole('heading', { name: 'second.com' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'first.com' })).not.toBeInTheDocument(); }); it('opens the wizard on a new connection from Add connection', async () => { @@ -619,7 +444,8 @@ describe('OrganizationSecurityPage', () => { renderPage(wrapper); - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1)); + expect(await screen.findByRole('button', { name: 'clerk.com' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); expect(screen.queryByText('Directory Sync')).not.toBeInTheDocument(); expect(fixtures.clerk.organization?.getDirectorySync).not.toHaveBeenCalled(); }); @@ -636,7 +462,7 @@ describe('OrganizationSecurityPage', () => { const startButton = await screen.findByRole('button', { name: 'Start configuration' }); expect(startButton).toBeEnabled(); expect(screen.queryByText('SSO Required')).not.toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); }); it('surfaces a load error when the directory request fails for any other reason', async () => { @@ -653,7 +479,7 @@ describe('OrganizationSecurityPage', () => { expect(await screen.findByText('Could not load Directory Sync')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Start configuration' })).not.toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(1); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); }); it('disables setup and flags SSO as required when no connection exists', async () => { @@ -697,8 +523,7 @@ describe('OrganizationSecurityPage', () => { const { userEvent } = renderPage(wrapper); - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: 'Deactivate' })).toBeInTheDocument(); @@ -718,13 +543,12 @@ describe('OrganizationSecurityPage', () => { const { userEvent } = renderPage(wrapper); - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); await userEvent.click(screen.getByRole('menuitem', { name: 'Deactivate' })); expect(activeDirectory.update).toHaveBeenCalledWith({ enabled: false }); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(screen.getByRole('button', { name: /open menu/i })); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Activate' })).toBeInTheDocument()); }); @@ -737,8 +561,7 @@ describe('OrganizationSecurityPage', () => { const { userEvent } = renderPage(wrapper); - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); await userEvent.click(screen.getByRole('menuitem', { name: 'Remove' })); expect(await screen.findByRole('heading', { name: 'Remove Directory Sync' })).toBeInTheDocument(); @@ -759,8 +582,7 @@ describe('OrganizationSecurityPage', () => { const { userEvent } = renderPage(wrapper); - await waitFor(() => expect(screen.getAllByRole('button', { name: /open menu/i })).toHaveLength(2)); - await userEvent.click(screen.getAllByRole('button', { name: /open menu/i })[1]); + await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' })); await waitFor(() => expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument()); diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPageWizardLoading.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPageWizardLoading.test.tsx index b3aea010d68..ba03f7c630f 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPageWizardLoading.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPageWizardLoading.test.tsx @@ -122,9 +122,10 @@ describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle const { userEvent } = render(, { wrapper }); - // Enter the wizard from the overview via Edit, which forces the first step. - await userEvent.click(await screen.findByRole('button', { name: /open menu/i })); - await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' })); + // Enter the wizard from the overview via Add connection, which forces the + // first step. The mocked hook pins the scope to `activeConnection`, so the + // wizard opens on it regardless of the requested scope. + await userEvent.click(await screen.findByRole('button', { name: /Add connection/i })); expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument(); // Navigate forward to the Activate step via the breadcrumb (reachable because diff --git a/packages/ui/src/components/OrganizationProfile/enterpriseConnectionStatusBadges.ts b/packages/ui/src/components/OrganizationProfile/enterpriseConnectionStatusBadges.ts new file mode 100644 index 00000000000..73b43cad2fa --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/enterpriseConnectionStatusBadges.ts @@ -0,0 +1,29 @@ +import type { LocalizationKey } from '../../customizables'; +import { localizationKeys } from '../../customizables'; +import type { OrganizationEnterpriseConnectionStatus } from '../ConfigureSSO/domain/organizationEnterpriseConnection'; + +export const STATUS_BADGES: Record< + OrganizationEnterpriseConnectionStatus, + { id: string; colorScheme?: 'primary' | 'danger' | 'warning' | 'success'; label: LocalizationKey } +> = { + unconfigured: { + id: 'unconfigured', + colorScheme: 'primary', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__unconfigured'), + }, + in_progress: { + id: 'inProgress', + colorScheme: 'warning', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__inProgress'), + }, + active: { + id: 'active', + colorScheme: 'success', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__active'), + }, + inactive: { + id: 'inactive', + colorScheme: 'danger', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__inactive'), + }, +}; diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 4c7e9b04aaf..751e8a8498e 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -240,6 +240,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'organizationProfileSecuritySsoDetailRowChip', 'organizationProfileSecuritySsoDetailRowLink', 'organizationProfileSecuritySsoProviderIcon', + 'organizationProfileSecuritySsoConnectionRow', + 'organizationProfileSecuritySsoConnectionPage', 'organizationListPreviewItems', 'organizationListPreviewItem', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index fec705169b0..32b033177ff 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -380,6 +380,8 @@ export type ElementsConfig = { organizationProfileSecuritySsoDetailRowChip: WithOptions; organizationProfileSecuritySsoDetailRowLink: WithOptions; organizationProfileSecuritySsoProviderIcon: WithOptions; + organizationProfileSecuritySsoConnectionRow: WithOptions; + organizationProfileSecuritySsoConnectionPage: WithOptions; organizationListPreviewItems: WithOptions; organizationListPreviewItem: WithOptions; From ab8cd37bc79aa010143968730f596e5130200300 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 14 Sep 2026 10:53:13 -0300 Subject: [PATCH 03/12] chore(repo): add changeset for the enterprise connection page --- .changeset/enterprise-connection-page.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/enterprise-connection-page.md diff --git a/.changeset/enterprise-connection-page.md b/.changeset/enterprise-connection-page.md new file mode 100644 index 00000000000..27907112d9c --- /dev/null +++ b/.changeset/enterprise-connection-page.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Each enterprise connection listed on the organization Security page now opens its own page. It shows the service provider values to copy into the identity provider, an editable identity provider configuration, the connection settings as switches, and the activate, deactivate, and remove actions. The row menu is gone; click the row instead. From b638cfde76354014f4c652e2b6d68dfae7e14a1c Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 14 Sep 2026 12:28:16 -0300 Subject: [PATCH 04/12] refactor(ui): match the connection page to the profile page patterns The page followed the Dashboard: action buttons in the header, a sentence above every section, auto-saving switches, and rows for facts nobody acts on. It now follows the profile pages: read-only rows with an inline Edit that opens a form card, checkboxes with a Save button, one header action, and the remove action in a section at the bottom. The Provider and Created rows and the section descriptions are gone. --- .changeset/enterprise-connection-page.md | 2 +- packages/localizations/src/en-US.ts | 28 +- packages/shared/src/types/elementIds.ts | 5 + packages/shared/src/types/localization.ts | 18 +- .../EnterpriseConnectionPage/DetailRow.tsx | 24 + .../GeneralSection.tsx | 86 +- .../IdentityProviderSection.tsx | 750 +++++++++++------- .../RemoveSection.tsx | 67 ++ .../ServiceProviderSection.tsx | 12 +- .../SettingsSection.tsx | 134 ++-- .../EnterpriseConnectionPage/index.tsx | 71 +- .../EnterpriseConnectionPage.test.tsx | 153 +++- .../OrganizationSecurityPage.test.tsx | 15 +- 13 files changed, 830 insertions(+), 535 deletions(-) create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/DetailRow.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/RemoveSection.tsx diff --git a/.changeset/enterprise-connection-page.md b/.changeset/enterprise-connection-page.md index 27907112d9c..79b93042992 100644 --- a/.changeset/enterprise-connection-page.md +++ b/.changeset/enterprise-connection-page.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Each enterprise connection listed on the organization Security page now opens its own page. It shows the service provider values to copy into the identity provider, an editable identity provider configuration, the connection settings as switches, and the activate, deactivate, and remove actions. The row menu is gone; click the row instead. +Each enterprise connection listed on the organization Security page now opens its own page. It lists the connection name and domains, the service provider values to copy into the identity provider, the identity provider configuration behind an Edit form, and the connection settings as a form you save. The header carries one action, either Activate, Deactivate, or Continue setup, and removing the connection lives in a section at the bottom of the page. The row menu is gone; click the row instead. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index f71ce691428..1854c095555 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1323,33 +1323,35 @@ export const enUS: LocalizationResource = { connectionPage: { actions: { activate: 'Activate', + continueSetup: 'Continue setup', deactivate: 'Deactivate', - openWizard: 'Open setup wizard', - remove: 'Remove', }, general: { - createdLabel: 'Created', domainsLabel: 'Domains', editNameButton: 'Edit', nameForm: { title: 'Rename connection', }, nameLabel: 'Name', - providerLabel: 'Provider', title: 'General', }, identityProvider: { - certificateExpires: 'Certificate expires {{date}}', + certificateExpires: 'Certificate expires', clientSecret: { placeholder: 'Leave empty to keep the current secret', }, - description: 'Retrieve these values from your identity provider.', + editButton: 'Edit', + form: { + title: 'Edit identity provider', + }, title: 'Identity provider', }, - saveButton: 'Save', + remove: { + button: 'Remove connection', + title: 'Remove connection', + }, serviceProvider: { acsUrl: 'Assertion consumer service (ACS) URL', - description: 'Enter these values into your identity provider.', entityId: 'Entity ID', metadataUrl: 'Metadata URL', redirectUri: 'Redirect URI', @@ -1357,23 +1359,23 @@ export const enUS: LocalizationResource = { }, settings: { allowAdditionalIdentifiers: { - description: 'Let users keep other sign-in methods alongside this connection.', + description: 'Members may keep other sign-in methods.', label: 'Allow additional identifiers', }, allowIdpInitiated: { - description: 'Let users start sign-in from the identity provider.', + description: 'Sign-in may start from the identity provider.', label: 'Allow IdP-initiated flow', }, allowSubdomains: { - description: "Accept email addresses on subdomains of the connection's domains.", + description: "Also match subdomains of the connection's domains.", label: 'Allow subdomains', }, forceAuthn: { - description: 'Ask the identity provider to re-authenticate the user on every sign-in.', + description: 'Re-authenticate at the identity provider on every sign-in.', label: 'Force authentication', }, syncUserAttributes: { - description: "Update the user's attributes from the identity provider on every sign-in.", + description: 'Refresh name and email from the identity provider on each sign-in.', label: 'Sync user attributes', }, title: 'Settings', diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index d23b9c9f9d3..869c6906dc0 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -41,6 +41,11 @@ export type FieldId = | 'redirectUri' | 'acsUrl' | 'spEntityId' + | 'syncUserAttributes' + | 'allowAdditionalIdentifiers' + | 'allowSubdomains' + | 'allowIdpInitiated' + | 'forceAuthn' | 'web3WalletName' | 'domain'; export type ProfileSectionId = diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 90373c1cc33..e36df482977 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1221,19 +1221,15 @@ export type __internal_LocalizationResource = { tooltipLabel: LocalizationValue; }; connectionPage: { - saveButton: LocalizationValue; actions: { activate: LocalizationValue; deactivate: LocalizationValue; - openWizard: LocalizationValue; - remove: LocalizationValue; + continueSetup: LocalizationValue; }; general: { title: LocalizationValue; nameLabel: LocalizationValue; domainsLabel: LocalizationValue; - providerLabel: LocalizationValue; - createdLabel: LocalizationValue; editNameButton: LocalizationValue; nameForm: { title: LocalizationValue; @@ -1241,7 +1237,6 @@ export type __internal_LocalizationResource = { }; serviceProvider: { title: LocalizationValue; - description: LocalizationValue; acsUrl: LocalizationValue; entityId: LocalizationValue; metadataUrl: LocalizationValue; @@ -1249,12 +1244,19 @@ export type __internal_LocalizationResource = { }; identityProvider: { title: LocalizationValue; - description: LocalizationValue; - certificateExpires: LocalizationValue<'date'>; + certificateExpires: LocalizationValue; + editButton: LocalizationValue; + form: { + title: LocalizationValue; + }; clientSecret: { placeholder: LocalizationValue; }; }; + remove: { + title: LocalizationValue; + button: LocalizationValue; + }; settings: { title: LocalizationValue; syncUserAttributes: { diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/DetailRow.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/DetailRow.tsx new file mode 100644 index 00000000000..3b0d7b94cac --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/DetailRow.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +import { ProfileSection } from '@/elements/Section'; + +import type { LocalizationKey } from '../../../customizables'; +import { Flex, Text } from '../../../customizables'; + +export const DetailRow = ({ label, children }: { label: LocalizationKey; children: ReactNode }): JSX.Element => ( + + + ({ minWidth: 0, gap: t.space.$1x5 })} + > + {children} + + +); diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx index ffc3d5058f0..bc1bebe8bc9 100644 --- a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/GeneralSection.tsx @@ -1,6 +1,5 @@ import type { EnterpriseConnectionResource } from '@clerk/shared/types'; import type React from 'react'; -import type { ReactNode } from 'react'; import { Action } from '@/elements/Action'; import { useActionContext } from '@/elements/Action/ActionRoot'; @@ -9,75 +8,42 @@ import { Form } from '@/elements/Form'; import { FormButtons } from '@/elements/FormButtons'; import { FormContainer } from '@/elements/FormContainer'; import { ProfileSection } from '@/elements/Section'; -import { formatDate } from '@/ui/utils/formatDate'; import { useFormControl } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; -import type { LocalizationKey } from '../../../customizables'; -import { Badge, descriptors, Flex, localizationKeys, Text } from '../../../customizables'; -import { providerLabel, toProviderCard } from '../../ConfigureSSO/domain/providers'; +import { Badge, descriptors, localizationKeys, Text } from '../../../customizables'; import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; -import type { EnterpriseConnectionProviderType } from '../../ConfigureSSO/types'; +import { DetailRow } from './DetailRow'; type GeneralSectionProps = { connection: EnterpriseConnectionResource; updateConnection: EnterpriseConnectionMutations['updateConnection']; }; -export const GeneralSection = ({ connection, updateConnection }: GeneralSectionProps): JSX.Element => { - const label = providerLabel(toProviderCard(connection.provider as EnterpriseConnectionProviderType)); - - return ( - - - - - - {connection.domains.map(domain => ( - - {domain} - - ))} - - - - {label && } - - - - {connection.createdAt && {formatDate(connection.createdAt)}} - - - - ); -}; - -const DetailRow = ({ label, children }: { label: LocalizationKey; children: ReactNode }): JSX.Element => ( - - - ({ minWidth: 0, gap: t.space.$1x5 })} - > - {children} - - +export const GeneralSection = ({ connection, updateConnection }: GeneralSectionProps): JSX.Element => ( + + + + + + {connection.domains.map(domain => ( + + {domain} + + ))} + + + ); const NameRow = ({ connection, updateConnection }: GeneralSectionProps): JSX.Element => ( diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx index cfb68cb80c4..a3ed9e5f6fa 100644 --- a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/IdentityProviderSection.tsx @@ -2,14 +2,18 @@ import type { EnterpriseConnectionResource } from '@clerk/shared/types'; import type React from 'react'; import { useState } from 'react'; -import { useCardState } from '@/elements/contexts'; +import { Action } from '@/elements/Action'; +import { useActionContext } from '@/elements/Action/ActionRoot'; +import { useCardState, withCardStateProvider } from '@/elements/contexts'; import { Form } from '@/elements/Form'; -import { FormButtonContainer } from '@/elements/FormButtons'; +import { FormButtons } from '@/elements/FormButtons'; +import { FormContainer } from '@/elements/FormContainer'; import { ProfileSection } from '@/elements/Section'; import { formatDate } from '@/ui/utils/formatDate'; import { useFormControl } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; +import type { LocalizationKey } from '../../../customizables'; import { Col, localizationKeys, Text } from '../../../customizables'; import { isOidcProvider } from '../../ConfigureSSO/domain/organizationEnterpriseConnection'; import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; @@ -28,332 +32,464 @@ import { type OidcIdpConfigurationMode, type SamlIdpConfigurationMode, } from '../../ConfigureSSO/steps/ConfigureStep/shared/IdentityProviderConfigurationModes'; +import { DetailRow } from './DetailRow'; type IdentityProviderSectionProps = { connection: EnterpriseConnectionResource; updateConnection: EnterpriseConnectionMutations['updateConnection']; }; +type FormScreenProps = IdentityProviderSectionProps & { onSuccess: () => void; onReset: () => void }; + +type Detail = { id: string; label: LocalizationKey; value: string }; + const SAML_MODES = ['metadataUrl', 'manual'] as const satisfies readonly SamlIdpConfigurationMode[]; const OIDC_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly OidcIdpConfigurationMode[]; -export const IdentityProviderSection = (props: IdentityProviderSectionProps): JSX.Element => ( - - - - {isOidcProvider(props.connection.provider) ? : } - - -); +const samlDetails = (connection: EnterpriseConnectionResource): Detail[] => { + const saml = connection.samlConnection; -const SaveButton = ({ isDisabled }: { isDisabled: boolean }): JSX.Element => ( - - - -); + const details: Detail[] = saml?.idpMetadataUrl + ? [ + { + id: 'idpMetadataUrl', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.label', + ), + value: saml.idpMetadataUrl, + }, + ] + : [ + { + id: 'idpSsoUrl', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', + ), + value: saml?.idpSsoUrl ?? '', + }, + { + id: 'idpEntityId', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.label', + ), + value: saml?.idpEntityId ?? '', + }, + ]; -const SamlForm = ({ connection, updateConnection }: IdentityProviderSectionProps): JSX.Element => { - const card = useCardState(); - const saml = connection.samlConnection; - const existingCertPresent = Boolean(saml?.idpCertificate); + if (saml && saml.idpCertificateExpiresAt > 0) { + details.push({ + id: 'idpCertificateExpiresAt', + label: localizationKeys('organizationProfile.securityPage.connectionPage.identityProvider.certificateExpires'), + value: formatDate(new Date(saml.idpCertificateExpiresAt)), + }); + } - const [mode, setMode] = useState( - saml?.idpSsoUrl || saml?.idpEntityId || saml?.idpCertificate ? 'manual' : 'metadataUrl', - ); - const [certFile, setCertFile] = useState(null); - - const metadataUrlField = useFormControl('idpMetadataUrl', saml?.idpMetadataUrl ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.label'), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.placeholder', - ), - isRequired: true, - }); - - const signOnUrlField = useFormControl('idpSsoUrl', saml?.idpSsoUrl ?? '', { - type: 'text', - label: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', - ), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.placeholder', - ), - isRequired: true, - }); - - const issuerField = useFormControl('idpEntityId', saml?.idpEntityId ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.label'), - placeholder: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.placeholder', - ), - isRequired: true, - }); - - const certificateField = useFormControl('idpCertificate', '', { - type: 'text', - label: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.label', - ), - isRequired: true, - }); - - const isValid = - mode === 'metadataUrl' - ? metadataUrlField.value.trim().length > 0 - : signOnUrlField.value.trim().length > 0 && - issuerField.value.trim().length > 0 && - (certFile !== null || existingCertPresent); - - const formProps: IdentityProviderConfigurationFormProps = - mode === 'metadataUrl' - ? { - mode: 'metadataUrl', - form: { field: metadataUrlField }, - labels: { - description: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.description', - ), + return details; +}; + +const oidcDetails = (connection: EnterpriseConnectionResource): Detail[] => { + const oauthConfig = connection.oauthConfig; + + return [ + { + id: 'clientId', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.label'), + value: oauthConfig?.clientId ?? '', + }, + ...(oauthConfig?.discoveryUrl + ? [ + { + id: 'discoveryUrl', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), + value: oauthConfig.discoveryUrl, }, - } - : { - mode: 'manual', - form: { - signOnUrlField, - issuerField, - certificateField, - certFile, - onCertFileChange: setCertFile, - existingCertPresent, + ] + : [ + { + id: 'authUrl', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), + value: oauthConfig?.authUrl ?? '', }, - labels: { - description: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.description', - ), - uploadFile: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.uploadFile', - ), - replaceFile: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.replaceFile', - ), - removeFile: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.removeFile', - ), - fileUploaded: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.fileUploaded', - ), + { + id: 'tokenUrl', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), + value: oauthConfig?.tokenUrl ?? '', }, - }; - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!isValid) { - return; - } - - card.setError(undefined); - - try { - const payload = await buildSamlConfigurationPayload({ - mode, - metadataUrl: { value: metadataUrlField.value }, - manual: { signOnUrl: signOnUrlField.value, issuer: issuerField.value, certFile }, - }); - - await updateConnection(connection.id, { saml: payload }); - } catch (err) { - if (mode === 'metadataUrl') { - applySamlSubmitError(err, card, metadataUrlField); - } else { - applySamlSubmitError(err, card, signOnUrlField, [issuerField, certificateField]); - } - } - }; + ]), + ]; +}; + +export const IdentityProviderSection = (props: IdentityProviderSectionProps): JSX.Element => { + const isOidc = isOidcProvider(props.connection.provider); + const details = isOidc ? oidcDetails(props.connection) : samlDetails(props.connection); return ( - - { - card.setError(undefined); - setMode(next); - }} - labels={{ - ariaLabel: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.ariaLabel', - ), - metadataUrl: localizationKeys( - 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.metadataUrl', - ), - manual: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.manual'), - }} - /> - - - - {saml && saml.idpCertificateExpiresAt > 0 && ( - - )} - - - + + + + + + {details.map(detail => ( + + {detail.value && {detail.value}} + + ))} + + + + + + + + + + + + + + + ); }; -const OidcForm = ({ connection, updateConnection }: IdentityProviderSectionProps): JSX.Element => { - const card = useCardState(); - const oauthConfig = connection.oauthConfig; - - const [mode, setMode] = useState( - oauthConfig?.authUrl || oauthConfig?.tokenUrl ? 'manual' : 'discoveryUrl', +const IdentityProviderScreen = ({ + isOidc, + ...props +}: IdentityProviderSectionProps & { isOidc: boolean }): JSX.Element => { + const { close } = useActionContext(); + + return isOidc ? ( + + ) : ( + ); +}; - const clientIdField = useFormControl('clientId', 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( - 'organizationProfile.securityPage.connectionPage.identityProvider.clientSecret.placeholder', - ), - }); - - 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 = - clientIdField.value.trim().length > 0 && - (mode === 'discoveryUrl' - ? discoveryUrlField.value.trim().length > 0 - : authUrlField.value.trim().length > 0 && tokenUrlField.value.trim().length > 0); - - const endpointsProps: OidcEndpointsConfigurationFormProps = - mode === 'discoveryUrl' - ? { - mode: 'discoveryUrl', - form: { discoveryUrlField }, - labels: { - description: localizationKeys( - 'configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.description', - ), - }, +const SamlForm = withCardStateProvider( + ({ connection, updateConnection, onSuccess, onReset }: FormScreenProps): JSX.Element => { + const card = useCardState(); + const saml = connection.samlConnection; + const existingCertPresent = Boolean(saml?.idpCertificate); + + const [mode, setMode] = useState( + saml?.idpSsoUrl || saml?.idpEntityId || saml?.idpCertificate ? 'manual' : 'metadataUrl', + ); + const [certFile, setCertFile] = useState(null); + + const metadataUrlField = useFormControl('idpMetadataUrl', saml?.idpMetadataUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.placeholder', + ), + isRequired: true, + }); + + const signOnUrlField = useFormControl('idpSsoUrl', saml?.idpSsoUrl ?? '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.label', + ), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signOnUrl.placeholder', + ), + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', saml?.idpEntityId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.label'), + placeholder: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.issuer.placeholder', + ), + isRequired: true, + }); + + const certificateField = useFormControl('idpCertificate', '', { + type: 'text', + label: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.label', + ), + isRequired: true, + }); + + const isValid = + mode === 'metadataUrl' + ? metadataUrlField.value.trim().length > 0 + : signOnUrlField.value.trim().length > 0 && + issuerField.value.trim().length > 0 && + (certFile !== null || existingCertPresent); + + const formProps: IdentityProviderConfigurationFormProps = + mode === 'metadataUrl' + ? { + mode: 'metadataUrl', + form: { field: metadataUrlField }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.metadataUrl.description', + ), + }, + } + : { + mode: 'manual', + form: { + signOnUrlField, + issuerField, + certificateField, + certFile, + onCertFileChange: setCertFile, + existingCertPresent, + }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.description', + ), + uploadFile: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.uploadFile', + ), + replaceFile: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.replaceFile', + ), + removeFile: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.removeFile', + ), + fileUploaded: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.manual.signingCertificate.fileUploaded', + ), + }, + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValid) { + return; + } + + card.setError(undefined); + + try { + const payload = await buildSamlConfigurationPayload({ + mode, + metadataUrl: { value: metadataUrlField.value }, + manual: { signOnUrl: signOnUrlField.value, issuer: issuerField.value, certFile }, + }); + + await updateConnection(connection.id, { saml: payload }); + onSuccess(); + } catch (err) { + if (mode === 'metadataUrl') { + applySamlSubmitError(err, card, metadataUrlField); + } else { + applySamlSubmitError(err, card, signOnUrlField, [issuerField, certificateField]); } - : { - mode: 'manual', - form: { authUrlField, tokenUrlField, userInfoUrlField }, - labels: { - description: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.description'), - }, - }; - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!isValid) { - return; - } - - card.setError(undefined); - - try { - await updateConnection(connection.id, { - oidc: { - clientId: clientIdField.value.trim(), - clientSecret: clientSecretField.value.trim() || undefined, - ...(mode === 'discoveryUrl' - ? { discoveryUrl: discoveryUrlField.value.trim() } - : { - authUrl: authUrlField.value.trim(), - tokenUrl: tokenUrlField.value.trim(), - userInfoUrl: userInfoUrlField.value.trim(), - }), - }, - }); - } catch (err) { - handleError(err as Error, [clientIdField, clientSecretField], card.setError); - } - }; + } + }; + + return ( + + + { + card.setError(undefined); + setMode(next); + }} + labels={{ + ariaLabel: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.ariaLabel', + ), + metadataUrl: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.metadataUrl', + ), + manual: localizationKeys( + 'configureSSO.configureStep.samlCustom.identityProviderMetadataStep.modes.manual', + ), + }} + /> + + + + + + + ); + }, +); - return ( - - - - - - - - - - { - card.setError(undefined); - setMode(next); - }} - labels={{ - ariaLabel: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.ariaLabel'), - discoveryUrl: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.discoveryUrl'), - manual: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.manual'), - }} - /> - - - - - - ); -}; +const OidcForm = withCardStateProvider( + ({ connection, updateConnection, onSuccess, onReset }: FormScreenProps): JSX.Element => { + const card = useCardState(); + const oauthConfig = connection.oauthConfig; + + const [mode, setMode] = useState( + oauthConfig?.authUrl || oauthConfig?.tokenUrl ? 'manual' : 'discoveryUrl', + ); + + const clientIdField = useFormControl('clientId', 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( + 'organizationProfile.securityPage.connectionPage.identityProvider.clientSecret.placeholder', + ), + }); + + 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 = + clientIdField.value.trim().length > 0 && + (mode === 'discoveryUrl' + ? discoveryUrlField.value.trim().length > 0 + : authUrlField.value.trim().length > 0 && tokenUrlField.value.trim().length > 0); + + const endpointsProps: 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 onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValid) { + return; + } + + card.setError(undefined); + + try { + await updateConnection(connection.id, { + oidc: { + clientId: clientIdField.value.trim(), + clientSecret: clientSecretField.value.trim() || undefined, + ...(mode === 'discoveryUrl' + ? { discoveryUrl: discoveryUrlField.value.trim() } + : { + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }), + }, + }); + onSuccess(); + } catch (err) { + handleError(err as Error, [clientIdField, clientSecretField], card.setError); + } + }; + + return ( + + + + + + + + + + + { + card.setError(undefined); + setMode(next); + }} + labels={{ + ariaLabel: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.ariaLabel'), + discoveryUrl: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.discoveryUrl'), + manual: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.modes.manual'), + }} + /> + + + + + + + ); + }, +); diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/RemoveSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/RemoveSection.tsx new file mode 100644 index 00000000000..24f82049e13 --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/RemoveSection.tsx @@ -0,0 +1,67 @@ +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import { useState } from 'react'; + +import { ProfileSection } from '@/elements/Section'; + +import { localizationKeys } from '../../../customizables'; +import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { ResetConnectionDialog } from '../../ConfigureSSO/ResetConnectionDialog'; + +type RemoveSectionProps = { + connection: EnterpriseConnectionResource; + deleteConnection: EnterpriseConnectionMutations['deleteConnection']; + organizationName: string; + contentRef: React.RefObject; + onBack: () => void; +}; + +export const RemoveSection = ({ + connection, + deleteConnection, + organizationName, + contentRef, + onBack, +}: RemoveSectionProps): JSX.Element => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + return ( + + ({ + paddingTop: 0, + paddingBottom: 0, + paddingInlineStart: t.space.$1, + })} + > + setIsDialogOpen(true)} + localizationKey={localizationKeys('organizationProfile.securityPage.connectionPage.remove.button')} + /> + + + setIsDialogOpen(false)} + confirmationValue={organizationName} + title={localizationKeys('organizationProfile.securityPage.removeDialog.title')} + subtitle={localizationKeys('organizationProfile.securityPage.removeDialog.subtitle', { + name: connection.name, + })} + confirmButtonLabel={localizationKeys('organizationProfile.securityPage.removeDialog.confirmButton')} + onDelete={async () => { + await deleteConnection(connection.id); + onBack(); + }} + contentRef={contentRef} + /> + + ); +}; diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx index 8078d1239de..6766a8cf66c 100644 --- a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/ServiceProviderSection.tsx @@ -64,16 +64,7 @@ const ServiceProviderSectionRoot = ({ children }: { children: ReactNode }): JSX. id='sso' centered={false} > - - - {children} - + {children} ); @@ -89,6 +80,7 @@ const CopyableValue = ({ label, value }: { label: LocalizationKey; value: string /> = [ { id: 'syncUserAttributes', appliesTo: 'all', - isChecked: connection => connection.syncUserAttributes, + isChecked: connection => Boolean(connection.syncUserAttributes), toParams: syncUserAttributes => ({ syncUserAttributes }), }, { @@ -57,70 +61,98 @@ const SETTINGS: ReadonlyArray = [ }, ]; +const mergeParams = ( + params: UpdateOrganizationEnterpriseConnectionParams[], +): UpdateOrganizationEnterpriseConnectionParams => + params.reduce( + (merged, next) => ({ + ...merged, + ...next, + ...(merged.saml || next.saml ? { saml: { ...merged.saml, ...next.saml } } : {}), + }), + {}, + ); + +const settingById = (id: SettingId): Setting => SETTINGS.find(setting => setting.id === id) as Setting; + +const useSettingField = (id: SettingId, connection: EnterpriseConnectionResource) => + useFormControl(id, '', { + type: 'checkbox', + label: localizationKeys(`organizationProfile.securityPage.connectionPage.settings.${id}.label`), + defaultChecked: settingById(id).isChecked(connection), + }); + +const settingDescription = (id: SettingId) => + localizationKeys(`organizationProfile.securityPage.connectionPage.settings.${id}.description`); + type SettingsSectionProps = { connection: EnterpriseConnectionResource; family: ProviderFamily; updateConnection: EnterpriseConnectionMutations['updateConnection']; }; -export const SettingsSection = ({ connection, family, updateConnection }: SettingsSectionProps): JSX.Element => ( - - - {SETTINGS.filter(setting => setting.appliesTo === 'all' || setting.appliesTo === family).map(setting => ( - - ))} - - -); - -const SettingRow = ({ - setting, - connection, - updateConnection, -}: { - setting: Setting; - connection: EnterpriseConnectionResource; - updateConnection: EnterpriseConnectionMutations['updateConnection']; -}): JSX.Element => { +export const SettingsSection = ({ connection, family, updateConnection }: SettingsSectionProps): JSX.Element => { const card = useCardState(); - const onChange = async (checked: boolean) => { + const fields: Record> = { + syncUserAttributes: useSettingField('syncUserAttributes', connection), + allowAdditionalIdentifiers: useSettingField('allowAdditionalIdentifiers', connection), + allowSubdomains: useSettingField('allowSubdomains', connection), + allowIdpInitiated: useSettingField('allowIdpInitiated', connection), + forceAuthn: useSettingField('forceAuthn', connection), + }; + + const applicable = SETTINGS.filter(setting => setting.appliesTo === 'all' || setting.appliesTo === family); + const changed = applicable.filter(setting => Boolean(fields[setting.id].checked) !== setting.isChecked(connection)); + + const onReset = () => { + card.setError(undefined); + applicable.forEach(setting => fields[setting.id].setChecked(setting.isChecked(connection))); + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (changed.length === 0) { + return; + } + card.setError(undefined); - card.setLoading(); try { - await updateConnection(connection.id, setting.toParams(checked)); + await updateConnection( + connection.id, + mergeParams(changed.map(setting => setting.toParams(Boolean(fields[setting.id].checked)))), + ); } catch (err) { handleError(err as Error, [], card.setError); - } finally { - card.setIdle(); } }; return ( - - void onChange(checked)} - label={localizationKeys(`organizationProfile.securityPage.connectionPage.settings.${setting.id}.label`)} - /> - - + + + {applicable.map(setting => ( + + + + ))} + + + + ); }; diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx index ba182578e14..21f2953d3d7 100644 --- a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx @@ -1,5 +1,4 @@ import type { EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types'; -import { useState } from 'react'; import { Card } from '@/elements/Card'; import { useCardState, withCardStateProvider } from '@/elements/contexts'; @@ -13,12 +12,12 @@ import { isOidcProvider } from '../../ConfigureSSO/domain/organizationEnterprise import { providerLabel, toProviderCard } from '../../ConfigureSSO/domain/providers'; import type { EnterpriseConnectionMutations } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; import { useOrganizationEnterpriseConnectionStatus } from '../../ConfigureSSO/hooks/useOrganizationEnterpriseConnectionStatus'; -import { ResetConnectionDialog } from '../../ConfigureSSO/ResetConnectionDialog'; import type { EnterpriseConnectionProviderType } from '../../ConfigureSSO/types'; import { STATUS_BADGES } from '../enterpriseConnectionStatusBadges'; import { SecurityBackControl } from '../SecurityBackControl'; import { GeneralSection } from './GeneralSection'; import { IdentityProviderSection } from './IdentityProviderSection'; +import { RemoveSection } from './RemoveSection'; import { OidcServiceProviderSection, SamlServiceProviderSection } from './ServiceProviderSection'; import { SettingsSection } from './SettingsSection'; @@ -41,7 +40,7 @@ export const EnterpriseConnectionPage = withCardStateProvider( onBack, onOpenWizard, }: EnterpriseConnectionPageProps): JSX.Element => { - const { updateConnection } = enterpriseConnectionMutations; + const { updateConnection, deleteConnection } = enterpriseConnectionMutations; const isOidc = isOidcProvider(connection.provider); return ( @@ -57,8 +56,6 @@ export const EnterpriseConnectionPage = withCardStateProvider( @@ -84,6 +81,14 @@ export const EnterpriseConnectionPage = withCardStateProvider( family={isOidc ? 'oidc' : 'saml'} updateConnection={updateConnection} /> + + @@ -91,16 +96,18 @@ export const EnterpriseConnectionPage = withCardStateProvider( }, ); +type ConnectionHeaderProps = Pick< + EnterpriseConnectionPageProps, + 'connection' | 'enterpriseConnectionMutations' | 'onBack' | 'onOpenWizard' +>; + const ConnectionHeader = ({ connection, - enterpriseConnectionMutations: { setConnectionActive, deleteConnection }, - organizationName, - contentRef, + enterpriseConnectionMutations: { setConnectionActive }, onBack, onOpenWizard, -}: EnterpriseConnectionPageProps): JSX.Element => { +}: ConnectionHeaderProps): JSX.Element => { const card = useCardState(); - const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false); const { status } = useOrganizationEnterpriseConnectionStatus(connection); const badge = STATUS_BADGES[status]; @@ -157,13 +164,8 @@ const ConnectionHeader = ({ colorScheme={badge.colorScheme} localizationKey={badge.label} /> - - ({ gap: t.space.$2 })} - > - {status === 'active' && ( + {status === 'active' ? ( + + - - - - - - - - + ); }; diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx index 6e02e6a9273..a827e918e75 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/EnterpriseConnectionPage.test.tsx @@ -125,6 +125,7 @@ describe('EnterpriseConnectionPage', () => { expect(screen.getByText('Service provider')).toBeInTheDocument(); expect(screen.getByText('Identity provider')).toBeInTheDocument(); expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Danger zone')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Remove connection' })).toBeInTheDocument(); expect(screen.getByDisplayValue('https://accounts.clerk.com/v1/acs')).toBeInTheDocument(); @@ -224,17 +225,32 @@ describe('EnterpriseConnectionPage', () => { }); describe('header actions', () => { - it('deactivates an active connection', async () => { + it('offers no header action for an active connection', async () => { const { wrapper, fixtures } = await createFixtures(withPageFixtures); withNoTestRuns(fixtures); - fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({ active: false } as any); - - const { userEvent } = renderPage(wrapper, fixtures, samlConnection({ active: true })); - await userEvent.click(await screen.findByRole('button', { name: 'Deactivate' })); + renderPage(wrapper, fixtures, samlConnection({ active: true })); - expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { active: false }); + expect(await screen.findByRole('heading', { name: 'clerk.com' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Activate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Deactivate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Continue setup' })).not.toBeInTheDocument(); + }); + + it('activates an inactive connection', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ + data: [{ id: 'run_1', status: 'success' }], + total_count: 1, + } as any); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({ active: true } as any); + + const { userEvent } = renderPage(wrapper, fixtures, samlConnection({ active: false })); + + await userEvent.click(await screen.findByRole('button', { name: 'Activate' })); + + expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { active: true }); }); it('offers Continue setup for a connection that is still mid-setup', async () => { @@ -254,15 +270,38 @@ describe('EnterpriseConnectionPage', () => { }); }); - describe('removing', () => { - it('removes the connection from the bottom section and returns to the overview', async () => { + describe('danger zone', () => { + it('deactivates an active connection', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + fixtures.clerk.organization?.updateEnterpriseConnection.mockResolvedValue({ active: false } as any); + + const { userEvent } = renderPage(wrapper, fixtures, samlConnection({ active: true })); + + await userEvent.click(await screen.findByRole('button', { name: 'Deactivate connection' })); + + expect(fixtures.clerk.organization?.updateEnterpriseConnection).toHaveBeenCalledWith('ent_1', { active: false }); + }); + + it('offers no Deactivate connection button for an inactive connection', async () => { + const { wrapper, fixtures } = await createFixtures(withPageFixtures); + withNoTestRuns(fixtures); + + renderPage(wrapper, fixtures, samlConnection({ active: false })); + + expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Deactivate connection' })).not.toBeInTheDocument(); + }); + + it('removes the connection from the danger zone and returns to the overview', async () => { const { wrapper, fixtures } = await createFixtures(withPageFixtures); withNoTestRuns(fixtures); fixtures.clerk.organization?.deleteEnterpriseConnection.mockResolvedValue({} as any); const { userEvent, onBack } = renderPage(wrapper, fixtures, samlConnection({ active: true })); - await userEvent.click(await screen.findByRole('button', { name: 'Remove connection' })); + expect(await screen.findAllByRole('button', { name: 'Remove connection' })).toHaveLength(1); + await userEvent.click(screen.getByRole('button', { name: 'Remove connection' })); const dialog = within(await screen.findByRole('dialog')); expect(dialog.getByRole('heading', { name: 'Remove SSO connection' })).toBeInTheDocument(); diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index 439477ab0b8..5932a124c58 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -400,8 +400,8 @@ describe('OrganizationSecurityPage', () => { await userEvent.click(await screen.findByRole('button', { name: /Add connection/i })); - expect(await screen.findByText('Adding a new SSO connection')).toBeInTheDocument(); - expect(screen.queryByText(/^Editing /)).not.toBeInTheDocument(); + expect(await screen.findByText(/add and verify ownership of the domains/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Add connection/i })).not.toBeInTheDocument(); }); }); diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 751e8a8498e..6aa8bca05df 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -558,7 +558,6 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'enterpriseConnectionButton', 'enterpriseConnectionButtonText', - 'configureSSOConnectionScopeBanner', 'configureSSOHeader', 'configureSSOHeaderBackButton', 'configureSSOMobileNavbar', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 32b033177ff..8d32391b38b 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -694,7 +694,6 @@ export type ElementsConfig = { enterpriseConnectionButton: WithOptions; enterpriseConnectionButtonText: WithOptions; - configureSSOConnectionScopeBanner: WithOptions; configureSSOHeader: WithOptions; configureSSOHeaderBackButton: WithOptions; configureSSOMobileNavbar: WithOptions; From b4e7af83758a155688ccd69911150d55c94ee4a4 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 15 Sep 2026 14:47:41 -0300 Subject: [PATCH 07/12] fix(ui): pick a connection's domains in the wizard, align the connection page The domains step shows a checkbox per verified organization domain. A domain another connection of the organization already authenticates is disabled and labelled with that connection's name, since FAPI rejects a domain shared by two connections. A new connection is created with the selected domains instead of every organization domain, and Continue gates on the connection's own domains rather than on every organization domain being verified. The provider step shows the create's `domains` field error instead of dropping it. On the connection page, domains render as plain rows under the name so the text lines up with it, and the identity provider row aligns like the other rows. Co-Authored-By: Claude Fable 5.1 --- .changeset/enterprise-connection-page.md | 2 + packages/localizations/src/en-US.ts | 5 +- packages/shared/src/types/localization.ts | 2 + .../components/ConfigureSSO/ConfigureSSO.tsx | 6 + .../ConfigureSSO/ConfigureSSOContext.tsx | 17 ++ .../ConfigureSSO/ConfigureSSOWizard.tsx | 12 +- .../organizationEnterpriseConnection.test.ts | 85 +++++++--- .../organizationEnterpriseConnection.ts | 53 ++++++- ...eOrganizationEnterpriseConnection.test.tsx | 59 ++++++- .../useOrganizationEnterpriseConnection.ts | 96 +++++++++--- .../steps/OrganizationDomainsStep.tsx | 93 ++++++++--- .../ConfigureSSO/steps/SelectProviderStep.tsx | 17 +- .../OrganizationDomainsStep.test.tsx | 147 ++++++++++++++++++ .../__tests__/SelectProviderStep.test.tsx | 27 +++- .../DomainsSection.tsx | 27 ++-- .../IdentityProviderSection.tsx | 5 +- .../OrganizationSecurityPage.tsx | 6 + ...nizationSecurityPageWizardLoading.test.tsx | 3 + .../src/customizables/elementDescriptors.ts | 1 + packages/ui/src/internal/appearance.ts | 3 +- 20 files changed, 560 insertions(+), 106 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/__tests__/OrganizationDomainsStep.test.tsx diff --git a/.changeset/enterprise-connection-page.md b/.changeset/enterprise-connection-page.md index 6ca577b1183..36ac149de0e 100644 --- a/.changeset/enterprise-connection-page.md +++ b/.changeset/enterprise-connection-page.md @@ -5,3 +5,5 @@ --- Each enterprise connection listed on the organization Security page now opens its own page. It lists the connection name and domains, the service provider values to copy into the identity provider, the identity provider configuration behind an Edit form, and the connection settings as a form you save. The header carries one action, either Activate or Continue setup, and deactivating or removing the connection lives in a Danger zone section at the bottom of the page. The row menu is gone; click the row instead. + +The setup wizard's domains step now shows a checkbox per verified domain, so an admin picks which domains a connection covers. A domain another connection of the organization already authenticates is disabled and labelled with that connection's name, and an error from creating the connection is shown on the provider step instead of being dropped. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 62a57a50348..86e90db0303 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -828,9 +828,11 @@ export const enUS: LocalizationResource = { }, organizationDomainsStep: { domainCard: { + badge__claimed: 'Used by {{connection}}', badge__expired: 'Expired', badge__unverified: 'Unverified', badge__verified: 'Verified', + checkboxLabel: 'Use {{domain}} for this connection', expiredAtLabel: "Domain verification expired on {{ date | shortDate('en-US') }}. Verify again to generate a new DNS record.", expiredLabel: 'Domain verification expired. Verify again to generate a new DNS record.', @@ -860,7 +862,8 @@ export const enUS: LocalizationResource = { subtitle__inactive: "You're about to remove {{domain}} from this enterprise connection.", title: 'Removing domain', }, - subtitle: 'Add and verify ownership of the domains your organization uses to sign in.', + subtitle: + 'Add and verify ownership of the domains your organization uses to sign in, then pick the ones this connection covers.', title: 'Add SSO domains', }, resetConnectionDialog: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index ccbb375d17f..f59aa3ac435 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1662,6 +1662,8 @@ export type __internal_LocalizationResource = { badge__verified: LocalizationValue; badge__unverified: LocalizationValue; badge__expired: LocalizationValue; + badge__claimed: LocalizationValue<'connection'>; + checkboxLabel: LocalizationValue<'domain'>; verifiedAtLabel: LocalizationValue<'date'>; expiredAtLabel: LocalizationValue<'date'>; expiredLabel: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f247f215933..63f6aa09c3d 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -47,6 +47,9 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec const { isLoading, enterpriseConnection, + connectionDomains, + setConnectionDomains, + claimedDomains, organizationEnterpriseConnection, testRuns, enterpriseConnectionMutations, @@ -64,6 +67,9 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec organizationEnterpriseConnection={organizationEnterpriseConnection} testRuns={testRuns} enterpriseConnection={enterpriseConnection} + connectionDomains={connectionDomains} + setConnectionDomains={setConnectionDomains} + claimedDomains={claimedDomains} contentRef={contentRef} enterpriseConnectionMutations={enterpriseConnectionMutations} organizationDomainMutations={organizationDomainMutations} diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index cb6f963ea62..272f9dd6657 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -18,6 +18,11 @@ export type { OrganizationDomainMutations }; */ export interface ConfigureSSOData { enterpriseConnection: EnterpriseConnectionResource | undefined; + /** The scoped connection's domains, a draft while the scope is `new`. */ + connectionDomains: string[]; + setConnectionDomains: (domains: string[]) => Promise; + /** Domains other connections of the organization authenticate, keyed to that connection's name. */ + claimedDomains: Map; /** Ref to the wizard's scrollable content container. */ contentRef: React.RefObject; enterpriseConnectionMutations: EnterpriseConnectionMutations; @@ -30,6 +35,9 @@ export interface ConfigureSSOData { interface ConfigureSSOProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; + connectionDomains: string[]; + setConnectionDomains: (domains: string[]) => Promise; + claimedDomains: Map; organizationEnterpriseConnection: OrganizationEnterpriseConnection; testRuns: TestRunsView; organizationDomains: OrganizationDomainResource[] | undefined; @@ -44,6 +52,9 @@ ConfigureSSOContext.displayName = 'ConfigureSSOContext'; export const ConfigureSSOProvider = ({ enterpriseConnection, + connectionDomains, + setConnectionDomains, + claimedDomains, organizationEnterpriseConnection, testRuns, organizationDomains, @@ -57,6 +68,9 @@ export const ConfigureSSOProvider = ({ () => ({ contentRef, enterpriseConnection, + connectionDomains, + setConnectionDomains, + claimedDomains, organizationEnterpriseConnection, testRuns, organizationDomains, @@ -72,6 +86,9 @@ export const ConfigureSSOProvider = ({ testRuns, organizationDomains, enterpriseConnection, + connectionDomains, + setConnectionDomains, + claimedDomains, onExit, ], ); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx index 5da7247775c..8fa7209e768 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx @@ -4,7 +4,7 @@ import { CardStateProvider } from '@/elements/contexts'; import { ConfigureSSOProvider } from './ConfigureSSOContext'; import { ConfigureSSOHeader } from './ConfigureSSOHeader'; -import { areAllOrganizationDomainsVerified } from './domain/organizationEnterpriseConnection'; +import { areConnectionDomainsReady } from './domain/organizationEnterpriseConnection'; import { Wizard, type WizardStepConfig } from './elements/Wizard'; import { ActivateStep, ConfigureStep, OrganizationDomainsStep, TestConfigurationStep } from './steps'; @@ -14,17 +14,17 @@ export type ConfigureSSOWizardProps = Omit { - const { organizationEnterpriseConnection: c, organizationDomains } = props; + const { organizationEnterpriseConnection: c, connectionDomains, organizationDomains } = props; - const allDomainsVerified = areAllOrganizationDomainsVerified(organizationDomains); + const domainsReady = areConnectionDomainsReady(connectionDomains, organizationDomains); const steps = React.useMemo( () => [ - { id: 'verify-domain', label: 'Domains', isComplete: () => allDomainsVerified }, + { id: 'verify-domain', label: 'Domains', isComplete: () => domainsReady }, { id: 'configure', label: 'Connection', - isReachable: () => allDomainsVerified || c.hasConnection, + isReachable: () => domainsReady || c.hasConnection, isComplete: () => c.hasMinimumConfiguration || c.isActive, }, { @@ -40,7 +40,7 @@ export const ConfigureSSOWizard = ({ title, forceInitialStep, ...props }: Config isComplete: () => c.isActive, }, ], - [c, allDomainsVerified], + [c, domainsReady], ); const initialStepId = forceInitialStep ? steps[0].id : undefined; 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 0974f811736..c96887ee67b 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -9,8 +9,10 @@ import type { import { describe, expect, it } from 'vitest'; import { - areAllOrganizationDomainsVerified, + areConnectionDomainsReady, connectionBackingEmail, + defaultConnectionDomains, + domainsClaimedByOtherConnections, isEnterpriseConnectionConfigured, organizationEnterpriseConnection, sortEnterpriseConnections, @@ -364,34 +366,73 @@ describe('isEnterpriseConnectionConfigured', () => { }); }); -describe('areAllOrganizationDomainsVerified', () => { - const makeDomain = (status: 'verified' | 'unverified' | null): OrganizationDomainResource => - ({ - id: `dmn_${status}`, - name: 'acme.com', - ownershipVerification: status ? { status } : null, - }) as OrganizationDomainResource; +const makeDomain = (name: string, status: 'verified' | 'unverified' | null): OrganizationDomainResource => + ({ + id: `dmn_${name}`, + name, + ownershipVerification: status ? { status } : null, + }) as OrganizationDomainResource; + +describe('domainsClaimedByOtherConnections', () => { + const connection = (id: string, name: string, domains: string[]) => + ({ id, name, domains }) as unknown as EnterpriseConnectionResource; + + it('maps each domain of every other connection to that connection name', () => { + const claimed = domainsClaimedByOtherConnections( + [connection('ent_1', 'Okta', ['acme.com']), connection('ent_2', 'Google', ['example.com', 'other.com'])], + 'ent_1', + ); + + expect(claimed.get('acme.com')).toBeUndefined(); + expect(claimed.get('example.com')).toBe('Google'); + expect(claimed.get('other.com')).toBe('Google'); + }); + + it('treats every connection as another one while the scope is new', () => { + const claimed = domainsClaimedByOtherConnections([connection('ent_1', 'Okta', ['acme.com'])], undefined); + + expect(claimed.get('acme.com')).toBe('Okta'); + }); +}); - it('undefined domains → false', () => { - expect(areAllOrganizationDomainsVerified(undefined)).toBe(false); +describe('defaultConnectionDomains', () => { + it('keeps verified domains no other connection claims', () => { + const domains = [ + makeDomain('acme.com', 'verified'), + makeDomain('pending.com', 'unverified'), + makeDomain('taken.com', 'verified'), + ]; + + expect(defaultConnectionDomains(domains, new Map([['taken.com', 'Okta']]))).toEqual(['acme.com']); }); - it('null domains → false', () => { - expect(areAllOrganizationDomainsVerified(null)).toBe(false); + + it('undefined domains → empty', () => { + expect(defaultConnectionDomains(undefined, new Map())).toEqual([]); }); - it('empty list → false', () => { - expect(areAllOrganizationDomainsVerified([])).toBe(false); +}); + +describe('areConnectionDomainsReady', () => { + it('no connection domains → false', () => { + expect(areConnectionDomainsReady([], [makeDomain('acme.com', 'verified')])).toBe(false); }); - it('a single verified domain → true', () => { - expect(areAllOrganizationDomainsVerified([makeDomain('verified')])).toBe(true); + it('every connection domain verified → true', () => { + expect(areConnectionDomainsReady(['acme.com'], [makeDomain('acme.com', 'verified')])).toBe(true); }); - it('every domain verified → true', () => { - expect(areAllOrganizationDomainsVerified([makeDomain('verified'), makeDomain('verified')])).toBe(true); + it('a connection domain still unverified → false', () => { + expect( + areConnectionDomainsReady( + ['acme.com', 'pending.com'], + [makeDomain('acme.com', 'verified'), makeDomain('pending.com', 'unverified')], + ), + ).toBe(false); }); - it('any unverified domain → false', () => { - expect(areAllOrganizationDomainsVerified([makeDomain('verified'), makeDomain('unverified')])).toBe(false); + it('an unverified organization domain outside the connection does not block', () => { + expect( + areConnectionDomainsReady(['acme.com'], [makeDomain('acme.com', 'verified'), makeDomain('pending.com', null)]), + ).toBe(true); }); - it('domain without ownership verification → false', () => { - expect(areAllOrganizationDomainsVerified([makeDomain(null)])).toBe(false); + it('a connection domain missing from the organization list counts as ready', () => { + expect(areConnectionDomainsReady(['legacy.com'], [])).toBe(true); }); }); diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index 260c3fcd0b6..d08be459fa0 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -87,8 +87,57 @@ export const isEnterpriseConnectionConfigured = ( 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'); +export const isOrganizationDomainVerified = (domain: OrganizationDomainResource): boolean => + domain.ownershipVerification?.status === 'verified'; + +/** + * Domains every connection other than `scopedConnectionId` already authenticates, + * keyed to that connection's name. FAPI rejects a domain shared by two + * connections of the same instance, so the wizard never offers these. + */ +export const domainsClaimedByOtherConnections = ( + connections: EnterpriseConnectionResource[], + scopedConnectionId: string | undefined, +): Map => { + const claimed = new Map(); + for (const connection of connections) { + if (connection.id === scopedConnectionId) { + continue; + } + for (const domain of connection.domains ?? []) { + claimed.set(domain, connection.name); + } + } + return claimed; +}; + +/** + * The domains a connection would receive before its admin touches the + * selection: every verified organization domain no other connection claims. + */ +export const defaultConnectionDomains = ( + organizationDomains: OrganizationDomainResource[] | null | undefined, + claimed: Map, +): string[] => + (organizationDomains ?? []) + .filter(domain => isOrganizationDomainVerified(domain) && !claimed.has(domain.name)) + .map(domain => domain.name); + +/** + * Whether the connection's domains let the wizard move past the domains step: + * at least one domain, none of them still pending verification. A connection + * domain missing from the organization list was accepted by FAPI already, so it + * does not block. + */ +export const areConnectionDomainsReady = ( + connectionDomains: readonly string[], + organizationDomains: OrganizationDomainResource[] | null | undefined, +): boolean => + connectionDomains.length > 0 && + connectionDomains.every(name => { + const organizationDomain = organizationDomains?.find(domain => domain.name === name); + return !organizationDomain || isOrganizationDomainVerified(organizationDomain); + }); const connectionStatus = ({ hasConnection, 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 49458352532..16a526b0f4b 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx @@ -11,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // `samlConnection.idpSsoUrl` + `idpEntityId`, and the active path reads `active`. type MockConnection = { id: string; + name?: string; + domains?: string[]; provider: string; active?: boolean; createdAt?: Date; @@ -50,10 +52,12 @@ const mutationSpies = vi.hoisted(() => ({ })); const domainsState = vi.hoisted(() => ({ - data: undefined as Array<{ name: string }> | undefined, + data: undefined as Array<{ name: string; ownershipVerification?: { status: string } }> | undefined, isLoading: false, })); +const verifiedDomain = (name: string) => ({ name, ownershipVerification: { status: 'verified' } }); + vi.mock('@clerk/shared/react', () => ({ __internal_useOrganizationEnterpriseConnections: () => ({ data: connectionsState.data, @@ -188,24 +192,39 @@ describe('useOrganizationEnterpriseConnection — test-runs gating', () => { }); describe('useOrganizationEnterpriseConnection — mutations', () => { - it('createConnection forwards the provider and the organization domains', async () => { - domainsState.data = [{ name: 'acme.com' }, { name: 'example.com' }]; + it('createConnection forwards the provider and the verified organization domains', async () => { + domainsState.data = [verifiedDomain('acme.com'), verifiedDomain('example.com'), { name: 'pending.com' }]; const { result } = renderHook(() => useOrganizationEnterpriseConnection()); await result.current.enterpriseConnectionMutations.createConnection('saml_okta'); expect(mutationSpies.create).toHaveBeenCalledTimes(1); - // `name` is derived by FAPI, so it is not sent from the client; `domains` - // are the verified organization domains passed straight through by the - // caller. + // `name` is derived by FAPI, so it is not sent from the client; an + // unverified domain never reaches the create. expect(mutationSpies.create).toHaveBeenCalledWith({ provider: 'saml_okta', domains: ['acme.com', 'example.com'], }); }); - it('createConnection forwards undefined domains when the organization has none', async () => { + it('createConnection leaves out a domain another connection already authenticates', async () => { + connectionsState.data = [{ ...configuredConnection('ent_other'), name: 'Other', domains: ['acme.com'] }]; + domainsState.data = [verifiedDomain('acme.com'), verifiedDomain('example.com')]; + + const { result } = renderHook(() => useOrganizationEnterpriseConnection()); + + act(() => result.current.selectConnection({ kind: 'new' })); + + expect(result.current.claimedDomains.get('acme.com')).toBe('Other'); + expect(result.current.connectionDomains).toEqual(['example.com']); + + await result.current.enterpriseConnectionMutations.createConnection('saml_okta'); + + expect(mutationSpies.create).toHaveBeenCalledWith({ provider: 'saml_okta', domains: ['example.com'] }); + }); + + it('createConnection forwards an empty domain list when the organization has none', async () => { domainsState.data = undefined; const { result } = renderHook(() => useOrganizationEnterpriseConnection()); @@ -215,10 +234,34 @@ describe('useOrganizationEnterpriseConnection — mutations', () => { expect(mutationSpies.create).toHaveBeenCalledTimes(1); expect(mutationSpies.create).toHaveBeenCalledWith({ provider: 'saml_okta', - domains: undefined, + domains: [], }); }); + it('setConnectionDomains edits the draft for a new scope and updates an existing connection', async () => { + domainsState.data = [verifiedDomain('acme.com'), verifiedDomain('example.com')]; + + const { result } = renderHook(() => useOrganizationEnterpriseConnection()); + + await act(() => result.current.setConnectionDomains(['example.com'])); + + expect(mutationSpies.update).not.toHaveBeenCalled(); + expect(result.current.connectionDomains).toEqual(['example.com']); + + await result.current.enterpriseConnectionMutations.createConnection('saml_okta'); + + expect(mutationSpies.create).toHaveBeenCalledWith({ provider: 'saml_okta', domains: ['example.com'] }); + + connectionsState.data = [{ ...configuredConnection('ent_1'), domains: ['acme.com'] }]; + const existing = renderHook(() => useOrganizationEnterpriseConnection()); + + expect(existing.result.current.connectionDomains).toEqual(['acme.com']); + + await act(() => existing.result.current.setConnectionDomains(['acme.com', 'example.com'])); + + expect(mutationSpies.update).toHaveBeenCalledWith('ent_1', { domains: ['acme.com', 'example.com'] }); + }); + it('setConnectionActive forwards only the active flag to update', async () => { const { result } = renderHook(() => useOrganizationEnterpriseConnection()); diff --git a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts index 1188f32c516..701d6ec3a4f 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts @@ -21,6 +21,8 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import type { ConnectionScope } from '../domain/connectionScope'; import { + defaultConnectionDomains, + domainsClaimedByOtherConnections, isEnterpriseConnectionConfigured, type OrganizationEnterpriseConnection, organizationEnterpriseConnection as buildOrganizationEnterpriseConnection, @@ -43,9 +45,9 @@ import { type RefreshTestRunsOptions, useEnterpriseConnectionTestRuns } from './ */ export interface EnterpriseConnectionMutations { /** - * Creates a new enterprise connection for the active organization. The - * verified organization domains are sourced from the hook itself, so callers - * never thread them through. + * Creates a new enterprise connection for the active organization with the + * domains held in [UseOrganizationEnterpriseConnectionResult.connectionDomains], + * so callers never thread them through. */ createConnection: (provider: ProviderType) => Promise; /** Replaces the connection `id` with a fresh one for `provider`. */ @@ -89,6 +91,16 @@ export interface UseOrganizationEnterpriseConnectionResult { selectConnection: (scope: ConnectionScope) => void; /** The scoped connection, `undefined` while the scope is `new`. */ enterpriseConnection: EnterpriseConnectionResource | undefined; + /** + * The domains the scoped connection authenticates. An existing connection + * reads them off the resource; a `new` scope keeps a draft until the create, + * seeded with every verified organization domain no other connection claims. + */ + connectionDomains: string[]; + /** Replaces [connectionDomains]: an update for an existing connection, a draft edit for a `new` scope. */ + setConnectionDomains: (domains: string[]) => Promise; + /** Domains other connections of the organization already authenticate, keyed to that connection's name. */ + claimedDomains: Map; /** The domain entity the wizard makes every flow decision from. */ organizationEnterpriseConnection: OrganizationEnterpriseConnection; enterpriseConnectionMutations: EnterpriseConnectionMutations; @@ -142,11 +154,20 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise const enterpriseConnections = useMemo(() => sortEnterpriseConnections(sourceConnections ?? []), [sourceConnections]); // `null` resolves to the first connection so the standalone host, which has no list UI, still edits a deterministic one. - const [scope, setScope] = useState(null); + const [requestedScope, setRequestedScope] = useState(null); + // The `new` scope's domains until the create lands; `null` means the admin has not touched the default yet. + const [draftDomains, setDraftDomains] = useState(null); + + const setScope = useCallback((next: ConnectionScope | null) => { + setRequestedScope(next); + setDraftDomains(null); + }, []); const connectionScope = useMemo( - () => scope ?? (enterpriseConnections[0] ? { kind: 'existing', id: enterpriseConnections[0].id } : { kind: 'new' }), - [scope, enterpriseConnections], + () => + requestedScope ?? + (enterpriseConnections[0] ? { kind: 'existing', id: enterpriseConnections[0].id } : { kind: 'new' }), + [requestedScope, enterpriseConnections], ); const enterpriseConnection = @@ -154,7 +175,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise ? enterpriseConnections.find(connection => connection.id === connectionScope.id) : undefined; - const selectConnection = useCallback((next: ConnectionScope) => setScope(next), []); + const selectConnection = useCallback((next: ConnectionScope) => setScope(next), [setScope]); // Whether the scoped connection already existed the first time the source // query settled. Captured during render (not in an effect) the first time the @@ -203,22 +224,34 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise const { session } = useSession(); const { organization } = useOrganization(); + const claimedDomains = useMemo( + () => domainsClaimedByOtherConnections(enterpriseConnections, enterpriseConnection?.id), + [enterpriseConnections, enterpriseConnection], + ); + + // A domain verified from this wizard joins the scoped connection on its own; + // one another connection claims stays out, since FAPI would reject it. const handleDomainOwnershipVerified = useCallback( async (verifiedDomains: OrganizationDomainResource[]) => { - if (!enterpriseConnection) { + const current = enterpriseConnection ? (enterpriseConnection.domains ?? []) : draftDomains; + if (current === null) { return; } - const verifiedDomainNames = verifiedDomains.map(domain => domain.name); - const domains = Array.from(new Set([...(enterpriseConnection.domains ?? []), ...verifiedDomainNames])); - const hasNewDomains = domains.length !== (enterpriseConnection.domains?.length ?? 0); - if (!hasNewDomains) { + const domains = Array.from( + new Set([...current, ...verifiedDomains.map(domain => domain.name).filter(name => !claimedDomains.has(name))]), + ); + if (domains.length === current.length) { return; } - await updateEnterpriseConnection(enterpriseConnection.id, { domains }); + if (enterpriseConnection) { + await updateEnterpriseConnection(enterpriseConnection.id, { domains }); + } else { + setDraftDomains(domains); + } }, - [enterpriseConnection, updateEnterpriseConnection], + [enterpriseConnection, draftDomains, claimedDomains, updateEnterpriseConnection], ); const { @@ -233,6 +266,25 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise onOwnershipVerified: handleDomainOwnershipVerified, }); + const connectionDomains = useMemo( + () => + enterpriseConnection + ? (enterpriseConnection.domains ?? []) + : (draftDomains ?? defaultConnectionDomains(organizationDomains, claimedDomains)), + [enterpriseConnection, draftDomains, organizationDomains, claimedDomains], + ); + + const setConnectionDomains = useCallback( + async (domains: string[]) => { + if (enterpriseConnection) { + await updateEnterpriseConnection(enterpriseConnection.id, { domains }); + } else { + setDraftDomains(domains); + } + }, + [enterpriseConnection, updateEnterpriseConnection], + ); + const organizationDomainMutations = useMemo( () => ({ createDomain, @@ -245,10 +297,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise const enterpriseConnectionMutations = useMemo(() => { const createConnection: EnterpriseConnectionMutations['createConnection'] = async provider => { - const created = await createEnterpriseConnection({ - provider, - domains: organizationDomains?.map(domain => domain.name), - }); + const created = await createEnterpriseConnection({ provider, domains: connectionDomains }); if (created) { setScope({ kind: 'existing', id: created.id }); @@ -264,10 +313,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise const replaced = enterpriseConnections.find(connection => connection.id === id); await deleteEnterpriseConnection(id); - const created = await createEnterpriseConnection({ - provider, - domains: replaced?.domains ?? organizationDomains?.map(domain => domain.name), - }); + const created = await createEnterpriseConnection({ provider, domains: replaced?.domains ?? connectionDomains }); if (created) { setScope({ kind: 'existing', id: created.id }); @@ -315,9 +361,10 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise }; }, [ organization, - organizationDomains, + connectionDomains, enterpriseConnections, connectionScope, + setScope, createEnterpriseConnection, updateEnterpriseConnection, deleteEnterpriseConnection, @@ -373,6 +420,9 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise connectionScope, selectConnection, enterpriseConnection, + connectionDomains, + setConnectionDomains, + claimedDomains, organizationEnterpriseConnection, enterpriseConnectionMutations, testRuns, diff --git a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx index 8c449089080..51a20307afa 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx @@ -8,6 +8,7 @@ import { Badge, Box, Button, + CheckboxInput, Col, descriptors, Flex, @@ -31,7 +32,7 @@ import { useFormControl } from '@/ui/utils/useFormControl'; import { getFieldError, getGlobalError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../ConfigureSSOContext'; -import { areAllOrganizationDomainsVerified } from '../domain/organizationEnterpriseConnection'; +import { areConnectionDomainsReady, isOrganizationDomainVerified } from '../domain/organizationEnterpriseConnection'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard/WizardContext'; import { RemoveDomainDialog } from '../RemoveDomainDialog'; @@ -40,11 +41,13 @@ export const OrganizationDomainsStep = (): JSX.Element => { const { t } = useLocalizations(); const { enterpriseConnection, + connectionDomains, + setConnectionDomains, + claimedDomains, organizationDomains, organizationEnterpriseConnection, contentRef, organizationDomainMutations: { createDomain, revalidate, prepareOwnershipVerification }, - enterpriseConnectionMutations: { updateConnection }, } = useConfigureSSO(); const { goPrev, goNext, isFirstStep, isLastStep } = useWizard(); const card = useCardState(); @@ -92,24 +95,36 @@ export const OrganizationDomainsStep = (): JSX.Element => { } }; + const handleToggleDomain = async (domain: OrganizationDomainResource, checked: boolean) => { + card.setError(undefined); + + const domains = checked + ? [...connectionDomains, domain.name] + : connectionDomains.filter(name => name !== domain.name); + + try { + await setConnectionDomains(domains); + } catch (err: any) { + const apiError = getFieldError(err) ?? getGlobalError(err); + card.setError(apiError); + } + }; + const handleRemoveDomain = async (domain: OrganizationDomainResource) => { - if (enterpriseConnection) { - const domains = enterpriseConnection.domains.filter(name => name !== domain.name); - await updateConnection(enterpriseConnection.id, { domains }); + if (connectionDomains.includes(domain.name)) { + await setConnectionDomains(connectionDomains.filter(name => name !== domain.name)); } await domain.delete(); await revalidate(); }; - const hasAllDomainsVerified = areAllOrganizationDomainsVerified(organizationDomains); + const domainsReady = areConnectionDomainsReady(connectionDomains, organizationDomains); - // A connection needs at least one verified domain to point at, so the last - // remaining verified domain cannot be removed while a connection exists - const verifiedDomainCount = - organizationDomains?.filter(domain => domain.ownershipVerification?.status === 'verified').length ?? 0; - const lockLastVerifiedDomain = Boolean(enterpriseConnection); - const lastVerifiedDomainTooltip = enterpriseConnection?.active + // An existing connection must keep at least one domain, so its last one can + // be neither deselected nor removed. + const lockLastConnectionDomain = Boolean(enterpriseConnection) && connectionDomains.length === 1; + const lastConnectionDomainTooltip = enterpriseConnection?.active ? localizationKeys('configureSSO.organizationDomainsStep.domainCard.removeButtonTooltip__lastVerifiedDomainActive') : localizationKeys('configureSSO.organizationDomainsStep.domainCard.removeButtonTooltip__lastVerifiedDomain'); @@ -161,17 +176,20 @@ export const OrganizationDomainsStep = (): JSX.Element => { })} > {organizationDomains.map(domain => { - const isVerified = domain.ownershipVerification?.status === 'verified'; - const isLastVerifiedDomain = isVerified && verifiedDomainCount === 1; - const isRemoveDisabled = lockLastVerifiedDomain && isLastVerifiedDomain; + const isSelected = connectionDomains.includes(domain.name); + const isLocked = isSelected && lockLastConnectionDomain; return ( void handleToggleDomain(domain, checked)} + isToggleDisabled={isLocked} onRemove={() => setDomainToRemove(domain)} onPrepareOwnershipVerification={() => handlePrepareOwnershipVerification(domain)} - isRemoveDisabled={isRemoveDisabled} - removeDisabledTooltip={lastVerifiedDomainTooltip} + isRemoveDisabled={isLocked} + removeDisabledTooltip={lastConnectionDomainTooltip} /> ); })} @@ -187,7 +205,7 @@ export const OrganizationDomainsStep = (): JSX.Element => { /> goNext()} - isDisabled={isLastStep || !hasAllDomainsVerified} + isDisabled={isLastStep || !domainsReady} /> @@ -364,25 +382,39 @@ const DomainSuggestion = ({ onSubmit }: { onSubmit: (domain: string) => Promise< const DomainCard = ({ domain, + isSelected, + claimedBy, + onToggle, + isToggleDisabled = false, onRemove, onPrepareOwnershipVerification, isRemoveDisabled = false, removeDisabledTooltip, }: { domain: OrganizationDomainResource; + /** Whether the scoped connection authenticates this domain. */ + isSelected: boolean; + /** The name of the other connection that already authenticates this domain. */ + claimedBy: string | undefined; + onToggle: (checked: boolean) => void; + isToggleDisabled?: boolean; onRemove: () => void; onPrepareOwnershipVerification: () => Promise; isRemoveDisabled?: boolean; removeDisabledTooltip?: ReturnType; }): JSX.Element | null => { + const { t } = useLocalizations(); + if (!domain.name) { return null; } const ownershipVerification = domain.ownershipVerification; - const isVerified = ownershipVerification?.status === 'verified'; + const isVerified = isOrganizationDomainVerified(domain); const isExpired = ownershipVerification?.status === 'expired'; const cardId = ownershipVerification?.status ?? 'unverified'; + // Only a verified domain no other connection claims can join this one. + const isSelectable = isVerified && !claimedBy; const removeButton = (