Skip to content

Commit 6cf1078

Browse files
authored
fix(ui): Add Forgot password? option to the SignInStart screen (#8733)
1 parent 400d10c commit 6cf1078

7 files changed

Lines changed: 168 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
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.

packages/ui/src/components/SignIn/SignInFactorOne.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { localizationKeys } from '../../localization';
1313
import { useRouter } from '../../router';
1414
import type { AlternativeMethodsMode } from './AlternativeMethods';
1515
import { AlternativeMethods } from './AlternativeMethods';
16-
import { hasMultipleEnterpriseConnections } from './shared';
16+
import { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
1717
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
1818
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
1919
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
@@ -62,6 +62,18 @@ function determineAlternativeMethodsMode(
6262
return 'forgot';
6363
}
6464

65+
function removeSignInResetPasswordIntentParam(): void {
66+
if (typeof window === 'undefined') {
67+
return;
68+
}
69+
70+
const url = new URL(window.location.href);
71+
if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
72+
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
73+
window.history.replaceState(window.history.state, '', url);
74+
}
75+
}
76+
6577
function SignInFactorOneInternal(): JSX.Element {
6678
const { __internal_setActiveInProgress } = useClerk();
6779
const signIn = useCoreSignIn();
@@ -97,13 +109,17 @@ function SignInFactorOneInternal(): JSX.Element {
97109
supportedFirstFactors,
98110
});
99111

100-
const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
101-
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
102-
);
103-
104112
const resetPasswordFactor = useResetPasswordFactor();
113+
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';
105114

106-
const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(false);
115+
const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
116+
const defaultShow = !currentFactor || !factorHasLocalStrategy(currentFactor);
117+
return defaultShow || (resetPasswordIntent && !resetPasswordFactor);
118+
});
119+
120+
const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(
121+
() => resetPasswordIntent && !!resetPasswordFactor,
122+
);
107123

108124
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);
109125

