Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/configure-sso-multiple-connections.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 12 additions & 3 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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',
Expand Down
14 changes: 11 additions & 3 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,7 +1197,7 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
removeDialog: {
title: LocalizationValue;
subtitle: LocalizationValue;
subtitle: LocalizationValue<'name'>;
confirmButton: LocalizationValue;
};
ssoSection: {
Expand All @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type ChangeProviderDialogProps = {
isSubmitting?: boolean;
nextProviderLabel: LocalizationKey;
currentProviderLabel: LocalizationKey;
connectionName: string;
contentRef: React.RefObject<HTMLDivElement>;
};

Expand Down Expand Up @@ -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);
Expand All @@ -67,6 +68,7 @@ const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProvider
localizationKey={localizationKeys('configureSSO.changeProviderDialog.subtitle', {
provider: nextProvider,
currentProvider,
name: connectionName,
})}
/>
</Col>
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec
const {
isLoading,
enterpriseConnection,
enterpriseConnections,
connectionScope,
organizationEnterpriseConnection,
testRuns,
enterpriseConnectionMutations,
Expand All @@ -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}
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<HTMLDivElement>;
enterpriseConnectionMutations: EnterpriseConnectionMutations;
Expand All @@ -30,6 +33,8 @@ export interface ConfigureSSOData {

interface ConfigureSSOProviderProps {
enterpriseConnection: EnterpriseConnectionResource | undefined;
enterpriseConnections: EnterpriseConnectionResource[];
connectionScope: ConnectionScope;
organizationEnterpriseConnection: OrganizationEnterpriseConnection;
testRuns: TestRunsView;
organizationDomains: OrganizationDomainResource[] | undefined;
Expand All @@ -44,6 +49,8 @@ ConfigureSSOContext.displayName = 'ConfigureSSOContext';

export const ConfigureSSOProvider = ({
enterpriseConnection,
enterpriseConnections,
connectionScope,
organizationEnterpriseConnection,
testRuns,
organizationDomains,
Expand All @@ -57,6 +64,8 @@ export const ConfigureSSOProvider = ({
() => ({
contentRef,
enterpriseConnection,
enterpriseConnections,
connectionScope,
organizationEnterpriseConnection,
testRuns,
organizationDomains,
Expand All @@ -72,6 +81,8 @@ export const ConfigureSSOProvider = ({
testRuns,
organizationDomains,
enterpriseConnection,
enterpriseConnections,
connectionScope,
onExit,
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +54,8 @@ export const ConfigureSSOWizard = ({ title, forceInitialStep, ...props }: Config
>
<ConfigureSSOHeader title={title} />

<ConnectionScopeBanner />

<Wizard.Match id='verify-domain'>
<CardStateProvider>
<OrganizationDomainsStep />
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ConnectionScopeBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert
elementDescriptor={[descriptors.alert, descriptors.configureSSOConnectionScopeBanner]}
variant='warning'
title={
enterpriseConnection
? localizationKeys('configureSSO.connectionScopeBanner.title__editing', { name: enterpriseConnection.name })
: localizationKeys('configureSSO.connectionScopeBanner.title__adding')
}
subtitle={
enterpriseConnection
? localizationKeys('configureSSO.connectionScopeBanner.subtitle__editing', { count })
: localizationKeys('configureSSO.connectionScopeBanner.subtitle__adding', { count })
}
sx={t => ({ marginInline: t.space.$5, marginBlockStart: t.space.$5 })}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ type ResetConnectionDialogProps = {
confirmationValue: string;
onDelete: () => Promise<unknown>;
contentRef: React.RefObject<HTMLDivElement>;
subtitle: LocalizationKey;
/** Defaults to the Reset copy; overridden when the dialog is reused for the Remove action. */
title?: LocalizationKey;
subtitle?: LocalizationKey;
confirmButtonLabel?: LocalizationKey;
};

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
({
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection,
...overrides,
}) as any;

const withOrganizationFixtures = (f: Parameters<Parameters<typeof createFixtures>[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(<ConfigureSSO />, { 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(<ConfigureSSO />, { 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 => {
Expand Down
Loading
Loading