From b90ea1534ad455544d00bf4756c2587438edb66c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 17 Jul 2026 11:34:56 -0400 Subject: [PATCH 1/4] feat(ui): add "Sign in as a different account" action to sign-in second factor Adds a footer action on the sign-in two-step verification (second factor) step that abandons the current sign-in attempt and returns to the sign-in start, so a user who reached 2FA with the wrong account (e.g. wrong social account) can sign in again instead of being stuck. --- .changeset/sign-in-different-account.md | 7 +++++++ packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/localization.ts | 6 ++++++ .../components/SignIn/SignInFactorTwoCodeForm.tsx | 3 +++ packages/ui/src/elements/VerificationCodeCard.tsx | 12 +++++++++++- 5 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .changeset/sign-in-different-account.md diff --git a/.changeset/sign-in-different-account.md b/.changeset/sign-in-different-account.md new file mode 100644 index 00000000000..0ac88ec8b31 --- /dev/null +++ b/.changeset/sign-in-different-account.md @@ -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. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d766d4ce383..65de459f45f 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -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", diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 9ca7ecee35e..d425d124d6b 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -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; diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx index 69dd2a85dd0..75b2513288f 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx @@ -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. @@ -134,6 +136,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => profileImageUrl={signIn.userData.imageUrl} identityPreviewEditButtonAriaLabel={localizationKeys('identityPreviewEditButton__identifier')} onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked} + onDifferentAccountClicked={signInAsDifferentUser} > {isResettingPassword(signIn) && ( ) => { @@ -41,7 +42,16 @@ export const VerificationCodeCard = (props: PropsWithChildren - + + {props.onDifferentAccountClicked && ( + + + + )} + ); }; From 35f1b84a76aa4b3d2eed435d05c61321c0859489 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 17 Jul 2026 11:40:16 -0400 Subject: [PATCH 2/4] test(e2e): verify sign in as a different account escapes the second-factor step --- .../tests/session-tasks-setup-mfa.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/integration/tests/session-tasks-setup-mfa.test.ts b/integration/tests/session-tasks-setup-mfa.test.ts index 1bdb3c1e8f7..f0d340e0827 100644 --- a/integration/tests/session-tasks-setup-mfa.test.ts +++ b/integration/tests/session-tasks-setup-mfa.test.ts @@ -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(); + }); }, ); From f98231bf3b7ed7af869835784b8951ba2c0273f6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 14:34:35 -0400 Subject: [PATCH 3/4] refactor(ui): label second-factor escape action "Back" and reuse existing key Move the escape action into the button stack (as a link, below "Use another method") and label it with the existing global backButton string instead of a new localization key. Drops the now-unused signIn.differentAccountAction key. --- .changeset/sign-in-different-account.md | 4 +--- .../tests/session-tasks-setup-mfa.test.ts | 4 ++-- packages/localizations/src/en-US.ts | 1 - packages/shared/src/types/localization.ts | 8 +------- .../ui/src/elements/VerificationCodeCard.tsx | 19 +++++++++---------- 5 files changed, 13 insertions(+), 23 deletions(-) diff --git a/.changeset/sign-in-different-account.md b/.changeset/sign-in-different-account.md index 0ac88ec8b31..3318b126a59 100644 --- a/.changeset/sign-in-different-account.md +++ b/.changeset/sign-in-different-account.md @@ -1,7 +1,5 @@ --- '@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. +Add a "Back" 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. diff --git a/integration/tests/session-tasks-setup-mfa.test.ts b/integration/tests/session-tasks-setup-mfa.test.ts index f0d340e0827..26a23e7335a 100644 --- a/integration/tests/session-tasks-setup-mfa.test.ts +++ b/integration/tests/session-tasks-setup-mfa.test.ts @@ -243,8 +243,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })( // 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 }); + // The "Back" action abandons the attempt and returns to the start. + const differentAccount = u.page.getByRole('link', { name: /^back$/i }); await expect(differentAccount).toBeVisible(); await differentAccount.click(); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 65de459f45f..d766d4ce383 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1286,7 +1286,6 @@ 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", diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index d425d124d6b..19074bdbae5 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -70,7 +70,7 @@ export type __internal_LocalizationResource = { locale: string; maintenanceMode: LocalizationValue; /** - * Add Role keys and their localized values, e.g. `roles: { 'org:teacher': 'Teacher'}`. + * Add Role keys and their localized values, e.g., `roles: { 'org:teacher': 'Teacher'}`. * * @experimental */ @@ -551,12 +551,6 @@ 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; diff --git a/packages/ui/src/elements/VerificationCodeCard.tsx b/packages/ui/src/elements/VerificationCodeCard.tsx index e8c076bf961..b72448e16a2 100644 --- a/packages/ui/src/elements/VerificationCodeCard.tsx +++ b/packages/ui/src/elements/VerificationCodeCard.tsx @@ -42,16 +42,7 @@ export const VerificationCodeCard = (props: PropsWithChildren - - {props.onDifferentAccountClicked && ( - - - - )} - + ); }; @@ -128,6 +119,14 @@ export const VerificationCodeContent = (props: PropsWithChildren )} + {props.onDifferentAccountClicked && ( + + + + )} From d7100d8311a95f31e4ed8adf059b3c3050fd87f7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 15:05:16 -0400 Subject: [PATCH 4/4] test(e2e): ensure fake user cleanup runs on failure in different-account test --- .../tests/session-tasks-setup-mfa.test.ts | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/integration/tests/session-tasks-setup-mfa.test.ts b/integration/tests/session-tasks-setup-mfa.test.ts index 26a23e7335a..f5f10d92704 100644 --- a/integration/tests/session-tasks-setup-mfa.test.ts +++ b/integration/tests/session-tasks-setup-mfa.test.ts @@ -213,45 +213,47 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })( }); 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 "Back" action abandons the attempt and returns to the start. - const differentAccount = u.page.getByRole('link', { name: /^back$/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(); + try { + // 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 "Back" action abandons the attempt and returns to the start. + const differentAccount = u.page.getByRole('link', { name: /^back$/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(); + } finally { + await user.deleteIfExists(); + } }); }, );