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; }