From 8791337a136333e14279e1727e1ac42189e2ba69 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 23 Sep 2026 12:18:24 -0300 Subject: [PATCH 1/2] feat(ui): show provider logo on enterprise connection chooser The enterprise account chooser now renders each connection's logo with ProviderIcon, and falls back to the name's initial when no logo exists. The spinner replaces the icon while a connection loads. The icon ID comes from the connection provider through the new getEnterpriseProviderIconId helper, which replaces four copies of the prefix-stripping regex in UserProfile and OrganizationProfile. Sign-in reads enterpriseConnectionLogoPublicUrl and enterpriseConnectionProvider from each enterprise_sso first factor. Sign-up maps logo_public_url and provider from GET /client/sign_ups/:id/enterprise_connections into SignUpEnterpriseConnection. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../enterprise-connection-chooser-logo.md | 8 +++ .../clerk-js/src/core/resources/SignUp.ts | 4 ++ .../core/resources/__tests__/SignUp.test.ts | 31 ++++++++++ packages/localizations/src/en-US.ts | 8 +-- packages/shared/src/types/factors.ts | 10 ++++ packages/shared/src/types/json.ts | 2 + packages/shared/src/types/signUp.ts | 2 + .../common/ChooseEnterpriseConnectionCard.tsx | 40 +++++++++---- packages/ui/src/common/ProviderIcon.tsx | 11 ++-- .../ui/src/common/ProviderInitialIcon.tsx | 2 +- .../EnterpriseConnectionPage/index.tsx | 6 +- .../SecuritySsoSection.tsx | 6 +- .../SignInFactorOneEnterpriseConnections.tsx | 2 + .../SignIn/SignInFactorOneSSOBypass.tsx | 2 + ...nInFactorOneEnterpriseConnections.test.tsx | 60 +++++++++++++++++++ .../SignUpEnterpriseConnections.test.tsx | 40 +++++++++++++ .../UserProfile/EnterpriseAccountsSection.tsx | 8 +-- packages/ui/src/test/fixture-helpers.ts | 6 +- 18 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 .changeset/enterprise-connection-chooser-logo.md create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOneEnterpriseConnections.test.tsx create mode 100644 packages/ui/src/components/SignUp/__tests__/SignUpEnterpriseConnections.test.tsx diff --git a/.changeset/enterprise-connection-chooser-logo.md b/.changeset/enterprise-connection-chooser-logo.md new file mode 100644 index 00000000000..25a5d1d6cac --- /dev/null +++ b/.changeset/enterprise-connection-chooser-logo.md @@ -0,0 +1,8 @@ +--- +'@clerk/ui': patch +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/localizations': patch +--- + +Show the provider logo next to each connection name on the enterprise account chooser, and update its copy to "Choose an account" and "Select an enterprise account to continue." diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 32f8e625239..f12541eeeef 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -1251,6 +1251,8 @@ class SignUpFuture implements SignUpFutureResource { class SignUpEnterpriseConnection extends BaseResource implements SignUpEnterpriseConnectionResource { id!: string; name!: string; + provider!: string; + logoPublicUrl: string | null = null; constructor(data: SignUpEnterpriseConnectionJSON) { super(); @@ -1261,6 +1263,8 @@ class SignUpEnterpriseConnection extends BaseResource implements SignUpEnterpris if (data) { this.id = data.id; this.name = data.name; + this.provider = data.provider; + this.logoPublicUrl = data.logo_public_url ?? null; } return this; diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index 0bcff445b5f..f06145bb1b8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -38,6 +38,37 @@ describe('SignUp', () => { expect(snapshot).toBeDefined(); }); + describe('__experimental_getEnterpriseConnections', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('maps the enterprise connection provider and logo', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue({ + response: [ + { + object: 'enterprise_connection', + id: 'ent_1', + name: 'Acme', + provider: 'saml_okta', + logo_public_url: 'https://img.clerk.com/acme.png', + }, + { object: 'enterprise_connection', id: 'ent_2', name: 'Globex', provider: 'oauth_microsoft' }, + ], + }); + + const signUp = new SignUp({ id: 'signup_123' } as any); + const connections = await signUp.__experimental_getEnterpriseConnections(); + + expect( + connections.map(({ id, name, provider, logoPublicUrl }) => ({ id, name, provider, logoPublicUrl })), + ).toEqual([ + { id: 'ent_1', name: 'Acme', provider: 'saml_okta', logoPublicUrl: 'https://img.clerk.com/acme.png' }, + { id: 'ent_2', name: 'Globex', provider: 'oauth_microsoft', logoPublicUrl: null }, + ]); + }); + }); + describe('prepareVerification', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 387aa8b15f7..52fd7643426 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1688,8 +1688,8 @@ export const enUS: LocalizationResource = { title: 'Check your email', }, enterpriseConnections: { - subtitle: 'Select the enterprise account with which you wish to continue.', - title: 'Choose your enterprise account', + subtitle: 'Select an enterprise account to continue.', + title: 'Choose an account', }, enterpriseSSO: { formButtonPrimary: 'Continue with SSO', @@ -1843,8 +1843,8 @@ export const enUS: LocalizationResource = { }, }, enterpriseConnections: { - subtitle: 'Select the enterprise account with which you wish to continue.', - title: 'Choose your enterprise account', + subtitle: 'Select an enterprise account to continue.', + title: 'Choose an account', }, legalConsent: { checkbox: { diff --git a/packages/shared/src/types/factors.ts b/packages/shared/src/types/factors.ts index 71088a89629..765f76fbcaf 100644 --- a/packages/shared/src/types/factors.ts +++ b/packages/shared/src/types/factors.ts @@ -134,6 +134,16 @@ export type EnterpriseSSOFactor = { * @experimental */ enterpriseConnectionName?: string; + /** + * The public URL of the enterprise connection logo. + * @experimental + */ + enterpriseConnectionLogoPublicUrl?: string | null; + /** + * The provider of the enterprise connection. + * @experimental + */ + enterpriseConnectionProvider?: string; }; /** @inline */ diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 0cb5392230a..4d2191fa723 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -171,6 +171,8 @@ export interface ProtectCheckJSON { export interface SignUpEnterpriseConnectionJSON extends ClerkResourceJSON { id: string; name: string; + provider: string; + logo_public_url?: string | null; } export interface SessionJSON extends ClerkResourceJSON { diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts index 92d4bd31c1e..28a744e44db 100644 --- a/packages/shared/src/types/signUp.ts +++ b/packages/shared/src/types/signUp.ts @@ -139,4 +139,6 @@ export interface SignUpResource extends ClerkResource { export interface SignUpEnterpriseConnectionResource extends ClerkResource { id: string; name: string; + provider: string; + logoPublicUrl: string | null; } diff --git a/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx b/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx index 0c865053090..97f10a5247f 100644 --- a/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx +++ b/packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx @@ -8,11 +8,13 @@ import { useCardState } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header'; import type { InternalTheme, PropsOfComponent } from '@/ui/styledSystem'; +import { getEnterpriseProviderIconId, ProviderIcon } from './ProviderIcon'; + type ChooseEnterpriseConnectionCardProps = { title: LocalizationKey; subtitle: LocalizationKey; onClick: (id: string) => Promise; - enterpriseConnections: Array<{ id: string; name: string }>; + enterpriseConnections: Array<{ id: string; name: string; logoPublicUrl?: string | null; provider?: string }>; }; /** @@ -40,11 +42,13 @@ export const ChooseEnterpriseConnectionCard = ({ elementDescriptor={descriptors.enterpriseConnectionsRoot} gap={2} > - {enterpriseConnections?.map(({ id, name }) => ( + {enterpriseConnections?.map(({ id, name, logoPublicUrl, provider }) => ( ))} @@ -60,12 +64,15 @@ export const ChooseEnterpriseConnectionCard = ({ type ChooseEnterpriseConnectionButtonProps = Omit, 'onClick'> & { id: string; - label?: string; + label: string; + logoPublicUrl?: string | null; + provider?: string; onClick: (id: string) => Promise; }; const ChooseEnterpriseConnectionButton = (props: ChooseEnterpriseConnectionButtonProps): JSX.Element => { - const { label, onClick, ...rest } = props; + const { label, logoPublicUrl, provider, onClick, ...rest } = props; + const providerIconId = provider ? getEnterpriseProviderIconId(provider) : undefined; const [isLoading, setIsLoading] = useState(false); const handleClick = () => { @@ -101,18 +108,27 @@ const ChooseEnterpriseConnectionButton = (props: ChooseEnterpriseConnectionButto overflow: 'hidden', }} > - {isLoading && ( - ({ flex: `0 0 ${theme.space.$4}` })} - > + ({ flex: `0 0 ${theme.space.$4}` })} + > + {isLoading ? ( - - )} + ) : ( + + )} + { - return (SUPPORTS_MASK_IMAGE as readonly string[]).includes(id); +export const getEnterpriseProviderIconId = (provider: string) => + provider.replace(/(oauth_|saml_)/, '').trim() as OAuthProvider; + +const supportsMaskImage = (id?: ProviderId): boolean => { + return !!id && (SUPPORTS_MASK_IMAGE as readonly string[]).includes(id); }; -const getIconImageStyles = (theme: InternalTheme, id: ProviderId, iconUrl: string) => { +const getIconImageStyles = (theme: InternalTheme, id: ProviderId | undefined, iconUrl: string) => { if (supportsMaskImage(id)) { return { '--cl-icon-fill': theme.colors.$colorForeground, @@ -41,7 +44,7 @@ export type ProviderIconProps = Omit< PropsOfComponent, 'elementDescriptor' | 'elementId' | 'aria-label' > & { - id: ProviderId; + id?: ProviderId; iconUrl?: string | null; name: string; size?: string; diff --git a/packages/ui/src/common/ProviderInitialIcon.tsx b/packages/ui/src/common/ProviderInitialIcon.tsx index a6249ce48fe..0f7f46cdff5 100644 --- a/packages/ui/src/common/ProviderInitialIcon.tsx +++ b/packages/ui/src/common/ProviderInitialIcon.tsx @@ -6,7 +6,7 @@ import { common } from '../styledSystem'; type ProviderInitialIconProps = PropsOfComponent & { value: string; - id: Web3Provider | OAuthProvider | PhoneCodeProvider; + id?: Web3Provider | OAuthProvider | PhoneCodeProvider; }; export const ProviderInitialIcon = (props: ProviderInitialIconProps) => { diff --git a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx index fb5103c5cde..8915ab99820 100644 --- a/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx +++ b/packages/ui/src/components/OrganizationProfile/EnterpriseConnectionPage/index.tsx @@ -1,4 +1,4 @@ -import type { EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types'; +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; import { Card } from '@/elements/Card'; import { useCardState, withCardStateProvider } from '@/elements/contexts'; @@ -6,7 +6,7 @@ import { Header } from '@/elements/Header'; import { ProfileCard } from '@/elements/ProfileCard'; import { handleError } from '@/utils/errorHandler'; -import { ProviderIcon } from '../../../common'; +import { getEnterpriseProviderIconId, ProviderIcon } from '../../../common'; import { Badge, Button, Col, descriptors, Flex, localizationKeys, Text } from '../../../customizables'; import { isOidcProvider } from '../../ConfigureSSO/domain/organizationEnterpriseConnection'; import { providerLabel, toProviderCard } from '../../ConfigureSSO/domain/providers'; @@ -146,7 +146,7 @@ const ConnectionHeader = ({ sx={t => ({ gap: t.space.$2 })} > ({ minWidth: 0, flex: 1, gap: t.space.$2 })} > { const enterpriseConnections = signIn.supportedFirstFactors.map(ff => ({ id: ff.enterpriseConnectionId, name: ff.enterpriseConnectionName, + logoPublicUrl: ff.enterpriseConnectionLogoPublicUrl, + provider: ff.enterpriseConnectionProvider, })); const handleEnterpriseSSO = (enterpriseConnectionId: string) => { diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx index cae4da31e05..770f60ce70f 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSSOBypass.tsx @@ -99,6 +99,8 @@ export const SignInFactorOneSSOBypass = (props: SignInFactorOneSSOBypassProps) = const enterpriseConnections = signIn.supportedFirstFactors.map(factor => ({ id: factor.enterpriseConnectionId, name: factor.enterpriseConnectionName, + logoPublicUrl: factor.enterpriseConnectionLogoPublicUrl, + provider: factor.enterpriseConnectionProvider, })); return ( diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneEnterpriseConnections.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneEnterpriseConnections.test.tsx new file mode 100644 index 00000000000..20557b6b03b --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneEnterpriseConnections.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { SignInFactorOne } from '../SignInFactorOne'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignInFactorOneEnterpriseConnections', () => { + it('renders each connection with its logo, or its initial when there is no logo', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.startSignInWithEnterpriseSSO({ + enterpriseConnections: [ + { + id: 'conn_msft', + name: 'Microsoft Engineering', + logoPublicUrl: 'https://img.clerk.com/msft.png', + provider: 'saml_microsoft', + }, + { id: 'conn_okta', name: 'Okta', provider: 'saml_okta' }, + ], + }); + }); + + render(, { wrapper }); + + await screen.findByText('Choose an account'); + screen.getByText('Select an enterprise account to continue.'); + + const logo = screen.getByLabelText("Microsoft Engineering's icon"); + expect(logo).toHaveStyle({ backgroundImage: 'url(https://img.clerk.com/msft.png)' }); + expect(logo).toHaveClass('cl-providerIcon__microsoft'); + + const oktaButton = screen.getByRole('button', { name: /Okta/ }); + expect(oktaButton.querySelector('.cl-socialButtonsProviderInitialIcon__okta')).toHaveTextContent('O'); + expect(screen.queryByLabelText("Okta's icon")).not.toBeInTheDocument(); + }); + + it('renders the logo and initial when the backend omits the provider', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.startSignInWithEnterpriseSSO({ + enterpriseConnections: [ + { id: 'conn_msft', name: 'Microsoft Engineering', logoPublicUrl: 'https://img.clerk.com/msft.png' }, + { id: 'conn_okta', name: 'Okta' }, + ], + }); + }); + + render(, { wrapper }); + + const logo = await screen.findByLabelText("Microsoft Engineering's icon"); + expect(logo).toHaveStyle({ backgroundImage: 'url(https://img.clerk.com/msft.png)' }); + + const oktaButton = screen.getByRole('button', { name: /Okta/ }); + expect(oktaButton.querySelector('.cl-socialButtonsProviderInitialIcon')).toHaveTextContent('O'); + }); +}); diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpEnterpriseConnections.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpEnterpriseConnections.test.tsx new file mode 100644 index 00000000000..8d96a50da71 --- /dev/null +++ b/packages/ui/src/components/SignUp/__tests__/SignUpEnterpriseConnections.test.tsx @@ -0,0 +1,40 @@ +import type { SignUpEnterpriseConnectionResource } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { SignUpEnterpriseConnections } from '../SignUpEnterpriseConnections'; + +const { createFixtures } = bindCreateFixtures('SignUp'); + +describe('SignUpEnterpriseConnections', () => { + it('renders each connection with its logo, or its initial when there is no logo', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.startSignUpWithEmailAddress(); + }); + fixtures.signUp.__experimental_getEnterpriseConnections = vi.fn().mockResolvedValue([ + { + id: 'conn_msft', + name: 'Microsoft Engineering', + logoPublicUrl: 'https://img.clerk.com/msft.png', + provider: 'saml_microsoft', + }, + { id: 'conn_okta', name: 'Okta', logoPublicUrl: null, provider: 'saml_okta' }, + ] as SignUpEnterpriseConnectionResource[]); + + render(, { wrapper }); + + await screen.findByText('Choose an account'); + screen.getByText('Select an enterprise account to continue.'); + + const logo = screen.getByLabelText("Microsoft Engineering's icon"); + expect(logo).toHaveStyle({ backgroundImage: 'url(https://img.clerk.com/msft.png)' }); + expect(logo).toHaveClass('cl-providerIcon__microsoft'); + + const oktaButton = screen.getByRole('button', { name: /Okta/ }); + expect(oktaButton.querySelector('.cl-socialButtonsProviderInitialIcon__okta')).toHaveTextContent('O'); + expect(screen.queryByLabelText("Okta's icon")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx index 13e787f6f72..237877d5340 100644 --- a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx @@ -1,6 +1,6 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { __internal_useUserEnterpriseConnections, useClerk, useReverification, useUser } from '@clerk/shared/react'; -import type { EnterpriseAccountResource, EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types'; +import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types'; import { Fragment, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -10,7 +10,7 @@ import { handleError } from '@/ui/utils/errorHandler'; import { sleep } from '@/ui/utils/sleep'; import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; -import { ProviderIcon } from '../../common'; +import { getEnterpriseProviderIconId, ProviderIcon } from '../../common'; import { useUserProfileContext } from '../../contexts'; import { Badge, Box, descriptors, Flex, localizationKeys, Text } from '../../customizables'; import { Action } from '../../elements/Action'; @@ -52,7 +52,7 @@ const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionRe }); }; - const providerIconId = connection.provider.replace(/(oauth_|saml_)/, '').trim() as OAuthProvider; + const providerIconId = getEnterpriseProviderIconId(connection.provider); const providerIconUrl = connection.logoPublicUrl?.trim() || ''; return ( @@ -214,7 +214,7 @@ const EnterpriseAccount = ({ account }: { account: EnterpriseAccountResource }) const EnterpriseAccountProviderIcon = ({ account }: { account: EnterpriseAccountResource }) => { const { provider, enterpriseConnection } = account; - const providerWithoutPrefix = provider.replace(/(oauth_|saml_)/, '').trim() as OAuthProvider; + const providerWithoutPrefix = getEnterpriseProviderIconId(provider); const connectionName = enterpriseConnection?.name ?? providerWithoutPrefix; return ( diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index ea4f1064bf5..06b7c214458 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -118,7 +118,7 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => { type SignInWithEnterpriseSSOParams = { identifier?: string; - enterpriseConnections?: Array<{ id: string; name: string }>; + enterpriseConnections?: Array<{ id: string; name: string; logoPublicUrl?: string; provider?: string }>; supportSSOBypass?: boolean; }; @@ -171,10 +171,12 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => { identifier, supported_identifiers: ['email_address'], supported_first_factors: enterpriseConnections?.length - ? enterpriseConnections.map(({ id, name }) => ({ + ? enterpriseConnections.map(({ id, name, logoPublicUrl, provider }) => ({ strategy: 'enterprise_sso', enterprise_connection_id: id, enterprise_connection_name: name, + enterprise_connection_logo_public_url: logoPublicUrl, + enterprise_connection_provider: provider, })) : [{ strategy: 'enterprise_sso' }], ...(supportSSOBypass && { From 77f99b6f6c22cc34c910b783033985af653a6089 Mon Sep 17 00:00:00 2001 From: nicolas lopes <57234795+NicolasLopes7@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:37:35 -0300 Subject: [PATCH 2/2] Update .changeset/enterprise-connection-chooser-logo.md Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com> --- .changeset/enterprise-connection-chooser-logo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/enterprise-connection-chooser-logo.md b/.changeset/enterprise-connection-chooser-logo.md index 25a5d1d6cac..2b4c5c07db1 100644 --- a/.changeset/enterprise-connection-chooser-logo.md +++ b/.changeset/enterprise-connection-chooser-logo.md @@ -5,4 +5,4 @@ '@clerk/localizations': patch --- -Show the provider logo next to each connection name on the enterprise account chooser, and update its copy to "Choose an account" and "Select an enterprise account to continue." +Show the provider logo next to each connection name on the enterprise account chooser.