Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4fb338f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
04ec552
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
15ec15e
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
9b999dd
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
38076d6
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
81efac2
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
6521a3f
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
ef4cde6
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
93183fe
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
39b8541
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
74400d5
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
c003f6d
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
a639180
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
b8c4e62
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
17e1629
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
326e221
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
ecc3697
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
d16cae5
revert changes
octoper Nov 28, 2025
cf616d1
chore: Remove stale changes
octoper Nov 28, 2025
87f9c52
chore: Remove stale changes
octoper Nov 28, 2025
638e43d
fix(localization,shared): Add localization types
octoper Nov 28, 2025
548c7c3
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
53de236
fix(localization): Add en-US localization
octoper Nov 28, 2025
b5f7793
chore(repo): Add changeset
octoper Nov 29, 2025
79a274a
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
c9ec623
chore: Remove console.log from tests
octoper Nov 30, 2025
c525144
feat(localizations): Generate all localizations
octoper Dec 1, 2025
e64e9cf
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
a09bc8a
fix(clerk-js): Fix error message
octoper Dec 1, 2025
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
7 changes: 7 additions & 0 deletions .changeset/sweet-poets-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { SignInSocialButtons } from './SignInSocialButtons';
import { useResetPasswordFactor } from './useResetPasswordFactor';
import { withHavingTrouble } from './withHavingTrouble';

type AlternativeMethodsMode = 'forgot' | 'pwned' | 'default';
export type AlternativeMethodsMode = 'forgot' | 'pwned' | 'passwordUntrusted' | 'default';

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand Down Expand Up @@ -55,7 +55,9 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
<Card.Content>
<Header.Root showLogo>
<Header.Title localizationKey={cardTitleKey} />
{!isReset && <Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />}
{!isReset && mode !== 'passwordUntrusted' && (
<Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />
)}
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
{/*TODO: extract main in its own component */}
Expand Down Expand Up @@ -183,6 +185,8 @@ function determineFlowPart(mode: AlternativeMethodsMode) {
return 'forgotPasswordMethods';
case 'pwned':
return 'passwordPwnedMethods';
case 'passwordUntrusted':
return 'passwordUntrustedMethods';
default:
return 'alternativeMethods';
}
Expand All @@ -194,6 +198,8 @@ function determineTitle(mode: AlternativeMethodsMode): LocalizationKey {
return localizationKeys('signIn.forgotPasswordAlternativeMethods.title');
case 'pwned':
return localizationKeys('signIn.passwordPwned.title');
case 'passwordUntrusted':
return localizationKeys('signIn.passwordPwned.title');
default:
return localizationKeys('signIn.alternativeMethods.title');
}
Expand All @@ -204,6 +210,8 @@ function determineIsReset(mode: AlternativeMethodsMode): boolean {
case 'forgot':
case 'pwned':
return true;
case 'passwordUntrusted':
return false;
default:
return false;
}
Expand Down
30 changes: 25 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useCoreSignIn, useEnvironment } from '../../contexts';
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
import { localizationKeys } from '../../localization';
import { useRouter } from '../../router';
import type { AlternativeMethodsMode } from './AlternativeMethods';
import { AlternativeMethods } from './AlternativeMethods';
import { hasMultipleEnterpriseConnections } from './shared';
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
Expand Down Expand Up @@ -41,6 +42,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

function determineAlternativeMethodsMode(
showForgotPasswordStrategies: boolean,
untrustedPasswordErrorCode: string | null,
): AlternativeMethodsMode {
if (!showForgotPasswordStrategies) {
return 'default';
}

if (untrustedPasswordErrorCode === 'form_password_pwned__sign_in') {
return 'pwned';
}

if (untrustedPasswordErrorCode === 'form_password_untrusted__sign_in') {
return 'passwordUntrusted';
}

return 'forgot';
}

