Skip to content

Commit

Permalink
feat(clerk-js,shared): Add a navigateWithError utility for SignIn (#2043
Browse files Browse the repository at this point in the history
) (#2074)

* feat(clerk-js,shared): Add a navigateWithError utility for SignIn (#2043)

(cherry picked from commit 0d1052a)

* fix(clekr-js): Correctly return the reject return value

---------

Co-authored-by: Mark Pitsilos <mark.pitsilos@gmail.com>
Co-authored-by: Nikos Douvlis <nikosdouvlis@gmail.com>
  • Loading branch information
3 people authored Nov 8, 2023
1 parent 864a472 commit f652a56
Show file tree
Hide file tree
Showing 13 changed files with 255 additions and 7 deletions.
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]);
}

return 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]);
}

return 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

0 comments on commit f652a56

Please sign in to comment.