Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9488a4d
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
b6e2595
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
10795dd
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
dab6d2d
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
c7f59ab
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
44b5c92
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
4231a0c
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
89b9595
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
1309dcf
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bc25e24
feat(clerk-js): Introduce 'passwordUntrusted' flow
octoper Nov 26, 2025
241595f
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
a11f2fc
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
ab06c2b
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
04a7824
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
44cfbc8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
2f34a1c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
424b1aa
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3eb3c67
revert changes
octoper Nov 28, 2025
098dde8
chore: Remove stale changes
octoper Nov 28, 2025
2e495fa
chore: Remove stale changes
octoper Nov 28, 2025
7328287
fix(localization,shared): Add localization types
octoper Nov 28, 2025
648a4b1
fix(clerk-js,shared): Export and use isPasswordUntrustedError
octoper Nov 28, 2025
399c05a
fix(localization): Add en-US localization
octoper Nov 28, 2025
4d6536f
chore(repo): Add changeset
octoper Nov 29, 2025
e35238e
tests(clerk-js): Add unit test for untrusted password screen
octoper Nov 30, 2025
149237a
chore: Remove console.log from tests
octoper Nov 30, 2025
6655eb5
feat(localizations): Generate all localizations
octoper Dec 1, 2025
8035457
feat: Update password error handling and localization for untrusted p…
octoper Dec 1, 2025
72079b9
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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce a new variant for the alternative methods screen to handle untrusted password error on sign-in
Original file line numberDiff line numberDiff 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';
Comment thread
octoper marked this conversation as resolved.

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
Expand DownExpand 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 DownExpand 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
31 changes: 26 additions & 5 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Original file line numberDiff line numberDiff 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 All@@ -19,6 +20,7 @@ import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
import type { PasswordErrorCode } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard';
import { useResetPasswordFactor } from './useResetPasswordFactor';
Expand All@@ -41,6 +43,25 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

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

if (passwordErrorCode === 'pwned') {
return 'pwned';
}

if (passwordErrorCode === 'untrusted') {
return 'passwordUntrusted';
}

return 'forgot';
}
Comment thread
octoper marked this conversation as resolved.

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

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

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

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

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

return (
<AlternativeMethods
Expand DownExpand Up@@ -175,8 +196,8 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePasswordCard
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
onPasswordError={errorCode => {
setPasswordErrorCode(errorCode);
toggleForgotPasswordStrategies();
}}
/>
Expand Down
Original file line numberDiff line numberDiff 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@@ -18,10 +18,12 @@ import { useRouter } from '../../router/RouteContext';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';

export type PasswordErrorCode = 'untrusted' | 'pwned';
Comment thread
octoper marked this conversation as resolved.

type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onPasswordPwned?: () => void;
onPasswordError?: (errorCode: PasswordErrorCode) => void;
};
Comment thread
octoper marked this conversation as resolved.

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

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

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

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

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

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

handleError(err, [passwordControl], card.setError);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,84 @@ describe('SignInFactorOne', () => {
),
).not.toBeInTheDocument();
});

it('using an untrusted password should show the untrusted password screen', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

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

await screen.findByText('Email code to hello@clerk.com');
});

it('Prompts the user to use a different method if the password is untrusted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.withSocialProvider({ provider: 'google', authenticatable: true });
f.startSignInWithEmailAddress({
supportEmailCode: true,
supportPassword: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

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

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

await screen.findByText('Password compromised');
await userEvent.click(screen.getByText('Email code to hello@clerk.com'));
await screen.findByText('Check your email');
});
});

