Skip to content
Open
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/beige-trees-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/ui': minor
---

When imported user password is too long to migrate to bcrypt, direct the user
to the reset password flow.
5 changes: 5 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,9 @@ export const enUS: LocalizationResource = {
passwordPwned: {
title: 'Password compromised',
},
passwordTooLong: {
title: 'Password must be reset',
},
passwordUntrusted: {
title: 'Password untrusted',
},
Expand Down Expand Up @@ -933,6 +936,8 @@ export const enUS: LocalizationResource = {
'This password has been found as part of a breach and can not be used, please reset your password.',
form_password_size_in_bytes_exceeded: undefined,
form_password_compromised__sign_in: undefined,
password_too_long_needs_reset__sign_in:
'The existing imported password is too long and cannot be used, please reset your password.',
form_password_untrusted__sign_in:
'Your password may be compromised. To protect your account, please continue with an alternative sign-in method. You will be required to reset your password after signing in.',
form_password_validation_failed: undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
isNetworkError,
isPasswordPwnedError,
isPasswordCompromisedError,
isPasswordTooLongError,
isReverificationCancelledError,
isUnauthorizedError,
isUserLockedError,
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/errors/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ export function isPasswordCompromisedError(err: any) {
return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_compromised';
}

/**
* Checks if the provided error is a clerk api response error indicating a password is too long to migrate.
*
* @internal
*/
export function isPasswordTooLongError(err: any) {
return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'password_too_long_needs_reset';
}

/**
* Checks if the provided error is an EmailLinkError.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,9 @@ export type __internal_LocalizationResource = {
passwordCompromised: {
title: LocalizationValue;
};
passwordTooLong: {
title: LocalizationValue;
};
passkey: {
title: LocalizationValue;
subtitle: LocalizationValue;
Expand Down Expand Up @@ -1360,6 +1363,7 @@ type UnstableErrors = WithParamName<{
/** @deprecated Use `form_password_compromised__sign_in` instead */
form_password_untrusted__sign_in: LocalizationValue;
form_password_compromised__sign_in: LocalizationValue;
password_too_long_needs_reset__sign_in: LocalizationValue;
form_username_invalid_length: LocalizationValue<'min_length' | 'max_length'>;
form_username_needs_non_number_char: LocalizationValue;
form_username_invalid_character: LocalizationValue;
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/components/SignIn/AlternativeMethods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { SignInSocialButtons } from './SignInSocialButtons';
import { useResetPasswordFactor } from './useResetPasswordFactor';
import { withHavingTrouble } from './withHavingTrouble';

export type AlternativeMethodsMode = 'forgot' | 'pwned' | 'passwordCompromised' | 'default';
export type AlternativeMethodsMode = 'forgot' | 'pwned' | 'passwordCompromised' | 'passwordTooLong' | 'default';

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand Down Expand Up @@ -55,7 +55,7 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
<Card.Content>
<Header.Root showLogo>
<Header.Title localizationKey={cardTitleKey} />
{!isReset && mode !== 'passwordCompromised' && (
{!isReset && mode !== 'passwordCompromised' && mode !== 'passwordTooLong' && (
<Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />
)}
</Header.Root>
Expand Down Expand Up @@ -187,6 +187,8 @@ function determineFlowPart(mode: AlternativeMethodsMode) {
return 'passwordPwnedMethods';
case 'passwordCompromised':
return 'passwordCompromisedMethods';
case 'passwordTooLong':
return 'passwordTooLongMethods';
default:
return 'alternativeMethods';
}
Expand All @@ -200,6 +202,8 @@ function determineTitle(mode: AlternativeMethodsMode): LocalizationKey {
return localizationKeys('signIn.passwordPwned.title');
case 'passwordCompromised':
return localizationKeys('signIn.passwordCompromised.title');
case 'passwordTooLong':
return localizationKeys('signIn.passwordTooLong.title');
default:
return localizationKeys('signIn.alternativeMethods.title');
}
Expand All @@ -209,6 +213,7 @@ function determineIsReset(mode: AlternativeMethodsMode): boolean {
switch (mode) {
case 'forgot':
case 'pwned':
case 'passwordTooLong':
return true;
case 'passwordCompromised':
return false;
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/SignIn/SignInFactorOne.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ function determineAlternativeMethodsMode(
return 'passwordCompromised';
}

if (passwordErrorCode === 'passwordTooLong') {
return 'passwordTooLong';
}

return 'forgot';
}

Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { isPasswordCompromisedError, isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error';
import {
isPasswordCompromisedError,
isPasswordPwnedError,
isPasswordTooLongError,
isUserLockedError,
} from '@clerk/shared/error';
import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors';
import { useClerk } from '@clerk/shared/react';
import React from 'react';
Expand All @@ -18,7 +23,7 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'compromised' | 'pwned';
export type PasswordErrorCode = 'compromised' | 'pwned' | 'passwordTooLong';

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
Expand Down Expand Up @@ -108,6 +113,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
onPasswordError('compromised');
return;
}

if (isPasswordTooLongError(err)) {
card.setError({ ...err.errors[0], code: 'password_too_long_needs_reset__sign_in' });
onPasswordError('passwordTooLong');
return;
}
}

handleError(err, [passwordControl], card.setError);
Expand Down
132 changes: 132 additions & 0 deletions packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,138 @@ describe('SignInFactorOne', () => {
).not.toBeInTheDocument();
});

it('Prompts the user to reset their password via email if it is too long', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportPassword: true,
supportEmailCode: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'password_too_long_needs_reset',
long_message: 'The existing imported password is too long and cannot be used, please reset your password.',
message: 'The existing imported password is too long and cannot be used, please reset your password.',
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);

const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await screen.findByText('Password too long');
await screen.findByText(
'The existing imported password is too long and cannot be used, please reset your password.',
);
await screen.findByText('Or, sign in with another method');

await userEvent.click(screen.getByText('Reset your password'));
await screen.findByText('First, enter the code sent to your email address');
});

it('Prompts the user to reset their password via phone if it is too long', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithPhoneNumber({
supportPassword: true,
supportPhoneCode: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'password_too_long_needs_reset',
long_message: 'The existing imported password is too long and cannot be used, please reset your password.',
message: 'The existing imported password is too long and cannot be used, please reset your password.',
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);

const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await screen.findByText('Password too long');
await screen.findByText(
'The existing imported password is too long and cannot be used, please reset your password.',
);
await screen.findByText('Or, sign in with another method');

await userEvent.click(screen.getByText('Reset your password'));
await screen.findByText('First, enter the code sent to your phone');
});

it('entering a password that is too long, then going back and clicking forgot password should result in the correct title', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportPassword: true,
supportEmailCode: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'password_too_long_needs_reset',
long_message: 'The existing imported password is too long and cannot be used, please reset your password.',
message: 'The existing imported password is too long and cannot be used, please reset your password.',
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);

const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await screen.findByText('Password too long');
await screen.findByText(
'The existing imported password is too long and cannot be used, please reset your password.',
);
await screen.findByText('Or, sign in with another method');

// Go back
await userEvent.click(screen.getByText('Back'));

// Choose to reset password via "Forgot password" instead
await userEvent.click(screen.getByText(/Forgot password/i));
await screen.findByText('Forgot Password?');
expect(
screen.queryByText(
'The existing imported password is too long and cannot be used, please reset your password.',
),
).not.toBeInTheDocument();
});

it('using an compromised password should show the compromised password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
Expand Down
Loading