From 35f41d8fc84797bf88cd1396b5d794f018b8cc70 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 11 Sep 2026 13:36:02 -0300 Subject: [PATCH 1/3] feat(ui): sort enterprise connections deterministically FAPI returns the organization's enterprise connections unordered. Every reader now sorts by createdAt, then id, so "the first connection" means the same connection on every render and in every host. --- .../ConfigureDirectorySyncContext.tsx | 7 ++-- .../SecurityDirectorySyncSection.tsx | 3 +- .../organizationEnterpriseConnection.test.ts | 41 +++++++++++++++++++ .../organizationEnterpriseConnection.ts | 23 ++++++++++- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx index a7c976d0d7d..e52e8468a1e 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -5,6 +5,7 @@ import { import type { DirectorySyncProvider, DirectorySyncResource, EnterpriseConnectionResource } from '@clerk/shared/types'; import React, { type PropsWithChildren } from 'react'; +import { sortEnterpriseConnections } from '../ConfigureSSO/domain/organizationEnterpriseConnection'; import type { DirectorySyncProviderMeta } from './providerMeta'; import { DIRECTORY_SYNC_PROVIDERS, directorySyncProviderForConnection } from './providerMeta'; @@ -49,9 +50,9 @@ export const ConfigureDirectorySyncProvider = ({ children, }: ConfigureDirectorySyncProviderProps): JSX.Element => { const { data: connections, isLoading: isLoadingConnections } = __internal_useOrganizationEnterpriseConnections(); - // The self-serve SSO flow enforces a single connection per organization; the - // directory hangs off that same connection. - const connection = connections?.[0]; + // Per-connection Directory Sync is not modelled yet, so the directory hangs + // off the organization's first connection in deterministic order. + const connection = sortEnterpriseConnections(connections ?? [])[0]; const enterpriseConnectionId = connection?.id ?? null; const { diff --git a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx index 5bbfe2dd5de..9a418e306ac 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/SecurityDirectorySyncSection.tsx @@ -13,6 +13,7 @@ import { handleError } from '@/utils/errorHandler'; import type { LocalizationKey } from '../../customizables'; import { Badge, Button, Col, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables'; +import { sortEnterpriseConnections } from '../ConfigureSSO/domain/organizationEnterpriseConnection'; import { ResetConnectionDialog } from '../ConfigureSSO/ResetConnectionDialog'; import { directorySyncProviderForConnection } from './providerMeta'; @@ -56,7 +57,7 @@ export const SecurityDirectorySyncSection = ({ isLoading: isLoadingConnections, error: connectionsError, } = __internal_useOrganizationEnterpriseConnections(); - const connection = connections?.[0]; + const connection = sortEnterpriseConnections(connections ?? [])[0]; const hasSsoConnection = Boolean(connection); const isGoogle = connection ? directorySyncProviderForConnection(connection.provider) === 'google' : false; const { 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 3b424285b7c..0974f811736 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -13,6 +13,7 @@ import { connectionBackingEmail, isEnterpriseConnectionConfigured, organizationEnterpriseConnection, + sortEnterpriseConnections, } from '../organizationEnterpriseConnection'; const makeSamlConnection = (overrides: Partial = {}): SamlAccountConnectionResource => @@ -425,3 +426,43 @@ describe('connectionBackingEmail', () => { expect(connectionBackingEmail(undefined)).toBeUndefined(); }); }); + +describe('sortEnterpriseConnections', () => { + const at = (id: string, createdAt: Date | null) => makeConnection({ id, createdAt }); + + it('orders by createdAt ascending regardless of the input order', () => { + const older = at('enc_b', new Date('2024-01-01T00:00:00Z')); + const newer = at('enc_a', new Date('2024-06-01T00:00:00Z')); + + expect(sortEnterpriseConnections([newer, older]).map(c => c.id)).toEqual(['enc_b', 'enc_a']); + }); + + it('puts connections without a createdAt last', () => { + const dated = at('enc_b', new Date('2024-01-01T00:00:00Z')); + const undated = at('enc_a', null); + + expect(sortEnterpriseConnections([undated, dated]).map(c => c.id)).toEqual(['enc_b', 'enc_a']); + }); + + it('breaks createdAt ties by id', () => { + const sameInstant = new Date('2024-01-01T00:00:00Z'); + const connections = [at('enc_c', sameInstant), at('enc_a', sameInstant), at('enc_b', sameInstant)]; + + expect(sortEnterpriseConnections(connections).map(c => c.id)).toEqual(['enc_a', 'enc_b', 'enc_c']); + }); + + it('breaks ties by id among connections without a createdAt', () => { + expect(sortEnterpriseConnections([at('enc_z', null), at('enc_a', null)]).map(c => c.id)).toEqual([ + 'enc_a', + 'enc_z', + ]); + }); + + it('does not mutate the input array', () => { + const connections = [at('enc_b', new Date('2024-06-01T00:00:00Z')), at('enc_a', new Date('2024-01-01T00:00:00Z'))]; + + sortEnterpriseConnections(connections); + + expect(connections.map(c => c.id)).toEqual(['enc_b', 'enc_a']); + }); +}); diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index fa4baeb775f..260c3fcd0b6 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -24,12 +24,33 @@ export const isOidcProvider = (provider: string): provider is OidcProviderType = export const connectionBackingEmail = (user: UserResource | null | undefined): EmailAddressResource | undefined => user?.primaryEmailAddress ?? user?.emailAddresses?.find(e => e.verification.status !== 'verified'); +/** FAPI returns the list unordered; every reader sorts through here so they agree on "the first one". */ +export const sortEnterpriseConnections = ( + connections: EnterpriseConnectionResource[], +): EnterpriseConnectionResource[] => + [...connections].sort((a, b) => { + const aCreatedAt = a.createdAt?.getTime(); + const bCreatedAt = b.createdAt?.getTime(); + + if (aCreatedAt !== bCreatedAt) { + if (aCreatedAt === undefined) { + return 1; + } + if (bCreatedAt === undefined) { + return -1; + } + return aCreatedAt - bCreatedAt; + } + + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + /** * The inputs {@link organizationEnterpriseConnection} composes. Every field is a * plain value — the entity is pure and knows nothing about React or the wizard. */ export interface OrganizationEnterpriseConnectionInput { - /** FAPI currently supports a single connection per organization. */ + /** The connection in scope, i.e. the one the wizard is editing. */ connection: EnterpriseConnectionResource | null | undefined; /** Probed upstream — not a property of the connection resource itself. */ hasSuccessfulTestRun: boolean; From 14f9a690c5d94e1688bf2c5824b32bde4f8a20c0 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 11 Sep 2026 13:36:02 -0300 Subject: [PATCH 2/3] feat(ui): scope the SSO wizard to one connection and list them all The Security page renders one row per enterprise connection with its own status badge, domains, and actions, plus an Add connection button. The wizard edits an explicit connection scope (new or an existing id) instead of the first item in the list, shows a banner naming that connection when the organization has more than one, and changeProvider takes the id of the connection it replaces. Reset, remove, and change-provider dialogs name the connection they act on. --- packages/localizations/src/en-US.ts | 15 +- packages/shared/src/types/localization.ts | 14 +- .../ConfigureSSO/ChangeProviderDialog.tsx | 4 +- .../components/ConfigureSSO/ConfigureSSO.tsx | 4 + .../ConfigureSSO/ConfigureSSOContext.tsx | 11 + .../ConfigureSSO/ConfigureSSOWizard.tsx | 3 + .../ConfigureSSO/ConnectionScopeBanner.tsx | 32 ++ .../ConfigureSSO/ResetConnectionDialog.tsx | 5 +- .../__tests__/ConfigureSSO.test.tsx | 69 ++++ .../__tests__/ResetConnectionDialog.test.tsx | 10 +- .../ConfigureSSO/domain/connectionScope.ts | 2 + .../ConfigureSSO/domain/providers.ts | 60 ++++ .../components/ConfigureSSO/elements/Step.tsx | 1 + ...eOrganizationEnterpriseConnection.test.tsx | 71 +++- .../useOrganizationEnterpriseConnection.ts | 98 ++++-- ...eOrganizationEnterpriseConnectionStatus.ts | 24 ++ .../ConfigureSSO/steps/SelectProviderStep.tsx | 57 +-- .../__tests__/SelectProviderStep.test.tsx | 29 +- .../OrganizationSecurityPage.tsx | 12 +- .../SecuritySsoSection.tsx | 327 +++++++++--------- .../OrganizationSecurityPage.test.tsx | 115 +++++- ...nizationSecurityPageWizardLoading.test.tsx | 3 + .../src/customizables/elementDescriptors.ts | 1 + packages/ui/src/internal/appearance.ts | 1 + 24 files changed, 705 insertions(+), 263 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/ConnectionScopeBanner.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/domain/connectionScope.ts create mode 100644 packages/ui/src/components/ConfigureSSO/domain/providers.ts create mode 100644 packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnectionStatus.ts diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index fb069af7045..d18b1e2e119 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -316,9 +316,16 @@ export const enUS: LocalizationResource = { changeProviderDialog: { cancelButton: 'Cancel', confirmButton: 'Change provider', - subtitle: 'Switching to {{provider}} will remove your {{currentProvider}} connection and require a new setup.', + subtitle: + 'Switching to {{provider}} will remove the {{currentProvider}} connection "{{name}}" and require a new setup.', title: 'Change provider to {{provider}}', }, + connectionScopeBanner: { + subtitle__adding: 'This organization already has {{count}} SSO connections.', + subtitle__editing: 'This organization has {{count}} SSO connections. Changes here apply only to this connection.', + title__adding: 'Adding a new SSO connection', + title__editing: 'Editing "{{name}}"', + }, configureStep: { activeConnectionWarning: { dismiss: 'Dismiss', @@ -862,7 +869,7 @@ export const enUS: LocalizationResource = { confirmationFieldPlaceholder: '{{name}}', resetButton: 'Reset connection', subtitle: - 'Are you sure you want to reset the connection? This action is irreversible and you will have to configure all steps again', + 'Are you sure you want to reset the connection "{{name}}"? This action is irreversible and you will have to configure all steps again', title: 'Reset connection', }, selectProviderStep: { @@ -1337,7 +1344,7 @@ export const enUS: LocalizationResource = { removeDialog: { confirmButton: 'Remove connection', subtitle: - 'Are you sure you want to remove the connection? This action is irreversible and deletes the connection and all of its configuration.', + 'Are you sure you want to remove the connection "{{name}}"? This action is irreversible and deletes the connection and all of its configuration.', title: 'Remove SSO connection', }, ssoSection: { @@ -1348,9 +1355,11 @@ export const enUS: LocalizationResource = { descriptionLine1: 'Require members with a matching email domain to sign in through your identity provider.', domainLabel: 'Domains:', menuAction__activate: 'Activate', + menuAction__continue: 'Continue configuration', menuAction__deactivate: 'Deactivate', menuAction__edit: 'Edit', menuAction__remove: 'Remove', + primaryButton__addConnection: 'Add connection', primaryButton__continueConfiguration: 'Continue configuration', primaryButton__startConfiguration: 'Start configuration', title: 'SSO', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 2689cc55d05..a7758f5425a 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1197,7 +1197,7 @@ export type __internal_LocalizationResource = { title: LocalizationValue; removeDialog: { title: LocalizationValue; - subtitle: LocalizationValue; + subtitle: LocalizationValue<'name'>; confirmButton: LocalizationValue; }; ssoSection: { @@ -1209,8 +1209,10 @@ export type __internal_LocalizationResource = { descriptionLine1: LocalizationValue; primaryButton__startConfiguration: LocalizationValue; primaryButton__continueConfiguration: LocalizationValue; + primaryButton__addConnection: LocalizationValue; domainLabel: LocalizationValue; menuAction__edit: LocalizationValue; + menuAction__continue: LocalizationValue; menuAction__activate: LocalizationValue; menuAction__deactivate: LocalizationValue; menuAction__remove: LocalizationValue; @@ -1554,12 +1556,18 @@ export type __internal_LocalizationResource = { navbar: { title: LocalizationValue; }; + connectionScopeBanner: { + title__editing: LocalizationValue<'name'>; + subtitle__editing: LocalizationValue<'count'>; + title__adding: LocalizationValue; + subtitle__adding: LocalizationValue<'count'>; + }; resetConnectionDialog: { cancelButton: LocalizationValue; confirmationFieldLabel: LocalizationValue<'name'>; confirmationFieldPlaceholder: LocalizationValue<'name'>; resetButton: LocalizationValue; - subtitle: LocalizationValue; + subtitle: LocalizationValue<'name'>; title: LocalizationValue; }; selectProviderStep: { @@ -1580,7 +1588,7 @@ export type __internal_LocalizationResource = { }; changeProviderDialog: { title: LocalizationValue<'provider'>; - subtitle: LocalizationValue<'provider' | 'currentProvider'>; + subtitle: LocalizationValue<'provider' | 'currentProvider' | 'name'>; cancelButton: LocalizationValue; confirmButton: LocalizationValue; }; diff --git a/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx b/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx index 44623de0505..b44606a03e0 100644 --- a/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx +++ b/packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx @@ -11,6 +11,7 @@ type ChangeProviderDialogProps = { isSubmitting?: boolean; nextProviderLabel: LocalizationKey; currentProviderLabel: LocalizationKey; + connectionName: string; contentRef: React.RefObject; }; @@ -40,7 +41,7 @@ export const ChangeProviderDialog = (props: ChangeProviderDialogProps): JSX.Elem }; const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProviderDialogProps) => { - const { onClose, onConfirm, isSubmitting, nextProviderLabel, currentProviderLabel } = props; + const { onClose, onConfirm, isSubmitting, nextProviderLabel, currentProviderLabel, connectionName } = props; const { t } = useLocalizations(); const nextProvider = t(nextProviderLabel); @@ -67,6 +68,7 @@ const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProvider localizationKey={localizationKeys('configureSSO.changeProviderDialog.subtitle', { provider: nextProvider, currentProvider, + name: connectionName, })} /> diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f247f215933..1fbcaea084e 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -47,6 +47,8 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec const { isLoading, enterpriseConnection, + enterpriseConnections, + connectionScope, organizationEnterpriseConnection, testRuns, enterpriseConnectionMutations, @@ -64,6 +66,8 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec organizationEnterpriseConnection={organizationEnterpriseConnection} testRuns={testRuns} enterpriseConnection={enterpriseConnection} + enterpriseConnections={enterpriseConnections} + connectionScope={connectionScope} 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..90e6d68fdca 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -1,6 +1,7 @@ import type { EnterpriseConnectionResource, OrganizationDomainResource } from '@clerk/shared/types'; import React, { type PropsWithChildren } from 'react'; +import type { ConnectionScope } from './domain/connectionScope'; import type { OrganizationEnterpriseConnection } from './domain/organizationEnterpriseConnection'; import type { EnterpriseConnectionMutations, @@ -18,6 +19,8 @@ export type { OrganizationDomainMutations }; */ export interface ConfigureSSOData { enterpriseConnection: EnterpriseConnectionResource | undefined; + enterpriseConnections: EnterpriseConnectionResource[]; + connectionScope: ConnectionScope; /** Ref to the wizard's scrollable content container. */ contentRef: React.RefObject; enterpriseConnectionMutations: EnterpriseConnectionMutations; @@ -30,6 +33,8 @@ export interface ConfigureSSOData { interface ConfigureSSOProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; + enterpriseConnections: EnterpriseConnectionResource[]; + connectionScope: ConnectionScope; organizationEnterpriseConnection: OrganizationEnterpriseConnection; testRuns: TestRunsView; organizationDomains: OrganizationDomainResource[] | undefined; @@ -44,6 +49,8 @@ ConfigureSSOContext.displayName = 'ConfigureSSOContext'; export const ConfigureSSOProvider = ({ enterpriseConnection, + enterpriseConnections, + connectionScope, organizationEnterpriseConnection, testRuns, organizationDomains, @@ -57,6 +64,8 @@ export const ConfigureSSOProvider = ({ () => ({ contentRef, enterpriseConnection, + enterpriseConnections, + connectionScope, organizationEnterpriseConnection, testRuns, organizationDomains, @@ -72,6 +81,8 @@ export const ConfigureSSOProvider = ({ testRuns, organizationDomains, enterpriseConnection, + enterpriseConnections, + connectionScope, onExit, ], ); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx index 5da7247775c..67c5501ec25 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx @@ -4,6 +4,7 @@ import { CardStateProvider } from '@/elements/contexts'; import { ConfigureSSOProvider } from './ConfigureSSOContext'; import { ConfigureSSOHeader } from './ConfigureSSOHeader'; +import { ConnectionScopeBanner } from './ConnectionScopeBanner'; import { areAllOrganizationDomainsVerified } from './domain/organizationEnterpriseConnection'; import { Wizard, type WizardStepConfig } from './elements/Wizard'; import { ActivateStep, ConfigureStep, OrganizationDomainsStep, TestConfigurationStep } from './steps'; @@ -53,6 +54,8 @@ export const ConfigureSSOWizard = ({ title, forceInitialStep, ...props }: Config > + + diff --git a/packages/ui/src/components/ConfigureSSO/ConnectionScopeBanner.tsx b/packages/ui/src/components/ConfigureSSO/ConnectionScopeBanner.tsx new file mode 100644 index 00000000000..597b7a18a96 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConnectionScopeBanner.tsx @@ -0,0 +1,32 @@ +import { descriptors, localizationKeys } from '@/customizables'; +import { Alert } from '@/ui/elements/Alert'; + +import { useConfigureSSO } from './ConfigureSSOContext'; + +export const ConnectionScopeBanner = (): JSX.Element | null => { + const { enterpriseConnections, enterpriseConnection } = useConfigureSSO(); + + if (enterpriseConnections.length <= 1) { + return null; + } + + const count = enterpriseConnections.length; + + return ( + ({ marginInline: t.space.$5, marginBlockStart: t.space.$5 })} + /> + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx index 2f25c627ac2..c18ce9d393c 100644 --- a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx +++ b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx @@ -15,9 +15,9 @@ type ResetConnectionDialogProps = { confirmationValue: string; onDelete: () => Promise; contentRef: React.RefObject; + subtitle: LocalizationKey; /** Defaults to the Reset copy; overridden when the dialog is reused for the Remove action. */ title?: LocalizationKey; - subtitle?: LocalizationKey; confirmButtonLabel?: LocalizationKey; }; @@ -47,9 +47,8 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El }; const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnectionDialogProps) => { - const { onClose, onDelete, confirmationValue } = props; + const { onClose, onDelete, confirmationValue, subtitle } = props; const title = props.title ?? localizationKeys('configureSSO.resetConnectionDialog.title'); - const subtitle = props.subtitle ?? localizationKeys('configureSSO.resetConnectionDialog.subtitle'); const confirmButtonLabel = props.confirmButtonLabel ?? localizationKeys('configureSSO.resetConnectionDialog.resetButton'); const card = useCardState(); diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx index b5309a864c5..fc3d88f7fbe 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx @@ -116,6 +116,75 @@ describe('ConfigureSSO', () => { }); }); + describe('connection scope banner', () => { + const samlConnection = { + idpSsoUrl: 'https://idp.example.com/sso', + idpEntityId: 'https://idp.example.com/entity', + idpCertificate: 'CERT', + }; + + const connection = (overrides: Record) => + ({ + provider: 'saml_okta', + active: true, + organizationId: 'Org1', + domains: ['clerk.com'], + samlConnection, + ...overrides, + }) as any; + + const withOrganizationFixtures = (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'] }], + }); + }; + + it('names the scoped connection when the organization has more than one', async () => { + const { wrapper, fixtures } = await createFixtures(withOrganizationFixtures); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([ + connection({ id: 'ent_2', name: 'second.com', createdAt: new Date('2024-06-01T00:00:00Z') }), + connection({ id: 'ent_1', name: 'first.com', createdAt: new Date('2024-01-01T00:00:00Z') }), + ]); + mockOrganizationDomains(fixtures, [verifiedDomain]); + fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ + data: [{ id: 'run_1', status: 'success' }], + total_count: 1, + } as any); + + const { findByText } = render(, { wrapper }); + + // The standalone host has no list UI, so it falls back to the oldest connection. + expect(await findByText('Editing "first.com"')).toBeInTheDocument(); + expect( + await findByText('This organization has 2 SSO connections. Changes here apply only to this connection.'), + ).toBeInTheDocument(); + }); + + it('stays silent for a single connection', async () => { + const { wrapper, fixtures } = await createFixtures(withOrganizationFixtures); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([ + connection({ id: 'ent_1', name: 'first.com', createdAt: new Date('2024-01-01T00:00:00Z') }), + ]); + mockOrganizationDomains(fixtures, [verifiedDomain]); + fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ + data: [{ id: 'run_1', status: 'success' }], + total_count: 1, + } as any); + + const { findByText, queryByText } = render(, { wrapper }); + + await findByText(/sso connection is active/i); + expect(queryByText(/^Editing /)).not.toBeInTheDocument(); + expect(queryByText('Adding a new SSO connection')).not.toBeInTheDocument(); + }); + }); + describe('state machine mounts on the right step', () => { it('mounts on select-provider when all organization domains are verified and there is no connection', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx index 40aa39a9314..79adf340160 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx @@ -32,7 +32,9 @@ const renderDialog = ( onDelete={() => deleteConnection('idn_connection_1')} contentRef={{ current: null }} title={props.title} - subtitle={props.subtitle} + subtitle={ + props.subtitle ?? localizationKeys('configureSSO.resetConnectionDialog.subtitle', { name: 'Acme SSO' }) + } confirmButtonLabel={props.confirmButtonLabel} /> , @@ -65,7 +67,7 @@ describe('ResetConnectionDialog', () => { expect(screen.getByRole('heading', { name: 'Reset connection' })).toBeInTheDocument(); expect( screen.getByText( - /Are you sure you want to reset the connection\? This action is irreversible and you will have to configure all steps again/i, + /Are you sure you want to reset the connection "Acme SSO"\? This action is irreversible and you will have to configure all steps again/i, ), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Reset connection' })).toBeInTheDocument(); @@ -78,13 +80,13 @@ describe('ResetConnectionDialog', () => { renderDialog(wrapper, { confirmationValue: 'Acme Inc', title: localizationKeys('organizationProfile.securityPage.removeDialog.title'), - subtitle: localizationKeys('organizationProfile.securityPage.removeDialog.subtitle'), + subtitle: localizationKeys('organizationProfile.securityPage.removeDialog.subtitle', { name: 'Acme SSO' }), confirmButtonLabel: localizationKeys('organizationProfile.securityPage.removeDialog.confirmButton'), }); expect(screen.getByRole('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\?/i)).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to remove the connection "Acme SSO"\?/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Remove connection' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Reset connection' })).not.toBeInTheDocument(); // Type-to-confirm is unchanged by the override. diff --git a/packages/ui/src/components/ConfigureSSO/domain/connectionScope.ts b/packages/ui/src/components/ConfigureSSO/domain/connectionScope.ts new file mode 100644 index 00000000000..a6e89c430cc --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/domain/connectionScope.ts @@ -0,0 +1,2 @@ +/** Which connection the wizard is editing. */ +export type ConnectionScope = { kind: 'new' } | { kind: 'existing'; id: string }; diff --git a/packages/ui/src/components/ConfigureSSO/domain/providers.ts b/packages/ui/src/components/ConfigureSSO/domain/providers.ts new file mode 100644 index 00000000000..c1e2bedb13e --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/domain/providers.ts @@ -0,0 +1,60 @@ +import type { LocalizationKey } from '@/customizables'; +import { localizationKeys } from '@/customizables'; + +import type { EnterpriseConnectionProviderType, ProviderType } from '../types'; +import { isOidcProvider } from './organizationEnterpriseConnection'; + +export interface ProviderOption { + id: ProviderType; + label: LocalizationKey; + iconId: string; +} + +export interface ProviderGroup { + id: 'saml' | 'oidc'; + label: LocalizationKey; + options: ReadonlyArray; +} + +export const PROVIDER_GROUPS: ReadonlyArray = [ + { + id: 'saml', + label: localizationKeys('configureSSO.selectProviderStep.saml.groupLabel'), + options: [ + { id: 'saml_okta', label: localizationKeys('configureSSO.selectProviderStep.saml.okta'), iconId: 'okta' }, + { + id: 'saml_microsoft', + label: localizationKeys('configureSSO.selectProviderStep.saml.microsoft'), + iconId: 'microsoft', + }, + { + id: 'saml_google', + label: localizationKeys('configureSSO.selectProviderStep.saml.google'), + iconId: 'google', + }, + { + id: 'saml_custom', + label: localizationKeys('configureSSO.selectProviderStep.saml.customSaml'), + iconId: 'saml', + }, + ], + }, + { + id: 'oidc', + label: localizationKeys('configureSSO.selectProviderStep.oidc.groupLabel'), + options: [ + { + id: 'oidc_custom', + label: localizationKeys('configureSSO.selectProviderStep.oidc.oidcProvider'), + iconId: 'oidc', + }, + ], + }, +]; + +export const providerLabel = (provider: ProviderType): LocalizationKey | undefined => + PROVIDER_GROUPS.flatMap(group => group.options).find(option => option.id === provider)?.label; + +/** Every OIDC variant is presented as the single OIDC card. */ +export const toProviderCard = (provider: EnterpriseConnectionProviderType): ProviderType => + isOidcProvider(provider) ? 'oidc_custom' : provider; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx index a26ac3c5b23..a4c0c24ceeb 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx @@ -229,6 +229,7 @@ const FooterReset = (): JSX.Element | null => { isOpen={isOpen} onClose={() => setIsOpen(false)} confirmationValue={organization?.name ?? ''} + subtitle={localizationKeys('configureSSO.resetConnectionDialog.subtitle', { name: enterpriseConnection.name })} onDelete={() => enterpriseConnectionMutations.deleteConnection(enterpriseConnection.id)} contentRef={contentRef} /> 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 5288dd01549..49458352532 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // The umbrella hook composes several `@clerk/shared/react` hooks. We mock the @@ -13,6 +13,7 @@ type MockConnection = { id: string; provider: string; active?: boolean; + createdAt?: Date; samlConnection?: { idpSsoUrl?: string; idpEntityId?: string } | null; }; @@ -225,4 +226,72 @@ describe('useOrganizationEnterpriseConnection — mutations', () => { expect(mutationSpies.update).toHaveBeenCalledWith('ent_1', { active: true }); }); + + it('changeProvider deletes the connection it is given, not the first one in the list', async () => { + connectionsState.data = [configuredConnection('ent_1'), configuredConnection('ent_2')]; + const callOrder: string[] = []; + mutationSpies.delete.mockImplementation(() => { + callOrder.push('delete'); + return Promise.resolve({}); + }); + mutationSpies.create.mockImplementation(() => { + callOrder.push('create'); + return Promise.resolve({ id: 'ent_3' }); + }); + + const { result } = renderHook(() => useOrganizationEnterpriseConnection()); + + await act(async () => { + await result.current.enterpriseConnectionMutations.changeProvider('ent_2', 'saml_google'); + }); + + expect(mutationSpies.delete).toHaveBeenCalledWith('ent_2'); + expect(callOrder).toEqual(['delete', 'create']); + expect(result.current.connectionScope).toEqual({ kind: 'existing', id: 'ent_3' }); + }); + + it('deleting the scoped connection resets the scope to new rather than falling back to another one', async () => { + connectionsState.data = [configuredConnection('ent_1'), configuredConnection('ent_2')]; + mutationSpies.delete.mockResolvedValue({}); + + const { result } = renderHook(() => useOrganizationEnterpriseConnection()); + + expect(result.current.connectionScope).toEqual({ kind: 'existing', id: 'ent_1' }); + + await act(async () => { + await result.current.enterpriseConnectionMutations.deleteConnection('ent_1'); + }); + + expect(result.current.connectionScope).toEqual({ kind: 'new' }); + expect(result.current.enterpriseConnection).toBeUndefined(); + }); +}); + +describe('useOrganizationEnterpriseConnection — connection scope', () => { + it('orders the connections by createdAt and scopes to the first one', () => { + connectionsState.data = [ + { ...configuredConnection('ent_b'), createdAt: new Date('2024-02-01T00:00:00Z') }, + { ...configuredConnection('ent_a'), createdAt: new Date('2024-01-01T00:00:00Z') }, + ]; + + const { result } = renderHook(() => useOrganizationEnterpriseConnection()); + + expect(result.current.enterpriseConnections.map(connection => connection.id)).toEqual(['ent_a', 'ent_b']); + expect(result.current.connectionScope).toEqual({ kind: 'existing', id: 'ent_a' }); + expect(result.current.enterpriseConnection?.id).toBe('ent_a'); + }); + + it('selectConnection pins the wizard to an explicit connection', () => { + connectionsState.data = [configuredConnection('ent_1'), configuredConnection('ent_2')]; + + const { result } = renderHook(() => useOrganizationEnterpriseConnection()); + + act(() => result.current.selectConnection({ kind: 'existing', id: 'ent_2' })); + + expect(result.current.enterpriseConnection?.id).toBe('ent_2'); + + act(() => result.current.selectConnection({ kind: 'new' })); + + expect(result.current.enterpriseConnection).toBeUndefined(); + }); }); diff --git a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts index 0337366b0cb..1188f32c516 100644 --- a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts @@ -17,12 +17,14 @@ import type { UpdateOrganizationEnterpriseConnectionParams, UserResource, } from '@clerk/shared/types'; -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { ConnectionScope } from '../domain/connectionScope'; import { isEnterpriseConnectionConfigured, type OrganizationEnterpriseConnection, organizationEnterpriseConnection as buildOrganizationEnterpriseConnection, + sortEnterpriseConnections, } from '../domain/organizationEnterpriseConnection'; import type { ProviderType } from '../types'; import { type RefreshTestRunsOptions, useEnterpriseConnectionTestRuns } from './useEnterpriseConnectionTestRuns'; @@ -46,11 +48,8 @@ export interface EnterpriseConnectionMutations { * never thread them through. */ createConnection: (provider: ProviderType) => Promise; - /** - * Swaps the active organization's connection to a different provider. This removes the existing - * connection and creates a fresh one. - */ - changeProvider: (provider: ProviderType) => Promise; + /** Replaces the connection `id` with a fresh one for `provider`. */ + changeProvider: (id: string, provider: ProviderType) => Promise; updateConnection: ( id: string, params: UpdateOrganizationEnterpriseConnectionParams, @@ -83,7 +82,12 @@ export interface UseOrganizationEnterpriseConnectionResult { user: UserResource | null | undefined; session: SignedInSessionResource | null | undefined; organization: OrganizationResource | null | undefined; - /** FAPI currently supports a single connection per organization. */ + /** Every connection of the organization, in deterministic order. */ + enterpriseConnections: EnterpriseConnectionResource[]; + /** Which connection the wizard is editing. */ + connectionScope: ConnectionScope; + selectConnection: (scope: ConnectionScope) => void; + /** The scoped connection, `undefined` while the scope is `new`. */ enterpriseConnection: EnterpriseConnectionResource | undefined; /** The domain entity the wizard makes every flow decision from. */ organizationEnterpriseConnection: OrganizationEnterpriseConnection; @@ -128,20 +132,34 @@ export interface TestRunsView { */ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterpriseConnectionResult => { const { - data: enterpriseConnections, + data: sourceConnections, isLoading: isLoadingEnterpriseConnections, createEnterpriseConnection, updateEnterpriseConnection, deleteEnterpriseConnection, } = __internal_useOrganizationEnterpriseConnections({ enabled: true }); - // FAPI currently supports a single enterprise connection per organization. - const enterpriseConnection = enterpriseConnections?.[0]; + 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 connectionScope = useMemo( + () => scope ?? (enterpriseConnections[0] ? { kind: 'existing', id: enterpriseConnections[0].id } : { kind: 'new' }), + [scope, enterpriseConnections], + ); - // Whether a connection already existed the first time the source query - // settled. Captured during render (not in an effect) the first time the query - // is no longer loading, so it reflects the connection state at *initial load* - // and is immune to a connection created mid-flow. + const enterpriseConnection = + connectionScope.kind === 'existing' + ? enterpriseConnections.find(connection => connection.id === connectionScope.id) + : undefined; + + const selectConnection = useCallback((next: ConnectionScope) => setScope(next), []); + + // Whether the scoped connection already existed the first time the source + // query settled. Captured during render (not in an effect) the first time the + // query is no longer loading, so it reflects the connection state at *initial + // load* and is immune to a connection created mid-flow. // // `undefined` until the first settle; render-phase assignment is safe here — // it records a one-time fact about load, it does not sync state to props. @@ -226,29 +244,36 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise ); const enterpriseConnectionMutations = useMemo(() => { - const createConnection: EnterpriseConnectionMutations['createConnection'] = provider => { - return createEnterpriseConnection({ + const createConnection: EnterpriseConnectionMutations['createConnection'] = async provider => { + const created = await createEnterpriseConnection({ provider, domains: organizationDomains?.map(domain => domain.name), }); - }; - const changeProvider: EnterpriseConnectionMutations['changeProvider'] = async provider => { - // FAPI can't switch an existing connection's provider in place, so for the MVP - // we delete the old connection and create a new one. This is intentionally - // non-atomic: if the create fails, the org is briefly left without a connection - // until the user retries. Recovery is by design — the next render revalidates - // the now-deleted connection away, so a retry is just a plain create. - if (enterpriseConnection) { - await deleteEnterpriseConnection(enterpriseConnection.id); + if (created) { + setScope({ kind: 'existing', id: created.id }); } - const domains = enterpriseConnection?.domains ?? organizationDomains?.map(domain => domain.name); + return created; + }; + + const changeProvider: EnterpriseConnectionMutations['changeProvider'] = async (id, provider) => { + // FAPI can't switch a connection's provider in place, so this deletes then + // recreates. Intentionally non-atomic: a failed create leaves the org one + // connection short until the user retries, which is then a plain create. + const replaced = enterpriseConnections.find(connection => connection.id === id); + await deleteEnterpriseConnection(id); - return createEnterpriseConnection({ + const created = await createEnterpriseConnection({ provider, - domains, + domains: replaced?.domains ?? organizationDomains?.map(domain => domain.name), }); + + if (created) { + setScope({ kind: 'existing', id: created.id }); + } + + return created; }; const updateConnection: EnterpriseConnectionMutations['updateConnection'] = (id, params) => @@ -257,7 +282,16 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise const setConnectionActive: EnterpriseConnectionMutations['setConnectionActive'] = (id, active) => updateEnterpriseConnection(id, { active }); - const deleteConnection: EnterpriseConnectionMutations['deleteConnection'] = id => deleteEnterpriseConnection(id); + const deleteConnection: EnterpriseConnectionMutations['deleteConnection'] = async id => { + const deleted = await deleteEnterpriseConnection(id); + + // Pin to `new` rather than `null`, or the wizard would reseat onto a connection the user never chose. + if (connectionScope.kind === 'existing' && connectionScope.id === id) { + setScope({ kind: 'new' }); + } + + return deleted; + }; const createTestRun: EnterpriseConnectionMutations['createTestRun'] = id => { // The flow never reaches the test step without an active organization; @@ -282,7 +316,8 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise }, [ organization, organizationDomains, - enterpriseConnection, + enterpriseConnections, + connectionScope, createEnterpriseConnection, updateEnterpriseConnection, deleteEnterpriseConnection, @@ -334,6 +369,9 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise // landing on the test step then shows table-level loading, never the global isLoading: isLoadingEnterpriseConnections || isLoadingOrganizationDomains || (hadInitialConnection && isLoadingTestRuns), + enterpriseConnections, + connectionScope, + selectConnection, enterpriseConnection, organizationEnterpriseConnection, enterpriseConnectionMutations, diff --git a/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnectionStatus.ts b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnectionStatus.ts new file mode 100644 index 00000000000..e93dfc37647 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnectionStatus.ts @@ -0,0 +1,24 @@ +import { __internal_useOrganizationEnterpriseConnectionTestRuns } from '@clerk/shared/react'; +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; + +import { + isEnterpriseConnectionConfigured, + type OrganizationEnterpriseConnection, + organizationEnterpriseConnection, +} from '../domain/organizationEnterpriseConnection'; + +/** Same probe and query key as the umbrella hook, so react-query dedupes it for the scoped connection. */ +export const useOrganizationEnterpriseConnectionStatus = ( + connection: EnterpriseConnectionResource, +): OrganizationEnterpriseConnection => { + const { data: successfulTestRuns } = __internal_useOrganizationEnterpriseConnectionTestRuns({ + enterpriseConnectionId: connection.id, + params: { initialPage: 1, pageSize: 1, status: ['success'] }, + enabled: isEnterpriseConnectionConfigured(connection) && !connection.active, + }); + + return organizationEnterpriseConnection({ + connection, + hasSuccessfulTestRun: (successfulTestRuns?.length ?? 0) > 0, + }); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx index c939cd88784..f63a40ebec7 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -21,61 +21,17 @@ import { handleError } from '@/utils/errorHandler'; import { ChangeProviderDialog } from '../ChangeProviderDialog'; import { useConfigureSSO } from '../ConfigureSSOContext'; -import { isOidcProvider } from '../domain/organizationEnterpriseConnection'; +import { PROVIDER_GROUPS, providerLabel, toProviderCard } from '../domain/providers'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; -import type { EnterpriseConnectionProviderType, ProviderType } from '../types'; +import type { ProviderType } from '../types'; const MONOCHROMATIC_PROVIDER_ICONS: ReadonlySet = new Set(['okta']); -const PROVIDER_GROUPS: ReadonlyArray<{ - id: 'saml' | 'oidc'; - label: LocalizationKey; - options: ReadonlyArray<{ id: ProviderType; label: LocalizationKey; iconId: string }>; -}> = [ - { - id: 'saml', - label: localizationKeys('configureSSO.selectProviderStep.saml.groupLabel'), - options: [ - { id: 'saml_okta', label: localizationKeys('configureSSO.selectProviderStep.saml.okta'), iconId: 'okta' }, - { - id: 'saml_microsoft', - label: localizationKeys('configureSSO.selectProviderStep.saml.microsoft'), - iconId: 'microsoft', - }, - { - id: 'saml_google', - label: localizationKeys('configureSSO.selectProviderStep.saml.google'), - iconId: 'google', - }, - { - id: 'saml_custom', - label: localizationKeys('configureSSO.selectProviderStep.saml.customSaml'), - iconId: 'saml', - }, - ], - }, - { - id: 'oidc', - label: localizationKeys('configureSSO.selectProviderStep.oidc.groupLabel'), - options: [ - { - id: 'oidc_custom', - label: localizationKeys('configureSSO.selectProviderStep.oidc.oidcProvider'), - iconId: 'oidc', - }, - ], - }, -]; - -const providerLabel = (provider: ProviderType): LocalizationKey | undefined => - PROVIDER_GROUPS.flatMap(group => group.options).find(option => option.id === provider)?.label; - -const toProviderCard = (provider: EnterpriseConnectionProviderType): ProviderType => - isOidcProvider(provider) ? 'oidc_custom' : provider; export const SelectProviderStep = (): JSX.Element => { const { organizationEnterpriseConnection: c, + enterpriseConnection, enterpriseConnectionMutations: { createConnection, changeProvider }, contentRef, } = useConfigureSSO(); @@ -134,7 +90,11 @@ export const SelectProviderStep = (): JSX.Element => { setIsSubmitting(true); try { - await changeProvider(selected); + if (enterpriseConnection) { + await changeProvider(enterpriseConnection.id, selected); + } else { + await createConnection(selected); + } void goNext(); } catch (err) { handleError(err as Error, [], card.setError); @@ -238,6 +198,7 @@ export const SelectProviderStep = (): JSX.Element => { isSubmitting={isSubmitting} nextProviderLabel={nextProviderLabel} currentProviderLabel={currentProviderLabel} + connectionName={enterpriseConnection?.name ?? ''} contentRef={contentRef} /> ) : null} diff --git a/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx index 3d698ac69ff..2aaecf2fec8 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx @@ -25,11 +25,15 @@ const changeProvider = vi.fn(); const contextState = vi.hoisted(() => ({ provider: undefined as 'saml_okta' | 'saml_custom' | 'saml_google' | undefined, hasConnection: false, + // The scoped connection is not necessarily the first one the API returned. + scopedConnectionId: 'ent_1', })); vi.mock('../../ConfigureSSOContext', () => ({ useConfigureSSO: () => ({ - enterpriseConnection: contextState.hasConnection ? { id: 'ent_1' } : undefined, + enterpriseConnection: contextState.hasConnection + ? { id: contextState.scopedConnectionId, name: 'acme.com' } + : undefined, contentRef: { current: null }, enterpriseConnectionMutations: { createConnection: createEnterpriseConnection, @@ -62,6 +66,7 @@ const resetMocks = () => { changeProvider.mockResolvedValue(undefined); contextState.provider = undefined; contextState.hasConnection = false; + contextState.scopedConnectionId = 'ent_1'; }; describe('SelectProviderStep', () => { @@ -273,13 +278,31 @@ describe('SelectProviderStep', () => { await userEvent.click(await screen.findByRole('button', { name: 'Change provider' })); await waitFor(() => { - expect(changeProvider).toHaveBeenCalledWith('saml_google'); + expect(changeProvider).toHaveBeenCalledWith('ent_1', 'saml_google'); }); await waitFor(() => { expect(goNext).toHaveBeenCalled(); }); }); + it('changes the scoped connection, not the first one in the list', async () => { + resetMocks(); + contextState.provider = 'saml_okta'; + contextState.hasConnection = true; + contextState.scopedConnectionId = 'ent_2'; + const { wrapper } = await createFixtures(); + const { userEvent } = renderStep(wrapper); + + await userEvent.click(screen.getByRole('radio', { name: 'Google Workspace' })); + await userEvent.click(screen.getByRole('button', { name: /Continue/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Change provider' })); + + await waitFor(() => { + expect(changeProvider).toHaveBeenCalledWith('ent_2', 'saml_google'); + }); + expect(createEnterpriseConnection).not.toHaveBeenCalled(); + }); + it('closes the dialog and surfaces the error on the step when the change fails', async () => { resetMocks(); contextState.provider = 'saml_okta'; @@ -298,7 +321,7 @@ describe('SelectProviderStep', () => { await userEvent.click(await screen.findByRole('button', { name: 'Change provider' })); await waitFor(() => { - expect(changeProvider).toHaveBeenCalledWith('saml_google'); + expect(changeProvider).toHaveBeenCalledWith('ent_1', 'saml_google'); }); // The dialog closes and the error surfaces on the step card. diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index c7445bcc6a3..9d52714eb32 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -10,6 +10,7 @@ import { ChevronLeft } from '../../icons'; import { ConfigureDirectorySyncWizard } from '../ConfigureDirectorySync/ConfigureDirectorySyncWizard'; import { SecurityDirectorySyncSection } from '../ConfigureDirectorySync/SecurityDirectorySyncSection'; import { ConfigureSSOWizard } from '../ConfigureSSO/ConfigureSSOWizard'; +import type { ConnectionScope } from '../ConfigureSSO/domain/connectionScope'; import { useOrganizationEnterpriseConnection } from '../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; import { SecuritySsoSection } from './SecuritySsoSection'; @@ -33,6 +34,9 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag organization, isLoading, enterpriseConnection, + enterpriseConnections, + connectionScope, + selectConnection, organizationEnterpriseConnection, testRuns, enterpriseConnectionMutations, @@ -48,7 +52,8 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag const exitWizard = () => setView('overview'); - const openWizard = (forceInitialStep = false) => { + const openWizard = (scope: ConnectionScope, forceInitialStep = false) => { + selectConnection(scope); setForceFirstStep(forceInitialStep); setView('wizard'); }; @@ -110,8 +115,7 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag return view === 'overview' ? ( ; - onConfigure: (forceInitialStep?: boolean) => void; + onConfigure: (scope: ConnectionScope, forceInitialStep?: boolean) => void; }; const STATUS_BADGES: Record< @@ -67,13 +68,7 @@ const STATUS_BADGES: Record< }; export const SecuritySsoSection = (props: SecuritySsoSectionProps): JSX.Element => { - const { connection, onConfigure } = props; - - const isConfigured = connection.status === 'active' || connection.status === 'inactive'; - - // The badge and menu read straight from the entity; revalidation drives the settled state. - const status: OrganizationEnterpriseConnectionStatus = connection.status; - const badge = STATUS_BADGES[status]; + const { enterpriseConnections, onConfigure } = props; return ( + enterpriseConnections.length === 0 ? ( + + ) : undefined } > - {status === 'unconfigured' && ( - onConfigure(true)} - /> - )} + {enterpriseConnections.length === 0 ? ( + + - {status === 'in_progress' && ( - onConfigure()} - /> - )} - - {isConfigured && ( - - onConfigure({ kind: 'new' }, true)} + localizationKey={localizationKeys( + 'organizationProfile.securityPage.ssoSection.primaryButton__startConfiguration', + )} + /> + + ) : ( + + + + + {enterpriseConnections.map(connection => ( + + + + ))} + + + onConfigure({ kind: 'new' }, true)} /> - + )} ); }; -type NotConfiguredContentProps = { - primaryButtonKey: LocalizationKey; - primaryButtonId: string; - onConfigure: () => void; +type ConnectionRowProps = SecuritySsoSectionProps & { + connection: EnterpriseConnectionResource; }; -const NotConfiguredContent = ({ - primaryButtonKey, - primaryButtonId, +const ConnectionRow = ({ + connection, + setConnectionActive, + deleteConnection, + organizationName, + contentRef, onConfigure, -}: NotConfiguredContentProps): JSX.Element => ( - - - -