Skip to content

Commit c84148b

Browse files
committed
fix: Improve error handling in transfer flow
1 parent 16207c3 commit c84148b

3 files changed

Lines changed: 65 additions & 3 deletions

File tree

packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
130130
afterSignUpUrl,
131131
navigateOnSetActive,
132132
unsafeMetadata: ctx.unsafeMetadata,
133-
});
133+
}).catch(reject);
134134
}
135135

136136
return reject(err);

packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => {
2121
props.setProps({ withSignUp: true });
2222

2323
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
24+
// The SDK updates firstFactorVerification on the resource *before* throwing
25+
// the API error. This coupling is intentional — the component reads the
26+
// resource status inside the catch block to decide whether to transfer.
2427
fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => {
25-
// Simulate SDK updating the resource before throwing (backend returns 404 with transferable in meta.client)
2628
fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any;
2729
return Promise.reject(
2830
new ClerkAPIResponseError('Error', {
@@ -127,4 +129,61 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => {
127129
expect(fixtures.signUp.create).not.toHaveBeenCalled();
128130
});
129131
});
132+
133+
it('proceeds to second factor for existing users (no transfer)', async () => {
134+
const { wrapper, fixtures, props } = await createFixtures(f => {
135+
f.withEmailAddress();
136+
f.withPreferredSignInStrategy({ strategy: 'otp' });
137+
f.withEnumerationProtection();
138+
f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false });
139+
});
140+
props.setProps({ withSignUp: true });
141+
142+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
143+
fixtures.signIn.attemptFirstFactor.mockResolvedValueOnce({
144+
status: 'needs_second_factor',
145+
firstFactorVerification: { status: 'verified' },
146+
} as any);
147+
148+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
149+
150+
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
151+
await waitFor(() => {
152+
expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-two');
153+
expect(fixtures.signUp.create).not.toHaveBeenCalled();
154+
});
155+
});
156+
157+
it('surfaces transfer errors instead of leaving the code form loading', async () => {
158+
const { wrapper, fixtures, props } = await createFixtures(f => {
159+
f.withEmailAddress();
160+
f.withPreferredSignInStrategy({ strategy: 'otp' });
161+
f.withEnumerationProtection();
162+
f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false });
163+
});
164+
props.setProps({ withSignUp: true });
165+
166+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
167+
fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => {
168+
fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any;
169+
return Promise.reject(
170+
new ClerkAPIResponseError('Error', {
171+
data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }],
172+
status: 404,
173+
}),
174+
);
175+
});
176+
fixtures.signUp.create.mockResolvedValueOnce({ status: 'abandoned' } as any);
177+
178+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
179+
const input = screen.getByLabelText(/Enter verification code/i);
180+
181+
await userEvent.type(input, '123456');
182+
183+
await waitFor(() => {
184+
expect(fixtures.signUp.create).toHaveBeenCalled();
185+
expect(input).toHaveValue('');
186+
expect(input).not.toBeDisabled();
187+
});
188+
});
130189
});

packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ClerkRuntimeError } from '@clerk/shared/error';
12
import type { DecorateUrl, LoadedClerk, SessionResource } from '@clerk/shared/types';
23

34
import type { RouteContextValue } from '../../router/RouteContext';
@@ -42,6 +43,8 @@ export async function handleSignUpIfMissingTransfer({
4243
case 'missing_requirements':
4344
return navigate(`../create/continue`);
4445
default:
45-
throw new Error(`Unexpected sign-up status after transfer: ${res.status}`);
46+
throw new ClerkRuntimeError(`Unexpected sign-up status after transfer: ${res.status}`, {
47+
code: 'sign_up_transfer_unexpected_status',
48+
});
4649
}
4750
}

0 commit comments

Comments
 (0)