Skip to content

Commit 5966383

Browse files
refactor(clerk-js): Ensure last used badge does not show with a single strategy (#7224)
1 parent 0597f38 commit 5966383

File tree

6 files changed

+160
-6
lines changed

6 files changed

+160
-6
lines changed

.changeset/cold-dancers-watch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Only render last used badge when there are multiple strategies enabled.

packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
3737
import { CaptchaElement } from '../../elements/CaptchaElement';
3838
import { useLoadingStatus } from '../../hooks';
3939
import { useSupportEmail } from '../../hooks/useSupportEmail';
40+
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
4041
import { useRouter } from '../../router';
4142
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
4243
import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared';
@@ -87,6 +88,7 @@ function SignInStartInternal(): JSX.Element {
8788
const ctx = useSignInContext();
8889
const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx;
8990
const supportEmail = useSupportEmail();
91+
const totalEnabledAuthMethods = useTotalEnabledAuthMethods();
9092
const identifierAttributes = useMemo<SignInStartIdentifier[]>(
9193
() => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers),
9294
[userSettings.enabledFirstFactorIdentifiers],
@@ -524,9 +526,10 @@ function SignInStartInternal(): JSX.Element {
524526
const { action, validLastAuthenticationStrategies, ...identifierFieldProps } = identifierField.props;
525527

526528
const lastAuthenticationStrategy = clerk.client?.lastAuthenticationStrategy;
527-
const isIdentifierLastAuthenticationStrategy = lastAuthenticationStrategy
528-
? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy)
529-
: false;
529+
const isIdentifierLastAuthenticationStrategy =
530+
lastAuthenticationStrategy && totalEnabledAuthMethods > 1
531+
? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy)
532+
: false;
530533

531534
return (
532535
<Flow.Part part='start'>

packages/clerk-js/src/ui/elements/SocialButtons.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
useAppearance,
2121
} from '../customizables';
2222
import { useEnabledThirdPartyProviders } from '../hooks';
23+
import { useTotalEnabledAuthMethods } from '../hooks/useTotalEnabledAuthMethods';
2324
import { mqu, type PropsOfComponent } from '../styledSystem';
2425
import { sleep } from '../utils/sleep';
2526
import { LastAuthenticationStrategyBadge } from './Badge';
@@ -65,6 +66,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
6566
} = props;
6667
const { web3Strategies, authenticatableOauthStrategies, strategyToDisplayData, alternativePhoneCodeChannels } =
6768
useEnabledThirdPartyProviders();
69+
const totalEnabledAuthMethods = useTotalEnabledAuthMethods();
6870
const card = useCardState();
6971
const clerk = useClerk();
7072
const { socialButtonsVariant } = useAppearance().parsedLayout;
@@ -215,7 +217,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
215217
label={label}
216218
textLocalizationKey={localizedText}
217219
icon={imageOrInitial}
218-
lastAuthenticationStrategy={strategy === lastAuthenticationStrategy}
220+
lastAuthenticationStrategy={strategy === lastAuthenticationStrategy && totalEnabledAuthMethods > 1}
219221
/>
220222
);
221223
})}

packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx
2-
import { render, screen } from '@testing-library/react';
2+
import { render, renderHook, screen } from '@testing-library/react';
33
import { describe, expect, it, vi } from 'vitest';
44

55
import { bindCreateFixtures } from '@/test/utils';
66
import { CardStateProvider } from '@/ui/elements/contexts';
7+
import { useTotalEnabledAuthMethods } from '@/ui/hooks/useTotalEnabledAuthMethods';
78

89
import { SocialButtons } from '../SocialButtons';
910

@@ -117,9 +118,101 @@ describe('SocialButtons', () => {
117118
});
118119

