diff --git a/.changeset/mfa-totp-verify-back-button.md b/.changeset/mfa-totp-verify-back-button.md
new file mode 100644
index 00000000000..ffbe0627b1c
--- /dev/null
+++ b/.changeset/mfa-totp-verify-back-button.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Add a "Back" action to the authenticator-app verification step in ``, so a user who needs to re-scan can return to the QR code instead of cancelling the whole setup. Going back now reuses the TOTP secret already issued rather than generating a new one, keeping any code the user has already scanned valid.
diff --git a/packages/ui/src/components/UserProfile/AddAuthenticatorApp.tsx b/packages/ui/src/components/UserProfile/AddAuthenticatorApp.tsx
index e41ae5f6567..7b4c90149ce 100644
--- a/packages/ui/src/components/UserProfile/AddAuthenticatorApp.tsx
+++ b/packages/ui/src/components/UserProfile/AddAuthenticatorApp.tsx
@@ -18,17 +18,18 @@ import { useActionContext } from '../../elements/Action/ActionRoot';
type AddAuthenticatorAppProps = FormProps & {
title: LocalizationKey;
+ pendingTotpRef: React.MutableRefObject;
};
type DisplayFormat = 'qr' | 'uri';
export const AddAuthenticatorApp = withCardStateProvider((props: AddAuthenticatorAppProps) => {
- const { title, onSuccess, onReset } = props;
+ const { title, onSuccess, onReset, pendingTotpRef } = props;
const { user } = useUser();
const card = useCardState();
const createTOTP = useReverification(() => user?.createTOTP());
const { close } = useActionContext();
- const [totp, setTOTP] = React.useState(undefined);
+ const [totp, setTOTP] = React.useState(pendingTotpRef.current);
const [displayFormat, setDisplayFormat] = React.useState('qr');
// TODO: React18
@@ -38,8 +39,18 @@ export const AddAuthenticatorApp = withCardStateProvider((props: AddAuthenticato
return;
}
+ // Each createTOTP() mints a new secret server-side, so reuse the one already
+ // issued this session — otherwise navigating back here invalidates the QR the
+ // user has scanned.
+ if (pendingTotpRef.current) {
+ return;
+ }
+
void createTOTP()
- .then(totp => setTOTP(totp))
+ .then(totp => {
+ pendingTotpRef.current = totp;
+ setTOTP(totp);
+ })
.catch(err => {
if (isClerkRuntimeError(err) && err.code === 'reverification_cancelled') {
return close();
diff --git a/packages/ui/src/components/UserProfile/MfaTOTPScreen.tsx b/packages/ui/src/components/UserProfile/MfaTOTPScreen.tsx
index 6793997b06c..9d51e6546f9 100644
--- a/packages/ui/src/components/UserProfile/MfaTOTPScreen.tsx
+++ b/packages/ui/src/components/UserProfile/MfaTOTPScreen.tsx
@@ -15,7 +15,8 @@ type MfaTOTPFormProps = FormProps;
export const MfaTOTPScreen = withCardStateProvider((props: MfaTOTPFormProps) => {
const { onReset } = props;
const wizard = useWizard();
- const ref = React.useRef();
+ const pendingTotpRef = React.useRef();
+ const verifiedTotpRef = React.useRef();
return (
@@ -23,12 +24,14 @@ export const MfaTOTPScreen = withCardStateProvider((props: MfaTOTPFormProps) =>
title={localizationKeys('userProfile.mfaTOTPPage.title')}
onSuccess={wizard.nextStep}
onReset={onReset}
+ pendingTotpRef={pendingTotpRef}
/>
contents={
}
/>
diff --git a/packages/ui/src/components/UserProfile/VerifyTOTP.tsx b/packages/ui/src/components/UserProfile/VerifyTOTP.tsx
index 7aad031dccf..3fe0dda0464 100644
--- a/packages/ui/src/components/UserProfile/VerifyTOTP.tsx
+++ b/packages/ui/src/components/UserProfile/VerifyTOTP.tsx
@@ -12,11 +12,12 @@ import { FormContainer } from '@/ui/elements/FormContainer';
import { Button, Col, descriptors, localizationKeys } from '../../customizables';
type VerifyTOTPProps = FormProps & {
- resourceRef: React.MutableRefObject;
+ verifiedTotpRef: React.MutableRefObject;
+ onBack: () => void;
};
export const VerifyTOTP = withCardStateProvider((props: VerifyTOTPProps) => {
- const { onSuccess, onReset, resourceRef } = props;
+ const { onSuccess, onReset, onBack, verifiedTotpRef } = props;
const { user } = useUser();
const otp = useFieldOTP({
@@ -27,7 +28,7 @@ export const VerifyTOTP = withCardStateProvider((props: VerifyTOTPProps) => {
.catch(reject);
},
onResolve: a => {
- resourceRef.current = a;
+ verifiedTotpRef.current = a;
onSuccess();
},
});
@@ -50,6 +51,14 @@ export const VerifyTOTP = withCardStateProvider((props: VerifyTOTPProps) => {
localizationKey={localizationKeys('userProfile.formButtonReset')}
elementDescriptor={descriptors.formButtonReset}
/>
+
+
);
diff --git a/packages/ui/src/components/UserProfile/__tests__/MfaTOTPScreen.test.tsx b/packages/ui/src/components/UserProfile/__tests__/MfaTOTPScreen.test.tsx
new file mode 100644
index 00000000000..6205801b441
--- /dev/null
+++ b/packages/ui/src/components/UserProfile/__tests__/MfaTOTPScreen.test.tsx
@@ -0,0 +1,57 @@
+import type { TOTPResource } from '@clerk/shared/types';
+import { act } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+import { ActionRoot } from '@/ui/elements/Action/ActionRoot';
+
+import { MfaTOTPScreen } from '../MfaTOTPScreen';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+const totp = {
+ uri: 'otpauth://totp/Test:test@clerk.com?secret=TESTSECRET&issuer=Test',
+ secret: 'TESTSECRET',
+} as TOTPResource;
+
+describe('MfaTOTPScreen', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('keeps the same TOTP secret when navigating back from the verification step', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withAuthenticatorApp();
+ f.withUser({ two_factor_enabled: true });
+ });
+
+ fixtures.clerk.user?.createTOTP.mockResolvedValue(totp);
+
+ const { findByText, findByRole, getByRole, userEvent } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await findByText(/scan the following QR code/i);
+ expect(fixtures.clerk.user?.createTOTP).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await userEvent.click(getByRole('button', { name: /continue/i }));
+ });
+
+ await act(async () => {
+ await userEvent.click(await findByRole('button', { name: /^back$/i }));
+ });
+
+ // Back returns to the QR step without minting a new secret, so the code the
+ // user already scanned stays valid.
+ await findByText(/scan the following QR code/i);
+ expect(fixtures.clerk.user?.createTOTP).toHaveBeenCalledTimes(1);
+ });
+});