@@ -159,10 +175,17 @@ function SignInFactorOneInternal(): JSX.Element {
159175
const canGoBack = factorHasLocalStrategy(currentFactor) && !passwordErrorCode;
160176

161177
const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies;
162-
const backHandler = () => {
178+
const leaveAlternativeMethods = () => {
179+
// This search param only exists if the user clicked "Forgot password?" on the
180+
// start page, it's a way to go directly to the password reset screen.
181+
// If it does exist, we want to remove it on exit so refresh works correctly after.
182+
removeSignInResetPasswordIntentParam();
183+
toggle?.();
184+
};
185+
const backHandler: React.MouseEventHandler<Element> = () => {
163186
card.setError(undefined);
164187
setPasswordErrorCode(null);
165-
toggle?.();
188+
leaveAlternativeMethods();
166189
};
167190

168191
const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
@@ -173,7 +196,7 @@ function SignInFactorOneInternal(): JSX.Element {
173196
onBackLinkClick={canGoBack ? backHandler : undefined}
174197
onFactorSelected={f => {
175198
selectFactor(f);
176-
toggle?.();
199+
leaveAlternativeMethods();
177200
}}
178201
currentFactor={currentFactor}
179202
/>

packages/ui/src/components/SignIn/SignInStart.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ import { useSupportEmail } from '../../hooks/useSupportEmail';
3939
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
4040
import { useRouter } from '../../router';
4141
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
42-
import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared';
42+
import {
43+
hasMultipleEnterpriseConnections,
44+
SIGN_IN_RESET_PASSWORD_INTENT_PARAM,
45+
useHandleAuthenticateWithPasskey,
46+
} from './shared';
4347
import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard';
4448
import { SignInSocialButtons } from './SignInSocialButtons';
4549
import {
@@ -347,7 +351,10 @@ function SignInStartInternal(): JSX.Element {
347351
});
348352
};
349353

350-
const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
354+
const signInWithFields = async (
355+
fields: Array<FormControlState<string>>,
356+
options?: { resetPasswordIntent?: boolean },
357+
) => {
351358
// If the user has already selected an alternative phone code provider, we use that.
352359
const preferredAlternativePhoneChannel =
353360
alternativePhoneCodeProvider?.channel ||
@@ -385,6 +392,11 @@ function SignInStartInternal(): JSX.Element {
385392
break;
386393
case 'needs_first_factor': {
387394
if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) {
395+
if (options?.resetPasswordIntent) {
396+
return navigate('factor-one', {
397+
searchParams: new URLSearchParams({ [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' }),
398+
});
399+
}
388400
return navigate('factor-one');
389401
}
390402

@@ -447,7 +459,7 @@ function SignInStartInternal(): JSX.Element {
447459
);
448460

449461
if (instantPasswordError) {
450-
await signInWithFields(identifierField);
462+
await signInWithFields([identifierField]);
451463
} else if (sessionAlreadyExistsError) {
452464
await clerk.setActive({
453465
session: clerk.client.lastActiveSessionId,
@@ -514,7 +526,18 @@ function SignInStartInternal(): JSX.Element {
514526

515527
const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
516528
e.preventDefault();
517-
return signInWithFields(identifierField, instantPasswordField);
529+
return signInWithFields([identifierField, instantPasswordField]);
530+
};
531+
532+
const handleForgotPasswordClick: React.MouseEventHandler = e => {
533+
e.preventDefault();
534+
// Surface the same native required-field validation as the Continue button
535+
// when the identifier is missing
536+
const form = e.currentTarget.closest('form');
537+
if (form && !form.reportValidity()) {
538+
return;
539+
}
540+
void signInWithFields([identifierField], { resetPasswordIntent: true });
518541
};
519542

520543
const DynamicField = useMemo(() => {
@@ -600,7 +623,10 @@ function SignInStartInternal(): JSX.Element {
600623
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
601624
/>
602625
</Form.ControlRow>
603-
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
626+
<InstantPasswordRow
627+
field={passwordBasedInstance ? instantPasswordField : undefined}
628+
onForgotPasswordClick={handleForgotPasswordClick}
629+
/>
604630
</Col>
605631
<Col center>
606632
<CaptchaElement />
@@ -663,7 +689,13 @@ const hasOnlyEnterpriseSSOFirstFactors = (signIn: SignInResource): boolean => {
663689
return signIn.supportedFirstFactors.every(ff => ff.strategy === 'enterprise_sso');
664690
};
665691

666-
const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> }) => {
692+
const InstantPasswordRow = ({
693+
field,
694+
onForgotPasswordClick,
695+
}: {
696+
field?: FormControlState<'password'>;
697+
onForgotPasswordClick?: React.MouseEventHandler;
698+
}) => {
667699
const [autofilled, setAutofilled] = useState(false);
668700
const ref = useRef<HTMLInputElement>(null);
669701
const show = !!(autofilled || field?.value);
@@ -706,6 +738,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
706738
>
707739
<Form.PasswordInput
708740
{...field.props}
741+
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
742+
onActionClicked={show ? onForgotPasswordClick : undefined}
709743
ref={ref}
710744
tabIndex={show ? undefined : -1}
711745
/>

packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { bindCreateFixtures } from '@/test/create-fixtures';
77
import { act, mockWebAuthn, render, screen } from '@/test/utils';
88

99
import { SignInFactorOne } from '../SignInFactorOne';
10+
import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared';
1011

1112
const { createFixtures } = bindCreateFixtures('SignIn');
1213

@@ -142,6 +143,46 @@ describe('SignInFactorOne', () => {
142143
expect(screen.queryByText('Sign in with your password')).not.toBeInTheDocument();
143144
});
144145

146+
describe('reset password intent from start page', () => {
147+
const { createFixtures: createFixturesWithResetIntent } = bindCreateFixtures('SignIn', {
148+
router: { queryParams: { [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' } },
149+
});
150+
151+
it('opens the forgot password screen when a reset factor exists', async () => {
152+
const { wrapper } = await createFixturesWithResetIntent(f => {
153+
f.withEmailAddress();
154+
f.withPassword();
155+
f.withPreferredSignInStrategy({ strategy: 'password' });
156+
f.startSignInWithEmailAddress({
157+
supportEmailCode: true,
158+
supportPassword: true,
159+
supportResetPassword: true,
160+
});
161+
});
162+
render(<SignInFactorOne />, { wrapper });
163+
await screen.findByText('Forgot Password?');
164+
await screen.findByText('Reset your password');
165+
});
166+
167+
it('opens use another method when no reset factor exists', async () => {
168+
const email = 'test@clerk.com';
169+
const { wrapper } = await createFixturesWithResetIntent(f => {
170+
f.withEmailAddress();
171+
f.withPassword();
172+
f.withPreferredSignInStrategy({ strategy: 'password' });
173+
f.startSignInWithEmailAddress({
174+
supportEmailCode: true,
175+
supportPassword: true,
176+
supportResetPassword: false,
177+
identifier: email,
178+
});
179+
});
180+
render(<SignInFactorOne />, { wrapper });
181+
await screen.findByText('Use another method');
182+
await screen.findByText(`Email code to ${email}`);
183+
});
184+
});
185+
145186
it('should render the Forgot Password alternative methods component when clicking on "Forgot password" (email)', async () => {
146187
const { wrapper, fixtures } = await createFixtures(f => {
147188
f.withEmailAddress();

packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CardStateProvider } from '@/ui/elements/contexts';
1010

1111
import { OptionsProvider } from '../../../contexts';
1212
import { AppearanceProvider } from '../../../customizables';
13+
import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared';
1314
import { SignInStart } from '../SignInStart';
1415

1516
const { createFixtures } = bindCreateFixtures('SignIn');
@@ -479,6 +480,47 @@ describe('SignInStart', () => {
479480
});
480481
});
481482

483+
describe('Forgot password on instant password field', () => {
484+
it('navigates to factor-one with reset intent when clicking Forgot password', async () => {
485+
const { wrapper, fixtures } = await createFixtures(f => {
486+
f.withEmailAddress();
487+
f.withPassword({ required: true });
488+
});
489+
fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource));
490+
const { userEvent, container } = render(<SignInStart />, { wrapper });
491+
492+
await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
493+
const instantPasswordField = container.querySelector('#password-field') as Element;
494+
fireEvent.change(instantPasswordField, { target: { value: 'some-password' } });
495+
496+
await userEvent.click(screen.getByText(/Forgot password/i));
497+
498+
await waitFor(() => {
499+
expect(fixtures.signIn.create).toHaveBeenCalledWith({
500+
identifier: 'hello@clerk.com',
501+
});
502+
expect(fixtures.router.navigate).toHaveBeenCalledWith('factor-one', {
503+
searchParams: new URLSearchParams({ [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' }),
504+
});
505+
});
506+
});
507+
508+
it('does not call create when identifier is empty', async () => {
509+
const { wrapper, fixtures } = await createFixtures(f => {
510+
f.withEmailAddress();
511+
f.withPassword({ required: true });
512+
});
513+
const { userEvent, container } = render(<SignInStart />, { wrapper });
514+
515+
const instantPasswordField = container.querySelector('#password-field') as Element;
516+
fireEvent.change(instantPasswordField, { target: { value: 'some-password' } });
517+
518+
await userEvent.click(screen.getByText(/Forgot password/i));
519+
520+
expect(fixtures.signIn.create).not.toHaveBeenCalled();
521+
});
522+
});
523+
482524
describe('Submitting form via instant password autofill', () => {
483525
const ERROR_CODES = ['strategy_for_user_invalid', 'form_password_incorrect', 'form_password_pwned'];
484526
ERROR_CODES.forEach(code => {

packages/ui/src/components/SignIn/shared.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { handleError } from '@/ui/utils/errorHandler';
1111
import { useCoreSignIn, useSignInContext } from '../../contexts';
1212
import { useSupportEmail } from '../../hooks/useSupportEmail';
1313

14+
/** Search param set when navigating from the start page "Forgot password?" action. */
15+
export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password';
16+
1417
function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise<unknown>) {
1518
const card = useCardState();
1619
// @ts-expect-error -- private method for the time being

packages/ui/src/test/mock-helpers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
8787
return clerkAny as DeepVitestMocked<LoadedClerk>;
8888
};
8989

90-
export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
90+
export const mockRouteContextValue = ({
91+
queryString = '',
92+
queryParams,
93+
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
9194
return {
9295
basePath: '',
9396
startPath: '',
@@ -96,7 +99,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
9699
indexPath: '',
97100
currentPath: '',
98101
queryString,
99-
queryParams: {},
102+
queryParams: queryParams ?? {},
100103
getMatchData: vi.fn(),
101104
matches: vi.fn(),
102105
baseNavigate: vi.fn(),

0 commit comments

Comments
 (0)