Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 469
feat(clerk-js): Untrusted password screen for sign-in#7331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
9488a4db6e259510795dddab6d2dc7f59ab44b5c924231a0c89b95951309dcfbc25e24241595fa11f2fcab06c2b04a782444cfbc82f34a1c424b1aa3eb3c67098dde82e495fa7328287648a4b1399c05a4d6536fe35238e149237a6655eb5803545772079b9File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@clerk/localizations': minor | ||
| '@clerk/clerk-js': minor | ||
| '@clerk/shared': minor | ||
| --- | ||
| Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -11,6 +11,7 @@ import { useCoreSignIn, useEnvironment } from '../../contexts'; | ||
| import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; | ||
| import { localizationKeys } from '../../localization'; | ||
| import { useRouter } from '../../router'; | ||
| import type { AlternativeMethodsMode } from './AlternativeMethods'; | ||
| import { AlternativeMethods } from './AlternativeMethods'; | ||
| import { hasMultipleEnterpriseConnections } from './shared'; | ||
| import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard'; | ||
| @@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard'; | ||
| import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections'; | ||
| import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard'; | ||
| import { SignInFactorOnePasskey } from './SignInFactorOnePasskey'; | ||
| import type { PasswordErrorCode } from './SignInFactorOnePasswordCard'; | ||
| import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard'; | ||
| import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard'; | ||
| import { useResetPasswordFactor } from './useResetPasswordFactor'; | ||
| @@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => { | ||
| return key; | ||
| }; | ||
| function determineAlternativeMethodsMode( | ||
| showForgotPasswordStrategies: boolean, | ||
| passwordErrorCode: PasswordErrorCode | null, | ||
| ): AlternativeMethodsMode { | ||
| if (!showForgotPasswordStrategies) { | ||
| return 'default'; | ||
| } | ||
| if (passwordErrorCode === 'pwned') { | ||
| return 'pwned'; | ||
| } | ||
| if (passwordErrorCode === 'untrusted') { | ||
| return 'passwordUntrusted'; | ||
| } | ||
| return 'forgot'; | ||
| } | ||
octoper marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| function SignInFactorOneInternal(): JSX.Element { | ||
| const { __internal_setActiveInProgress } = useClerk(); | ||
| const signIn = useCoreSignIn(); | ||
| @@ -84,7 +105,7 @@ function SignInFactorOneInternal(): JSX.Element { | ||
| const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(false); | ||
| const [isPasswordPwned, setIsPasswordPwned] = React.useState(false); | ||
| const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null); | ||
| React.useEffect(() => { | ||
| if (__internal_setActiveInProgress) { | ||
| @@ -139,11 +160,11 @@ function SignInFactorOneInternal(): JSX.Element { | ||
| const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies; | ||
| const backHandler = () => { | ||
| card.setError(undefined); | ||
| setIsPasswordPwned(false); | ||
| setPasswordErrorCode(null); | ||
| toggle?.(); | ||
| }; | ||
| const mode = showForgotPasswordStrategies ? (isPasswordPwned ? 'pwned' : 'forgot') : 'default'; | ||
| const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode); | ||
| return ( | ||
| <AlternativeMethods | ||
| @@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element { | ||
| <SignInFactorOnePasswordCard | ||
| onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies} | ||
| onShowAlternativeMethodsClick={toggleAllStrategies} | ||
| onPasswordPwned={() => { | ||
| setIsPasswordPwned(true); | ||
| onPasswordError={errorCode => { | ||
| setPasswordErrorCode(errorCode); | ||
| toggleForgotPasswordStrategies(); | ||
| }} | ||
| /> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error'; | ||
| import { isPasswordPwnedError, isPasswordUntrustedError, isUserLockedError } from '@clerk/shared/error'; | ||
| import { useClerk } from '@clerk/shared/react'; | ||
| import React from 'react'; | ||
| @@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext'; | ||
| import { HavingTrouble } from './HavingTrouble'; | ||
| import { useResetPasswordFactor } from './useResetPasswordFactor'; | ||
| export type PasswordErrorCode = 'untrusted' | 'pwned'; | ||
octoper marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| type SignInFactorOnePasswordProps = { | ||
| onForgotPasswordMethodClick: React.MouseEventHandler | undefined; | ||
| onShowAlternativeMethodsClick: React.MouseEventHandler | undefined; | ||
| onPasswordPwned?: () => void; | ||
| onPasswordError?: (errorCode: PasswordErrorCode) => void; | ||
| }; | ||
octoper marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const usePasswordControl = (props: SignInFactorOnePasswordProps) => { | ||
| @@ -50,7 +52,7 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => { | ||
| }; | ||
| export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => { | ||
| const { onShowAlternativeMethodsClick, onPasswordPwned } = props; | ||
| const { onShowAlternativeMethodsClick, onPasswordError } = props; | ||
| const passwordInputRef = React.useRef<HTMLInputElement>(null); | ||
| const card = useCardState(); | ||
| const { setActive } = useClerk(); | ||
| @@ -64,20 +66,20 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) | ||
| const clerk = useClerk(); | ||
| const goBack = () => { | ||
| return navigate('../'); | ||
| void navigate('../'); | ||
| }; | ||
| const handlePasswordSubmit: React.FormEventHandler = async e => { | ||
| const handlePasswordSubmit: React.FormEventHandler<HTMLFormElement> = e => { | ||
| e.preventDefault(); | ||
| return signIn | ||
| void signIn | ||
| .attemptFirstFactor({ strategy: 'password', password: passwordControl.value }) | ||
| .then(res => { | ||
| switch (res.status) { | ||
| case 'complete': | ||
| return setActive({ | ||
| session: res.createdSessionId, | ||
| navigate: async ({ session }) => { | ||
| await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); | ||
| navigate: ({ session }) => { | ||
| void navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); | ||
| }, | ||
| }); | ||
| case 'needs_second_factor': | ||
| @@ -92,10 +94,18 @@ 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; | ||
| if (onPasswordError) { | ||
| if (isPasswordPwnedError(err)) { | ||
| card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' }); | ||
| onPasswordError('pwned'); | ||
| return; | ||
| } | ||
| if (isPasswordUntrustedError(err)) { | ||
| card.setError({ ...err.errors[0], code: 'form_password_untrusted__sign_in' }); | ||
| onPasswordError('untrusted'); | ||
| return; | ||
| } | ||
| } | ||
| handleError(err, [passwordControl], card.setError); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -352,6 +352,84 @@ describe('SignInFactorOne', () => { | ||
| ), | ||
| ).not.toBeInTheDocument(); | ||
| }); | ||
| it('using an untrusted password should show the untrusted password screen', async () => { | ||
| const { wrapper, fixtures } = await createFixtures(f => { | ||
| f.withEmailAddress(); | ||
| f.withPassword(); | ||
| f.withPreferredSignInStrategy({ strategy: 'password' }); | ||
| f.startSignInWithEmailAddress({ | ||
| supportEmailCode: true, | ||
| supportPassword: true, | ||
| supportResetPassword: true, | ||
| }); | ||
| }); | ||
| fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); | ||
| const errJSON = { | ||
| code: 'form_password_untrusted', | ||
| long_message: | ||
| "Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.", | ||
| message: | ||
| "Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.", | ||
| meta: { param_name: 'password' }, | ||
| }; | ||
octoper marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 compromised'); | ||
| await screen.findByText( | ||
| "Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.", | ||
| ); | ||
| await screen.findByText('Email code to hello@clerk.com'); | ||
| }); | ||
| it('Prompts the user to use a different method if the password is untrusted', async () => { | ||
| const { wrapper, fixtures } = await createFixtures(f => { | ||
| f.withEmailAddress(); | ||
| f.withPassword(); | ||
| f.withPreferredSignInStrategy({ strategy: 'password' }); | ||
| f.withSocialProvider({ provider: 'google', authenticatable: true }); | ||
| f.startSignInWithEmailAddress({ | ||
| supportEmailCode: true, | ||
| supportPassword: true, | ||
| supportResetPassword: true, | ||
| }); | ||
| }); | ||
| fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); | ||
| const errJSON = { | ||
| code: 'form_password_untrusted', | ||
| long_message: | ||
| "Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.", | ||
| message: | ||
| "Your password appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.", | ||
| meta: { param_name: 'password' }, | ||
| }; | ||
octoper marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 compromised'); | ||
| await userEvent.click(screen.getByText('Email code to hello@clerk.com')); | ||
| await screen.findByText('Check your email'); | ||
| }); | ||
| }); | ||
| describe('Forgot Password', () => { | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.