Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sign-in-different-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/ui': patch
'@clerk/localizations': patch
'@clerk/shared': patch
---

Add a "Sign in as a different account" action to the sign-in two-step verification (second factor) step. This lets a user who reached the 2FA screen with the wrong account (for example, after signing in with the wrong social account) abandon the attempt and return to the sign-in start to sign in again, instead of being stuck with no way out.
50 changes: 50 additions & 0 deletions integration/tests/session-tasks-setup-mfa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,5 +203,55 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })(

await user.deleteIfExists();
});

test('can sign in as a different account from the two-step verification step', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

// Enroll SMS as a second factor using the user's existing phone number.
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
await u.page.getByRole('button', { name: /sms code/i }).click();
const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
await u.page.getByRole('button', { name: formattedPhoneNumber }).click();
await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
await u.po.signIn.continue();
await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

// Sign out and back in so the sign-in flow now requires the second factor.
await u.page.signOut();
await u.page.context().clearCookies();

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.getIdentifierInput().fill(user.email);
await u.po.signIn.setInstantPassword(user.password);
await u.po.signIn.continue();

// We are now on the two-step verification (SMS second factor) step, with no way to
// complete it if this is the wrong account.
await u.page.getByText(/check your phone/i).waitFor({ state: 'visible' });

// The "Sign in as a different account" action abandons the attempt and returns to the start.
const differentAccount = u.page.getByRole('link', { name: /sign in as a different account/i });
await expect(differentAccount).toBeVisible();
await differentAccount.click();

// Back on the sign-in start, where a different account can be used.
await expect(u.po.signIn.getIdentifierInput()).toBeVisible();

await user.deleteIfExists();
});
Comment on lines +207 to +255

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Cover the email-code and TOTP variants.

This only exercises SMS, while the feature is introduced for TOTP, SMS, and email-code second-factor screens. Add equivalent coverage, or parameterize this scenario, for the remaining factor types. As per coding guidelines, “Include tests for all new features.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integration/tests/session-tasks-setup-mfa.test.ts` around lines 207 - 255,
Extend the test around “Sign in as a different account” to cover email-code and
TOTP second-factor flows in addition to SMS, preferably by parameterizing the
factor setup and verification-step assertions. Preserve the existing
account-switching assertions while using each factor’s appropriate enrollment
and challenge UI.

Source: Coding guidelines

},
);
1 change: 1 addition & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@ export const enUS: LocalizationResource = {
subtitle: 'Your backup code is the one you got when setting up two-step authentication.',
title: 'Enter a backup code',
},
differentAccountAction: 'Sign in as a different account',
emailCode: {
formTitle: 'Verification code',
resendButton: "Didn't receive a code? Resend",
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,12 @@ export type __internal_LocalizationResource = {
formSubtitle: LocalizationValue;
resendButton: LocalizationValue;
};
/**
* Footer action on the second-factor step that abandons the current sign-in
* attempt and returns the user to the sign-in start so they can sign in with
* a different account.
*/
differentAccountAction: LocalizationValue;
newDeviceVerificationNotice: LocalizationValue;
phoneCodeMfa: {
title: LocalizationValue;
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
const supportEmail = useSupportEmail();
const clerk = useClerk();

const signInAsDifferentUser = () => navigate('../');

// Only show the new device verification notice if the user is new
// and no attributes are explicitly used for second factor.
// Retained for backwards compatibility.
Expand Down Expand Up @@ -134,6 +136,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
profileImageUrl={signIn.userData.imageUrl}
identityPreviewEditButtonAriaLabel={localizationKeys('identityPreviewEditButton__identifier')}
onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked}
onDifferentAccountClicked={signInAsDifferentUser}
>
{isResettingPassword(signIn) && (
<Text
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/elements/VerificationCodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type VerificationCodeCardProps = {
onShowAlternativeMethodsClicked?: React.MouseEventHandler;
onIdentityPreviewEditClicked?: React.MouseEventHandler;
onBackLinkClicked?: React.MouseEventHandler;
onDifferentAccountClicked?: React.MouseEventHandler;
};

export const VerificationCodeCard = (props: PropsWithChildren<VerificationCodeCardProps>) => {
Expand All @@ -41,7 +42,16 @@ export const VerificationCodeCard = (props: PropsWithChildren<VerificationCodeCa
<VerificationCodeContent {...props} />
</Card.Content>

<Card.Footer />
<Card.Footer>
{props.onDifferentAccountClicked && (
<Card.Action elementId='signIn'>
<Card.ActionLink
localizationKey={localizationKeys('signIn.differentAccountAction')}
onClick={props.onDifferentAccountClicked}
/>
</Card.Action>
)}
</Card.Footer>
</Card.Root>
);
};
Expand Down
Loading