Skip to content

Commit

Permalink
feat(clerk-js,localizations,shared,types): Prompt user to reset pwned…
Browse files Browse the repository at this point in the history
… password at sign-in
  • Loading branch information
yourtallness committed Mar 28, 2024
1 parent 84c05e6 commit 2982469
Show file tree
Hide file tree
Showing 36 changed files with 1,025 additions and 54 deletions.
36 changes: 27 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type AlternativeMethodsProps = {
onFactorSelected: (factor: SignInFactor) => void;
currentFactor: SignInFactor | undefined | null;
asForgotPassword?: boolean;
isPasswordPwned?: boolean;
};

export type AlternativeMethodListProps = AlternativeMethodsProps & { onHavingTroubleClick: React.MouseEventHandler };
Expand All @@ -28,23 +29,31 @@ export const AlternativeMethods = (props: AlternativeMethodsProps) => {
};

const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, asForgotPassword = false } = props;
const {
onBackLinkClick,
onHavingTroubleClick,
onFactorSelected,
asForgotPassword = false,
isPasswordPwned = false,
} = props;
const card = useCardState();
const resetPasswordFactor = useResetPasswordFactor();
const { firstPartyFactors, hasAnyStrategy } = useAlternativeStrategies({
filterOutFactor: props?.currentFactor,
});

const cardTitleKey = asForgotPassword
? isPasswordPwned
? 'signIn.passwordPwned.title'
: 'signIn.forgotPasswordAlternativeMethods.title'
: 'signIn.alternativeMethods.title';

return (
<Flow.Part part={asForgotPassword ? 'forgotPasswordMethods' : 'alternativeMethods'}>
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Header.Title
localizationKey={localizationKeys(
asForgotPassword ? 'signIn.forgotPasswordAlternativeMethods.title' : 'signIn.alternativeMethods.title',
)}
/>
<Header.Title localizationKey={localizationKeys(cardTitleKey)} />
{!asForgotPassword && (
<Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />
)}
Expand All @@ -61,7 +70,10 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
localizationKey={getButtonLabel(resetPasswordFactor)}
elementDescriptor={descriptors.alternativeMethodsBlockButton}
isDisabled={card.isLoading}
onClick={() => onFactorSelected(resetPasswordFactor)}
onClick={() => {
card.setError(undefined);
onFactorSelected(resetPasswordFactor);
}}
/>
)}
{asForgotPassword && hasAnyStrategy && (
Expand Down Expand Up @@ -90,7 +102,10 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
key={i}
textVariant='buttonLarge'
isDisabled={card.isLoading}
onClick={() => onFactorSelected(factor)}
onClick={() => {
card.setError(undefined);
onFactorSelected(factor);
}}
/>
))}
</Flex>
Expand All @@ -99,7 +114,10 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
<BackLink
boxElementDescriptor={descriptors.backRow}
linkElementDescriptor={descriptors.backLink}
onClick={onBackLinkClick}
onClick={e => {
card.setError(undefined);
onBackLinkClick(e);
}}
/>
)}
</Col>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export function _SignInFactorOne(): JSX.Element {

const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(false);

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);

React.useEffect(() => {
// Handle the case where a user lands on alternative methods screen,
// clicks a social button but then navigates back to sign in.
Expand Down Expand Up @@ -98,6 +100,7 @@ export function _SignInFactorOne(): JSX.Element {
return (
<AlternativeMethods
asForgotPassword={showForgotPasswordStrategies}
isPasswordPwned={isPasswordPwned}
onBackLinkClick={canGoBack ? toggle : undefined}
onFactorSelected={f => {
selectFactor(f);
Expand Down Expand Up @@ -135,6 +138,10 @@ export function _SignInFactorOne(): JSX.Element {
}}
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
toggleForgotPasswordStrategies();
}}
/>
);
case 'email_code':
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isUserLockedError } from '@clerk/shared/error';
import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import type { ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';
Expand All @@ -17,6 +17,7 @@ type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onFactorPrepare: (f: ResetPasswordCodeFactor) => void;
onPasswordPwned?: () => void;
};

const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
Expand All @@ -40,12 +41,13 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
: onShowAlternativeMethodsClick
? onShowAlternativeMethodsClick
: () => null,
// onPasswordPwned,
},
};
};

export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => {
const { onShowAlternativeMethodsClick } = props;
const { onShowAlternativeMethodsClick, onPasswordPwned } = props;
const card = useCardState();
const { setActive } = useClerk();
const signIn = useCoreSignIn();
Expand Down Expand Up @@ -81,6 +83,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
return clerk.__internal_navigateWithError('..', err.errors[0]);
}

if (isPasswordPwnedError(err) && onPasswordPwned) {
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
onPasswordPwned();
return;
}

