diff --git a/.changeset/configure-sso-multiple-connections.md b/.changeset/configure-sso-multiple-connections.md new file mode 100644 index 00000000000..10fb0e03ed0 --- /dev/null +++ b/.changeset/configure-sso-multiple-connections.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +The organization Security page now lists every enterprise SSO connection of the organization, each with its own status, domains, and actions. The SSO wizard edits one explicit connection, and a banner names it when the organization has more than one. Changing a provider or removing a connection now targets that connection instead of the first one returned by the API. 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/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/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/__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/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/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; 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 => ( - - - -