diff --git a/.changeset/spotty-apples-march.md b/.changeset/spotty-apples-march.md
new file mode 100644
index 0000000000..c8f1e1be1c
--- /dev/null
+++ b/.changeset/spotty-apples-march.md
@@ -0,0 +1,6 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/shared': minor
+---
+
+Add a private \_\_navigateWithError util function to clerk for use in User Lockout scenarios
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index af44dc292b..f085cd0ba9 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -20,6 +20,7 @@ import type {
BeforeEmitCallback,
BuildUrlWithAuthParams,
Clerk as ClerkInterface,
+ ClerkAPIError,
ClerkOptions,
ClientResource,
CreateOrganizationParams,
@@ -161,6 +162,8 @@ export default class Clerk implements ClerkInterface {
public readonly frontendApi: string;
public readonly publishableKey?: string;
+ protected internal_last_error: ClerkAPIError | null = null;
+
#domain: DomainOrProxyUrl['domain'];
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
#authService: SessionCookieService | null = null;
@@ -1198,6 +1201,16 @@ export default class Clerk implements ClerkInterface {
}
};
+ get __internal_last_error(): ClerkAPIError | null {
+ const value = this.internal_last_error;
+ this.internal_last_error = null;
+ return value;
+ }
+
+ set __internal_last_error(value: ClerkAPIError | null) {
+ this.internal_last_error = value;
+ }
+
updateClient = (newClient: ClientResource): void => {
if (!this.client) {
// This is the first time client is being
@@ -1271,6 +1284,11 @@ export default class Clerk implements ClerkInterface {
return this.#componentControls?.ensureMounted().then(controls => controls.updateProps(props));
};
+ __internal_navigateWithError(to: string, err: ClerkAPIError) {
+ this.__internal_last_error = err;
+ return this.navigate(to);
+ }
+
#hasJustSynced = () => getClerkQueryParam(CLERK_SYNCED) === 'true';
#clearJustSynced = () => removeClerkQueryParam(CLERK_SYNCED);
diff --git a/packages/clerk-js/src/core/resources/Error.ts b/packages/clerk-js/src/core/resources/Error.ts
index a8baf8742a..80ff047b8a 100644
--- a/packages/clerk-js/src/core/resources/Error.ts
+++ b/packages/clerk-js/src/core/resources/Error.ts
@@ -9,6 +9,7 @@ export {
isKnownError,
isMagicLinkError,
isMetamaskError,
+ isUserLockedError,
MagicLinkError,
MagicLinkErrorCode,
parseError,
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx
index d6b0a9aa59..8c31f33718 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx
@@ -1,3 +1,4 @@
+import { isUserLockedError } from '@clerk/shared';
import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';
@@ -34,6 +35,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
const { navigateAfterSignIn } = useSignInContext();
const { setActive } = useCoreClerk();
const supportEmail = useSupportEmail();
+ const clerk = useCoreClerk();
const goBack = () => {
return navigate('../');
@@ -69,7 +71,14 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
})
- .catch(err => reject(err));
+ .catch(err => {
+ if (isUserLockedError(err)) {
+ // @ts-expect-error -- private method for the time being
+ return clerk.__internal_navigateWithError('..', err.errors[0]);
+ }
+
+ return reject(err);
+ });
};
return (
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx
index 44acdc7086..dea2ef6134 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx
@@ -1,3 +1,4 @@
+import { isUserLockedError } from '@clerk/shared/error';
import type { EmailLinkFactor, SignInResource } from '@clerk/types';
import React from 'react';
@@ -29,6 +30,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard
const { setActive } = useCoreClerk();
const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn);
const [showVerifyModal, setShowVerifyModal] = React.useState(false);
+ const clerk = useCoreClerk();
React.useEffect(() => {
void startEmailLinkVerification();
@@ -45,7 +47,14 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard
redirectUrl: buildEmailLinkRedirectUrl(signInContext, signInUrl),
})
.then(res => handleVerificationResult(res))
- .catch(err => handleError(err, [], card.setError));
+ .catch(err => {
+ if (isUserLockedError(err)) {
+ // @ts-expect-error -- private method for the time being
+ return clerk.__internal_navigateWithError('..', err.errors[0]);
+ }
+
+ handleError(err, [], card.setError);
+ });
};
const handleVerificationResult = async (si: SignInResource) => {
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx
index a6cb43b79a..37e697949f 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx
@@ -1,3 +1,4 @@
+import { isUserLockedError } from '@clerk/shared/error';
import type { ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';
@@ -53,6 +54,7 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
const { navigate } = useRouter();
const [showHavingTrouble, setShowHavingTrouble] = React.useState(false);
const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]);
+ const clerk = useCoreClerk();
const goBack = () => {
return navigate('../');
@@ -72,7 +74,14 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
})
- .catch(err => handleError(err, [passwordControl], card.setError));
+ .catch(err => {
+ if (isUserLockedError(err)) {
+ // @ts-expect-error -- private method for the time being
+ return clerk.__internal_navigateWithError('..', err.errors[0]);
+ }
+
+ handleError(err, [passwordControl], card.setError);
+ });
};
if (showHavingTrouble) {
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
index 8eebbb8c36..e368b6288e 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
@@ -1,3 +1,4 @@
+import { isUserLockedError } from '@clerk/shared/error';
import type { SignInResource } from '@clerk/types';
import React from 'react';
@@ -28,6 +29,7 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
label: localizationKeys('formFieldLabel__backupCode'),
isRequired: true,
});
+ const clerk = useCoreClerk();
const isResettingPassword = (resource: SignInResource) =>
isResetPasswordStrategy(resource.firstFactorVerification?.strategy) &&
@@ -50,7 +52,14 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
})
- .catch(err => handleError(err, [codeControl], card.setError));
+ .catch(err => {
+ if (isUserLockedError(err)) {
+ // @ts-expect-error -- private method for the time being
+ return clerk.__internal_navigateWithError('..', err.errors[0]);
+ }
+
+ handleError(err, [codeControl], card.setError);
+ });
};
return (
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx
index 52ea37786b..cf1b4f308e 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx
@@ -1,3 +1,4 @@
+import { isUserLockedError } from '@clerk/shared/error';
import type { PhoneCodeFactor, SignInResource, TOTPFactor } from '@clerk/types';
import React from 'react';
@@ -34,6 +35,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
const { setActive } = useCoreClerk();
const { navigate } = useRouter();
const supportEmail = useSupportEmail();
+ const clerk = useCoreClerk();
React.useEffect(() => {
if (props.factorAlreadyPrepared) {
@@ -48,7 +50,14 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
return props
.prepare?.()
.then(() => props.onFactorPrepare())
- .catch(err => handleError(err, [], card.setError));
+ .catch(err => {
+ if (isUserLockedError(err)) {
+ // @ts-expect-error -- private method for the time being
+ return clerk.__internal_navigateWithError('..', err.errors[0]);
+ }
+
+ handleError(err, [], card.setError);
+ });
}
: undefined;
@@ -73,7 +82,14 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
})
- .catch(err => reject(err));
+ .catch(err => {
+ if (isUserLockedError(err)) {
+ // @ts-expect-error -- private method for the time being
+ return clerk.__internal_navigateWithError('..', err.errors[0]);
+ }
+
+ return reject(err);
+ });
};
return (
diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx
index da106ec050..b0f8a75cea 100644
--- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx
@@ -1,3 +1,4 @@
+import { parseError } from '@clerk/shared/error';
import type { SignInResource } from '@clerk/types';
import { describe, it, jest } from '@jest/globals';
import { waitFor } from '@testing-library/dom';
@@ -193,6 +194,38 @@ describe('SignInFactorOne', () => {
await waitFor(() => expect(screen.getByText('Incorrect Password')).toBeDefined());
});
});
+
+ it('redirects back to sign-in if the user is locked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.withPreferredSignInStrategy({ strategy: 'password' });
+ f.startSignInWithPhoneNumber({ supportPassword: true });
+ });
+ fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+
+ const errJSON = {
+ code: 'user_locked',
+ long_message: 'Your account is locked. Please try again after 1 hour.',
+ message: 'Account locked',
+ meta: { duration_in_seconds: 3600 },
+ };
+
+ fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
+ new ClerkAPIResponseError('Error', {
+ data: [errJSON],
+ status: 422,
+ }),
+ );
+ await runFakeTimers(async () => {
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText('Password'), '123456');
+ await userEvent.click(screen.getByText('Continue'));
+ await waitFor(() => {
+ expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
+ });
+ });
+ });
});
describe('Forgot Password', () => {
@@ -405,6 +438,35 @@ describe('SignInFactorOne', () => {
await waitFor(() => expect(screen.getByText('Incorrect code')).toBeDefined());
});
});
+
+ it('redirects back to sign-in if the user is locked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPreferredSignInStrategy({ strategy: 'otp' });
+ f.startSignInWithPhoneNumber({ supportPhoneCode: true, supportPassword: false });
+ });
+ fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+
+ const errJSON = {
+ code: 'user_locked',
+ long_message: 'Your account is locked. Please try again after 2 hours.',
+ message: 'Account locked',
+ meta: { duration_in_seconds: 7200 },
+ };
+
+ fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
+ new ClerkAPIResponseError('Error', {
+ data: [errJSON],
+ status: 422,
+ }),
+ );
+
+ await runFakeTimers(async () => {
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
+ expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
+ });
+ });
});
describe('Phone Code', () => {
@@ -484,6 +546,36 @@ describe('SignInFactorOne', () => {
await waitFor(() => expect(screen.getByText('Incorrect phone code')).toBeDefined());
});
});
+
+ it('redirects back to sign-in if the user is locked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withPhoneNumber();
+ f.withPreferredSignInStrategy({ strategy: 'otp' });
+ f.startSignInWithPhoneNumber({ supportPhoneCode: true, supportPassword: false });
+ });
+ fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+
+ const errJSON = {
+ code: 'user_locked',
+ long_message: 'Your account is locked. Please contact support for more information.',
+ message: 'Account locked',
+ };
+
+ fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
+ new ClerkAPIResponseError('Error', {
+ data: [errJSON],
+ status: 422,
+ }),
+ );
+
+ await runFakeTimers(async () => {
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
+ await waitFor(() => {
+ expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
+ });
+ });
+ });
});
});
diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx
index 0d26c8d47e..c86d39de7a 100644
--- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx
@@ -1,3 +1,4 @@
+import { parseError } from '@clerk/shared/error';
import type { SignInResource } from '@clerk/types';
import { describe, it } from '@jest/globals';
@@ -200,6 +201,36 @@ describe('SignInFactorTwo', () => {
await waitFor(() => expect(screen.getByText('Incorrect phone code')).toBeDefined());
});
}, 10000);
+
+ it('redirects back to sign-in if the user is locked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.withPreferredSignInStrategy({ strategy: 'otp' });
+ f.startSignInWithPhoneNumber({ supportPhoneCode: true });
+ f.startSignInFactorTwo({ identifier: '+3012345567890', supportPhoneCode: true, supportTotp: false });
+ });
+ fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+
+ const errJSON = {
+ code: 'user_locked',
+ long_message: 'Your account is locked. Please contact support for more information.',
+ message: 'Account locked',
+ };
+
+ fixtures.signIn.attemptSecondFactor.mockRejectedValueOnce(
+ new ClerkAPIResponseError('Error', {
+ data: [errJSON],
+ status: 422,
+ }),
+ );
+
+ await runFakeTimers(async () => {
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
+ expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
+ });
+ });
});
describe('Authenticator app', () => {
@@ -349,6 +380,42 @@ describe('SignInFactorTwo', () => {
await waitFor(() => expect(screen.getByText('Incorrect backup code')).toBeDefined());
});
}, 10000);
+
+ it('redirects back to sign-in if the user is locked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.withPreferredSignInStrategy({ strategy: 'otp' });
+ f.startSignInFactorTwo({
+ supportPhoneCode: false,
+ supportBackupCode: true,
+ });
+ });
+ fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+
+ const errJSON = {
+ code: 'user_locked',
+ long_message: 'Your account is locked. Please try again after 30 minutes.',
+ message: 'Account locked',
+ meta: { duration_in_seconds: 1800 },
+ };
+
+ fixtures.signIn.attemptSecondFactor.mockRejectedValueOnce(
+ new ClerkAPIResponseError('Error', {
+ data: [errJSON],
+ status: 422,
+ }),
+ );
+
+ await runFakeTimers(async () => {
+ const { userEvent, getByLabelText, getByText } = render(, { wrapper });
+ await userEvent.type(getByLabelText('Backup code'), '123456');
+ await userEvent.click(getByText('Continue'));
+ await waitFor(() => {
+ expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
+ });
+ });
+ });
});
});
diff --git a/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx b/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx
index 744c080e53..d974c12402 100644
--- a/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx
+++ b/packages/clerk-js/src/ui/elements/contexts/CardStateContext.tsx
@@ -16,10 +16,12 @@ type CardStateCtxValue = {
const [CardStateCtx, _useCardState] = createContextAndHook('CardState');
const CardStateProvider = (props: React.PropsWithChildren) => {
+ const { translateError } = useLocalizations();
+
const [state, setState] = useSafeState({
status: 'idle',
metadata: undefined,
- error: undefined,
+ error: translateError(window?.Clerk?.__internal_last_error || undefined),
});
const value = React.useMemo(() => ({ value: { state, setState } }), [state, setState]);
diff --git a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts
index bb03f1628a..8f4d5e38ac 100644
--- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts
+++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts
@@ -48,6 +48,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked;
};
diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts
index 38cb1927d3..9ac224afe7 100644
--- a/packages/shared/src/error.ts
+++ b/packages/shared/src/error.ts
@@ -65,6 +65,10 @@ export function isMetamaskError(err: any): err is MetamaskError {
return 'code' in err && [4001, 32602, 32603].includes(err.code) && 'message' in err;
}
+export function isUserLockedError(err: any) {
+ return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'user_locked';
+}
+
export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIError[] {
return data.length > 0 ? data.map(parseError) : [];
}
@@ -242,10 +246,15 @@ export type ErrorThrowerOptions = {
export interface ErrorThrower {
setPackageName(options: ErrorThrowerOptions): ErrorThrower;
+
setMessages(options: ErrorThrowerOptions): ErrorThrower;
+
throwInvalidPublishableKeyError(params: { key?: string }): never;
+
throwInvalidFrontendApiError(params: { key?: string }): never;
+
throwInvalidProxyUrl(params: { url?: string }): never;
+
throwMissingPublishableKeyError(): never;
}