function SignInFactorOneInternal(): JSX.Element {
const { __internal_setActiveInProgress } = useClerk();
const signIn = useCoreSignIn();
Expand Down Expand Up @@ -84,7 +104,7 @@ function SignInFactorOneInternal(): JSX.Element {

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [untrustedPasswordErrorCode, setUntrustedPasswordErrorCode] = React.useState<string | null>(null);

React.useEffect(() => {
if (__internal_setActiveInProgress) {
Expand Down Expand Up @@ -139,11 +159,11 @@ function SignInFactorOneInternal(): JSX.Element {
const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies;
const backHandler = () => {
card.setError(undefined);
setIsPasswordPwned(false);
setUntrustedPasswordErrorCode(null);
toggle?.();
};

const mode = showForgotPasswordStrategies ? (isPasswordPwned ? 'pwned' : 'forgot') : 'default';
const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, untrustedPasswordErrorCode);

return (
<AlternativeMethods
Expand Down Expand Up @@ -175,8 +195,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onUntrustedPassword={errorCode => {
setUntrustedPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error';
import { isPasswordPwnedError, isPasswordUntrustedError, isUserLockedError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import React from 'react';

Expand All @@ -21,7 +21,7 @@ import { useResetPasswordFactor } from './useResetPasswordFactor';
type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onUntrustedPassword?: (errorCode: string) => void;
};

const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
Expand Down Expand Up @@ -50,7 +50,7 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
};

export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => {
const { onShowAlternativeMethodsClick, onPasswordPwned } = props;
const { onShowAlternativeMethodsClick, onUntrustedPassword } = props;
const passwordInputRef = React.useRef<HTMLInputElement>(null);
const card = useCardState();
const { setActive } = useClerk();
Expand All @@ -64,20 +64,20 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
const clerk = useClerk();

const goBack = () => {
return navigate('../');
void navigate('../');
};

const handlePasswordSubmit: React.FormEventHandler = async e => {
const handlePasswordSubmit: React.FormEventHandler<HTMLFormElement> = e => {
e.preventDefault();
return signIn
void signIn
.attemptFirstFactor({ strategy: 'password', password: passwordControl.value })
.then(res => {
switch (res.status) {
case 'complete':
return setActive({
session: res.createdSessionId,
navigate: async ({ session }) => {
await navigateOnSetActive({ session, redirectUrl: afterSignInUrl });
navigate: ({ session }) => {
void navigateOnSetActive({ session, redirectUrl: afterSignInUrl });
},
});
case 'needs_second_factor':
Expand All @@ -92,10 +92,18 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
return clerk.__internal_navigateWithError('..', err.errors[0]);
}

if (isPasswordPwnedError(err) && onPasswordPwned) {
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
onPasswordPwned();
return;
if (onUntrustedPassword) {
if (isPasswordPwnedError(err)) {
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
onUntrustedPassword('form_password_pwned__sign_in');
return;
}

if (isPasswordUntrustedError(err)) {
card.setError({ ...err.errors[0], code: 'form_password_untrusted__sign_in' });
onUntrustedPassword('form_password_untrusted__sign_in');
return;
}
}

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1003,4 +1003,89 @@ describe('SignInFactorOne', () => {
});
});
});

describe('Password untrusted', () => {
it('it shows the untrusted password screen if the users password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'form_password_untrusted',
long_message:
"Your appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
message:
"Your appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);
const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await screen.findByText('Password compromised');
await screen.findByText(
"Your appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
);

await screen.findByText('Email code to [email protected]');
await screen.findByText('Continue with Google');
});

it('clicking the email code method should prompt the user to verify their email', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
supportEmailLink: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'form_password_untrusted',
long_message:
"Your appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
message:
"Your appears to have been compromised or it's no longer trusted and cannot be used. Please use another method to continue.",
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);
const { userEvent, debug } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

console.log(debug());

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to [email protected]'));
await screen.findByText('Check your email');
});
});
});
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
3 changes: 3 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@ export const enUS: LocalizationResource = {
passwordPwned: {
title: 'Password compromised',
},
passwordUntrusted: {
title: 'Password compromised',
},
phoneCode: {
formTitle: 'Verification code',
resendButton: "Didn't receive a code? Resend",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
isMetamaskError,
isNetworkError,
isPasswordPwnedError,
isPasswordUntrustedError,
isReverificationCancelledError,
isUnauthorizedError,
isUserLockedError,
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/errors/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ export function isPasswordPwnedError(err: any) {
return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_pwned';
}

/**
* Checks if the provided error is a clerk api response error indicating a password was pwned.
*
* @internal
*/
export function isPasswordUntrustedError(err: any) {
return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_untrusted';
}

/**
* Checks if the provided error is an EmailLinkError.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ export type __internal_LocalizationResource = {
passwordPwned: {
title: LocalizationValue;
};
passwordUntrusted: {
title: LocalizationValue;
};
passkey: {
title: LocalizationValue;
subtitle: LocalizationValue;
Expand Down