Skip to content

Commit ce8b914

Browse files
feat(clerk-js): Untrusted password screen for sign-in (#7331)
Co-authored-by: Laura Beatris <[email protected]>
1 parent d2b971d commit ce8b914

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+355
-19
lines changed

.changeset/sweet-poets-sell.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/shared': minor
5+
---
6+
7+
Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in

packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { SignInSocialButtons } from './SignInSocialButtons';
1818
import { useResetPasswordFactor } from './useResetPasswordFactor';
1919
import { withHavingTrouble } from './withHavingTrouble';
2020

21-
type AlternativeMethodsMode = 'forgot' | 'pwned' | 'default';
21+
export type AlternativeMethodsMode = 'forgot' | 'pwned' | 'passwordUntrusted' | 'default';
2222

2323
export type AlternativeMethodsProps = {
2424
onBackLinkClick: React.MouseEventHandler | undefined;
@@ -55,7 +55,9 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
5555
<Card.Content>
5656
<Header.Root showLogo>
5757
<Header.Title localizationKey={cardTitleKey} />
58-
{!isReset && <Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />}
58+
{!isReset && mode !== 'passwordUntrusted' && (
59+
<Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />
60+
)}
5961
</Header.Root>
6062
<Card.Alert>{card.error}</Card.Alert>
6163
{/*TODO: extract main in its own component */}
@@ -183,6 +185,8 @@ function determineFlowPart(mode: AlternativeMethodsMode) {
183185
return 'forgotPasswordMethods';
184186
case 'pwned':
185187
return 'passwordPwnedMethods';
188+
case 'passwordUntrusted':
189+
return 'passwordUntrustedMethods';
186190
default:
187191
return 'alternativeMethods';
188192
}
@@ -194,6 +198,8 @@ function determineTitle(mode: AlternativeMethodsMode): LocalizationKey {
194198
return localizationKeys('signIn.forgotPasswordAlternativeMethods.title');
195199
case 'pwned':
196200
return localizationKeys('signIn.passwordPwned.title');
201+
case 'passwordUntrusted':
202+
return localizationKeys('signIn.passwordPwned.title');
197203
default:
198204
return localizationKeys('signIn.alternativeMethods.title');
199205
}
@@ -204,6 +210,8 @@ function determineIsReset(mode: AlternativeMethodsMode): boolean {
204210
case 'forgot':
205211
case 'pwned':
206212
return true;
213+
case 'passwordUntrusted':
214+
return false;
207215
default:
208216
return false;
209217
}

packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useCoreSignIn, useEnvironment } from '../../contexts';
1111
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
1212
import { localizationKeys } from '../../localization';
1313
import { useRouter } from '../../router';
14+
import type { AlternativeMethodsMode } from './AlternativeMethods';
1415
import { AlternativeMethods } from './AlternativeMethods';
1516
import { hasMultipleEnterpriseConnections } from './shared';
1617
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
1920
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
2021
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
2122
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
23+
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
2224
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
2325
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
2426
import { useResetPasswordFactor } from './useResetPasswordFactor';
@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
4143
return key;
4244
};
4345

