Skip to content

Commit 0d1052a

Browse files
authored
feat(clerk-js,shared): Add a navigateWithError utility for SignIn (#2043)
1 parent ee432df commit 0d1052a

13 files changed

+255
-7
lines changed

.changeset/spotty-apples-march.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
---
5+
6+
Add a private \_\_navigateWithError util function to clerk for use in User Lockout scenarios

packages/clerk-js/src/core/clerk.ts

+18
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
BeforeEmitCallback,
2121
BuildUrlWithAuthParams,
2222
Clerk as ClerkInterface,
23+
ClerkAPIError,
2324
ClerkOptions,
2425
ClientResource,
2526
CreateOrganizationParams,
@@ -161,6 +162,8 @@ export default class Clerk implements ClerkInterface {
161162
public readonly frontendApi: string;
162163
public readonly publishableKey?: string;
163164

165+
protected internal_last_error: ClerkAPIError | null = null;
166+
164167
#domain: DomainOrProxyUrl['domain'];
165168
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
166169
#authService: SessionCookieService | null = null;
@@ -1198,6 +1201,16 @@ export default class Clerk implements ClerkInterface {
11981201
}
11991202
};
12001203

1204+
get __internal_last_error(): ClerkAPIError | null {
1205+
const value = this.internal_last_error;
1206+
this.internal_last_error = null;
1207+
return value;
1208+
}
1209+
1210+
set __internal_last_error(value: ClerkAPIError | null) {
1211+
this.internal_last_error = value;
1212+
}
1213+
12011214
updateClient = (newClient: ClientResource): void => {
12021215
if (!this.client) {
12031216
// This is the first time client is being
@@ -1271,6 +1284,11 @@ export default class Clerk implements ClerkInterface {
12711284
return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props));
12721285
};
12731286

1287+
__internal_navigateWithError(to: string, err: ClerkAPIError) {
1288+
this.__internal_last_error = err;
1289+
return this.navigate(to);
1290+
}
1291+
12741292
#hasJustSynced = () => getClerkQueryParam(CLERK_SYNCED) === 'true';
12751293
#clearJustSynced = () => removeClerkQueryParam(CLERK_SYNCED);
12761294

packages/clerk-js/src/core/resources/Error.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
isKnownError,
1010
isMagicLinkError,
1111
isMetamaskError,
12+
isUserLockedError,
1213
MagicLinkError,
1314
MagicLinkErrorCode,
1415
parseError,

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isUserLockedError } from '@clerk/shared';
12
import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor } from '@clerk/types';
23
import React from 'react';
34

@@ -34,6 +35,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
3435
const { navigateAfterSignIn } = useSignInContext();
3536
const { setActive } = useCoreClerk();
3637
const supportEmail = useSupportEmail();
38+
const clerk = useCoreClerk();
3739

3840
const goBack = () => {
3941
return navigate('../');
@@ -69,7 +71,14 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
6971
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
7072
}
7173
})
72-
.catch(err => reject(err));
74+
.catch(err => {
75+
if (isUserLockedError(err)) {
76+
// @ts-expect-error -- private method for the time being
77+
return clerk.__internal_navigateWithError('..', err.errors[0]);
78+
}
79+
80+
reject(err);
81+
});
7382
};
7483

7584
return (

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isUserLockedError } from '@clerk/shared/error';
12
import type { EmailLinkFactor, SignInResource } from '@clerk/types';
23
import React from 'react';
34

@@ -29,6 +30,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard
2930
const { setActive } = useCoreClerk();
3031
const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn);
3132
const [showVerifyModal, setShowVerifyModal] = React.useState(false);
33+
const clerk = useCoreClerk();
3234

3335
React.useEffect(() => {
3436
void startEmailLinkVerification();
@@ -45,7 +47,14 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard
4547
redirectUrl: buildEmailLinkRedirectUrl(signInContext, signInUrl),
4648
})
4749
.then(res => handleVerificationResult(res))
48-
.catch(err => handleError(err, [], card.setError));
50+
.catch(err => {
51+
if (isUserLockedError(err)) {
52+
// @ts-expect-error -- private method for the time being
53+
return clerk.__internal_navigateWithError('..', err.errors[0]);
54+
}
55+
56+
handleError(err, [], card.setError);
57+
});
4958
};
5059

5160
const handleVerificationResult = async (si: SignInResource) => {

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isUserLockedError } from '@clerk/shared/error';
12
import type { ResetPasswordCodeFactor } from '@clerk/types';
23
import React from 'react';
34

@@ -53,6 +54,7 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
5354
const { navigate } = useRouter();
5455
const [showHavingTrouble, setShowHavingTrouble] = React.useState(false);
5556
const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]);
57+
const clerk = useCoreClerk();
5658

5759
const goBack = () => {
5860
return navigate('../');
@@ -72,7 +74,14 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
7274
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
7375
}
7476
})
75-
.catch(err => handleError(err, [passwordControl], card.setError));
77+
.catch(err => {
78+
if (isUserLockedError(err)) {
79+
// @ts-expect-error -- private method for the time being
80+
return clerk.__internal_navigateWithError('..', err.errors[0]);
81+
}
82+
83+
handleError(err, [passwordControl], card.setError);
84+
});
7685
};
7786

7887
if (showHavingTrouble) {

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isUserLockedError } from '@clerk/shared/error';
12
import type { SignInResource } from '@clerk/types';
23
import React from 'react';
34

@@ -28,6 +29,7 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
2829
label: localizationKeys('formFieldLabel__backupCode'),
2930
isRequired: true,
3031
});
32+
const clerk = useCoreClerk();
3133

