diff --git a/.changeset/sign-in-start-forgot-password-core-2.md b/.changeset/sign-in-start-forgot-password-core-2.md new file mode 100644 index 00000000000..8302d79e110 --- /dev/null +++ b/.changeset/sign-in-start-forgot-password-core-2.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Add a "Forgot password?" action on the sign-in start page when the password field is shown. This improves the account recovery UX when strict user enumeration protection is enabled. diff --git a/packages/clerk-js/src/test/mock-helpers.ts b/packages/clerk-js/src/test/mock-helpers.ts index d76dea115bb..0e7822ee56d 100644 --- a/packages/clerk-js/src/test/mock-helpers.ts +++ b/packages/clerk-js/src/test/mock-helpers.ts @@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked; }; -export const mockRouteContextValue = ({ queryString = '' }: Partial>) => { +export const mockRouteContextValue = ({ + queryString = '', + queryParams, +}: Partial>) => { return { basePath: '', startPath: '', @@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial( - () => !currentFactor || !factorHasLocalStrategy(currentFactor), - ); - const resetPasswordFactor = useResetPasswordFactor(); + const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true'; + + const [showAllStrategies, setShowAllStrategies] = React.useState(() => { + const defaultShow = !currentFactor || !factorHasLocalStrategy(currentFactor); + return defaultShow || (resetPasswordIntent && !resetPasswordFactor); + }); - const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(false); + const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState( + () => resetPasswordIntent && !!resetPasswordFactor, + ); const [passwordErrorCode, setPasswordErrorCode] = React.useState(null); @@ -158,10 +192,19 @@ function SignInFactorOneInternal(): JSX.Element { const canGoBack = factorHasLocalStrategy(currentFactor); const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies; - const backHandler = () => { + const leaveAlternativeMethods = () => { + // This search param only exists if the user clicked "Forgot password?" on the + // start page, it's a way to go directly to the password reset screen. + // If it does exist, we want to remove it on exit so refresh works correctly after. + if (removeSignInResetPasswordIntentParam()) { + router.refresh(); + } + toggle?.(); + }; + const backHandler: React.MouseEventHandler = () => { card.setError(undefined); setPasswordErrorCode(null); - toggle?.(); + leaveAlternativeMethods(); }; const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode); @@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element { onBackLinkClick={canGoBack ? backHandler : undefined} onFactorSelected={f => { selectFactor(f); - toggle?.(); + leaveAlternativeMethods(); }} currentFactor={currentFactor} /> diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 73415827c51..fb5bfb56757 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -40,7 +40,11 @@ import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; -import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared'; +import { + hasMultipleEnterpriseConnections, + SIGN_IN_RESET_PASSWORD_INTENT_PARAM, + useHandleAuthenticateWithPasskey, +} from './shared'; import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard'; import { SignInSocialButtons } from './SignInSocialButtons'; import { @@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element { }); }; - const signInWithFields = async (...fields: Array>) => { + const signInWithFields = async ( + fields: Array>, + options?: { resetPasswordIntent?: boolean }, + ) => { // If the user has already selected an alternative phone code provider, we use that. const preferredAlternativePhoneChannel = alternativePhoneCodeProvider?.channel || @@ -390,6 +397,11 @@ function SignInStartInternal(): JSX.Element { break; case 'needs_first_factor': { if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) { + if (options?.resetPasswordIntent) { + return navigate('factor-one', { + searchParams: new URLSearchParams({ [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' }), + }); + } return navigate('factor-one'); } @@ -450,7 +462,7 @@ function SignInStartInternal(): JSX.Element { ); if (instantPasswordError) { - await signInWithFields(identifierField); + await signInWithFields([identifierField]); } else if (sessionAlreadyExistsError) { await clerk.setActive({ session: clerk.client.lastActiveSessionId, @@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element { const handleFirstPartySubmit = async (e: React.FormEvent) => { e.preventDefault(); - return signInWithFields(identifierField, instantPasswordField); + return signInWithFields([identifierField, instantPasswordField]); + }; + + const handleForgotPasswordClick: React.MouseEventHandler = e => { + e.preventDefault(); + // Surface the same native required-field validation as the Continue button + // when the identifier is missing + const form = e.currentTarget.closest('form'); + if (form && !form.reportValidity()) { + return; + } + void signInWithFields([identifierField], { resetPasswordIntent: true }); }; const DynamicField = useMemo(() => { @@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element { isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy} /> - + @@ -676,7 +702,13 @@ const hasOnlyEnterpriseSSOFirstFactors = (signIn: SignInResource): boolean => { return signIn.supportedFirstFactors.every(ff => ff.strategy === 'enterprise_sso'); }; -const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> }) => { +const InstantPasswordRow = ({ + field, + onForgotPasswordClick, +}: { + field?: FormControlState<'password'>; + onForgotPasswordClick?: React.MouseEventHandler; +}) => { const [autofilled, setAutofilled] = useState(false); const ref = useRef(null); const show = !!(autofilled || field?.value); @@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> }) > diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx index a4a2525ed61..7eb9f2f0ac8 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { act, mockWebAuthn, render, screen } from '@/test/utils'; +import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared'; import { SignInFactorOne } from '../SignInFactorOne'; const { createFixtures } = bindCreateFixtures('SignIn'); @@ -141,6 +142,84 @@ describe('SignInFactorOne', () => { expect(screen.queryByText('Sign in with your password')).not.toBeInTheDocument(); }); + describe('reset password intent from start page', () => { + const { createFixtures: createFixturesWithResetIntent } = bindCreateFixtures('SignIn', { + router: { queryParams: { [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' } }, + }); + + it('opens the forgot password screen when a reset factor exists', async () => { + const { wrapper } = await createFixturesWithResetIntent(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.startSignInWithEmailAddress({ + supportEmailCode: true, + supportPassword: true, + supportResetPassword: true, + }); + }); + render(, { wrapper }); + await screen.findByText('Forgot Password?'); + await screen.findByText('Reset your password'); + }); + + it('opens use another method when no reset factor exists', async () => { + const email = 'test@clerk.com'; + const { wrapper } = await createFixturesWithResetIntent(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.startSignInWithEmailAddress({ + supportEmailCode: true, + supportPassword: true, + supportResetPassword: false, + identifier: email, + }); + }); + render(, { wrapper }); + await screen.findByText('Use another method'); + await screen.findByText(`Email code to ${email}`); + }); + + it.each([ + { + name: 'path router', + initialUrl: `/sign-in/factor-one?${SIGN_IN_RESET_PASSWORD_INTENT_PARAM}=true&preserved=value`, + expectedUrl: '/sign-in/factor-one?preserved=value', + }, + { + name: 'hash router', + initialUrl: `/sign-in#/factor-one?${SIGN_IN_RESET_PASSWORD_INTENT_PARAM}=true&preserved=value`, + expectedUrl: '/sign-in#/factor-one?preserved=value', + }, + ])('removes the reset intent when leaving under the $name', async ({ initialUrl, expectedUrl }) => { + const originalUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.history.replaceState(window.history.state, '', initialUrl); + + try { + const { wrapper, fixtures } = await createFixturesWithResetIntent(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.startSignInWithEmailAddress({ + supportEmailCode: true, + supportPassword: true, + supportResetPassword: true, + }); + }); + const { userEvent } = render(, { wrapper }); + await screen.findByText('Reset your password'); + + await userEvent.click(screen.getByText('Back')); + + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(expectedUrl); + expect(fixtures.router.refresh).toHaveBeenCalled(); + } finally { + window.history.replaceState(window.history.state, '', originalUrl); + } + }); + }); + it('should render the Forgot Password alternative methods component when clicking on "Forgot password" (email)', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress(); diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index fd647caf3cf..afcda916263 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -10,6 +10,7 @@ import { CardStateProvider } from '@/ui/elements/contexts'; import { OptionsProvider } from '../../../contexts'; import { AppearanceProvider } from '../../../customizables'; +import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared'; import { SignInStart } from '../SignInStart'; const { createFixtures } = bindCreateFixtures('SignIn'); @@ -503,6 +504,47 @@ describe('SignInStart', () => { }); }); + describe('Forgot password on instant password field', () => { + it('navigates to factor-one with reset intent when clicking Forgot password', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword({ required: true }); + }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent, container } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + const instantPasswordField = container.querySelector('#password-field') as Element; + fireEvent.change(instantPasswordField, { target: { value: 'some-password' } }); + + await userEvent.click(screen.getByText(/Forgot password/i)); + + await waitFor(() => { + expect(fixtures.signIn.create).toHaveBeenCalledWith({ + identifier: 'hello@clerk.com', + }); + expect(fixtures.router.navigate).toHaveBeenCalledWith('factor-one', { + searchParams: new URLSearchParams({ [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' }), + }); + }); + }); + + it('does not call create when identifier is empty', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword({ required: true }); + }); + const { userEvent, container } = render(, { wrapper }); + + const instantPasswordField = container.querySelector('#password-field') as Element; + fireEvent.change(instantPasswordField, { target: { value: 'some-password' } }); + + await userEvent.click(screen.getByText(/Forgot password/i)); + + expect(fixtures.signIn.create).not.toHaveBeenCalled(); + }); + }); + describe('Submitting form via instant password autofill', () => { const ERROR_CODES = ['strategy_for_user_invalid', 'form_password_incorrect', 'form_password_pwned']; ERROR_CODES.forEach(code => { diff --git a/packages/clerk-js/src/ui/components/SignIn/shared.ts b/packages/clerk-js/src/ui/components/SignIn/shared.ts index e4939eec64d..ee0b2672945 100644 --- a/packages/clerk-js/src/ui/components/SignIn/shared.ts +++ b/packages/clerk-js/src/ui/components/SignIn/shared.ts @@ -11,6 +11,9 @@ import { __internal_WebAuthnAbortService } from '../../../utils/passkeys'; import { useCoreSignIn, useSignInContext } from '../../contexts'; import { useSupportEmail } from '../../hooks/useSupportEmail'; +/** Search param set when navigating from the start page "Forgot password?" action. */ +export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password'; + function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise) { const card = useCardState(); // @ts-expect-error -- private method for the time being