119120
describe('With last authentication strategy', () => {
120-
it('should show "Continue with" prefix for single strategy', async () => {
121+
it('should NOT show "Last used" badge when only one total auth method exists (single social provider, no email/username)', async () => {
122+
const { wrapper, fixtures } = await createFixtures(f => {
123+
f.withSocialProvider({ provider: 'google' });
124+
// Explicitly disable email and username to ensure only one auth method
125+
// Note: By default fixtures have these disabled, but we set them explicitly to be sure
126+
f.withEmailAddress({ enabled: false, used_for_first_factor: false });
127+
f.withUsername({ enabled: false, used_for_first_factor: false });
128+
});
129+
130+
// Verify that email and username are disabled
131+
const enabledIdentifiers = fixtures.environment.userSettings.enabledFirstFactorIdentifiers;
132+
expect(enabledIdentifiers).not.toContain('email_address');
133+
expect(enabledIdentifiers).not.toContain('username');
134+
// Verify only one social provider is enabled
135+
expect(fixtures.environment.userSettings.authenticatableSocialStrategies).toHaveLength(1);
136+
137+
// Verify the total count is actually 1 using the hook
138+
const { result } = renderHook(() => useTotalEnabledAuthMethods(), { wrapper });
139+
expect(result.current).toBe(1);
140+
141+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
142+
143+
render(
144+
<CardStateProvider>
145+
<SocialButtons
146+
{...defaultProps}
147+
showLastAuthenticationStrategy
148+
/>
149+
</CardStateProvider>,
150+
{ wrapper },
151+
);
152+
153+
const button = screen.getByRole('button', { name: /google/i });
154+
expect(button).toHaveTextContent('Continue with Google');
155+
expect(button).not.toHaveTextContent('Last used');
156+
expect(screen.queryByText('Last used')).not.toBeInTheDocument();
157+
});
158+
159+
it('should show "Last used" badge when email is enabled even with single social provider', async () => {
160+
const { wrapper, fixtures } = await createFixtures(f => {
161+
f.withSocialProvider({ provider: 'google' });
162+
f.withEmailAddress({ enabled: true, used_for_first_factor: true });
163+
f.withUsername({ enabled: false, used_for_first_factor: false });
164+
});
165+
166+
// Verify the total count is 2 (email + google)
167+
const { result } = renderHook(() => useTotalEnabledAuthMethods(), { wrapper });
168+
expect(result.current).toBe(2);
169+
170+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
171+
172+
render(
173+
<CardStateProvider>
174+
<SocialButtons
175+
{...defaultProps}
176+
showLastAuthenticationStrategy
177+
/>
178+
</CardStateProvider>,
179+
{ wrapper },
180+
);
181+
182+
const button = screen.getByRole('button', { name: /google/i });
183+
expect(button).toHaveTextContent('Continue with Google');
184+
expect(button).toHaveTextContent('Last used');
185+
expect(screen.getByText('Last used')).toBeInTheDocument();
186+
});
187+
188+
it('should show "Last used" badge when only one social provider but email/username is also enabled', async () => {
121189
const { wrapper, fixtures } = await createFixtures(f => {
122190
f.withSocialProvider({ provider: 'google' });
191+
f.withEmailAddress({ enabled: true, used_for_first_factor: true });
192+
});
193+
194+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
195+
196+
render(
197+
<CardStateProvider>
198+
<SocialButtons
199+
{...defaultProps}
200+
showLastAuthenticationStrategy
201+
/>
202+
</CardStateProvider>,
203+
{ wrapper },
204+
);
205+
206+
const button = screen.getByRole('button', { name: /google/i });
207+
expect(button).toHaveTextContent('Continue with Google');
208+
expect(button).toHaveTextContent('Last used');
209+
expect(screen.getByText('Last used')).toBeInTheDocument();
210+
});
211+
212+
it('should show "Continue with" prefix for single strategy when multiple auth methods exist', async () => {
213+
const { wrapper, fixtures } = await createFixtures(f => {
214+
f.withSocialProvider({ provider: 'google' });
215+
f.withEmailAddress({ enabled: true, used_for_first_factor: true });
123216
});
124217

125218
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
@@ -217,6 +310,7 @@ describe('SocialButtons', () => {
217310
it('should handle SAML strategies converted to OAuth', async () => {
218311
const { wrapper, fixtures } = await createFixtures(f => {
219312
f.withSocialProvider({ provider: 'google' });
313+
f.withEmailAddress({ enabled: true, used_for_first_factor: true });
220314
});
221315

222316
// SAML strategy should be converted to OAuth
@@ -235,5 +329,32 @@ describe('SocialButtons', () => {
235329
const googleButton = screen.getByRole('button', { name: /google/i });
236330
expect(googleButton).toHaveTextContent('Continue with Google');
237331
});
332+
333+
it('should NOT show "Last used" badge when only one total auth method exists (single social provider, no email/username, SAML)', async () => {
334+
const { wrapper, fixtures } = await createFixtures(f => {
335+
f.withSocialProvider({ provider: 'google' });
336+
// Disable email and username to ensure only one auth method
337+
f.withEmailAddress({ enabled: false, used_for_first_factor: false });
338+
f.withUsername({ enabled: false, used_for_first_factor: false });
339+
});
340+
341+
// SAML strategy should be converted to OAuth but badge should not show
342+
fixtures.clerk.client.lastAuthenticationStrategy = 'saml_google' as any;
343+
344+
render(
345+
<CardStateProvider>
346+
<SocialButtons
347+
{...defaultProps}
348+
showLastAuthenticationStrategy
349+
/>
350+
</CardStateProvider>,
351+
{ wrapper },
352+
);
353+
354+
const googleButton = screen.getByRole('button', { name: /google/i });
355+
expect(googleButton).toHaveTextContent('Continue with Google');
356+
expect(googleButton).not.toHaveTextContent('Last used');
357+
expect(screen.queryByText('Last used')).not.toBeInTheDocument();
358+
});
238359
});
239360
});

packages/clerk-js/src/ui/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * from './usePrefersReducedMotion';
1616
export * from './useSafeState';
1717
export * from './useScrollLock';
1818
export * from './useSearchInput';
19+
export * from './useTotalEnabledAuthMethods';
1920
export * from './useWindowEventListener';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Attribute } from '@clerk/shared/types';
2+
3+
import { useEnvironment } from '../contexts/EnvironmentContext';
4+
import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders';
5+
6+
export const useTotalEnabledAuthMethods = (): number => {
7+
const { userSettings } = useEnvironment();
8+
const { authenticatableOauthStrategies, web3Strategies, alternativePhoneCodeChannels } =
9+
useEnabledThirdPartyProviders();
10+
11+
const firstFactorCount = userSettings.enabledFirstFactorIdentifiers.filter(
12+
(attr: Attribute) => attr !== 'passkey',
13+
).length;
14+
15+
const oauthCount = authenticatableOauthStrategies.length;
16+
const web3Count = web3Strategies.length;
17+
const alternativePhoneCodeCount = alternativePhoneCodeChannels.length;
18+
19+
const totalCount = firstFactorCount + oauthCount + web3Count + alternativePhoneCodeCount;
20+
21+
return totalCount;
22+
};

0 commit comments

Comments
 (0)