Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(clerk-js): Backport forgot password from sign-in start by jescalan · Pull Request #9224 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-in-start-forgot-password-core-2.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/clerk-js/src/test/mock-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,7 +105,10 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked<LoadedCle
return clerkAny as DeepVitestMocked<LoadedClerk>;
};

export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMocked<RouteContextValue>>) => {
export const mockRouteContextValue = ({
queryString = '',
queryParams,
}: Partial<DeepVitestMocked<RouteContextValue>>) => {
return {
basePath: '',
startPath: '',
Expand All@@ -114,7 +117,7 @@ export const mockRouteContextValue = ({ queryString = '' }: Partial<DeepVitestMo
indexPath: '',
currentPath: '',
queryString,
queryParams: {},
queryParams: queryParams ?? {},
getMatchData: vi.fn(),
matches: vi.fn(),
baseNavigate: vi.fn(),
Expand Down
61 changes: 52 additions & 9 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ErrorCard } from '@/ui/elements/ErrorCard';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { hasUrlInFragment } from '../../../utils';
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
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 { hasMultipleEnterpriseConnections, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
Expand DownExpand Up@@ -62,6 +63,35 @@ function determineAlternativeMethodsMode(
return 'forgot';
}

function removeSignInResetPasswordIntentParam(): boolean {
if (typeof window === 'undefined') {
return false;
}

const url = new URL(window.location.href);
let removed = false;

if (url.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
url.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
removed = true;
}

if (hasUrlInFragment(url)) {
const fragmentUrl = new URL(url.hash.substring(1), url.origin);
if (fragmentUrl.searchParams.has(SIGN_IN_RESET_PASSWORD_INTENT_PARAM)) {
fragmentUrl.searchParams.delete(SIGN_IN_RESET_PASSWORD_INTENT_PARAM);
url.hash = `#${fragmentUrl.pathname}${fragmentUrl.search}${fragmentUrl.hash}`;
removed = true;
}
}

if (removed) {
window.history.replaceState(window.history.state, '', url);
}

return removed;
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand DownExpand Up@@ -97,13 +127,17 @@ function SignInFactorOneInternal(): JSX.Element {
supportedFirstFactors,
});

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(
() => !currentFactor || !factorHasLocalStrategy(currentFactor),
);

const resetPasswordFactor = useResetPasswordFactor();
const resetPasswordIntent = router.queryParams[SIGN_IN_RESET_PASSWORD_INTENT_PARAM] === 'true';

const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(() => {
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<PasswordErrorCode | null>(null);

Expand DownExpand Up@@ -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<Element> = () => {
card.setError(undefined);
setPasswordErrorCode(null);
toggle?.();
leaveAlternativeMethods();
};

const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode);
Expand All@@ -172,7 +215,7 @@ function SignInFactorOneInternal(): JSX.Element {
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
leaveAlternativeMethods();
}}
currentFactor={currentFactor}
/>
Expand Down
46 changes: 40 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -352,7 +356,10 @@ function SignInStartInternal(): JSX.Element {
});
};

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
const signInWithFields = async (
fields: Array<FormControlState<string>>,
options?: { resetPasswordIntent?: boolean },
) => {
// If the user has already selected an alternative phone code provider, we use that.
const preferredAlternativePhoneChannel =
alternativePhoneCodeProvider?.channel ||
Expand DownExpand Up@@ -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');
}

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -517,7 +529,18 @@ function SignInStartInternal(): JSX.Element {

const handleFirstPartySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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(() => {
Expand DownExpand Up@@ -610,7 +633,10 @@ function SignInStartInternal(): JSX.Element {
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
/>
</Form.ControlRow>
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
<InstantPasswordRow
field={passwordBasedInstance ? instantPasswordField : undefined}
onForgotPasswordClick={handleForgotPasswordClick}
/>
</Col>
<Col center>
<Form.SubmitButton hasArrow />
Expand DownExpand Up@@ -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<HTMLInputElement>(null);
const show = !!(autofilled || field?.value);
Expand DownExpand Up@@ -719,6 +751,8 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
>
<Form.PasswordInput
{...field.props}
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
onActionClicked={show ? onForgotPasswordClick : undefined}
ref={ref}
tabIndex={show ? undefined : -1}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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(<SignInFactorOne />, { 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();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
Expand DownExpand Up@@ -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(<SignInStart />, { 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(<SignInStart />, { 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 => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>) {
const card = useCardState();
// @ts-expect-error -- private method for the time being
Expand Down
Loading