3234
const isResettingPassword = (resource: SignInResource) =>
3335
isResetPasswordStrategy(resource.firstFactorVerification?.strategy) &&
@@ -50,7 +52,14 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
5052
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
5153
}
5254
})
53-
.catch(err => handleError(err, [codeControl], card.setError));
55+
.catch(err => {
56+
if (isUserLockedError(err)) {
57+
// @ts-expect-error -- private method for the time being
58+
return clerk.__internal_navigateWithError('..', err.errors[0]);
59+
}
60+
61+
handleError(err, [codeControl], card.setError);
62+
});
5463
};
5564

5665
return (

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

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isUserLockedError } from '@clerk/shared/error';
12
import type { PhoneCodeFactor, SignInResource, TOTPFactor } from '@clerk/types';
23
import React from 'react';
34

@@ -34,6 +35,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
3435
const { setActive } = useCoreClerk();
3536
const { navigate } = useRouter();
3637
const supportEmail = useSupportEmail();
38+
const clerk = useCoreClerk();
3739

3840
React.useEffect(() => {
3941
if (props.factorAlreadyPrepared) {
@@ -48,7 +50,14 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
4850
return props
4951
.prepare?.()
5052
.then(() => props.onFactorPrepare())
51-
.catch(err => handleError(err, [], card.setError));
53+
.catch(err => {
54+
if (isUserLockedError(err)) {
55+
// @ts-expect-error -- private method for the time being
56+
return clerk.__internal_navigateWithError('..', err.errors[0]);
57+
}
58+
59+
handleError(err, [], card.setError);
60+
});
5261
}
5362
: undefined;
5463

@@ -73,7 +82,14 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
7382
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
7483
}
7584
})
76-
.catch(err => reject(err));
85+
.catch(err => {
86+
if (isUserLockedError(err)) {
87+
// @ts-expect-error -- private method for the time being
88+
return clerk.__internal_navigateWithError('..', err.errors[0]);
89+
}
90+
91+
reject(err);
92+
});
7793
};
7894

7995
return (

packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx

+92
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseError } from '@clerk/shared/error';
12
import type { SignInResource } from '@clerk/types';
23
import { describe, it, jest } from '@jest/globals';
34
import { waitFor } from '@testing-library/dom';
@@ -193,6 +194,38 @@ describe('SignInFactorOne', () => {
193194
await waitFor(() => expect(screen.getByText('Incorrect Password')).toBeDefined());
194195
});
195196
});
197+
198+
it('redirects back to sign-in if the user is locked', async () => {
199+
const { wrapper, fixtures } = await createFixtures(f => {
200+
f.withEmailAddress();
201+
f.withPassword();
202+
f.withPreferredSignInStrategy({ strategy: 'password' });
203+
f.startSignInWithPhoneNumber({ supportPassword: true });
204+
});
205+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
206+
207+
const errJSON = {
208+
code: 'user_locked',
209+
long_message: 'Your account is locked. Please try again after 1 hour.',
210+
message: 'Account locked',
211+
meta: { duration_in_seconds: 3600 },
212+
};
213+
214+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
215+
new ClerkAPIResponseError('Error', {
216+
data: [errJSON],
217+
status: 422,
218+
}),
219+
);
220+
await runFakeTimers(async () => {
221+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
222+
await userEvent.type(screen.getByLabelText('Password'), '123456');
223+
await userEvent.click(screen.getByText('Continue'));
224+
await waitFor(() => {
225+
expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
226+
});
227+
});
228+
});
196229
});
197230

198231
describe('Forgot Password', () => {
@@ -405,6 +438,35 @@ describe('SignInFactorOne', () => {
405438
await waitFor(() => expect(screen.getByText('Incorrect code')).toBeDefined());
406439
});
407440
});
441+
442+
it('redirects back to sign-in if the user is locked', async () => {
443+
const { wrapper, fixtures } = await createFixtures(f => {
444+
f.withEmailAddress();
445+
f.withPreferredSignInStrategy({ strategy: 'otp' });
446+
f.startSignInWithPhoneNumber({ supportPhoneCode: true, supportPassword: false });
447+
});
448+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
449+
450+
const errJSON = {
451+
code: 'user_locked',
452+
long_message: 'Your account is locked. Please try again after 2 hours.',
453+
message: 'Account locked',
454+
meta: { duration_in_seconds: 7200 },
455+
};
456+
457+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
458+
new ClerkAPIResponseError('Error', {
459+
data: [errJSON],
460+
status: 422,
461+
}),
462+
);
463+
464+
await runFakeTimers(async () => {
465+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
466+
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
467+
expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
468+
});
469+
});
408470
});
409471

410472
describe('Phone Code', () => {
@@ -484,6 +546,36 @@ describe('SignInFactorOne', () => {
484546
await waitFor(() => expect(screen.getByText('Incorrect phone code')).toBeDefined());
485547
});
486548
});
549+
550+
it('redirects back to sign-in if the user is locked', async () => {
551+
const { wrapper, fixtures } = await createFixtures(f => {
552+
f.withPhoneNumber();
553+
f.withPreferredSignInStrategy({ strategy: 'otp' });
554+
f.startSignInWithPhoneNumber({ supportPhoneCode: true, supportPassword: false });
555+
});
556+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
557+
558+
const errJSON = {
559+
code: 'user_locked',
560+
long_message: 'Your account is locked. Please contact support for more information.',
561+
message: 'Account locked',
562+
};
563+
564+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
565+
new ClerkAPIResponseError('Error', {
566+
data: [errJSON],
567+
status: 422,
568+
}),
569+
);
570+
571+
await runFakeTimers(async () => {
572+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
573+
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
574+
await waitFor(() => {
575+
expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
576+
});
577+
});
578+
});
487579
});
488580
});
489581

0 commit comments

Comments
 (0)