46+
function determineAlternativeMethodsMode(
47+
showForgotPasswordStrategies: boolean,
48+
passwordErrorCode: PasswordErrorCode | null,
49+
): AlternativeMethodsMode {
50+
if (!showForgotPasswordStrategies) {
51+
return 'default';
52+
}
53+
54+
if (passwordErrorCode === 'pwned') {
55+
return 'pwned';
56+
}
57+
58+
if (passwordErrorCode === 'untrusted') {
59+
return 'passwordUntrusted';
60+
}
61+
62+
return 'forgot';
63+
}
64+
4465
function SignInFactorOneInternal(): JSX.Element {
4566
const { __internal_setActiveInProgress } = useClerk();
4667
const signIn = useCoreSignIn();
@@ -84,7 +105,7 @@ function SignInFactorOneInternal(): JSX.Element {
84105

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

87-
const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
108+
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);
88109

89110
React.useEffect(() => {
90111
if (__internal_setActiveInProgress) {
@@ -139,11 +160,11 @@ function SignInFactorOneInternal(): JSX.Element {
139160
const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies;
140161
const backHandler = () => {
141162
card.setError(undefined);
142-
setIsPasswordPwned(false);
163+
setPasswordErrorCode(null);
143164
toggle?.();
144165
};
145166

146-
const mode = showForgotPasswordStrategies ? (isPasswordPwned ? 'pwned' : 'forgot') : 'default';
167+
const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
147168

148169
return (
149170
<AlternativeMethods
@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
175196
<SignInFactorOnePasswordCard
176197
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
177198
onShowAlternativeMethodsClick={toggleAllStrategies}
178-
onPasswordPwned={() => {
179-
setIsPasswordPwned(true);
199+
onPasswordError={errorCode => {
200+
setPasswordErrorCode(errorCode);
180201
toggleForgotPasswordStrategies();
181202
}}
182203
/>

packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error';
1+
import { isPasswordPwnedError, isPasswordUntrustedError, isUserLockedError } from '@clerk/shared/error';
22
import { useClerk } from '@clerk/shared/react';
33
import React from 'react';
44

@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
1818
import { HavingTrouble } from './HavingTrouble';
1919
import { useResetPasswordFactor } from './useResetPasswordFactor';
2020

21+
export type PasswordErrorCode = 'untrusted' | 'pwned';
22+
2123
type SignInFactorOnePasswordProps = {
2224
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
2325
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
24-
onPasswordPwned?: () => void;
26+
onPasswordError?: (errorCode: PasswordErrorCode) => void;
2527
};
2628

2729
const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
@@ -50,7 +52,7 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
5052
};
5153

5254
export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => {
53-
const { onShowAlternativeMethodsClick, onPasswordPwned } = props;
55+
const { onShowAlternativeMethodsClick, onPasswordError } = props;
5456
const passwordInputRef = React.useRef<HTMLInputElement>(null);
5557
const card = useCardState();
5658
const { setActive } = useClerk();
@@ -64,20 +66,20 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
6466
const clerk = useClerk();
6567

6668
const goBack = () => {
67-
return navigate('../');
69+
void navigate('../');
6870
};
6971

70-
const handlePasswordSubmit: React.FormEventHandler = async e => {
72+
const handlePasswordSubmit: React.FormEventHandler<HTMLFormElement> = e => {
7173
e.preventDefault();
72-
return signIn
74+
void signIn
7375
.attemptFirstFactor({ strategy: 'password', password: passwordControl.value })
7476
.then(res => {
7577
switch (res.status) {
7678
case 'complete':
7779
return setActive({
7880
session: res.createdSessionId,
79-
navigate: async ({ session }) => {
80-
await navigateOnSetActive({ session, redirectUrl: afterSignInUrl });
81+
navigate: ({ session }) => {
82+
void navigateOnSetActive({ session, redirectUrl: afterSignInUrl });
8183
},
8284
});
8385
case 'needs_second_factor':
@@ -92,10 +94,18 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
9294
return clerk.__internal_navigateWithError('..', err.errors[0]);
9395
}
9496

95-
if (isPasswordPwnedError(err) && onPasswordPwned) {
96-
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
97-
onPasswordPwned();
98-
return;
97+
if (onPasswordError) {
98+
if (isPasswordPwnedError(err)) {
99+
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
100+
onPasswordError('pwned');
101+
return;
102+
}
103+
104+
if (isPasswordUntrustedError(err)) {
105+
card.setError({ ...err.errors[0], code: 'form_password_untrusted__sign_in' });
106+
onPasswordError('untrusted');
107+
return;
108+
}
99109
}
100110

101111
handleError(err, [passwordControl], card.setError);

packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
352352
),
353353
).not.toBeInTheDocument();
354354
});
355+
356+
it('using an untrusted password should show the untrusted password screen', async () => {
357+
const { wrapper, fixtures } = await createFixtures(f => {
358+
f.withEmailAddress();
359+
f.withPassword();
360+
f.withPreferredSignInStrategy({ strategy: 'password' });
361+
f.startSignInWithEmailAddress({
362+
supportEmailCode: true,
363+
supportPassword: true,
364+
supportResetPassword: true,
365+
});
366+
});
367+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
368+
369+
const errJSON = {
370+
code: 'form_password_untrusted',
371+
long_message:
372+
"Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
373+
message:
374+
"Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
375+
meta: { param_name: 'password' },
376+
};
377+
378+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
379+
new ClerkAPIResponseError('Error', {
380+
data: [errJSON],
381+
status: 422,
382+
}),
383+
);
384+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
385+
await userEvent.type(screen.getByLabelText('Password'), '123456');
386+
await userEvent.click(screen.getByText('Continue'));
387+
388+
await screen.findByText('Password compromised');
389+
await screen.findByText(
390+
"Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
391+
);
392+
393+
await screen.findByText('Email code to [email protected]');
394+
});
395+
396+
it('Prompts the user to use a different method if the password is untrusted', async () => {
397+
const { wrapper, fixtures } = await createFixtures(f => {
398+
f.withEmailAddress();
399+
f.withPassword();
400+
f.withPreferredSignInStrategy({ strategy: 'password' });
401+
f.withSocialProvider({ provider: 'google', authenticatable: true });
402+
f.startSignInWithEmailAddress({
403+
supportEmailCode: true,
404+
supportPassword: true,
405+
supportResetPassword: true,
406+
});
407+
});
408+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
409+
410+
const errJSON = {
411+
code: 'form_password_untrusted',
412+
long_message:
413+
"Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
414+
message:
415+
"Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
416+
meta: { param_name: 'password' },
417+
};
418+
419+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
420+
new ClerkAPIResponseError('Error', {
421+
data: [errJSON],
422+
status: 422,
423+
}),
424+
);
425+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
426+
await userEvent.type(screen.getByLabelText('Password'), '123456');
427+
await userEvent.click(screen.getByText('Continue'));
428+
429+
await screen.findByText('Password compromised');
430+
await userEvent.click(screen.getByText('Email code to [email protected]'));
431+
await screen.findByText('Check your email');
432+
});
355433
});
356434

357435
describe('Forgot Password', () => {

packages/clerk-js/src/ui/elements/contexts/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export type FlowMetadata = {
120120
| 'alternativeMethods'
121121
| 'forgotPasswordMethods'
122122
| 'passwordPwnedMethods'
123+
| 'passwordUntrustedMethods'
123124
| 'havingTrouble'
124125
| 'ssoCallback'
125126
| 'popupCallback'

packages/localizations/src/ar-SA.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
693693
passwordPwned: {
694694
title: 'كلمة المرور غير آمنة',
695695
},
696+
passwordUntrusted: {
697+
title: undefined,
698+
},
696699
phoneCode: {
697700
formTitle: 'رمز التحقق',
698701
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
895898
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
896899
form_password_size_in_bytes_exceeded:
897900
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
901+
form_password_untrusted__sign_in: undefined,
898902
form_password_validation_failed: 'كلمة مرور خاطئة',
899903
form_username_invalid_character: undefined,
900904
form_username_invalid_length: undefined,

packages/localizations/src/be-BY.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
700700
passwordPwned: {
701701
title: 'Пароль быў узламаны',
702702
},
703+
passwordUntrusted: {
704+
title: undefined,
705+
},
703706
phoneCode: {
704707
formTitle: 'Код верыфікацыі',
705708
resendButton: 'Пераадправіць код',
@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
904907
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
905908
form_password_size_in_bytes_exceeded:
906909
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
910+
form_password_untrusted__sign_in: undefined,
907911
form_password_validation_failed: 'Неверагодны пароль',
908912
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
909913
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',

packages/localizations/src/bg-BG.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
696696
passwordPwned: {
697697
title: undefined,
698698
},
699+
passwordUntrusted: {
700+
title: undefined,
701+
},
699702
phoneCode: {
700703
formTitle: 'Код за потвърждение',
701704
resendButton: 'Не сте получили код? Изпрати отново',
@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
897900
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
898901
form_password_pwned__sign_in: undefined,
899902
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
903+
form_password_untrusted__sign_in: undefined,
900904
form_password_validation_failed: 'Невалидна парола.',
901905
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
902906
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',

packages/localizations/src/bn-IN.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
699699
passwordPwned: {
700700
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
701701
},
702+
passwordUntrusted: {
703+
title: undefined,
704+
},
702705
phoneCode: {
703706
formTitle: 'যাচাইকরণ কোড',
704707
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
906909
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
907910
form_password_size_in_bytes_exceeded:
908911
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
912+
form_password_untrusted__sign_in: undefined,
909913
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
910914
form_username_invalid_character:
911915
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',

0 commit comments

Comments
 (0)