describe('Forgot Password', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,7 @@ export type FlowMetadata = {
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'passwordUntrustedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popupCallback'
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -693,6 +693,9 @@ export const arSA: LocalizationResource = {
passwordPwned: {
title: 'كلمة المرور غير آمنة',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'رمز التحقق',
resendButton: 'لم يصلك الرمز؟ حاول مرة أخرى',
Expand DownExpand Up@@ -895,6 +898,7 @@ export const arSA: LocalizationResource = {
form_password_pwned__sign_in: 'لا يمكن أستعمال كلمة السر هذه لانها غير أمنة, الرجاء اختيار كلمة مرور أخرى',
form_password_size_in_bytes_exceeded:
'تجاوزت كلمة المرور الحد الأقصى للحروف المدخلة, الرجاء أدخال كلمة مرور أقصر أو حذف بعض الأحرف الخاصة',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'كلمة مرور خاطئة',
form_username_invalid_character: undefined,
form_username_invalid_length: undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -700,6 +700,9 @@ export const beBY: LocalizationResource = {
passwordPwned: {
title: 'Пароль быў узламаны',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код верыфікацыі',
resendButton: 'Пераадправіць код',
Expand DownExpand Up@@ -904,6 +907,7 @@ export const beBY: LocalizationResource = {
form_password_pwned__sign_in: 'Гэты пароль быў узламаны, калі ласка, абярыце іншы.',
form_password_size_in_bytes_exceeded:
'Ваш пароль перавышае максімальна дапушчальнае колькасць байтаў, скараціце яго або выдаліце некаторыя спецыяльныя сімвалы.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Неверагодны пароль',
form_username_invalid_character: 'Імя карыстальніка змяшчае недапушчальныя сімвалы.',
form_username_invalid_length: 'Імя карыстальніка павінна быць ад 3 да 50 сімвалаў.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const bgBG: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Код за потвърждение',
resendButton: 'Не сте получили код? Изпрати отново',
Expand DownExpand Up@@ -897,6 +900,7 @@ export const bgBG: LocalizationResource = {
form_password_pwned: 'Тази парола е компрометирана в изтекли данни. Моля, изберете друга.',
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded: 'Паролата ви е твърде дълга. Моля, съкратете я.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Невалидна парола.',
form_username_invalid_character: 'Потребителското име съдържа невалидни символи.',
form_username_invalid_length: 'Потребителското име трябва да бъде между 3 и 256 символа.',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,6 +699,9 @@ export const bnIN: LocalizationResource = {
passwordPwned: {
title: 'পাসওয়ার্ড সমঝোতা হয়েছে',
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'যাচাইকরণ কোড',
resendButton: 'কোনো কোড পাননি? পুনরায় পাঠান',
Expand DownExpand Up@@ -906,6 +909,7 @@ export const bnIN: LocalizationResource = {
'এই পাসওয়ার্ডটি একটি ডেটা লঙ্ঘনের অংশ হিসাবে পাওয়া গেছে এবং ব্যবহার করা যাবে না, দয়া করে আপনার পাসওয়ার্ড রিসেট করুন।',
form_password_size_in_bytes_exceeded:
'আপনার পাসওয়ার্ড অনুমোদিত সর্বাধিক বাইট সংখ্যা অতিক্রম করেছে, দয়া করে এটি ছোট করুন বা কিছু বিশেষ অক্ষর সরান।',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'ভুল পাসওয়ার্ড',
form_username_invalid_character:
'আপনার ব্যবহারকারীর নামে অবৈধ অক্ষর রয়েছে। দয়া করে শুধুমাত্র অক্ষর, সংখ্যা এবং আন্ডারস্কোর ব্যবহার করুন।',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -696,6 +696,9 @@ export const caES: LocalizationResource = {
passwordPwned: {
title: undefined,
},
passwordUntrusted: {
title: undefined,
},
phoneCode: {
formTitle: 'Codi de verificació',
resendButton: 'No has rebut el codi? Reenvia',
Expand DownExpand Up@@ -899,6 +902,7 @@ export const caES: LocalizationResource = {
form_password_pwned__sign_in: undefined,
form_password_size_in_bytes_exceeded:
'La teva contrasenya ha superat el nombre màxim de bytes permesos, si us plau, redueix-la o elimina alguns caràcters especials.',
form_password_untrusted__sign_in: undefined,
form_password_validation_failed: 'Contrasenya incorrecta',
form_username_invalid_character: "El nom d'usuari conté caràcters no vàlids.",
form_username_invalid_length: "El nom d'usuari ha de tenir entre 3 i 50 caràcters.",
Expand Down
Loading
Loading