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 { totalCount: 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 { totalCount: 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.totalCount).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.totalCount).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 = (): { totalCount: 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 };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you return this value as totalEnabledAuthMethods, you won't have to rename it every time we use the hook.

const { totalEnabledAuthMethods } = useTotalEnabledAuthMethods();

also since you're only returning one value you don't really need to wrap it in an object. However, if you prefer the object notation, I think this might be a little cleaner:

const totalEnabledAuthMethods = useTotalEnabledAuthMethods();

// ...

if (totalEnabledAuthMethods.count > 1) {
  // ...
}

};
Loading