Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/spotty-apples-march.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
BeforeEmitCallback,
BuildUrlWithAuthParams,
Clerk as ClerkInterface,
ClerkAPIError,
ClerkOptions,
ClientResource,
CreateOrganizationParams,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/Error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
isKnownError,
isMagicLinkError,
isMetamaskError,
isUserLockedError,
MagicLinkError,
MagicLinkErrorCode,
parseError,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isUserLockedError } from '@clerk/shared';
import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';

Expand Down Expand Up @@ -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('../');
Expand Down Expand Up @@ -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]);
}

reject(err);
});
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isUserLockedError } from '@clerk/shared/error';
import type { EmailLinkFactor, SignInResource } from '@clerk/types';
import React from 'react';

Expand Down Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isUserLockedError } from '@clerk/shared/error';
import type { ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';

Expand Down Expand Up @@ -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('../');
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isUserLockedError } from '@clerk/shared/error';
import type { SignInResource } from '@clerk/types';
import React from 'react';

Expand Down Expand Up @@ -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) &&
Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isUserLockedError } from '@clerk/shared/error';
import type { PhoneCodeFactor, SignInResource, TOTPFactor } from '@clerk/types';
import React from 'react';

Expand Down Expand Up @@ -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) {
Expand All @@ -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;

Expand All @@ -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]);
}

reject(err);
});
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(<SignInFactorOne />, { 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', () => {
Expand Down Expand Up @@ -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(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
});
});
});

describe('Phone Code', () => {
Expand Down Expand Up @@ -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(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
await waitFor(() => {
expect(fixtures.clerk.__internal_navigateWithError).toHaveBeenCalledWith('..', parseError(errJSON));
});
});
});
});
});

Expand Down
Loading