Skip to content
9 changes: 6 additions & 3 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,6 +88,7 @@ function SignInStartInternal(): JSX.Element {
const ctx = useSignInContext();
const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx;
const supportEmail = useSupportEmail();
const totalEnabledAuthMethods = useTotalEnabledAuthMethods();
const identifierAttributes = useMemo<SignInStartIdentifier[]>(
() => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers),
[userSettings.enabledFirstFactorIdentifiers],
Expand Down Expand Up @@ -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 (
<Flow.Part part='start'>
Expand Down
4 changes: 3 additions & 1 deletion packages/clerk-js/src/ui/elements/SocialButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,6 +66,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
} = props;
const { web3Strategies, authenticatableOauthStrategies, strategyToDisplayData, alternativePhoneCodeChannels } =
useEnabledThirdPartyProviders();
const totalEnabledAuthMethods = useTotalEnabledAuthMethods();
const card = useCardState();
const clerk = useClerk();
const { socialButtonsVariant } = useAppearance().parsedLayout;
Expand Down Expand Up @@ -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}
/>
);
})}
Expand Down
125 changes: 123 additions & 2 deletions packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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).toBe(1);

fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';

render(
<CardStateProvider>
<SocialButtons
{...defaultProps}
showLastAuthenticationStrategy
/>
</CardStateProvider>,
{ 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).toBe(2);

fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';

render(
<CardStateProvider>
<SocialButtons
{...defaultProps}
showLastAuthenticationStrategy
/>
</CardStateProvider>,
{ 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(
<CardStateProvider>
<SocialButtons
{...defaultProps}
showLastAuthenticationStrategy
/>
</CardStateProvider>,
{ 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';
Expand Down Expand Up @@ -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
Expand All @@ -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(
<CardStateProvider>
<SocialButtons
{...defaultProps}
showLastAuthenticationStrategy
/>
</CardStateProvider>,
{ 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();
});
});
});
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export * from './usePrefersReducedMotion';
export * from './useSafeState';
export * from './useScrollLock';
export * from './useSearchInput';
export * from './useTotalEnabledAuthMethods';
export * from './useWindowEventListener';
22 changes: 22 additions & 0 deletions packages/clerk-js/src/ui/hooks/useTotalEnabledAuthMethods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Attribute } from '@clerk/shared/types';

import { useEnvironment } from '../contexts/EnvironmentContext';
import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders';

export const useTotalEnabledAuthMethods = (): number => {
const { userSettings } = useEnvironment();
const { authenticatableOauthStrategies, web3Strategies, alternativePhoneCodeChannels } =
useEnabledThirdPartyProviders();

const firstFactorCount = userSettings.enabledFirstFactorIdentifiers.filter(
(attr: Attribute) => attr !== 'passkey',
).length;

const oauthCount = authenticatableOauthStrategies.length;
const web3Count = web3Strategies.length;
const alternativePhoneCodeCount = alternativePhoneCodeChannels.length;

const totalCount = firstFactorCount + oauthCount + web3Count + alternativePhoneCodeCount;

return totalCount;
};
Loading