handleError(err, [passwordControl], card.setError);
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,98 @@ describe('SignInFactorOne', () => {
});
});
});

it('Prompts the user to reset their password via email if it has been pwned', 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: 'form_password_pwned',
long_message:
'Password has been found in an online data breach. For account safety, please reset your password.',
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
meta: { param_name: 'password' },
};

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(() => {
screen.getByText('Password compromised');
screen.getByText(
'This password has been found as part of a breach and can not be used, please reset your password.',
);
screen.getByText('Or, sign in with another method');
});

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

it('Prompts the user to reset their password via phone if it has been pwned', 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: 'form_password_pwned',
long_message:
'Password has been found in an online data breach. For account safety, please reset your password.',
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
meta: { param_name: 'password' },
};

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(() => {
screen.getByText('Password compromised');
screen.getByText(
'This password has been found as part of a breach and can not be used, please reset your password.',
);
screen.getByText('Or, sign in with another method');
});

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

describe('Forgot Password', () => {
Expand Down
29 changes: 29 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { LocalizationResource } from '@clerk/types';

export const arSA: LocalizationResource = {
locale: 'ar-SA',
__experimental_formFieldLabel__passkeyName: undefined,
backButton: 'الرجوع',
badge__default: 'الأفتراضي',
badge__otherImpersonatorDevice: 'جهاز منتحل آخر',
Expand Down Expand Up @@ -279,6 +280,7 @@ export const arSA: LocalizationResource = {
blockButton__backupCode: 'استخدم رمز النسخ الاحتياطي',
blockButton__emailCode: 'رمز البريد الإلكتروني ل {{identifier}}',
blockButton__emailLink: 'رابط البريد الإلكتروني ل {{identifier}}',
blockButton__passkey: undefined,
blockButton__password: 'تسجيل الدخول بكلمة السر الخاصة بك',
blockButton__phoneCode: 'أرسال رسالة نصية ل{{identifier}}',
blockButton__totp: 'استخدم تطبيق المصادقة الخاص بك',
Expand Down Expand Up @@ -350,11 +352,18 @@ export const arSA: LocalizationResource = {
subtitle: 'حدث خطأ',
title: 'لا يمكن تسجيل الدخول',
},
passkey: {
subtitle: undefined,
title: undefined,
},
password: {
actionLink: 'أستعمل طريقة أخرى',
subtitle: 'للمتابعة إلى {{applicationName}}',
title: 'ادخل كلمة المرور',
},
passwordPwned: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand All @@ -381,6 +390,7 @@ export const arSA: LocalizationResource = {
actionLink: 'إنشاء حساب جديد',
actionLink__use_email: 'استخدم البريد الإلكتروني',
actionLink__use_email_username: 'استخدم البريد الإلكتروني أو اسم المستخدم',
actionLink__use_passkey: undefined,
actionLink__use_phone: 'استخدم رقم الجوال',
actionLink__use_username: 'استخدم اسم المستخدم',
actionText: 'ليس لديك حساب؟',
Expand Down Expand Up @@ -460,13 +470,19 @@ export const arSA: LocalizationResource = {
form_password_length_too_short: '',
form_password_not_strong_enough: 'كلمة المرور ليست قوية',
form_password_pwned: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: '',
form_username_invalid_length: '',
identification_deletion_failed: 'لا يمكن حذف هويتك الآخيرة ',
not_allowed_access: '',
passkey_already_exists: undefined,
passkey_not_supported: undefined,
passkey_registration_cancelled: undefined,
passkey_retrieval_cancelled: undefined,
passkeys_pa_not_supported: undefined,
passwordComplexity: {
maximumLength: 'أقل من {{length}} حروف',
minimumLength: '{{length}} حروف أو أكثر',
Expand Down Expand Up @@ -524,6 +540,14 @@ export const arSA: LocalizationResource = {
action__signOutAll: 'تسجيل الخروج من جميع الحسابات',
},
userProfile: {
__experimental_passkeyScreen: {
removeResource: {
messageLine1: undefined,
title: undefined,
},
subtitle__rename: undefined,
title__rename: undefined,
},
backupCodePage: {
actionLabel__copied: 'تم النسخ',
actionLabel__copy: 'نسخ الكل',
Expand Down Expand Up @@ -675,6 +699,11 @@ export const arSA: LocalizationResource = {
title: 'تحديث الملف الشخصي',
},
start: {
__experimental_passkeysSection: {
menuAction__destructive: undefined,
menuAction__rename: undefined,
title: undefined,
},
activeDevicesSection: {
destructiveAction: 'قم بتسجيل الخروج من الجهاز',
title: 'الأجهزة النشطة',
Expand Down
Loading

0 comments on commit 2982469

Please sign in to comment.