From 9e330d20a6f90a6acb5ad8c5c0fe1c555960affa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 14 Nov 2025 17:10:18 -0500 Subject: [PATCH 1/5] init --- .../src/ui/components/SignIn/SignInStart.tsx | 9 +++-- .../src/ui/elements/SocialButtons.tsx | 4 +- packages/clerk-js/src/ui/hooks/index.ts | 1 + .../ui/hooks/useTotalEnabledAuthMethods.ts | 37 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index c12bd76fce9..3bbd0612af1 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -37,6 +37,7 @@ import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useLoadingStatus } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; +import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared'; @@ -87,6 +88,7 @@ function SignInStartInternal(): JSX.Element { const ctx = useSignInContext(); const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx; const supportEmail = useSupportEmail(); + const { totalCount: totalEnabledAuthMethods } = useTotalEnabledAuthMethods(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), [userSettings.enabledFirstFactorIdentifiers], @@ -524,9 +526,10 @@ function SignInStartInternal(): JSX.Element { const { action, validLastAuthenticationStrategies, ...identifierFieldProps } = identifierField.props; const lastAuthenticationStrategy = clerk.client?.lastAuthenticationStrategy; - const isIdentifierLastAuthenticationStrategy = lastAuthenticationStrategy - ? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy) - : false; + const isIdentifierLastAuthenticationStrategy = + lastAuthenticationStrategy && totalEnabledAuthMethods > 1 + ? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy) + : false; return ( diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index 0ef1deb7023..fa6646a7300 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -20,6 +20,7 @@ import { useAppearance, } from '../customizables'; import { useEnabledThirdPartyProviders } from '../hooks'; +import { useTotalEnabledAuthMethods } from '../hooks/useTotalEnabledAuthMethods'; import { mqu, type PropsOfComponent } from '../styledSystem'; import { sleep } from '../utils/sleep'; import { LastAuthenticationStrategyBadge } from './Badge'; @@ -65,6 +66,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { } = props; const { web3Strategies, authenticatableOauthStrategies, strategyToDisplayData, alternativePhoneCodeChannels } = useEnabledThirdPartyProviders(); + const { totalCount: totalEnabledAuthMethods } = useTotalEnabledAuthMethods(); const card = useCardState(); const clerk = useClerk(); const { socialButtonsVariant } = useAppearance().parsedLayout; @@ -215,7 +217,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { label={label} textLocalizationKey={localizedText} icon={imageOrInitial} - lastAuthenticationStrategy={strategy === lastAuthenticationStrategy} + lastAuthenticationStrategy={strategy === lastAuthenticationStrategy && totalEnabledAuthMethods > 1} /> ); })} diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index b456a3dd943..7b531d210e4 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -16,4 +16,5 @@ export * from './usePrefersReducedMotion'; export * from './useSafeState'; export * from './useScrollLock'; export * from './useSearchInput'; +export * from './useTotalEnabledAuthMethods'; export * from './useWindowEventListener'; diff --git a/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts b/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts new file mode 100644 index 00000000000..231b66e422c --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts @@ -0,0 +1,37 @@ +import type { Attribute } from '@clerk/shared/types'; + +import { useEnvironment } from '../contexts/EnvironmentContext'; +import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders'; + +/** + * Calculates the total count of all enabled authentication methods. + * This includes: + * - First factor identifiers (email_address, username, phone_number) + * - OAuth strategies + * - Web3 strategies + * - Alternative phone code channels + * + * Note: When both email and username are enabled, they count as 2 separate methods + * even though the UI may show them as one combined field. + */ +export const useTotalEnabledAuthMethods = (): { totalCount: number } => { + const { userSettings } = useEnvironment(); + const { authenticatableOauthStrategies, web3Strategies, alternativePhoneCodeChannels } = + useEnabledThirdPartyProviders(); + + // Count enabled first factor identifiers + // Use the raw array (not grouped) to get accurate counts + // Filter out 'passkey' as it's not counted as a separate method in the UI + const firstFactorCount = userSettings.enabledFirstFactorIdentifiers.filter( + (attr: Attribute) => attr !== 'passkey', + ).length; + + // Count third-party authentication methods + const oauthCount = authenticatableOauthStrategies.length; + const web3Count = web3Strategies.length; + const alternativePhoneCodeCount = alternativePhoneCodeChannels.length; + + const totalCount = firstFactorCount + oauthCount + web3Count + alternativePhoneCodeCount; + + return { totalCount }; +}; From 45437aaa95654c13853b740d017a49cddf1022a6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 17 Nov 2025 09:45:30 -0500 Subject: [PATCH 2/5] add tests --- .../elements/__tests__/SocialButtons.test.tsx | 125 +++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx index 3976d0f3c88..3198a632a85 100644 --- a/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx +++ b/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx @@ -1,9 +1,10 @@ // packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx -import { render, screen } from '@testing-library/react'; +import { render, renderHook, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; +import { useTotalEnabledAuthMethods } from '@/ui/hooks/useTotalEnabledAuthMethods'; import { SocialButtons } from '../SocialButtons'; @@ -117,9 +118,101 @@ describe('SocialButtons', () => { }); describe('With last authentication strategy', () => { - it('should show "Continue with" prefix for single strategy', async () => { + it('should NOT show "Last used" badge when only one total auth method exists (single social provider, no email/username)', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + // Explicitly disable email and username to ensure only one auth method + // Note: By default fixtures have these disabled, but we set them explicitly to be sure + f.withEmailAddress({ enabled: false, used_for_first_factor: false }); + f.withUsername({ enabled: false, used_for_first_factor: false }); + }); + + // Verify that email and username are disabled + const enabledIdentifiers = fixtures.environment.userSettings.enabledFirstFactorIdentifiers; + expect(enabledIdentifiers).not.toContain('email_address'); + expect(enabledIdentifiers).not.toContain('username'); + // Verify only one social provider is enabled + expect(fixtures.environment.userSettings.authenticatableSocialStrategies).toHaveLength(1); + + // Verify the total count is actually 1 using the hook + const { result } = renderHook(() => useTotalEnabledAuthMethods(), { wrapper }); + expect(result.current.totalCount).toBe(1); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /google/i }); + expect(button).toHaveTextContent('Continue with Google'); + expect(button).not.toHaveTextContent('Last used'); + expect(screen.queryByText('Last used')).not.toBeInTheDocument(); + }); + + it('should show "Last used" badge when email is enabled even with single social provider', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withEmailAddress({ enabled: true, used_for_first_factor: true }); + f.withUsername({ enabled: false, used_for_first_factor: false }); + }); + + // Verify the total count is 2 (email + google) + const { result } = renderHook(() => useTotalEnabledAuthMethods(), { wrapper }); + expect(result.current.totalCount).toBe(2); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /google/i }); + expect(button).toHaveTextContent('Continue with Google'); + expect(button).toHaveTextContent('Last used'); + expect(screen.getByText('Last used')).toBeInTheDocument(); + }); + + it('should show "Last used" badge when only one social provider but email/username is also enabled', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withSocialProvider({ provider: 'google' }); + f.withEmailAddress({ enabled: true, used_for_first_factor: true }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /google/i }); + expect(button).toHaveTextContent('Continue with Google'); + expect(button).toHaveTextContent('Last used'); + expect(screen.getByText('Last used')).toBeInTheDocument(); + }); + + it('should show "Continue with" prefix for single strategy when multiple auth methods exist', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withEmailAddress({ enabled: true, used_for_first_factor: true }); }); fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; @@ -217,6 +310,7 @@ describe('SocialButtons', () => { it('should handle SAML strategies converted to OAuth', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withSocialProvider({ provider: 'google' }); + f.withEmailAddress({ enabled: true, used_for_first_factor: true }); }); // SAML strategy should be converted to OAuth @@ -235,5 +329,32 @@ describe('SocialButtons', () => { const googleButton = screen.getByRole('button', { name: /google/i }); expect(googleButton).toHaveTextContent('Continue with Google'); }); + + it('should NOT show "Last used" badge when only one total auth method exists (single social provider, no email/username, SAML)', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + // Disable email and username to ensure only one auth method + f.withEmailAddress({ enabled: false, used_for_first_factor: false }); + f.withUsername({ enabled: false, used_for_first_factor: false }); + }); + + // SAML strategy should be converted to OAuth but badge should not show + fixtures.clerk.client.lastAuthenticationStrategy = 'saml_google' as any; + + render( + + + , + { wrapper }, + ); + + const googleButton = screen.getByRole('button', { name: /google/i }); + expect(googleButton).toHaveTextContent('Continue with Google'); + expect(googleButton).not.toHaveTextContent('Last used'); + expect(screen.queryByText('Last used')).not.toBeInTheDocument(); + }); }); }); From 2e4b99ddd2839404f4c7926c9c49e40faec7668f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 17 Nov 2025 09:55:27 -0500 Subject: [PATCH 3/5] Update useTotalEnabledAuthMethods.ts --- .../src/ui/hooks/useTotalEnabledAuthMethods.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts b/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts index 231b66e422c..3ad1f956166 100644 --- a/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts +++ b/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts @@ -3,30 +3,15 @@ import type { Attribute } from '@clerk/shared/types'; import { useEnvironment } from '../contexts/EnvironmentContext'; import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders'; -/** - * Calculates the total count of all enabled authentication methods. - * This includes: - * - First factor identifiers (email_address, username, phone_number) - * - OAuth strategies - * - Web3 strategies - * - Alternative phone code channels - * - * Note: When both email and username are enabled, they count as 2 separate methods - * even though the UI may show them as one combined field. - */ export const useTotalEnabledAuthMethods = (): { totalCount: number } => { const { userSettings } = useEnvironment(); const { authenticatableOauthStrategies, web3Strategies, alternativePhoneCodeChannels } = useEnabledThirdPartyProviders(); - // Count enabled first factor identifiers - // Use the raw array (not grouped) to get accurate counts - // Filter out 'passkey' as it's not counted as a separate method in the UI const firstFactorCount = userSettings.enabledFirstFactorIdentifiers.filter( (attr: Attribute) => attr !== 'passkey', ).length; - // Count third-party authentication methods const oauthCount = authenticatableOauthStrategies.length; const web3Count = web3Strategies.length; const alternativePhoneCodeCount = alternativePhoneCodeChannels.length; From 6d302cbc2acb1774d2a8eabf704465822dc69a29 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 17 Nov 2025 17:55:44 -0500 Subject: [PATCH 4/5] return the count --- packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx | 2 +- packages/clerk-js/src/ui/elements/SocialButtons.tsx | 2 +- .../clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx | 4 ++-- packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 3bbd0612af1..a2ebd00c733 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -88,7 +88,7 @@ function SignInStartInternal(): JSX.Element { const ctx = useSignInContext(); const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx; const supportEmail = useSupportEmail(); - const { totalCount: totalEnabledAuthMethods } = useTotalEnabledAuthMethods(); + const totalEnabledAuthMethods = useTotalEnabledAuthMethods(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), [userSettings.enabledFirstFactorIdentifiers], diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index fa6646a7300..59fa511e1cb 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -66,7 +66,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { } = props; const { web3Strategies, authenticatableOauthStrategies, strategyToDisplayData, alternativePhoneCodeChannels } = useEnabledThirdPartyProviders(); - const { totalCount: totalEnabledAuthMethods } = useTotalEnabledAuthMethods(); + const totalEnabledAuthMethods = useTotalEnabledAuthMethods(); const card = useCardState(); const clerk = useClerk(); const { socialButtonsVariant } = useAppearance().parsedLayout; diff --git a/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx index 3198a632a85..a4eb72d8a63 100644 --- a/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx +++ b/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx @@ -136,7 +136,7 @@ describe('SocialButtons', () => { // Verify the total count is actually 1 using the hook const { result } = renderHook(() => useTotalEnabledAuthMethods(), { wrapper }); - expect(result.current.totalCount).toBe(1); + expect(result.current).toBe(1); fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; @@ -165,7 +165,7 @@ describe('SocialButtons', () => { // Verify the total count is 2 (email + google) const { result } = renderHook(() => useTotalEnabledAuthMethods(), { wrapper }); - expect(result.current.totalCount).toBe(2); + expect(result.current).toBe(2); fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; diff --git a/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts b/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts index 3ad1f956166..bbf34443f70 100644 --- a/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts +++ b/packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts @@ -3,7 +3,7 @@ import type { Attribute } from '@clerk/shared/types'; import { useEnvironment } from '../contexts/EnvironmentContext'; import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders'; -export const useTotalEnabledAuthMethods = (): { totalCount: number } => { +export const useTotalEnabledAuthMethods = (): number => { const { userSettings } = useEnvironment(); const { authenticatableOauthStrategies, web3Strategies, alternativePhoneCodeChannels } = useEnabledThirdPartyProviders(); @@ -18,5 +18,5 @@ export const useTotalEnabledAuthMethods = (): { totalCount: number } => { const totalCount = firstFactorCount + oauthCount + web3Count + alternativePhoneCodeCount; - return { totalCount }; + return totalCount; }; From 1fcd647eb0e3338b2a12d36bad96c2535b8d213f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 17 Nov 2025 19:21:28 -0500 Subject: [PATCH 5/5] add changeset --- .changeset/cold-dancers-watch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cold-dancers-watch.md diff --git a/.changeset/cold-dancers-watch.md b/.changeset/cold-dancers-watch.md new file mode 100644 index 00000000000..e81578ee6d4 --- /dev/null +++ b/.changeset/cold-dancers-watch.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Only render last used badge when there are multiple strategies enabled.