From 8e91fcd6658f5e042680f17f30545c502d391ba2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 24 Sep 2026 16:12:26 -0600 Subject: [PATCH 01/91] feat(mosaic): add account password model and policy tests --- ...ser-profile-password-section.model.test.ts | 299 ++++++++++++++++++ .../user-profile-password-section.model.ts | 145 +++++++++ 2 files changed, 444 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts new file mode 100644 index 00000000000..4274d235350 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -0,0 +1,299 @@ +import type { PasswordSettingsData } from '@clerk/shared/types'; +import { createDeferredPromise } from '@clerk/shared/utils'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfilePasswordModel } from './user-profile-password-section.model'; + +type TestUser = { + id: string; + passwordEnabled: boolean; + enterpriseAccounts: { active: boolean }[]; + updatePassword: ReturnType; +}; + +type TestSession = { id: string; publicUserData: { identifier: string | null } }; + +const passwordSettings: PasswordSettingsData = { + min_length: 8, + max_length: 72, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + require_special_char: false, + allowed_special_characters: '', + disable_hibp: false, + show_zxcvbn: true, + min_zxcvbn_strength: 2, +}; + +function createEnvironment() { + return { + userSettings: { instanceIsPasswordBased: true, passwordSettings }, + authConfig: { reverification: false }, + }; +} + +let isUserLoaded: boolean; +let isSessionLoaded: boolean; +let user: TestUser | null; +let session: TestSession | null; +let environment: ReturnType | undefined; + +const clerk = { + get user() { + return user; + }, + get session() { + return session; + }, + get __internal_environment() { + return environment; + }, +}; + +vi.mock('@clerk/shared/react', () => ({ + useClerk: () => clerk, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), +})); + +beforeEach(() => { + isUserLoaded = true; + isSessionLoaded = true; + user = { id: 'user_1', passwordEnabled: false, enterpriseAccounts: [], updatePassword: vi.fn() }; + session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; + environment = createEnvironment(); +}); + +afterEach(cleanup); + +function ready(model: ReturnType) { + expect(model.status).toBe('ready'); + if (model.status !== 'ready') { + throw new Error('expected ready model'); + } + return model; +} + +describe('useUserProfilePasswordModel', () => { + it.each([undefined, ''])('rejects a missing required current password (%j)', async currentPassword => { + if (!user) { + throw new Error('expected user'); + } + user.passwordEnabled = true; + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ + currentPassword, + newPassword: 'new password', + signOutOfOtherSessions: true, + }), + ).rejects.toThrow('Current password is required.'); + expect(user.updatePassword).not.toHaveBeenCalled(); + }); + + it.each([ + 'signed out', + 'different user', + 'different session', + 'no session', + 'disabled', + 'enterprise', + 'mode', + 'proof', + ])('rejects a captured action after the context changes: %s', async change => { + if (!user || !session || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = true; + const updatePassword = user.updatePassword; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const action = ready(result.current).updatePassword; + + switch (change) { + case 'signed out': + user = null; + break; + case 'different user': + user = { ...user, id: 'user_2' }; + break; + case 'different session': + session = { ...session, id: 'session_2' }; + break; + case 'no session': + session = null; + break; + case 'disabled': + environment.userSettings.instanceIsPasswordBased = false; + break; + case 'enterprise': + user.enterpriseAccounts = [{ active: true }]; + break; + case 'mode': + user.passwordEnabled = false; + break; + case 'proof': + environment.authConfig.reverification = true; + break; + } + + const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; + await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + rerender(); + await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + expect(updatePassword).not.toHaveBeenCalled(); + }); + + it('rejects updates when a loaded user has no active session', async () => { + if (!user) { + throw new Error('expected user'); + } + session = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), + ).rejects.toThrow('Password update is no longer available.'); + expect(user.updatePassword).not.toHaveBeenCalled(); + }); + + it.each([ + { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true, currentPassword: undefined }, + { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false, currentPassword: undefined }, + { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false, currentPassword: ' old secret ' }, + { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true, currentPassword: undefined }, + ])('sends the legacy payload for %j', async policy => { + if (!user || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = policy.passwordEnabled; + environment.authConfig.reverification = policy.reverification; + const { result } = renderHook(() => useUserProfilePasswordModel()); + const input = { + currentPassword: ' old secret ', + newPassword: ' new e\u0301 secret ', + confirmPassword: 'must never reach the SDK', + signOutOfOtherSessions: policy.signOutOfOtherSessions, + }; + + await ready(result.current).updatePassword(input); + + const expected = { + newPassword: ' new e\u0301 secret ', + signOutOfOtherSessions: policy.signOutOfOtherSessions, + }; + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith( + policy.currentPassword === undefined ? expected : { ...expected, currentPassword: ' old secret ' }, + ); + }); + + it('waits for the SDK resource and derives change mode only after hydration', async () => { + if (!user) { + throw new Error('expected user'); + } + const request = createDeferredPromise(); + user.updatePassword.mockReturnValueOnce(request.promise); + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const promise = ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }); + expect(ready(result.current).mode).toBe('set'); + expect(user.passwordEnabled).toBe(false); + + user = { ...user, passwordEnabled: true }; + request.resolve(user); + await expect(promise).resolves.toBe(user); + rerender(); + expect(ready(result.current).mode).toBe('change'); + }); + + it.each([ + { errors: [{ code: 'form_password_incorrect', meta: { paramName: 'current_password' } }] }, + { errors: [{ code: 'session_reverification_required' }] }, + new Error('network failure'), + ])('preserves the SDK rejection for the caller', async error => { + if (!user) { + throw new Error('expected user'); + } + user.updatePassword.mockRejectedValueOnce(error); + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), + ).rejects.toBe(error); + }); + + it.each([ + { passwordEnabled: false, reverification: false, mode: 'set', requiresCurrentPassword: false }, + { passwordEnabled: false, reverification: true, mode: 'set', requiresCurrentPassword: false }, + { passwordEnabled: true, reverification: false, mode: 'change', requiresCurrentPassword: true }, + { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: false }, + ])('derives $mode mode with reverification=$reverification', policy => { + if (!user || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = policy.passwordEnabled; + environment.authConfig.reverification = policy.reverification; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ + status: 'ready', + mode: policy.mode, + requiresCurrentPassword: policy.requiresCurrentPassword, + userId: 'user_1', + sessionId: 'session_1', + identifier: 'person@example.com', + passwordSettings, + }); + }); + + it('keeps a loaded user visible when there is no session identifier', () => { + session = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ status: 'ready', sessionId: null, identifier: '' }); + }); + + it.each([false, true])('makes active enterprise accounts readonly with passwordEnabled=%s', passwordEnabled => { + if (!user) { + throw new Error('expected user'); + } + user.passwordEnabled = passwordEnabled; + user.enterpriseAccounts = [{ active: false }, { active: true }, { active: true }]; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ + status: 'readonly', + reason: 'enterprise_account', + mode: passwordEnabled ? 'change' : 'set', + }); + + user.enterpriseAccounts = [{ active: false }]; + rerender(); + expect(result.current.status).toBe('ready'); + }); + + it('hides the section when the loaded user is absent', () => { + user = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ status: 'hidden', reason: 'no_user' }); + }); + + it.each([false, true])('hides disabled instance passwords when user passwordEnabled is %s', passwordEnabled => { + if (!user || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = passwordEnabled; + environment.userSettings.instanceIsPasswordBased = false; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ status: 'hidden', reason: 'password_disabled' }); + }); + + it.each(['user', 'session', 'environment'])('waits for %s to load', resource => { + if (resource === 'user') { + isUserLoaded = false; + } else if (resource === 'session') { + isSessionLoaded = false; + } else { + environment = undefined; + } + + const { result } = renderHook(() => useUserProfilePasswordModel()); + + expect(result.current).toEqual({ status: 'loading' }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts new file mode 100644 index 00000000000..25a0cbb57de --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -0,0 +1,145 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/passwords/complexity'; +import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; +import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; +import { useClerk, useSession, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; +import { useCallback } from 'react'; + +import { FormSubmitError } from '../../../components/form'; +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; + +export function passwordFormError(error: unknown, requiresCurrentPassword: boolean): unknown { + if (!isClerkAPIResponseError(error)) { + return error; + } + const fields: { currentPassword?: string; newPassword?: string } = {}; + let message: string | undefined; + for (const item of error.errors) { + const text = item.longMessage || item.message; + const name = item.meta?.paramName; + if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { + fields.currentPassword ??= text; + } else if (name === 'new_password' || name === 'newPassword' || name === 'password') { + fields.newPassword ??= text; + } else { + message ??= text; + } + } + return new FormSubmitError({ message, fields }); +} + +type EditablePasswordPolicy = + | { mode: 'set'; requiresCurrentPassword: false } + | { mode: 'change'; requiresCurrentPassword: boolean }; + +type UnavailablePasswordModel = + | { status: 'hidden'; reason: 'no_user' | 'password_disabled' } + | { status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account' }; + +export type UserProfilePasswordModel = + | { status: 'loading' } + | UnavailablePasswordModel + | (EditablePasswordPolicy & { + status: 'ready'; + userId: string; + sessionId: string | null; + identifier: string; + passwordSettings: PasswordSettingsData; + validatePassword: (password: string) => Promise; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; + }); + +function getPasswordPolicy( + user: UserResource | null | undefined, + environment: EnvironmentResource, +): UnavailablePasswordModel | (EditablePasswordPolicy & { status: 'ready'; userId: string }) { + if (!user) { + return { status: 'hidden', reason: 'no_user' }; + } + + if (!environment.userSettings.instanceIsPasswordBased) { + return { status: 'hidden', reason: 'password_disabled' }; + } + + const policy: EditablePasswordPolicy = user.passwordEnabled + ? { mode: 'change', requiresCurrentPassword: !environment.authConfig.reverification } + : { mode: 'set', requiresCurrentPassword: false }; + + if (user.enterpriseAccounts.some(account => account.active)) { + return { status: 'readonly', mode: policy.mode, reason: 'enterprise_account' }; + } + + return { status: 'ready', userId: user.id, ...policy }; +} + +export function useUserProfilePasswordModel(): UserProfilePasswordModel { + const clerk = useClerk(); + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + const passwordSettings = environment?.userSettings.passwordSettings; + const moduleManager = clerk.__internal_moduleManager; + const validatePassword = useCallback( + async (password: string): Promise => { + if (!passwordSettings) { + return {}; + } + const complexity = validateComplexity(password, passwordSettings); + if (Object.keys(complexity).length > 0 || !passwordSettings.show_zxcvbn || !moduleManager) { + return { complexity }; + } + const { loadZxcvbn } = createLoadZxcvbn(moduleManager); + const strength = createValidatePasswordStrength(passwordSettings)(await loadZxcvbn())(password); + return { complexity, strength }; + }, + [passwordSettings, moduleManager], + ); + + if (!isUserLoaded || !isSessionLoaded || !environment) { + return { status: 'loading' }; + } + + const policy = getPasswordPolicy(user, environment); + if (policy.status !== 'ready') { + return policy; + } + + const userId = policy.userId; + const sessionId = session?.id ?? null; + + return { + ...policy, + userId, + sessionId, + identifier: session?.publicUserData.identifier ?? '', + passwordSettings: environment.userSettings.passwordSettings, + validatePassword, + updatePassword: async ({ currentPassword, newPassword, signOutOfOtherSessions }) => { + const currentUser = clerk.user; + const currentPolicy = getPasswordPolicy(currentUser, environment); + if ( + !currentUser || + currentUser.id !== userId || + !sessionId || + clerk.session?.id !== sessionId || + currentPolicy.status !== 'ready' || + currentPolicy.mode !== policy.mode || + currentPolicy.requiresCurrentPassword !== policy.requiresCurrentPassword + ) { + throw new Error('Password update is no longer available.'); + } + + if (policy.requiresCurrentPassword && !currentPassword) { + throw new Error('Current password is required.'); + } + + return currentUser.updatePassword({ + newPassword, + signOutOfOtherSessions, + ...(policy.requiresCurrentPassword ? { currentPassword } : {}), + }); + }, + }; +} From 29480c35e503a6cb38cbfa2a443baa97015c75d9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 24 Sep 2026 16:12:43 -0600 Subject: [PATCH 02/91] feat(mosaic): support password feedback and verification in the editor --- ...r-profile-edit-password.controller.test.ts | 158 +++++++++++++- .../user-profile-edit-password.controller.ts | 63 +++++- .../user-profile-edit-password.dialog.tsx | 206 ++++++++++-------- .../user-profile-password-row.view.tsx | 10 +- .../user-profile-password-section.types.ts | 2 + .../user-profile-password-section.view.tsx | 4 +- 6 files changed, 331 insertions(+), 112 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index b82f79e1d5a..83f52e86d23 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,20 +1,23 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileEditPasswordSubmitResult } from './user-profile-edit-password.controller'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -function deferred() { - let resolve: () => void = () => {}; - const promise = new Promise(r => { +function deferred() { + let resolve: (result: T) => void = () => {}; + const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), + onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => + Promise.resolve({ status: 'saved' }), requiresCurrentPassword = true, ) { return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); @@ -33,6 +36,147 @@ function fill(result: Controller, { current = 'old-secret', next = 'new-secret-1 } describe('useUserProfileEditPasswordController', () => { + it('ignores older password feedback and clears it when the editor closes', async () => { + const older = deferred(); + const newer = deferred(); + const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ + onSubmit: () => Promise.resolve({ status: 'saved' }), + validatePassword, + }), + ); + open(result); + fill(result, { next: 'first password' }); + await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('first password')); + fill(result, { next: 'second password' }); + await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('second password')); + + await act(async () => { + newer.resolve({ type: 'success', message: 'Strong password.' }); + await newer.promise; + }); + await act(async () => { + older.resolve({ type: 'warning', message: 'Weak password.' }); + await older.promise; + }); + + expect(result.current.passwordFeedback).toEqual({ type: 'success', message: 'Strong password.' }); + act(() => result.current.onOpenChange(false)); + expect(result.current.passwordFeedback).toBeUndefined(); + }); + + it('can submit while an advisory password check is pending', async () => { + const check = deferred(); + const validatePassword = vi.fn(() => check.promise); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit, validatePassword })); + open(result); + fill(result); + await waitFor(() => expect(validatePassword).toHaveBeenCalled()); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + await act(async () => { + check.resolve({ type: 'error', message: 'Too short.' }); + await check.promise; + }); + expect(result.current.passwordFeedback).toBeUndefined(); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('ignores dismissal and duplicate saves in the same event as submission', async () => { + const save = deferred(); + const onSubmit = vi.fn(() => save.promise); + const { result } = renderController(onSubmit); + open(result); + fill(result); + + act(() => { + result.current.form.submit(); + result.current.onOpenChange(false); + result.current.form.submit(); + }); + + expect(result.current.isOpen).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); + await act(async () => { + save.resolve({ status: 'saved' }); + await save.promise; + }); + await waitFor(() => expect(result.current.isOpen).toBe(false)); + }); + + it('shows password-rule feedback without blocking an otherwise valid submission', async () => { + const validatePassword = vi.fn(() => + Promise.resolve({ + type: 'warning', + message: 'Try a longer password.', + }), + ); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit, validatePassword }), + ); + open(result); + fill(result, { next: ' new secret ' }); + + await waitFor(() => + expect(result.current.passwordFeedback).toEqual({ type: 'warning', message: 'Try a longer password.' }), + ); + expect(validatePassword).toHaveBeenCalledWith(' new secret '); + expect(result.current.form.canSubmit).toBe(true); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + expect(onSubmit).toHaveBeenCalledWith({ + currentPassword: 'old-secret', + newPassword: ' new secret ', + signOutOfOtherSessions: true, + }); + }); + + it('returns to editing without losing the draft when the flow cancels verification', async () => { + const { result } = renderHook(() => + useUserProfileEditPasswordController({ + requiresCurrentPassword: true, + onSubmit: () => Promise.resolve({ status: 'cancelled' }), + }), + ); + open(result); + fill(result); + act(() => result.current.form.setValue('signOutOfOtherSessions', false)); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.form.isSubmitting).toBe(false)); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.error).toBeUndefined(); + expect(result.current.form.values).toEqual({ + currentPassword: 'old-secret', + newPassword: 'new-secret-123', + confirmPassword: 'new-secret-123', + signOutOfOtherSessions: false, + }); + expect(result.current.form.canSubmit).toBe(true); + }); + + it('blocks direct submission with an empty confirmation', () => { + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const { result } = renderController(onSubmit); + open(result); + fill(result); + act(() => result.current.form.setValue('confirmPassword', '')); + + act(() => result.current.form.submit()); + + expect(result.current.form.canSubmit).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isOpen).toBe(true); + }); + it('opens with empty fields and sign-out of other devices on', () => { const { result } = renderController(); expect(result.current.isOpen).toBe(false); @@ -51,7 +195,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); const { result } = renderController(onSubmit); open(result); fill(result); @@ -70,7 +214,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('leaves the current password out when reverification stands in for it', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); const { result } = renderController(onSubmit, false); open(result); act(() => result.current.form.setValue('newPassword', 'new-secret-123')); @@ -163,7 +307,7 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.isOpen).toBe(true); await act(async () => { - save.resolve(); + save.resolve({ status: 'saved' }); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index eeb5ab7c25d..75ba6b5b65b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; +import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; import type { UserProfileEditPasswordValue, @@ -15,23 +16,30 @@ const initialValues: UserProfileEditPasswordValues = { signOutOfOtherSessions: true, }; +export type UserProfileEditPasswordSubmitResult = { status: 'saved' } | { status: 'cancelled' }; + export interface UserProfileEditPasswordControllerOptions { requiresCurrentPassword?: boolean; - onSubmit: (value: UserProfileEditPasswordValue) => Promise; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; + validatePassword?: (password: string) => Promise; } export interface UserProfileEditPasswordController { isOpen: boolean; onOpenChange: (open: boolean) => void; form: UseFormResult; + passwordFeedback: FieldFeedback | undefined; } export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, + validatePassword, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); + const [passwordFeedback, setPasswordFeedback] = useState(); + const submitting = useRef(false); const form = useForm({ initialValues, @@ -41,24 +49,57 @@ export function useUserProfileEditPasswordController({ value !== '' && value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, }, }, - canSubmit: values => values.newPassword !== '' && (!requiresCurrentPassword || values.currentPassword !== ''), + canSubmit: values => + values.newPassword !== '' && + values.confirmPassword === values.newPassword && + (!requiresCurrentPassword || values.currentPassword !== ''), onSubmit: async values => { - await onSubmit({ - currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, - newPassword: values.newPassword, - signOutOfOtherSessions: values.signOutOfOtherSessions, - }); - setIsOpen(false); + submitting.current = true; + try { + const result = await onSubmit({ + currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, + newPassword: values.newPassword, + signOutOfOtherSessions: values.signOutOfOtherSessions, + }); + if (result.status === 'saved') { + setIsOpen(false); + } + } finally { + submitting.current = false; + } }, }); + const password = form.values.newPassword; + useEffect(() => { + setPasswordFeedback(undefined); + if (!isOpen || password === '' || !validatePassword) { + return; + } + + let active = true; + void Promise.resolve() + .then(() => validatePassword(password)) + .then( + feedback => { + if (active) { + setPasswordFeedback(feedback); + } + }, + () => {}, + ); + return () => { + active = false; + }; + }, [isOpen, password, validatePassword]); + const onOpenChange = (open: boolean) => { - if (!open && form.isSubmitting) { + if (submitting.current || form.isSubmitting) { return; } form.reset(); setIsOpen(open); }; - return { isOpen, onOpenChange, form }; + return { isOpen, onOpenChange, form, passwordFeedback }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index f0fd1f94040..b88e792ecc3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,6 +1,6 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; @@ -9,7 +9,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import type { UseFormResult } from '../../../components/form'; +import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; @@ -21,6 +21,9 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { + children?: ReactNode; + passwordFeedback?: FieldFeedback; + identifier?: string; open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -30,6 +33,9 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ + children, + passwordFeedback, + identifier = '', open, onOpenChange, trigger, @@ -57,97 +63,109 @@ export function UserProfileEditPasswordDialog({ elevation='overlay' renderBranding={false} > - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - } - > - {form.error ? ( - + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } > - {form.error} - - ) : null} - {showCurrentPassword ? ( - - ) : null} - - -
- form.setValue('signOutOfOtherSessions', event.target.checked)} - /> -
- } - size='sm' - xstyle={styles.checkboxLabel} - > - {m.signOutOfOtherSessionsLabel} - - - {m.signOutOfOtherSessionsDescription} - -
-
-
- - + + )} @@ -160,12 +178,14 @@ function PasswordField({ form, inputRef, name, + advisoryFeedback, }: { label: string; autoComplete: 'current-password' | 'new-password'; form: UseFormResult; inputRef?: RefObject; name: UserProfileEditPasswordField; + advisoryFeedback?: FieldFeedback; }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); @@ -198,7 +218,11 @@ function PasswordField({ - {error ? {error} : null} + {error ? ( + {error} + ) : advisoryFeedback ? ( + {advisoryFeedback.message} + ) : null} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 45572922b09..09aff9ba276 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -15,6 +15,7 @@ import type { } from './user-profile-password-section.types'; export function UserProfilePasswordRowView({ + action, hasPassword = false, requiresCurrentPassword = false, managedBy, @@ -27,7 +28,9 @@ export function UserProfilePasswordRowView({ {hasPassword ? m.masked : m.noPasswordSet} - {managedBy ? ( + {action ? ( + {action} + ) : managedBy ? ( @@ -78,7 +81,10 @@ function EditPassword({ const m = useMessages('userProfilePasswordSection'); const controller = useUserProfileEditPasswordController({ requiresCurrentPassword: hasPassword && requiresCurrentPassword, - onSubmit, + onSubmit: async value => { + await onSubmit(value); + return { status: 'saved' }; + }, }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 44fc4da20c6..f4823a9080e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -18,9 +18,11 @@ export interface UserProfilePasswordManagedBy { } export interface UserProfilePasswordSectionViewProps { + action?: ReactNode; hasPassword?: boolean; requiresCurrentPassword?: boolean; /** Replaces the edit action with the enterprise provider’s name. */ managedBy?: UserProfilePasswordManagedBy; onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; } +import type { ReactNode } from 'react'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index f941a18e8f4..d453b40def7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -12,13 +12,14 @@ export type { } from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ + action, hasPassword = false, requiresCurrentPassword = false, managedBy, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { const m = useMessages('userProfilePasswordSection'); - if (!hasPassword && !managedBy && !onSubmitPassword) { + if (!hasPassword && !managedBy && !onSubmitPassword && !action) { return null; } @@ -29,6 +30,7 @@ export function UserProfilePasswordSectionView({ Date: Thu, 24 Sep 2026 16:12:59 -0600 Subject: [PATCH 03/91] feat(mosaic): connect password editing to account updates and reverification --- .changeset/calm-password-controller.md | 2 + .../src/components/field/field.test.tsx | 18 ++ .../mosaic/src/components/field/field.tsx | 29 +- packages/mosaic/src/components/field/index.ts | 1 + ...file-password-section.integration.test.tsx | 298 ++++++++++++++++++ .../user-profile-edit-password.dialog.tsx | 249 ++++++++------- .../user-profile-password-section.messages.ts | 14 + .../user-profile-password-section.tsx | 149 +++++++++ packages/mosaic/src/utils/feedback.styles.ts | 3 + 9 files changed, 649 insertions(+), 114 deletions(-) create mode 100644 .changeset/calm-password-controller.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx diff --git a/.changeset/calm-password-controller.md b/.changeset/calm-password-controller.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-password-controller.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index 8d5a1e61c10..ba1de600172 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -35,6 +35,24 @@ function stubPrototype(target: object, name: string, descriptor: PropertyDescrip } describe('Mosaic Field', () => { + it('associates text-only info feedback with the control in a live region', () => { + render( + + Password + + + Try a longer password. + + , + ); + + expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Try a longer password.'); + expect(screen.getByRole('status')).toHaveTextContent('Try a longer password.'); + const info = screen.getByText('Try a longer password.').closest('p'); + expect(info).toHaveClass('cl-field-info'); + expect(info?.querySelector('svg')).toBeNull(); + }); + afterEach(() => { restores.splice(0).forEach(restore => restore()); }); diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 28b5c1470b0..a52ac031dc9 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -174,11 +174,12 @@ const Message = React.forwardRef(function Mos return {rendered}; }); -type FieldFeedbackKind = 'error' | 'success'; +type FieldFeedbackKind = 'error' | 'success' | 'info'; -const FEEDBACK: Record = { +const FEEDBACK: Record = { error: { slot: 'field-error', icon: 'exclamation-circle', color: feedbackStyles.error }, success: { slot: 'field-success', icon: 'checkmark', color: feedbackStyles.success }, + info: { slot: 'field-info', color: feedbackStyles.info }, }; interface FieldFeedbackProps extends MosaicComponentProps<'p'> { @@ -212,7 +213,7 @@ const FieldFeedback = React.forwardRef rest, ), id, - children: {message}, + children: icon ? {message} : message, }, }); }); @@ -243,5 +244,25 @@ const FieldSuccess = React.forwardRef(f ); }); +export type FieldInfoProps = MosaicComponentProps<'p'>; + +const FieldInfo = React.forwardRef(function MosaicFieldInfo(props, ref) { + return ( + + ); +}); + /** Styled parts for composing an automatically associated single-control field. */ -export const Field = { Root, Label, Description, Message, Error: FieldError, Success: FieldSuccess }; +export const Field = { + Root, + Label, + Description, + Message, + Error: FieldError, + Success: FieldSuccess, + Info: FieldInfo, +}; diff --git a/packages/mosaic/src/components/field/index.ts b/packages/mosaic/src/components/field/index.ts index 95609821d13..98780409ce4 100644 --- a/packages/mosaic/src/components/field/index.ts +++ b/packages/mosaic/src/components/field/index.ts @@ -2,6 +2,7 @@ export { Field } from './field'; export type { FieldDescriptionProps, FieldErrorProps, + FieldInfoProps, FieldLabelProps, FieldMessageProps, FieldRootProps, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx new file mode 100644 index 00000000000..48162056af0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -0,0 +1,298 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { ClerkInstanceContext } from '@clerk/shared/react'; +import type { LoadedClerk } from '@clerk/shared/types'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; +import type { UserProfileEditPasswordValue } from '../user-profile-password-section/user-profile-password-section.types'; + +const updatePassword = vi.fn<(input: UserProfileEditPasswordValue) => Promise>(); +const user: { + id: string; + passwordEnabled: boolean; + enterpriseAccounts: { active: boolean }[]; + updatePassword: typeof updatePassword; +} = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword }; +const session = { + id: 'session_1', + publicUserData: { identifier: 'person@example.com' }, + startVerification: vi.fn(), + attemptFirstFactorVerification: vi.fn(), +}; +const environment = { + userSettings: { + instanceIsPasswordBased: true, + passwordSettings: { min_length: 8, max_length: 72, show_zxcvbn: false }, + }, + authConfig: { reverification: true }, + displayConfig: { preferredSignInStrategy: 'password', supportEmail: 'support@example.com' }, +}; +const clerk = { + user, + session, + __internal_environment: environment, + __internal_getOption: () => undefined, + setActive: vi.fn(), +}; +let isSessionLoaded = true; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => clerk, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + isSessionLoaded = true; + user.updatePassword.mockReset(); + user.passwordEnabled = true; + user.enterpriseAccounts = []; + environment.authConfig.reverification = true; + environment.userSettings.instanceIsPasswordBased = true; + user.updatePassword.mockResolvedValue(user); + clerk.setActive.mockResolvedValue(undefined); + session.startVerification.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + session.attemptFirstFactorVerification.mockResolvedValue({ status: 'complete' }); +}); + +function passwordTree() { + return ( + + + + + + ); +} + +function renderPassword() { + return render(passwordTree()); +} + +async function editPassword() { + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'new-password-123'); + await events.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + await events.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); + return events; +} + +describe('UserProfilePasswordSection', () => { + it('keeps the editor pending until verification is ready and returns to the draft', async () => { + let finishVerification: (value: unknown) => void = () => {}; + session.startVerification.mockReturnValueOnce( + new Promise(resolve => { + finishVerification = resolve; + }), + ); + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(session.startVerification).toHaveBeenCalled()); + + expect(screen.getByLabelText('New password')).toBeVisible(); + expect(screen.getByLabelText('New password')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByText('Verification required')).not.toBeInTheDocument(); + + await act(() => { + finishVerification({ status: 'needs_first_factor', supportedFirstFactors: [{ strategy: 'password' }] }); + }); + await waitFor(() => expect(screen.getByLabelText('Password')).toBeVisible()); + const back = screen.getByRole('button', { name: 'Back', exact: true }); + await events.click(back); + await waitFor(() => expect(screen.getByLabelText('New password')).toBeVisible()); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + await waitFor(() => expect(screen.getByLabelText('New password')).toHaveFocus()); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('shows a failed retry instead of reporting a save when verification is required again', async () => { + user.updatePassword.mockRejectedValue( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await events.type(await screen.findByLabelText('Password'), 'current-password'); + await events.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your password was not saved. Please try verifying again.', + ); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(user.updatePassword).toHaveBeenCalledTimes(2); + }); + + it('hides the section when instance passwords are disabled', () => { + environment.userSettings.instanceIsPasswordBased = false; + renderPassword(); + expect(screen.queryByRole('region', { name: 'Authentication' })).not.toBeInTheDocument(); + }); + + it('keeps the active verification mounted while session data briefly reloads', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + const { rerender } = renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByLabelText('Password'); + + isSessionLoaded = false; + rerender(passwordTree()); + expect(screen.getByLabelText('Password')).toBeVisible(); + isSessionLoaded = true; + rerender(passwordTree()); + await events.click(screen.getByRole('button', { name: 'Back', exact: true })); + expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + }); + + it('keeps an enterprise-managed password visible without offering a mutation', () => { + user.enterpriseAccounts = [{ active: true }]; + renderPassword(); + expect(screen.getByText('Your organization manages your password.')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); + }); + + it('shows the configured password rule without making it a new submit restriction', async () => { + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); + expect( + screen + .getAllByRole('status') + .some(status => status.textContent?.includes('Your password must contain 8 or more characters.')), + ).toBe(true); + await events.type(screen.getByLabelText('Confirm password'), 'short'); + + expect(await screen.findByText('Your password must contain 8 or more characters.')).toBeVisible(); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + await events.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).not.toHaveAttribute('aria-disabled', 'true'); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ newPassword: 'short', signOutOfOtherSessions: true }); + }); + + it('shows password API errors at the visible field and preserves the draft', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [ + { + code: 'form_password_pwned', + message: 'Choose a different password.', + meta: { param_name: 'new_password' }, + }, + ], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription('Choose a different password.'), + ); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + }); + + it('returns from verification to the same draft without showing an error', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByLabelText('Password'); + await events.click(screen.getByRole('button', { name: 'Back', exact: true })); + + expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(user.updatePassword).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Save changes' })).not.toBeDisabled(); + }); + + it('verifies, retries the original values, and waits for the retry before closing', async () => { + let finish: () => void = () => {}; + const retry = new Promise(resolve => { + finish = resolve; + }); + user.updatePassword + .mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ) + .mockImplementationOnce(() => retry.then(() => user)); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + + await events.type(await screen.findByLabelText('Password'), 'current-password'); + await events.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(user.updatePassword).toHaveBeenCalledTimes(2)); + expect(user.updatePassword.mock.calls[1]).toEqual(user.updatePassword.mock.calls[0]); + expect(clerk.setActive).toHaveBeenCalledWith({ session: 'session_1' }); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-disabled', 'true'); + + await act(async () => { + finish(); + await retry; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('saves through the model and closes after the request succeeds', async () => { + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ + newPassword: 'new-password-123', + signOutOfOtherSessions: false, + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index b88e792ecc3..59d4e76f5c1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,7 +1,7 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { ReactNode, RefObject } from 'react'; -import { useId, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -9,6 +9,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import { Flow } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; @@ -48,6 +49,16 @@ export function UserProfileEditPasswordDialog({ const signOutDescriptionId = useId(); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; + const restoreEditorFocus = useRef(false); + + useEffect(() => { + if (children) { + restoreEditorFocus.current = true; + } else if (restoreEditorFocus.current && !form.isSubmitting) { + restoreEditorFocus.current = false; + initialFocusRef.current?.focus({ preventScroll: true }); + } + }, [children, form.isSubmitting]); return ( - {children ?? ( - <> - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - } - > - - {form.error ? ( - - {form.error} - - ) : null} - {showCurrentPassword ? ( - - ) : null} - - -
- form.setValue('signOutOfOtherSessions', event.target.checked)} - /> -
- } - size='sm' - xstyle={styles.checkboxLabel} - > - {m.signOutOfOtherSessionsLabel} - - - {m.signOutOfOtherSessionsDescription} - -
-
-
- - + {() => ( + <> + + <> + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } > - {m.cancel} - - } - /> - - {m.save} - - - - )} + + {form.error ? ( + + {form.error} + + ) : null} + {showCurrentPassword ? ( + + ) : null} + + +
+ form.setValue('signOutOfOtherSessions', event.target.checked)} + /> +
+ } + size='sm' + xstyle={styles.checkboxLabel} + > + {m.signOutOfOtherSessionsLabel} + + + {m.signOutOfOtherSessionsDescription} + +
+
+ + + + {m.cancel} + + } + /> + + {m.save} + + + + + {children} + + )} +
@@ -189,15 +211,17 @@ function PasswordField({ }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); + const [focused, setFocused] = useState(false); const { feedback } = form.fields[name]; - const error = feedback?.type === 'error' ? feedback.message : undefined; + const message = feedback?.type === 'error' ? feedback : advisoryFeedback; + const feedbackType = message?.type === 'info' && !focused ? 'error' : message?.type; const { ref, ...control } = form.register(name); const mergedRef = useMergeRefs([ref, inputRef]); return ( {label} @@ -207,6 +231,11 @@ function PasswordField({ autoComplete={autoComplete} type={visible ? 'text' : 'password'} {...control} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + control.onBlur(); + }} /> + } + > + {verification.status !== 'idle' && verification.status !== 'loading' ? ( + <> + + + + + + ) : undefined} + + } + /> + ); +} diff --git a/packages/mosaic/src/utils/feedback.styles.ts b/packages/mosaic/src/utils/feedback.styles.ts index 92bcd4fbd4c..52af213cff4 100644 --- a/packages/mosaic/src/utils/feedback.styles.ts +++ b/packages/mosaic/src/utils/feedback.styles.ts @@ -46,6 +46,9 @@ export const feedbackStyles = stylex.create({ error: { color: colorVars['--cl-color-negative'], }, + info: { + color: colorVars['--cl-color-foreground-secondary'], + }, success: { color: colorVars['--cl-color-positive'], }, From 80d8f626b5533620302a13dd2736c286593c4e25 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 24 Sep 2026 16:13:16 -0600 Subject: [PATCH 04/91] feat(swingset): add live password editing demo --- .../swingset/src/app/(clerk)/live-sidebar.tsx | 5 ++- .../src/app/(clerk)/live/password/page.tsx | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 packages/swingset/src/app/(clerk)/live/password/page.tsx diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 2e206b399de..dd6d08e7d7a 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -18,7 +18,10 @@ import { SidebarRail, } from '@/components/ui/sidebar'; -const flows = [{ title: 'Reverification', href: '/live/reverification' }]; +const flows = [ + { title: 'Password', href: '/live/password' }, + { title: 'Reverification', href: '/live/reverification' }, +]; export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); diff --git a/packages/swingset/src/app/(clerk)/live/password/page.tsx b/packages/swingset/src/app/(clerk)/live/password/page.tsx new file mode 100644 index 00000000000..7cb0e53e3b6 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/password/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { UserProfilePasswordSection } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function PasswordLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Password

+

Set or change the password for your signed-in account.

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage your password. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} From 7d738b566293f4cf062135c99a15a3a4a1aecb5b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 09:42:18 -0600 Subject: [PATCH 05/91] refactor(mosaic): rename Field.Info to Field.Hint --- packages/mosaic/src/components/field/field.test.tsx | 2 +- packages/mosaic/src/components/field/field.tsx | 6 +++--- packages/mosaic/src/components/field/index.ts | 2 +- .../user-profile-edit-password.dialog.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index ba1de600172..fd614248747 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -41,7 +41,7 @@ describe('Mosaic Field', () => { Password - Try a longer password. + Try a longer password.
, ); diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index a52ac031dc9..19f6984a81a 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -244,9 +244,9 @@ const FieldSuccess = React.forwardRef(f ); }); -export type FieldInfoProps = MosaicComponentProps<'p'>; +export type FieldHintProps = MosaicComponentProps<'p'>; -const FieldInfo = React.forwardRef(function MosaicFieldInfo(props, ref) { +const FieldHint = React.forwardRef(function MosaicFieldHint(props, ref) { return ( {feedbackType === 'error' ? message?.message : null} - {feedbackType === 'info' || feedbackType === 'warning' ? message?.message : null} + {feedbackType === 'info' || feedbackType === 'warning' ? message?.message : null} {feedbackType === 'success' ? message?.message : null} From b3ed23f244b76c489405fbcb2073ed75090b2b33 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 10:23:54 -0600 Subject: [PATCH 06/91] fix(mosaic): restore password API error formatting --- ...file-password-section.integration.test.tsx | 26 +++- .../user-profile-password-feedback.test.ts | 122 ++++++++++++++++++ .../user-profile-password-feedback.ts | 97 ++++++++++++++ .../user-profile-password-section.messages.ts | 35 ++++- .../user-profile-password-section.model.ts | 22 ---- .../user-profile-password-section.tsx | 8 +- 6 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 48162056af0..bd189b4000c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -226,11 +226,35 @@ describe('UserProfilePasswordSection', () => { await events.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription('Choose a different password.'), + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ), ); expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); }); + it('prioritizes the backend minimum-length error over an earlier complexity error', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [ + { code: 'form_password_no_uppercase', message: 'Raw uppercase', meta: { param_name: 'new_password' } }, + { code: 'form_password_length_too_short', message: 'Raw minimum', meta: { param_name: 'new_password' } }, + ], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + }); + it('returns from verification to the same draft without showing an error', async () => { user.updatePassword.mockRejectedValueOnce( new ClerkAPIResponseError('Verify', { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts new file mode 100644 index 00000000000..7075a2f8271 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -0,0 +1,122 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it } from 'vitest'; + +import { FormSubmitError } from '../../../components/form'; +import { resolveLocalization } from '../../../localization'; +import { passwordFormError } from './user-profile-password-feedback'; + +const settings = { min_length: 12, max_length: 64 }; +const localization = resolveLocalization({ + locale: 'en', + overrides: { + userProfilePasswordSection: { + suggestions: { anotherWord: 'Custom suggestion.' }, + passwordErrors: { form_password_size_in_bytes_exceeded: 'Custom byte error.' }, + }, + }, +}); +const messages = localization.messages.userProfilePasswordSection; +function format(data: ConstructorParameters[1]['data'], current = false) { + const result = passwordFormError( + new ClerkAPIResponseError('Invalid', { status: 422, data }), + current, + settings, + messages, + localization.locale, + ); + expect(result).toBeInstanceOf(FormSubmitError); + if (!(result instanceof FormSubmitError)) { + throw new Error('Expected form error'); + } + return result; +} + +describe('password error feedback', () => { + it('combines recognized requirements in a localized list', () => { + expect( + format([ + { code: 'form_password_no_uppercase', message: 'raw', meta: { param_name: 'new_password' } }, + { code: 'form_password_no_number', message: 'raw', meta: { param_name: 'new_password' } }, + ]).fields?.newPassword, + ).toBe('Your password must contain an uppercase letter and a number.'); + }); + + it('formats backend strength suggestions using Mosaic overrides', () => { + expect( + format([ + { + code: 'form_password_not_strong_enough', + message: 'raw', + meta: { + param_name: 'new_password', + zxcvbn: { suggestions: [{ code: 'anotherWord', message: 'raw suggestion' }] }, + }, + }, + ]).fields?.newPassword, + ).toBe('Your password is not strong enough. Custom suggestion.'); + }); + + it('localizes byte errors and falls back for unknown errors', () => { + expect( + format([{ code: 'form_password_size_in_bytes_exceeded', message: 'raw', meta: { param_name: 'password' } }]) + .fields?.newPassword, + ).toBe('Custom byte error.'); + expect( + format([ + { + code: 'future_error', + message: 'short', + long_message: 'Detailed error', + meta: { param_name: 'new_password' }, + }, + ]).fields?.newPassword, + ).toBe('Detailed error'); + }); + + it('keeps current-password errors and banner fallbacks separate', () => { + const errors = [ + { code: 'form_password_incorrect', message: 'Incorrect', meta: { param_name: 'current_password' } }, + ]; + expect(format(errors, true).fields?.currentPassword).toBe('Incorrect'); + expect(format(errors).banner).toBe('Incorrect'); + expect(format([{ code: 'unknown', message: 'Other', meta: { param_name: 'unmapped' } }]).banner).toBe('Other'); + }); + + it('uses the configured maximum length', () => { + expect( + format([{ code: 'form_password_length_too_long', message: 'raw', meta: { param_name: 'new_password' } }]).fields + ?.newPassword, + ).toBe('Your password must contain less than 64 characters.'); + }); + + it('retains the server fallback for untranslated special codes', () => { + expect( + format([ + { + code: 'form_new_password_matches_current', + message: 'Use a different password', + meta: { param_name: 'new_password' }, + }, + ]).fields?.newPassword, + ).toBe('Use a different password'); + }); + + it('handles missing and unrecognized suggestion metadata without leaking codes', () => { + expect( + format([{ code: 'form_password_not_strong_enough', message: 'raw', meta: { param_name: 'new_password' } }]).fields + ?.newPassword, + ).toBe('Your password is not strong enough.'); + expect( + format([ + { + code: 'form_password_not_strong_enough', + message: 'raw', + meta: { + param_name: 'new_password', + zxcvbn: { suggestions: [{ code: 'futureSuggestion', message: 'raw' }] }, + }, + }, + ]).fields?.newPassword, + ).toBe('Your password is not strong enough.'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts new file mode 100644 index 00000000000..2136695613b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -0,0 +1,97 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; + +import { FormSubmitError } from '../../../components/form'; +import type { MosaicMessages } from '../../../localization'; +import { fill } from '../../../localization'; + +type Messages = MosaicMessages['userProfilePasswordSection']; +type Settings = Pick; + +function lookup(messages: Record, key: string): string | undefined { + return Object.hasOwn(messages, key) ? messages[key] : undefined; +} + +export function passwordStrengthMessage(codes: string[], messages: Messages): string { + return [messages.rules.weak, ...codes.map(code => lookup(messages.suggestions, code))].filter(Boolean).join(' '); +} + +function passwordError(errors: ClerkAPIError[], settings: Settings, messages: Messages, locale: string) { + const first = errors[0]; + if (!first) { + return undefined; + } + const known = lookup(messages.passwordErrors, first.code); + if (known !== undefined) { + return known || first.message; + } + if (first.code === 'form_password_not_strong_enough') { + return passwordStrengthMessage(first.meta?.zxcvbn?.suggestions?.map(suggestion => suggestion.code) ?? [], messages); + } + const codes: Record = { + form_password_length_too_short: 'min_length', + form_password_length_too_long: 'max_length', + form_password_no_uppercase: 'require_uppercase', + form_password_no_lowercase: 'require_lowercase', + form_password_no_number: 'require_numbers', + form_password_no_special_char: 'require_special_char', + }; + const failures = errors.flatMap(error => { + const code = lookup(codes, error.code); + return code ? [code] : []; + }); + return passwordComplexityMessage(failures, settings, messages, locale) || first.longMessage || first.message; +} + +export function passwordComplexityMessage(failures: string[], settings: Settings, messages: Messages, locale: string) { + const rules: Record = { + min_length: fill(messages.complexity.minimumLength, { length: settings.min_length }), + max_length: fill(messages.complexity.maximumLength, { length: settings.max_length }), + require_uppercase: messages.complexity.uppercase, + require_lowercase: messages.complexity.lowercase, + require_numbers: messages.complexity.number, + require_special_char: messages.complexity.special, + }; + const requirements = (failures.includes('min_length') ? ['min_length'] : failures).flatMap(code => { + const text = lookup(rules, code); + return text ? [text] : []; + }); + if (!requirements.length) { + return undefined; + } + const list = + typeof Intl.ListFormat === 'function' + ? new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(requirements) + : requirements.join(', '); + return fill(messages.complexity.sentence, { requirements: list }); +} + +export function passwordFormError( + error: unknown, + requiresCurrentPassword: boolean, + settings: Settings, + messages: Messages, + locale: string, +): unknown { + if (!isClerkAPIResponseError(error)) { + return error; + } + const fields: { currentPassword?: string; newPassword?: string } = {}; + const passwordErrors: ClerkAPIError[] = []; + let message: string | undefined; + for (const item of error.errors) { + const text = item.longMessage || item.message; + const name = item.meta?.paramName; + if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { + fields.currentPassword ??= text; + } else if (name === 'new_password' || name === 'newPassword' || name === 'password') { + passwordErrors.push(item); + } else { + message ??= text; + } + } + if (passwordErrors.length) { + fields.newPassword = passwordError(passwordErrors, settings, messages, locale); + } + return new FormSubmitError({ message, fields }); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 41dcba08b47..1c2d8e06ac1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -29,10 +29,43 @@ export const userProfilePasswordSectionMessages = { uppercase: 'Add an uppercase letter.', number: 'Add a number.', special: 'Add a special character.', - weak: 'Choose a stronger password.', + weak: 'Your password is not strong enough.', stronger: 'Your password could be stronger.', strong: 'Strong password.', }, + complexity: { + sentence: 'Your password must contain {requirements}.', + minimumLength: '{length} or more characters', + maximumLength: 'less than {length} characters', + lowercase: 'a lowercase letter', + uppercase: 'an uppercase letter', + number: 'a number', + special: 'a special character', + }, + passwordErrors: { + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_size_in_bytes_exceeded: '', + form_new_password_matches_current: '', + }, + suggestions: { + allUppercase: 'Capitalize some, but not all letters.', + anotherWord: 'Add more words that are less common.', + associatedYears: 'Avoid years that are associated with you.', + capitalization: 'Capitalize more than the first letter.', + dates: 'Avoid dates and years that are associated with you.', + l33t: "Avoid predictable letter substitutions like '@' for 'a'.", + longerKeyboardPattern: 'Use longer keyboard patterns and change typing direction multiple times.', + noNeed: 'You can create strong passwords without using symbols, numbers, or uppercase letters.', + pwned: 'If you use this password elsewhere, you should change it.', + recentYears: 'Avoid recent years.', + repeated: 'Avoid repeated words and characters.', + reverseWords: 'Avoid reversed spellings of common words.', + sequences: 'Avoid common character sequences.', + useWords: 'Use multiple words, but avoid common phrases.', + }, errors: { verificationIncomplete: 'Your password was not saved. Please try verifying again.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 25a0cbb57de..a1ed7040e48 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -1,4 +1,3 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/passwords/complexity'; import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; @@ -6,30 +5,9 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; import { useCallback } from 'react'; -import { FormSubmitError } from '../../../components/form'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -export function passwordFormError(error: unknown, requiresCurrentPassword: boolean): unknown { - if (!isClerkAPIResponseError(error)) { - return error; - } - const fields: { currentPassword?: string; newPassword?: string } = {}; - let message: string | undefined; - for (const item of error.errors) { - const text = item.longMessage || item.message; - const name = item.meta?.paramName; - if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { - fields.currentPassword ??= text; - } else if (name === 'new_password' || name === 'newPassword' || name === 'password') { - fields.newPassword ??= text; - } else { - message ??= text; - } - } - return new FormSubmitError({ message, fields }); -} - type EditablePasswordPolicy = | { mode: 'set'; requiresCurrentPassword: false } | { mode: 'change'; requiresCurrentPassword: boolean }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 9c207c9aa25..00dd13dd319 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -7,12 +7,13 @@ import { Button } from '../../../components/button'; import { Card } from '../../../components/card'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; -import { fill, useMessages } from '../../../localization'; +import { fill, useLocale, useMessages } from '../../../localization'; import { Reverification, useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; +import { passwordFormError } from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; -import { passwordFormError, useUserProfilePasswordModel } from './user-profile-password-section.model'; +import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; export interface UserProfilePasswordSectionProps { @@ -51,6 +52,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw function PasswordFlow({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); + const locale = useLocale(); const [updatePassword, verification] = useReverificationFlow(model.updatePassword); const { validatePassword, passwordSettings } = model; const feedback = useCallback( @@ -93,7 +95,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 10:24:17 -0600 Subject: [PATCH 07/91] fix(mosaic): show password strength suggestions --- ...file-password-section.integration.test.tsx | 32 ++++++++++++++++++- .../user-profile-password-section.messages.ts | 4 +-- .../user-profile-password-section.tsx | 4 +-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index bd189b4000c..cd44e6e90ce 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -26,7 +26,14 @@ const session = { const environment = { userSettings: { instanceIsPasswordBased: true, - passwordSettings: { min_length: 8, max_length: 72, show_zxcvbn: false }, + passwordSettings: { + min_length: 8, + max_length: 72, + show_zxcvbn: false, + min_zxcvbn_strength: 3, + require_uppercase: false, + require_numbers: false, + }, }, authConfig: { reverification: true }, displayConfig: { preferredSignInStrategy: 'password', supportEmail: 'support@example.com' }, @@ -35,11 +42,18 @@ const clerk = { user, session, __internal_environment: environment, + __internal_moduleManager: {}, __internal_getOption: () => undefined, setActive: vi.fn(), }; let isSessionLoaded = true; +vi.mock('@clerk/shared/internal/clerk-js/passwords/loadZxcvbn', () => ({ + createLoadZxcvbn: () => ({ + loadZxcvbn: () => Promise.resolve(() => ({ score: 0, feedback: { suggestions: ['anotherWord'] } })), + }), +})); + vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); return { @@ -57,6 +71,9 @@ beforeEach(() => { user.passwordEnabled = true; user.enterpriseAccounts = []; environment.authConfig.reverification = true; + environment.userSettings.passwordSettings.show_zxcvbn = false; + environment.userSettings.passwordSettings.require_uppercase = false; + environment.userSettings.passwordSettings.require_numbers = false; environment.userSettings.instanceIsPasswordBased = true; user.updatePassword.mockResolvedValue(user); clerk.setActive.mockResolvedValue(undefined); @@ -255,6 +272,19 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); }); + it('shows specific client strength suggestions without blocking submission', async () => { + environment.userSettings.passwordSettings.show_zxcvbn = true; + renderPassword(); + const events = await editPassword(); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password is not strong enough. Add more words that are less common.', + ), + ); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + expect(user.updatePassword).toHaveBeenCalled(); + }); + it('returns from verification to the same draft without showing an error', async () => { user.updatePassword.mockRejectedValueOnce( new ClerkAPIResponseError('Verify', { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 1c2d8e06ac1..7a0b464bde9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -30,8 +30,8 @@ export const userProfilePasswordSectionMessages = { number: 'Add a number.', special: 'Add a special character.', weak: 'Your password is not strong enough.', - stronger: 'Your password could be stronger.', - strong: 'Strong password.', + stronger: 'Your password works, but could be stronger. Try adding more characters.', + strong: 'Your password meets all the necessary requirements.', }, complexity: { sentence: 'Your password must contain {requirements}.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 00dd13dd319..50f6acc6479 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -11,7 +11,7 @@ import { fill, useLocale, useMessages } from '../../../localization'; import { Reverification, useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { passwordFormError } from './user-profile-password-feedback'; +import { passwordFormError, passwordStrengthMessage } from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; @@ -72,7 +72,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 10:24:37 -0600 Subject: [PATCH 08/91] fix(mosaic): match legacy password hint wording --- ...file-password-section.integration.test.tsx | 14 ++++++++++ .../user-profile-password-section.messages.ts | 7 +---- .../user-profile-password-section.tsx | 28 +++++++++---------- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index cd44e6e90ce..7d4d5b7c667 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -272,6 +272,20 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); }); + it('uses legacy wording and list formatting for live complexity feedback', async () => { + environment.userSettings.passwordSettings.require_uppercase = true; + environment.userSettings.passwordSettings.require_numbers = true; + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'longpassword'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain a number and an uppercase letter.', + ), + ); + }); + it('shows specific client strength suggestions without blocking submission', async () => { environment.userSettings.passwordSettings.show_zxcvbn = true; renderPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 7a0b464bde9..794ac7a8707 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -23,16 +23,11 @@ export const userProfilePasswordSectionMessages = { back: 'Back', save: 'Save changes', rules: { - minLength: 'Your password must contain {length} or more characters.', - maxLength: 'Use fewer than {length} characters.', - lowercase: 'Add a lowercase letter.', - uppercase: 'Add an uppercase letter.', - number: 'Add a number.', - special: 'Add a special character.', weak: 'Your password is not strong enough.', stronger: 'Your password works, but could be stronger. Try adding more characters.', strong: 'Your password meets all the necessary requirements.', }, + complexity: { sentence: 'Your password must contain {requirements}.', minimumLength: '{length} or more characters', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 50f6acc6479..43ecb4d1e0c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -7,11 +7,15 @@ import { Button } from '../../../components/button'; import { Card } from '../../../components/card'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; -import { fill, useLocale, useMessages } from '../../../localization'; +import { useLocale, useMessages } from '../../../localization'; import { Reverification, useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { passwordFormError, passwordStrengthMessage } from './user-profile-password-feedback'; +import { + passwordComplexityMessage, + passwordFormError, + passwordStrengthMessage, +} from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; @@ -58,18 +62,12 @@ function PasswordFlow({ model }: { model: Extract => { const { complexity, strength } = await validatePassword(password); - if (complexity?.min_length) { - return { type: 'info', message: fill(m.rules.minLength, { length: passwordSettings.min_length }) }; - } - const messages = [ - complexity?.max_length && fill(m.rules.maxLength, { length: passwordSettings.max_length }), - complexity?.require_lowercase && m.rules.lowercase, - complexity?.require_uppercase && m.rules.uppercase, - complexity?.require_numbers && m.rules.number, - complexity?.require_special_char && m.rules.special, - ].filter(Boolean); - if (messages.length > 0) { - return { type: 'error', message: messages.join(' ') }; + const failures = Object.entries(complexity ?? {}) + .filter(([, failed]) => failed) + .map(([code]) => code); + const message = passwordComplexityMessage(failures, passwordSettings, m, locale); + if (message) { + return { type: complexity?.min_length ? 'info' : 'error', message }; } if (strength?.state === 'fail') { return { type: 'error', message: passwordStrengthMessage(strength.result.feedback.suggestions, m) }; @@ -79,7 +77,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 12:31:34 -0600 Subject: [PATCH 09/91] refactor(mosaic): take the password section as a security panel slot --- .../user-profile-security-panel.view.test.tsx | 11 ++--- .../__tests__/user-profile.view.test.tsx | 3 +- .../user-profile-security-panel.view.tsx | 44 +++++-------------- .../src/stories/fixtures/user-profile.ts | 3 +- 4 files changed, 20 insertions(+), 41 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 052f8b22877..4cbe3ba7444 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -4,11 +4,12 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; const props: UserProfileSecurityPanelViewProps = { - hasPassword: true, + passwordSlot: , passkeys: [ { id: 'passkey_1', @@ -162,7 +163,7 @@ describe('UserProfileSecurityPanelView', () => { it('keeps supported empty authentication methods actionable', () => { renderView({ - hasPassword: false, + passwordSlot: undefined, passkeys: [], mfaMethods: [], devices: [], @@ -190,7 +191,7 @@ describe('UserProfileSecurityPanelView', () => { it('keeps the authentication section on MFA when existing passkeys are hidden', () => { renderView({ - hasPassword: false, + passwordSlot: undefined, passkeysVisible: false, onAddPasskey: vi.fn(), onRenamePasskey: vi.fn(), @@ -205,7 +206,7 @@ describe('UserProfileSecurityPanelView', () => { }); it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => { - renderView({ hasPassword: false, passkeys: [], onAddPasskey: undefined }); + renderView({ passwordSlot: undefined, passkeys: [], onAddPasskey: undefined }); const section = screen.getByRole('region', { name: 'Authentication' }); expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); @@ -220,7 +221,7 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(async () => { await removal.promise; }); - const { rerender } = renderView({ hasPassword: false, mfaMethods: undefined, onRemovePasskey }); + const { rerender } = renderView({ passwordSlot: undefined, mfaMethods: undefined, onRemovePasskey }); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index 49e389f9ebc..e37381e2a6f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -6,10 +6,11 @@ import { Dialog } from '../../../components/dialog'; import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileViewProps } from '../user-profile.view'; import { UserProfileView } from '../user-profile.view'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; const pages: UserProfileViewProps['pages'] = { account: { name: 'Preston Booth', username: 'prestonxyz' }, - security: { hasPassword: true }, + security: { passwordSlot: }, billing: { subscription: { planName: 'Basic Plan', diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 95dafc3cdfa..499c0e496f1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -13,29 +13,15 @@ import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-p import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; -import type { - UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, - UserProfilePasswordSectionViewProps, -} from './user-profile-password-section/user-profile-password-section.view'; -import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; -export type { - UserProfileDevice, - UserProfileEditPasswordValue, - UserProfileMfaAddableMethod, - UserProfileMfaMethod, - UserProfilePasskey, - UserProfilePasswordManagedBy, -}; +export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; -export interface UserProfileSecurityPanelViewProps - extends - Omit, - Pick< - UserProfilePasswordSectionViewProps, - 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' - > { +export interface UserProfileSecurityPanelViewProps extends Omit { + /** + * The password section. Omit it when passwords are unavailable rather than passing a section that renders + * nothing, so the Authentication heading stays correct. + */ + passwordSlot?: ReactNode; passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; @@ -55,16 +41,13 @@ export interface UserProfileSecurityPanelViewProps } export function UserProfileSecurityPanelView({ - hasPassword = false, - requiresCurrentPassword, - managedBy, + passwordSlot, passkeys, passkeysVisible = true, mfaMethods, addableMfaMethods, mfaAddControl, devices, - onSubmitPassword, onAddPasskey, addPasskeyError, onRenamePasskey, @@ -77,7 +60,7 @@ export function UserProfileSecurityPanelView({ onSignOutAllOtherDevices, onDeleteAccount, }: UserProfileSecurityPanelViewProps): ReactElement { - const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); + const showPassword = Boolean(passwordSlot); const showPasskeys = passkeys !== undefined && passkeysVisible; const hasAuthentication = showPassword || showPasskeys || mfaMethods !== undefined; @@ -87,14 +70,7 @@ export function UserProfileSecurityPanelView({ {hasAuthentication ? ( - {showPassword ? ( - - ) : null} + {passwordSlot} {showPasskeys ? ( current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), }, security: { - ...editPassword, + passwordSlot: , passkeys: passkeys.passkeys, addPasskeyError: passkeys.addError, onRenamePasskey: passkeys.onRename, From cf939baf789aaf0d49dca61537892f11aa511cf1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:32:37 -0600 Subject: [PATCH 10/91] fix(mosaic): confirm met password requirements when strength checks are off --- ...rofile-password-section.integration.test.tsx | 17 +++++++++++++++++ .../user-profile-password-section.tsx | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 7d4d5b7c667..4cf1efa631d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -286,6 +286,23 @@ describe('UserProfilePasswordSection', () => { ); }); + it('confirms that requirements are met when strength checking is disabled', async () => { + renderPassword(); + const events = await editPassword(); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password meets all the necessary requirements.', + ), + ); + await events.clear(screen.getByLabelText('New password')); + await events.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + }); + it('shows specific client strength suggestions without blocking submission', async () => { environment.userSettings.passwordSettings.show_zxcvbn = true; renderPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 43ecb4d1e0c..8996dd66380 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -75,7 +75,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 12:32:44 -0600 Subject: [PATCH 11/91] fix(mosaic): use the legacy read-only password copy --- .../user-profile-password-section.integration.test.tsx | 6 +++++- .../user-profile-password-section.messages.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 4cf1efa631d..c8fbb3e9f7d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -194,7 +194,11 @@ describe('UserProfilePasswordSection', () => { it('keeps an enterprise-managed password visible without offering a mutation', () => { user.enterpriseAccounts = [{ active: true }]; renderPassword(); - expect(screen.getByText('Your organization manages your password.')).toBeVisible(); + expect( + screen.getByText( + 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + ), + ).toBeVisible(); expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 794ac7a8707..1419b590bff 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -3,7 +3,7 @@ export const userProfilePasswordSectionMessages = { masked: '••••••••••••••••••', noPasswordSet: 'No password set', managedBy: 'Managed by {name}', - readonly: 'Your organization manages your password.', + readonly: 'Your password can currently not be edited because you can sign in only via the enterprise connection.', change: 'Change password', set: 'Set password', From 98d65010c9647825d44282c65f3b4a6e5642c8c4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:32:57 -0600 Subject: [PATCH 12/91] fix(mosaic): localize password update errors raised by the model --- .../user-profile-password-feedback.test.ts | 11 +++++++++++ .../user-profile-password-feedback.ts | 6 ++++++ .../user-profile-password-section.messages.ts | 2 ++ .../user-profile-password-section.model.test.ts | 8 ++++---- .../user-profile-password-section.model.ts | 5 +++-- .../user-profile-password-section.types.ts | 7 +++++++ 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index 7075a2f8271..b7ed3089ffa 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { FormSubmitError } from '../../../components/form'; import { resolveLocalization } from '../../../localization'; import { passwordFormError } from './user-profile-password-feedback'; +import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; const settings = { min_length: 12, max_length: 64 }; const localization = resolveLocalization({ @@ -119,4 +120,14 @@ describe('password error feedback', () => { ]).fields?.newPassword, ).toBe('Your password is not strong enough.'); }); + + it('localizes update errors raised before the request is sent', () => { + const translate = (code: UserProfilePasswordUpdateError['code']) => + passwordFormError(new UserProfilePasswordUpdateError(code), true, settings, messages, localization.locale); + + expect(translate('unavailable')).toMatchObject({ banner: 'Password update is no longer available.' }); + expect(translate('current_password_required')).toMatchObject({ + fields: { currentPassword: 'Current password is required.' }, + }); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index 2136695613b..5234c466186 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -4,6 +4,7 @@ import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; import { FormSubmitError } from '../../../components/form'; import type { MosaicMessages } from '../../../localization'; import { fill } from '../../../localization'; +import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; type Messages = MosaicMessages['userProfilePasswordSection']; type Settings = Pick; @@ -73,6 +74,11 @@ export function passwordFormError( messages: Messages, locale: string, ): unknown { + if (error instanceof UserProfilePasswordUpdateError) { + return error.code === 'current_password_required' + ? new FormSubmitError({ fields: { currentPassword: messages.errors.currentPasswordRequired } }) + : new FormSubmitError({ message: messages.errors.unavailable }); + } if (!isClerkAPIResponseError(error)) { return error; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 1419b590bff..da7c6e15f69 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -64,6 +64,8 @@ export const userProfilePasswordSectionMessages = { errors: { verificationIncomplete: 'Your password was not saved. Please try verifying again.', + unavailable: 'Password update is no longer available.', + currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index 4274d235350..b87d80e54c0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -89,7 +89,7 @@ describe('useUserProfilePasswordModel', () => { newPassword: 'new password', signOutOfOtherSessions: true, }), - ).rejects.toThrow('Current password is required.'); + ).rejects.toMatchObject({ code: 'current_password_required' }); expect(user.updatePassword).not.toHaveBeenCalled(); }); @@ -139,9 +139,9 @@ describe('useUserProfilePasswordModel', () => { } const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; - await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); rerender(); - await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); expect(updatePassword).not.toHaveBeenCalled(); }); @@ -153,7 +153,7 @@ describe('useUserProfilePasswordModel', () => { const { result } = renderHook(() => useUserProfilePasswordModel()); await expect( ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), - ).rejects.toThrow('Password update is no longer available.'); + ).rejects.toMatchObject({ code: 'unavailable' }); expect(user.updatePassword).not.toHaveBeenCalled(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index a1ed7040e48..e92874edeea 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; +import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; type EditablePasswordPolicy = | { mode: 'set'; requiresCurrentPassword: false } @@ -106,11 +107,11 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { currentPolicy.mode !== policy.mode || currentPolicy.requiresCurrentPassword !== policy.requiresCurrentPassword ) { - throw new Error('Password update is no longer available.'); + throw new UserProfilePasswordUpdateError('unavailable'); } if (policy.requiresCurrentPassword && !currentPassword) { - throw new Error('Current password is required.'); + throw new UserProfilePasswordUpdateError('current_password_required'); } return currentUser.updatePassword({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index f4823a9080e..bda9a4d4862 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,3 +1,10 @@ +export class UserProfilePasswordUpdateError extends Error { + constructor(readonly code: 'unavailable' | 'current_password_required') { + super(code); + this.name = 'UserProfilePasswordUpdateError'; + } +} + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { From bec26447bafb7584df87d089d2a5de9e9c2f0d06 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:33:02 -0600 Subject: [PATCH 13/91] fix(mosaic): use the legacy copy when the new password matches the current one --- .../user-profile-password-feedback.test.ts | 25 +++++++++++++++++-- .../user-profile-password-section.messages.ts | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index b7ed3089ffa..88274166aeb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -90,7 +90,7 @@ describe('password error feedback', () => { ).toBe('Your password must contain less than 64 characters.'); }); - it('retains the server fallback for untranslated special codes', () => { + it('uses the legacy wording when the new password matches the current one', () => { expect( format([ { @@ -99,7 +99,28 @@ describe('password error feedback', () => { meta: { param_name: 'new_password' }, }, ]).fields?.newPassword, - ).toBe('Use a different password'); + ).toBe('New password cannot be the same as the current password.'); + }); + + it('retains the server fallback for untranslated special codes', () => { + const defaults = resolveLocalization({ locale: 'en' }); + const result = passwordFormError( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [ + { + code: 'form_password_size_in_bytes_exceeded', + message: 'Password is too large', + meta: { param_name: 'new_password' }, + }, + ], + }), + false, + settings, + defaults.messages.userProfilePasswordSection, + defaults.locale, + ); + expect(result).toMatchObject({ fields: { newPassword: 'Password is too large' } }); }); it('handles missing and unrecognized suggestion metadata without leaking codes', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index da7c6e15f69..b18d07c4bd5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -43,7 +43,7 @@ export const userProfilePasswordSectionMessages = { form_password_matches_identifier: 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', form_password_size_in_bytes_exceeded: '', - form_new_password_matches_current: '', + form_new_password_matches_current: 'New password cannot be the same as the current password.', }, suggestions: { allUppercase: 'Capitalize some, but not all letters.', From a4e8f846fcae02de11968a1afe2841f343ce295a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:33:03 -0600 Subject: [PATCH 14/91] refactor(mosaic): move password reverification into the editor controller and dialog --- ...r-profile-edit-password.controller.test.ts | 72 +++++++++++++++---- .../user-profile-edit-password.controller.ts | 28 ++++++-- .../user-profile-edit-password.dialog.tsx | 42 ++++++++--- .../user-profile-password-row.view.tsx | 5 +- .../user-profile-password-section.tsx | 52 +++----------- 5 files changed, 123 insertions(+), 76 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 83f52e86d23..2c232fa0903 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,13 +1,15 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; +import type { ReverificationController } from '../../reverification'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditPasswordSubmitResult } from './user-profile-edit-password.controller'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -function deferred() { +function deferred() { let resolve: (result: T) => void = () => {}; const promise = new Promise(r => { resolve = r; @@ -16,8 +18,7 @@ function deferred() { } function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => - Promise.resolve({ status: 'saved' }), + onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), requiresCurrentPassword = true, ) { return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); @@ -42,7 +43,7 @@ describe('useUserProfileEditPasswordController', () => { const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); const { result } = renderHook(() => useUserProfileEditPasswordController({ - onSubmit: () => Promise.resolve({ status: 'saved' }), + onSubmit: () => Promise.resolve(), validatePassword, }), ); @@ -69,7 +70,7 @@ describe('useUserProfileEditPasswordController', () => { it('can submit while an advisory password check is pending', async () => { const check = deferred(); const validatePassword = vi.fn(() => check.promise); - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit, validatePassword })); open(result); fill(result); @@ -102,7 +103,7 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.isOpen).toBe(true); expect(onSubmit).toHaveBeenCalledTimes(1); await act(async () => { - save.resolve({ status: 'saved' }); + save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); @@ -115,7 +116,7 @@ describe('useUserProfileEditPasswordController', () => { message: 'Try a longer password.', }), ); - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit, validatePassword }), ); @@ -142,7 +143,7 @@ describe('useUserProfileEditPasswordController', () => { const { result } = renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword: true, - onSubmit: () => Promise.resolve({ status: 'cancelled' }), + onSubmit: () => Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })), }), ); open(result); @@ -164,7 +165,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('blocks direct submission with an empty confirmation', () => { - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit); open(result); fill(result); @@ -195,7 +196,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit); open(result); fill(result); @@ -214,7 +215,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('leaves the current password out when reverification stands in for it', async () => { - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit, false); open(result); act(() => result.current.form.setValue('newPassword', 'new-secret-123')); @@ -307,7 +308,7 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.isOpen).toBe(true); await act(async () => { - save.resolve({ status: 'saved' }); + save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); @@ -356,4 +357,49 @@ describe('useUserProfileEditPasswordController', () => { message: "Passwords don't match.", }); }); + + it('keeps the draft and reports a failed retry when verification is required again', async () => { + const onSubmit = vi.fn(() => Promise.resolve(reverificationError())); + const { result } = renderController(onSubmit); + open(result); + fill(result); + + act(() => result.current.form.submit()); + + await waitFor(() => + expect(result.current.form.error).toBe('Your password was not saved. Please try verifying again.'), + ); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.newPassword).toBe('new-secret-123'); + }); + + it('formats a failed save before the form shows it', async () => { + const failure = new Error('raw'); + const formatError = vi.fn(() => new UserProfileSaveError('Formatted.')); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(failure), formatError }), + ); + open(result); + fill(result); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.form.error).toBe('Formatted.')); + expect(formatError).toHaveBeenCalledWith(failure); + }); + + it('cancels an active verification instead of closing the editor', () => { + const onCancel = vi.fn(); + const reverification = { status: 'unavailable', phase: 'active', onCancel } as ReverificationController; + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), reverification }), + ); + open(result); + + act(() => result.current.onOpenChange(false)); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(result.current.isOpen).toBe(true); + expect(result.current.reverification).toBe(reverification); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 75ba6b5b65b..ee45483bbd5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,9 +1,12 @@ +import { isReverificationHint } from '@clerk/shared/authorization-errors'; +import { isReverificationCancelledError } from '@clerk/shared/error'; import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; +import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordValue, UserProfileEditPasswordValues, @@ -16,12 +19,12 @@ const initialValues: UserProfileEditPasswordValues = { signOutOfOtherSessions: true, }; -export type UserProfileEditPasswordSubmitResult = { status: 'saved' } | { status: 'cancelled' }; - export interface UserProfileEditPasswordControllerOptions { requiresCurrentPassword?: boolean; - onSubmit: (value: UserProfileEditPasswordValue) => Promise; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; validatePassword?: (password: string) => Promise; + formatError?: (error: unknown) => unknown; + reverification?: ReverificationController; } export interface UserProfileEditPasswordController { @@ -29,12 +32,15 @@ export interface UserProfileEditPasswordController { onOpenChange: (open: boolean) => void; form: UseFormResult; passwordFeedback: FieldFeedback | undefined; + reverification?: ReverificationController; } export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, validatePassword, + formatError = error => error, + reverification, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); @@ -61,9 +67,15 @@ export function useUserProfileEditPasswordController({ newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); - if (result.status === 'saved') { - setIsOpen(false); + if (isReverificationHint(result)) { + throw new Error(m.errors.verificationIncomplete); + } + setIsOpen(false); + } catch (error) { + if (isReverificationCancelledError(error)) { + return; } + throw formatError(error); } finally { submitting.current = false; } @@ -94,6 +106,10 @@ export function useUserProfileEditPasswordController({ }, [isOpen, password, validatePassword]); const onOpenChange = (open: boolean) => { + if (!open && reverification && reverification.status !== 'idle') { + reverification.onCancel?.(); + return; + } if (submitting.current || form.isSubmitting) { return; } @@ -101,5 +117,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback }; + return { isOpen, onOpenChange, form, passwordFeedback, reverification }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 2df58f13529..d7fb84d9cca 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,6 +1,6 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { ReactNode, RefObject } from 'react'; +import type { RefObject } from 'react'; import { useEffect, useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; @@ -15,6 +15,8 @@ import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; import { useMessages } from '../../../localization'; +import type { ReverificationController } from '../../reverification'; +import { Reverification } from '../../reverification'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordField, @@ -22,7 +24,7 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { - children?: ReactNode; + reverification?: ReverificationController; passwordFeedback?: FieldFeedback; identifier?: string; open: boolean; @@ -34,7 +36,7 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ - children, + reverification, passwordFeedback, identifier = '', open, @@ -50,15 +52,20 @@ export function UserProfileEditPasswordDialog({ const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; const restoreEditorFocus = useRef(false); + const verification = + reverification && reverification.status !== 'idle' && reverification.status !== 'loading' + ? reverification + : undefined; + const verifying = verification !== undefined; useEffect(() => { - if (children) { + if (verifying) { restoreEditorFocus.current = true; } else if (restoreEditorFocus.current && !form.isSubmitting) { restoreEditorFocus.current = false; initialFocusRef.current?.focus({ preventScroll: true }); } - }, [children, form.isSubmitting]); + }, [verifying, form.isSubmitting]); return ( {() => ( <> @@ -184,7 +191,24 @@ export function UserProfileEditPasswordDialog({ - {children} + + {verification ? ( + <> + + + + + + ) : null} + )} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 09aff9ba276..345ab04cd43 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -81,10 +81,7 @@ function EditPassword({ const m = useMessages('userProfilePasswordSection'); const controller = useUserProfileEditPasswordController({ requiresCurrentPassword: hasPassword && requiresCurrentPassword, - onSubmit: async value => { - await onSubmit(value); - return { status: 'saved' }; - }, + onSubmit, }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 8996dd66380..579c1a8c633 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -1,14 +1,11 @@ -import { isReverificationHint } from '@clerk/shared/authorization-errors'; -import { isReverificationCancelledError } from '@clerk/shared/error'; import type { ReactNode } from 'react'; import { useCallback, useRef } from 'react'; import { Button } from '../../../components/button'; -import { Card } from '../../../components/card'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; import { useLocale, useMessages } from '../../../localization'; -import { Reverification, useReverificationFlow } from '../../reverification'; +import { useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import { @@ -57,7 +54,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw function PasswordFlow({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const locale = useLocale(); - const [updatePassword, verification] = useReverificationFlow(model.updatePassword); + const [updatePassword, reverification] = useReverificationFlow(model.updatePassword); const { validatePassword, passwordSettings } = model; const feedback = useCallback( async (password: string): Promise => { @@ -82,20 +79,9 @@ function PasswordFlow({ model }: { model: Extract { - try { - const result = await updatePassword(value); - if (isReverificationHint(result)) { - throw new Error(m.errors.verificationIncomplete); - } - return { status: 'saved' }; - } catch (error) { - if (isReverificationCancelledError(error)) { - return { status: 'cancelled' }; - } - throw passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale); - } - }, + onSubmit: updatePassword, + formatError: error => passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale), + reverification, }); return ( @@ -107,15 +93,10 @@ function PasswordFlow({ model }: { model: Extract { - if (!open && verification.status !== 'idle') { - verification.onCancel?.(); - return; - } - controller.onOpenChange(open); - }} + onOpenChange={controller.onOpenChange} hasPassword={model.mode === 'change'} requiresCurrentPassword={model.requiresCurrentPassword} + reverification={controller.reverification} trigger={ - - - ) : undefined} - + /> } /> ); From 7d4c33b284707d339f8557bd0ce5915857af5702 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:51:39 -0600 Subject: [PATCH 15/91] fix(mosaic): show password requirements after the new password is left empty --- ...file-password-section.integration.test.tsx | 19 ++++++++++++++++ ...r-profile-edit-password.controller.test.ts | 22 +++++++++++++++++++ .../user-profile-edit-password.controller.ts | 5 +++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index c8fbb3e9f7d..785ad11c1e8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -290,6 +290,25 @@ describe('UserProfilePasswordSection', () => { ); }); + it('shows the minimum length as an error after the new password is left empty', async () => { + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ); + + await events.click(screen.getByLabelText('Confirm password')); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + }); + it('confirms that requirements are met when strength checking is disabled', async () => { renderPassword(); const events = await editPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 2c232fa0903..6bc498b824b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -67,6 +67,28 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.passwordFeedback).toBeUndefined(); }); + it('checks an empty new password only once it has been left', async () => { + const validatePassword = vi.fn(() => + Promise.resolve({ type: 'info', message: 'Your password must contain 8 or more characters.' }), + ); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), + ); + open(result); + await act(() => Promise.resolve()); + expect(validatePassword).not.toHaveBeenCalled(); + + act(() => result.current.form.touch('newPassword')); + + await waitFor(() => + expect(result.current.passwordFeedback).toEqual({ + type: 'info', + message: 'Your password must contain 8 or more characters.', + }), + ); + expect(validatePassword).toHaveBeenCalledWith(''); + }); + it('can submit while an advisory password check is pending', async () => { const check = deferred(); const validatePassword = vi.fn(() => check.promise); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index ee45483bbd5..de8278b9601 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -83,9 +83,10 @@ export function useUserProfileEditPasswordController({ }); const password = form.values.newPassword; + const passwordLeft = form.fields.newPassword.touched; useEffect(() => { setPasswordFeedback(undefined); - if (!isOpen || password === '' || !validatePassword) { + if (!isOpen || (password === '' && !passwordLeft) || !validatePassword) { return; } @@ -103,7 +104,7 @@ export function useUserProfileEditPasswordController({ return () => { active = false; }; - }, [isOpen, password, validatePassword]); + }, [isOpen, password, passwordLeft, validatePassword]); const onOpenChange = (open: boolean) => { if (!open && reverification && reverification.status !== 'idle') { From e5209e8f07a5716fe5a4dd0e1cf401f9d823e2c4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 13:11:12 -0600 Subject: [PATCH 16/91] fix(mosaic): keep the password mismatch after the confirmation is cleared --- ...file-password-section.integration.test.tsx | 17 ++++++++++++++ ...r-profile-edit-password.controller.test.ts | 23 +++++++++++++++++++ .../user-profile-edit-password.controller.ts | 2 +- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 785ad11c1e8..ed3360c6b40 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -309,6 +309,23 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); }); + it('keeps the mismatch visible after the confirmation is cleared', async () => { + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'new-password-123'); + await events.type(screen.getByLabelText('Confirm password'), 'new-password-12'); + await events.click(screen.getByLabelText('New password')); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); + + await events.clear(screen.getByLabelText('Confirm password')); + + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); + expect(screen.getByLabelText('Confirm password')).toHaveAttribute('aria-invalid', 'true'); + }); + it('confirms that requirements are met when strength checking is disabled', async () => { renderPassword(); const events = await editPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 6bc498b824b..f8e1018481b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -287,6 +287,29 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); }); + it('keeps the mismatch after the confirmation is cleared', () => { + const { result } = renderController(); + open(result); + act(() => result.current.form.setValue('newPassword', 'new-secret-123')); + act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); + act(() => result.current.form.touch('confirmPassword')); + + act(() => result.current.form.setValue('confirmPassword', '')); + + expect(result.current.form.fields.confirmPassword.feedback).toEqual({ + type: 'error', + message: "Passwords don't match.", + }); + }); + + it('leaves an untouched empty confirmation quiet', () => { + const { result } = renderController(); + open(result); + act(() => result.current.form.setValue('newPassword', 'new-secret-123')); + + expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); + }); + it('forgets what was typed when the dialog is cancelled', () => { const { result } = renderController(); open(result); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index de8278b9601..c88cbb3573a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -52,7 +52,7 @@ export function useUserProfileEditPasswordController({ fields: { confirmPassword: { validate: (value, values) => - value !== '' && value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, + value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, }, }, canSubmit: values => From 251da773aedabaadd92205ab06b344a0cc8aec89 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:18:17 -0600 Subject: [PATCH 17/91] fix(mosaic): align password reverification with controller status --- ...user-profile-edit-password.dialog.test.tsx | 41 +++++++++++++++++++ ...r-profile-edit-password.controller.test.ts | 2 +- .../user-profile-edit-password.dialog.tsx | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx index c4bbc9a52c1..db8aed9287f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { FormField, UseFormResult } from '../../../components/form'; import { MosaicProvider } from '../../../MosaicProvider'; +import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; @@ -68,7 +69,47 @@ const confirmPasswordField = () => screen.getByLabelText('Confirm password'); const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); +function verification(status: 'ready' | 'retrying', onCancel?: () => void): ReverificationController { + const view = { + step: 'password', + value: '', + onValueChange: vi.fn(), + isPending: status === 'retrying', + onSubmit: vi.fn(), + onShowMethods: vi.fn(), + onShowHelp: vi.fn(), + onBack: vi.fn(), + onEmailSupport: vi.fn(), + onResend: vi.fn(), + canResend: true, + methods: [], + onSelectMethod: vi.fn(), + }; + return status === 'ready' ? { ...view, status, onCancel } : { ...view, status }; +} + describe('UserProfileEditPasswordDialog', () => { + it('allows Back while verification can be cancelled and disables it during the retry', async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + const { rerender, props } = renderView({ reverification: verification('ready', onCancel) }); + const back = screen.getByRole('button', { name: 'Back', exact: true }); + + expect(back).not.toBeDisabled(); + await user.click(back); + expect(onCancel).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); + }); + it('names the dialog for a change and masks every field', () => { renderView( {}, diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index f8e1018481b..4f312d423e9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -435,7 +435,7 @@ describe('useUserProfileEditPasswordController', () => { it('cancels an active verification instead of closing the editor', () => { const onCancel = vi.fn(); - const reverification = { status: 'unavailable', phase: 'active', onCancel } as ReverificationController; + const reverification: ReverificationController = { status: 'unavailable', onCancel }; const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), reverification }), ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index d7fb84d9cca..3f077d97cb0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -200,7 +200,7 @@ export function UserProfileEditPasswordDialog({ variant='outline' color='neutral' fullWidth - disabled={verification.phase === 'retrying'} + disabled={!verification.onCancel} onClick={verification.onCancel} > {m.back} From c62ab3362220e0e61d91db242bcbb2f63b3a671d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:40:48 -0600 Subject: [PATCH 18/91] refactor(mosaic): remove password reverification flow --- ...user-profile-edit-password.dialog.test.tsx | 42 +-- ...file-password-section.integration.test.tsx | 103 +++----- ...r-profile-edit-password.controller.test.ts | 35 ++- .../user-profile-edit-password.controller.ts | 20 +- .../user-profile-edit-password.dialog.tsx | 250 +++++++----------- .../user-profile-password-section.messages.ts | 2 - ...ser-profile-password-section.model.test.ts | 105 ++++---- .../user-profile-password-section.model.ts | 2 +- .../user-profile-password-section.tsx | 10 +- 9 files changed, 212 insertions(+), 357 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx index db8aed9287f..302407d1462 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -4,7 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { FormField, UseFormResult } from '../../../components/form'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; @@ -69,45 +68,14 @@ const confirmPasswordField = () => screen.getByLabelText('Confirm password'); const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); -function verification(status: 'ready' | 'retrying', onCancel?: () => void): ReverificationController { - const view = { - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: status === 'retrying', - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - onResend: vi.fn(), - canResend: true, - methods: [], - onSelectMethod: vi.fn(), - }; - return status === 'ready' ? { ...view, status, onCancel } : { ...view, status }; -} - describe('UserProfileEditPasswordDialog', () => { - it('allows Back while verification can be cancelled and disables it during the retry', async () => { - const onCancel = vi.fn(); + it('closes directly when Cancel is selected', async () => { const user = userEvent.setup(); - const { rerender, props } = renderView({ reverification: verification('ready', onCancel) }); - const back = screen.getByRole('button', { name: 'Back', exact: true }); + const { props } = renderView(); - expect(back).not.toBeDisabled(); - await user.click(back); - expect(onCancel).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); - rerender( - - - , - ); - expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); }); it('names the dialog for a change and masks every field', () => { @@ -178,7 +146,7 @@ describe('UserProfileEditPasswordDialog', () => { expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); }); - it('skips the current password when reverification stands in for it', async () => { + it('skips the current password when it is not required', async () => { renderView({ requiresCurrentPassword: false }); expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index ed3360c6b40..4a33f9a9965 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -101,6 +101,7 @@ function renderPassword() { async function editPassword() { const events = userEvent.setup(); await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('Current password'), 'old-secret'); await events.type(screen.getByLabelText('New password'), 'new-password-123'); await events.type(screen.getByLabelText('Confirm password'), 'new-password-123'); await events.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); @@ -108,43 +109,31 @@ async function editPassword() { } describe('UserProfilePasswordSection', () => { - it('keeps the editor pending until verification is ready and returns to the draft', async () => { - let finishVerification: (value: unknown) => void = () => {}; - session.startVerification.mockReturnValueOnce( + it('keeps the editor pending until the direct update finishes', async () => { + let finishUpdate: (value: unknown) => void = () => {}; + user.updatePassword.mockReturnValueOnce( new Promise(resolve => { - finishVerification = resolve; - }), - ); - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], + finishUpdate = resolve; }), ); renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await waitFor(() => expect(session.startVerification).toHaveBeenCalled()); + await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); expect(screen.getByLabelText('New password')).toBeVisible(); expect(screen.getByLabelText('New password')).toBeDisabled(); expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); expect(screen.queryByText('Verification required')).not.toBeInTheDocument(); + expect(session.startVerification).not.toHaveBeenCalled(); await act(() => { - finishVerification({ status: 'needs_first_factor', supportedFirstFactors: [{ strategy: 'password' }] }); + finishUpdate(user); }); - await waitFor(() => expect(screen.getByLabelText('Password')).toBeVisible()); - const back = screen.getByRole('button', { name: 'Back', exact: true }); - await events.click(back); - await waitFor(() => expect(screen.getByLabelText('New password')).toBeVisible()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - await waitFor(() => expect(screen.getByLabelText('New password')).toHaveFocus()); - await events.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - it('shows a failed retry instead of reporting a save when verification is required again', async () => { + it('shows a direct API error without verification or an automatic retry', async () => { user.updatePassword.mockRejectedValue( new ClerkAPIResponseError('Verify', { status: 403, @@ -154,14 +143,12 @@ describe('UserProfilePasswordSection', () => { renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await events.type(await screen.findByLabelText('Password'), 'current-password'); - await events.click(screen.getByRole('button', { name: 'Continue' })); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'Your password was not saved. Please try verifying again.', - ); + expect(await screen.findByRole('alert')).toHaveTextContent('Verify'); expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(user.updatePassword).toHaveBeenCalledTimes(2); + expect(user.updatePassword).toHaveBeenCalledOnce(); + expect(session.startVerification).not.toHaveBeenCalled(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); }); it('hides the section when instance passwords are disabled', () => { @@ -170,25 +157,17 @@ describe('UserProfilePasswordSection', () => { expect(screen.queryByRole('region', { name: 'Authentication' })).not.toBeInTheDocument(); }); - it('keeps the active verification mounted while session data briefly reloads', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], - }), - ); + it('keeps the password draft while session data briefly reloads', async () => { const { rerender } = renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - await screen.findByLabelText('Password'); + await editPassword(); isSessionLoaded = false; rerender(passwordTree()); - expect(screen.getByLabelText('Password')).toBeVisible(); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); isSessionLoaded = true; rerender(passwordTree()); - await events.click(screen.getByRole('button', { name: 'Back', exact: true })); - expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Current password')).toHaveValue('old-secret'); }); it('keeps an enterprise-managed password visible without offering a mutation', () => { @@ -206,6 +185,7 @@ describe('UserProfilePasswordSection', () => { renderPassword(); const events = userEvent.setup(); await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('Current password'), 'old-secret'); await events.type(screen.getByLabelText('New password'), 'short'); await waitFor(() => expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( @@ -226,7 +206,11 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); expect(screen.getByRole('button', { name: 'Save changes' })).not.toHaveAttribute('aria-disabled', 'true'); await events.click(screen.getByRole('button', { name: 'Save changes' })); - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ newPassword: 'short', signOutOfOtherSessions: true }); + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ + currentPassword: 'old-secret', + newPassword: 'short', + signOutOfOtherSessions: true, + }); }); it('shows password API errors at the visible field and preserves the draft', async () => { @@ -356,55 +340,43 @@ describe('UserProfilePasswordSection', () => { expect(user.updatePassword).toHaveBeenCalled(); }); - it('returns from verification to the same draft without showing an error', async () => { + it('keeps the draft and checkbox choice after a rejected update', async () => { user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], + new ClerkAPIResponseError('Update failed', { + status: 500, + data: [{ code: 'server_error', message: 'Update failed' }], }), ); renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await screen.findByLabelText('Password'); - await events.click(screen.getByRole('button', { name: 'Back', exact: true })); - expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + expect(await screen.findByRole('alert')).toHaveTextContent('Update failed'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); expect(screen.getByRole('checkbox')).not.toBeChecked(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(user.updatePassword).toHaveBeenCalledTimes(1); expect(screen.getByRole('button', { name: 'Save changes' })).not.toBeDisabled(); }); - it('verifies, retries the original values, and waits for the retry before closing', async () => { + it('waits for the direct update before closing', async () => { let finish: () => void = () => {}; - const retry = new Promise(resolve => { + const update = new Promise(resolve => { finish = resolve; }); - user.updatePassword - .mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], - }), - ) - .mockImplementationOnce(() => retry.then(() => user)); + user.updatePassword.mockImplementationOnce(() => update.then(() => user)); renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await events.type(await screen.findByLabelText('Password'), 'current-password'); - await events.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => expect(user.updatePassword).toHaveBeenCalledTimes(2)); - expect(user.updatePassword.mock.calls[1]).toEqual(user.updatePassword.mock.calls[0]); - expect(clerk.setActive).toHaveBeenCalledWith({ session: 'session_1' }); + await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(session.startVerification).not.toHaveBeenCalled(); await act(async () => { finish(); - await retry; + await update; }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); @@ -415,6 +387,7 @@ describe('UserProfilePasswordSection', () => { await events.click(screen.getByRole('button', { name: 'Save changes' })); expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ + currentPassword: 'old-secret', newPassword: 'new-password-123', signOutOfOtherSessions: false, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 4f312d423e9..3728c08549e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,10 +1,7 @@ -import { reverificationError } from '@clerk/shared/authorization-errors'; -import { ClerkRuntimeError } from '@clerk/shared/error'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; -import type { ReverificationController } from '../../reverification'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; @@ -161,11 +158,11 @@ describe('useUserProfileEditPasswordController', () => { }); }); - it('returns to editing without losing the draft when the flow cancels verification', async () => { + it('keeps the draft and shows a direct save error', async () => { const { result } = renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword: true, - onSubmit: () => Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })), + onSubmit: () => Promise.reject(new Error('Save failed')), }), ); open(result); @@ -176,7 +173,7 @@ describe('useUserProfileEditPasswordController', () => { await waitFor(() => expect(result.current.form.isSubmitting).toBe(false)); expect(result.current.isOpen).toBe(true); - expect(result.current.form.error).toBeUndefined(); + expect(result.current.form.error).toBe('Save failed'); expect(result.current.form.values).toEqual({ currentPassword: 'old-secret', newPassword: 'new-secret-123', @@ -236,7 +233,7 @@ describe('useUserProfileEditPasswordController', () => { await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('leaves the current password out when reverification stands in for it', async () => { + it('leaves the current password out when setting a first password', async () => { const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit, false); open(result); @@ -403,19 +400,18 @@ describe('useUserProfileEditPasswordController', () => { }); }); - it('keeps the draft and reports a failed retry when verification is required again', async () => { - const onSubmit = vi.fn(() => Promise.resolve(reverificationError())); + it('does not retry a rejected direct save', async () => { + const onSubmit = vi.fn(() => Promise.reject(new Error('Save failed'))); const { result } = renderController(onSubmit); open(result); fill(result); act(() => result.current.form.submit()); - await waitFor(() => - expect(result.current.form.error).toBe('Your password was not saved. Please try verifying again.'), - ); + await waitFor(() => expect(result.current.form.error).toBe('Save failed')); expect(result.current.isOpen).toBe(true); expect(result.current.form.values.newPassword).toBe('new-secret-123'); + expect(onSubmit).toHaveBeenCalledOnce(); }); it('formats a failed save before the form shows it', async () => { @@ -433,18 +429,19 @@ describe('useUserProfileEditPasswordController', () => { expect(formatError).toHaveBeenCalledWith(failure); }); - it('cancels an active verification instead of closing the editor', () => { - const onCancel = vi.fn(); - const reverification: ReverificationController = { status: 'unavailable', onCancel }; + it('closes after a failed direct save', async () => { const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), reverification }), + useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(new Error('Save failed')) }), ); open(result); + fill(result); + + act(() => result.current.form.submit()); + await waitFor(() => expect(result.current.form.error).toBe('Save failed')); act(() => result.current.onOpenChange(false)); - expect(onCancel).toHaveBeenCalledOnce(); - expect(result.current.isOpen).toBe(true); - expect(result.current.reverification).toBe(reverification); + expect(result.current.isOpen).toBe(false); + expect(result.current.form.values.newPassword).toBe(''); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index c88cbb3573a..0219b5014d1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,12 +1,9 @@ -import { isReverificationHint } from '@clerk/shared/authorization-errors'; -import { isReverificationCancelledError } from '@clerk/shared/error'; import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; -import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordValue, UserProfileEditPasswordValues, @@ -24,7 +21,6 @@ export interface UserProfileEditPasswordControllerOptions { onSubmit: (value: UserProfileEditPasswordValue) => Promise; validatePassword?: (password: string) => Promise; formatError?: (error: unknown) => unknown; - reverification?: ReverificationController; } export interface UserProfileEditPasswordController { @@ -32,7 +28,6 @@ export interface UserProfileEditPasswordController { onOpenChange: (open: boolean) => void; form: UseFormResult; passwordFeedback: FieldFeedback | undefined; - reverification?: ReverificationController; } export function useUserProfileEditPasswordController({ @@ -40,7 +35,6 @@ export function useUserProfileEditPasswordController({ onSubmit, validatePassword, formatError = error => error, - reverification, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); @@ -62,19 +56,13 @@ export function useUserProfileEditPasswordController({ onSubmit: async values => { submitting.current = true; try { - const result = await onSubmit({ + await onSubmit({ currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); - if (isReverificationHint(result)) { - throw new Error(m.errors.verificationIncomplete); - } setIsOpen(false); } catch (error) { - if (isReverificationCancelledError(error)) { - return; - } throw formatError(error); } finally { submitting.current = false; @@ -107,10 +95,6 @@ export function useUserProfileEditPasswordController({ }, [isOpen, password, passwordLeft, validatePassword]); const onOpenChange = (open: boolean) => { - if (!open && reverification && reverification.status !== 'idle') { - reverification.onCancel?.(); - return; - } if (submitting.current || form.isSubmitting) { return; } @@ -118,5 +102,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback, reverification }; + return { isOpen, onOpenChange, form, passwordFeedback }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 3f077d97cb0..538eec96d74 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,7 +1,7 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { RefObject } from 'react'; -import { useEffect, useId, useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -9,14 +9,11 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import { Flow } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; import { useMessages } from '../../../localization'; -import type { ReverificationController } from '../../reverification'; -import { Reverification } from '../../reverification'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordField, @@ -24,7 +21,6 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { - reverification?: ReverificationController; passwordFeedback?: FieldFeedback; identifier?: string; open: boolean; @@ -36,7 +32,6 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ - reverification, passwordFeedback, identifier = '', open, @@ -51,21 +46,6 @@ export function UserProfileEditPasswordDialog({ const signOutDescriptionId = useId(); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; - const restoreEditorFocus = useRef(false); - const verification = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' - ? reverification - : undefined; - const verifying = verification !== undefined; - - useEffect(() => { - if (verifying) { - restoreEditorFocus.current = true; - } else if (restoreEditorFocus.current && !form.isSubmitting) { - restoreEditorFocus.current = false; - initialFocusRef.current?.focus({ preventScroll: true }); - } - }, [verifying, form.isSubmitting]); return ( - + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } > - {() => ( - <> - - <> - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - } - > - - {form.error ? ( - - {form.error} - - ) : null} - {showCurrentPassword ? ( - - ) : null} - - -
- form.setValue('signOutOfOtherSessions', event.target.checked)} - /> -
- } - size='sm' - xstyle={styles.checkboxLabel} - > - {m.signOutOfOtherSessionsLabel} - - - {m.signOutOfOtherSessionsDescription} - -
-
-
- - - {m.cancel} - - } - /> - - {m.save} - - - -
- - {verification ? ( - <> - - - - - - ) : null} - - - )} -
+ + {form.error ? ( + + {form.error} + + ) : null} + {showCurrentPassword ? ( + + ) : null} + + +
+ form.setValue('signOutOfOtherSessions', event.target.checked)} + /> +
+ } + size='sm' + xstyle={styles.checkboxLabel} + > + {m.signOutOfOtherSessionsLabel} + + + {m.signOutOfOtherSessionsDescription} + +
+
+ + + + {m.cancel} + + } + /> + + {m.save} + +
diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index b18d07c4bd5..85fd184af7b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -20,7 +20,6 @@ export const userProfilePasswordSectionMessages = { signOutOfOtherSessionsDescription: 'It is recommended to sign out of all other devices which may have used your old password.', cancel: 'Cancel', - back: 'Back', save: 'Save changes', rules: { weak: 'Your password is not strong enough.', @@ -63,7 +62,6 @@ export const userProfilePasswordSectionMessages = { }, errors: { - verificationIncomplete: 'Your password was not saved. Please try verifying again.', unavailable: 'Password update is no longer available.', currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index b87d80e54c0..30ed4e570e2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -93,57 +93,48 @@ describe('useUserProfilePasswordModel', () => { expect(user.updatePassword).not.toHaveBeenCalled(); }); - it.each([ - 'signed out', - 'different user', - 'different session', - 'no session', - 'disabled', - 'enterprise', - 'mode', - 'proof', - ])('rejects a captured action after the context changes: %s', async change => { - if (!user || !session || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = true; - const updatePassword = user.updatePassword; - const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); - const action = ready(result.current).updatePassword; + it.each(['signed out', 'different user', 'different session', 'no session', 'disabled', 'enterprise', 'mode'])( + 'rejects a captured action after the context changes: %s', + async change => { + if (!user || !session || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = true; + const updatePassword = user.updatePassword; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const action = ready(result.current).updatePassword; - switch (change) { - case 'signed out': - user = null; - break; - case 'different user': - user = { ...user, id: 'user_2' }; - break; - case 'different session': - session = { ...session, id: 'session_2' }; - break; - case 'no session': - session = null; - break; - case 'disabled': - environment.userSettings.instanceIsPasswordBased = false; - break; - case 'enterprise': - user.enterpriseAccounts = [{ active: true }]; - break; - case 'mode': - user.passwordEnabled = false; - break; - case 'proof': - environment.authConfig.reverification = true; - break; - } + switch (change) { + case 'signed out': + user = null; + break; + case 'different user': + user = { ...user, id: 'user_2' }; + break; + case 'different session': + session = { ...session, id: 'session_2' }; + break; + case 'no session': + session = null; + break; + case 'disabled': + environment.userSettings.instanceIsPasswordBased = false; + break; + case 'enterprise': + user.enterpriseAccounts = [{ active: true }]; + break; + case 'mode': + user.passwordEnabled = false; + break; + } - const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; - await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); - rerender(); - await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); - expect(updatePassword).not.toHaveBeenCalled(); - }); + const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); + rerender(); + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); + expect(updatePassword).not.toHaveBeenCalled(); + }, + ); it('rejects updates when a loaded user has no active session', async () => { if (!user) { @@ -158,11 +149,11 @@ describe('useUserProfilePasswordModel', () => { }); it.each([ - { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true, currentPassword: undefined }, - { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false, currentPassword: undefined }, - { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false, currentPassword: ' old secret ' }, - { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true, currentPassword: undefined }, - ])('sends the legacy payload for %j', async policy => { + { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true }, + { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false }, + { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false }, + { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true }, + ])('sends the direct password payload for %j', async policy => { if (!user || !environment) { throw new Error('expected loaded fixtures'); } @@ -183,7 +174,7 @@ describe('useUserProfilePasswordModel', () => { signOutOfOtherSessions: policy.signOutOfOtherSessions, }; expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith( - policy.currentPassword === undefined ? expected : { ...expected, currentPassword: ' old secret ' }, + policy.passwordEnabled ? { ...expected, currentPassword: ' old secret ' } : expected, ); }); @@ -224,8 +215,8 @@ describe('useUserProfilePasswordModel', () => { { passwordEnabled: false, reverification: false, mode: 'set', requiresCurrentPassword: false }, { passwordEnabled: false, reverification: true, mode: 'set', requiresCurrentPassword: false }, { passwordEnabled: true, reverification: false, mode: 'change', requiresCurrentPassword: true }, - { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: false }, - ])('derives $mode mode with reverification=$reverification', policy => { + { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: true }, + ])('derives $mode mode independently of reverification=$reverification', policy => { if (!user || !environment) { throw new Error('expected loaded fixtures'); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index e92874edeea..eb386f7de62 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -43,7 +43,7 @@ function getPasswordPolicy( } const policy: EditablePasswordPolicy = user.passwordEnabled - ? { mode: 'change', requiresCurrentPassword: !environment.authConfig.reverification } + ? { mode: 'change', requiresCurrentPassword: true } : { mode: 'set', requiresCurrentPassword: false }; if (user.enterpriseAccounts.some(account => account.active)) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 579c1a8c633..747486d5a02 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -5,7 +5,6 @@ import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; import { useLocale, useMessages } from '../../../localization'; -import { useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import { @@ -44,17 +43,16 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw return null; } return ( - ); } -function PasswordFlow({ model }: { model: Extract }) { +function PasswordEditor({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const locale = useLocale(); - const [updatePassword, reverification] = useReverificationFlow(model.updatePassword); const { validatePassword, passwordSettings } = model; const feedback = useCallback( async (password: string): Promise => { @@ -79,9 +77,8 @@ function PasswordFlow({ model }: { model: Extract passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale), - reverification, }); return ( @@ -96,7 +93,6 @@ function PasswordFlow({ model }: { model: Extract Date: Tue, 29 Sep 2026 11:02:04 -0600 Subject: [PATCH 19/91] test(mosaic): cover password editing with real Clerk --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 35 +- packages/mosaic/src/__tests__/feature/fapi.ts | 39 ++ ...user-profile-edit-password.dialog.test.tsx | 242 ----------- .../user-profile-password-loading.test.tsx | 73 ++++ ...file-password-section.integration.test.tsx | 396 ----------------- .../user-profile-password.feature.test.tsx | 261 ++++++++++++ ...r-profile-edit-password.controller.test.ts | 403 +----------------- ...ser-profile-password-section.model.test.ts | 184 +------- 8 files changed, 426 insertions(+), 1207 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index f622ef0d6a3..322debec7f2 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -29,6 +29,7 @@ export interface FakeFapiState { memberships: OrganizationMembershipJSON[]; invitations: UserOrganizationInvitationJSON[]; suggestions: OrganizationSuggestionJSON[]; + passwordUpdates: URLSearchParams[]; } export type FakeFapiSeed = Partial; @@ -82,6 +83,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { memberships: [], invitations: [], suggestions: [], + passwordUpdates: [], ...seed, }; @@ -124,6 +126,19 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null }; return envelope({ ...session, status: 'removed' }, state.client); }), + http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => { + const session = findSession(state, state.client.last_active_session_id); + if (!session) { + return missing(); + } + state.passwordUpdates.push(new URLSearchParams(await request.text())); + const updatedUser = { ...session.user, password_enabled: true }; + state.client = { + ...state.client, + sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user: updatedUser } : item)), + }; + return envelope(updatedUser, state.client); + }), http.post(fapiUrl('/v1/client/sessions'), ({ request }) => { if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { return undefined; @@ -169,7 +184,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { export interface HeldRequests { requests: Request[]; release: () => void; - fail: (code?: string) => void; + fail: (code?: string, longMessage?: string, paramName?: string) => void; } interface Hold { @@ -211,7 +226,21 @@ export function holdRequests(method: 'get' | 'post', path: string): HeldRequests return { requests, release: () => settle(undefined), - fail: (code = 'form_param_invalid') => - settle(HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status: 400 })), + fail: (code = 'form_param_invalid', longMessage = code, paramName?: string) => + settle( + HttpResponse.json( + { + errors: [ + { + code, + message: code, + long_message: longMessage, + ...(paramName ? { meta: { param_name: paramName } } : {}), + }, + ], + }, + { status: 400 }, + ), + ), }; } diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 1473d6e67c6..568ecb4b0f5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -5,6 +5,7 @@ import type { ClientJSON, DisplayConfigJSON, EmailAddressJSON, + EnterpriseAccountJSON, EnvironmentJSON, OAuthProviders, OrganizationJSON, @@ -264,6 +265,44 @@ export function fapiUser(overrides: Partial & Pick): U }; } +export function fapiEnterpriseAccount( + overrides: Partial & Pick, +): EnterpriseAccountJSON { + return { + object: 'enterprise_account', + active: true, + email_address: 'sso@example.com', + enterprise_connection: { + object: 'enterprise_connection', + id: 'sso_1', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: true, + domain: 'example.com', + logo_public_url: null, + name: 'Company SSO', + protocol: 'saml', + provider: 'saml_okta', + sync_user_attributes: false, + allow_organization_account_linking: false, + created_at: createdAt, + updated_at: createdAt, + enterprise_connection_id: 'sso_1', + }, + first_name: null, + last_name: null, + protocol: 'saml', + provider: 'saml_okta', + provider_user_id: null, + public_metadata: {}, + verification: null, + last_authenticated_at: null, + enterprise_connection_id: 'sso_1', + ...overrides, + }; +} + export function fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx deleted file mode 100644 index 302407d1462..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import type { FormField, UseFormResult } from '../../../components/form'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; -import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; -import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; - -type Form = UseFormResult; - -const untouched: FormField = { feedback: undefined, isValidating: false, touched: false, isDirty: false }; - -function stubForm(overrides: Partial
= {}): Form { - const form: Form = { - id: 'edit-password', - values: { currentPassword: '', newPassword: '', confirmPassword: '', signOutOfOtherSessions: true }, - fields: { - currentPassword: untouched, - newPassword: untouched, - confirmPassword: untouched, - signOutOfOtherSessions: untouched, - }, - error: undefined, - isSubmitting: false, - isDirty: false, - canSubmit: true, - register: name => ({ - name, - value: form.values[name], - onChange: event => form.setValue(name, event.target.value), - onBlur: () => form.touch(name), - ref: () => undefined, - }), - setValue: vi.fn(), - touch: vi.fn(), - submit: vi.fn(), - handleSubmit: vi.fn((event: { preventDefault: () => void }) => event.preventDefault()), - reset: vi.fn(), - ...overrides, - }; - return form; -} - -function renderView(overrides: Partial = {}, form: Partial = {}) { - const props: UserProfileEditPasswordDialogProps = { - open: true, - onOpenChange: vi.fn(), - hasPassword: true, - requiresCurrentPassword: true, - form: stubForm(form), - ...overrides, - }; - return { - props, - ...render( - - - , - ), - }; -} - -const currentPasswordField = () => screen.getByLabelText('Current password'); -const newPasswordField = () => screen.getByLabelText('New password'); -const confirmPasswordField = () => screen.getByLabelText('Confirm password'); -const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); -const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); - -describe('UserProfileEditPasswordDialog', () => { - it('closes directly when Cancel is selected', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - }); - - it('names the dialog for a change and masks every field', () => { - renderView( - {}, - { values: { currentPassword: 'old', newPassword: 'new', confirmPassword: 'new', signOutOfOtherSessions: true } }, - ); - - expect(screen.getByRole('dialog', { name: 'Change password' })).toBeInTheDocument(); - expect(currentPasswordField()).toHaveAttribute('type', 'password'); - expect(currentPasswordField()).toHaveValue('old'); - expect(newPasswordField()).toHaveAttribute('type', 'password'); - expect(newPasswordField()).toHaveAttribute('autocomplete', 'new-password'); - expect(confirmPasswordField()).toHaveAttribute('type', 'password'); - expect(signOutCheckbox()).toBeChecked(); - expect(signOutCheckbox()).toHaveAccessibleDescription( - 'It is recommended to sign out of all other devices which may have used your old password.', - ); - }); - - it('writes typing and leaving a field back to the form by name', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - await user.type(newPasswordField(), 'a'); - await user.tab(); - await user.click(signOutCheckbox()); - - expect(props.form.setValue).toHaveBeenCalledWith('newPassword', 'a'); - expect(props.form.touch).toHaveBeenCalledWith('newPassword'); - expect(props.form.setValue).toHaveBeenCalledWith('signOutOfOtherSessions', false); - }); - - it('reveals a password from its own eye toggle and hides it again', async () => { - const user = userEvent.setup(); - renderView( - {}, - { - values: { - currentPassword: '', - newPassword: 'new-secret-123', - confirmPassword: '', - signOutOfOtherSessions: true, - }, - }, - ); - const [, newPasswordToggle] = screen.getAllByRole('button', { name: 'Show password' }); - if (!newPasswordToggle) { - throw new Error('New password visibility toggle is missing'); - } - - await user.click(newPasswordToggle); - - expect(newPasswordField()).toHaveAttribute('type', 'text'); - expect(newPasswordField()).toHaveValue('new-secret-123'); - expect(currentPasswordField()).toHaveAttribute('type', 'password'); - expect(confirmPasswordField()).toHaveAttribute('type', 'password'); - - await user.click(screen.getByRole('button', { name: 'Hide password' })); - - expect(newPasswordField()).toHaveAttribute('type', 'password'); - }); - - it('names the dialog for a first password and skips the current one', () => { - renderView({ hasPassword: false }); - - expect(screen.getByRole('dialog', { name: 'Set password' })).toBeInTheDocument(); - expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); - }); - - it('skips the current password when it is not required', async () => { - renderView({ requiresCurrentPassword: false }); - - expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); - await waitFor(() => expect(newPasswordField()).toHaveFocus()); - }); - - it('opens on the current password rather than the corner dismiss', async () => { - renderView(); - - await waitFor(() => expect(currentPasswordField()).toHaveFocus()); - }); - - it('announces the failure in a negative banner', () => { - renderView({}, { error: 'Your password could not be updated.' }); - - const banner = screen.getByRole('alert'); - expect(banner).toHaveTextContent('Your password could not be updated.'); - expect(newPasswordField()).not.toHaveAttribute('aria-invalid', 'true'); - }); - - it('renders field errors under their controls with no banner', () => { - const errored = (message: string): FormField => ({ - feedback: { type: 'error', message }, - isValidating: false, - touched: true, - }); - renderView( - {}, - { - fields: { - currentPassword: errored('Incorrect password.'), - newPassword: errored('Your password must contain 8 or more characters.'), - confirmPassword: errored("Passwords don't match."), - signOutOfOtherSessions: untouched, - }, - }, - ); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(currentPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(currentPasswordField()).toHaveAccessibleDescription('Incorrect password.'); - expect(newPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(newPasswordField()).toHaveAccessibleDescription('Your password must contain 8 or more characters.'); - expect(confirmPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(confirmPasswordField()).toHaveAccessibleDescription("Passwords don't match."); - }); - - it('withholds the save while the form says it cannot submit', async () => { - const user = userEvent.setup(); - const { props } = renderView({}, { canSubmit: false }); - - expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); - await user.click(saveButton()); - - expect(props.form.handleSubmit).not.toHaveBeenCalled(); - }); - - it('submits the form from the save button once every required field is filled', async () => { - const user = userEvent.setup(); - const { props } = renderView( - {}, - { - values: { - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - confirmPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }, - }, - ); - - await user.click(saveButton()); - - expect(props.form.handleSubmit).toHaveBeenCalledTimes(1); - }); - - it('stays inert while the save runs', async () => { - const user = userEvent.setup(); - const { props } = renderView({}, { isSubmitting: true }); - - await user.type(newPasswordField(), 'abc'); - - expect(currentPasswordField()).toBeDisabled(); - expect(newPasswordField()).toBeDisabled(); - expect(confirmPasswordField()).toBeDisabled(); - expect(signOutCheckbox()).toBeDisabled(); - screen.getAllByRole('button', { name: 'Show password' }).forEach(toggle => expect(toggle).toBeDisabled()); - expect(props.form.setValue).not.toHaveBeenCalled(); - expect(saveButton()).toHaveAttribute('aria-busy', 'true'); - await user.click(saveButton()); - expect(props.form.handleSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx new file mode 100644 index 00000000000..b8759f6b0ad --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx @@ -0,0 +1,73 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { ClerkInstanceContext } from '@clerk/shared/react'; +import type { LoadedClerk } from '@clerk/shared/types'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; + +const user = { + id: 'user_1', + passwordEnabled: true, + enterpriseAccounts: [], + updatePassword: vi.fn(), +}; +const session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; +const clerk = { + user, + session, + __internal_environment: { + userSettings: { + instanceIsPasswordBased: true, + passwordSettings: { + min_length: 8, + max_length: 72, + show_zxcvbn: false, + min_zxcvbn_strength: 3, + require_uppercase: false, + require_numbers: false, + }, + }, + displayConfig: { preferredSignInStrategy: 'password' }, + }, + __internal_moduleManager: {}, +}; +let isSessionLoaded = true; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => clerk, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + }; +}); + +function tree() { + return ( + + + + + + ); +} + +it('keeps the draft while session data briefly reloads', async () => { + isSessionLoaded = true; + const { rerender } = render(tree()); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'new-password-123'); + + isSessionLoaded = false; + rerender(tree()); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + + isSessionLoaded = true; + rerender(tree()); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx deleted file mode 100644 index 4a33f9a9965..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type * as SharedReact from '@clerk/shared/react'; -import { ClerkInstanceContext } from '@clerk/shared/react'; -import type { LoadedClerk } from '@clerk/shared/types'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; -import type { UserProfileEditPasswordValue } from '../user-profile-password-section/user-profile-password-section.types'; - -const updatePassword = vi.fn<(input: UserProfileEditPasswordValue) => Promise>(); -const user: { - id: string; - passwordEnabled: boolean; - enterpriseAccounts: { active: boolean }[]; - updatePassword: typeof updatePassword; -} = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword }; -const session = { - id: 'session_1', - publicUserData: { identifier: 'person@example.com' }, - startVerification: vi.fn(), - attemptFirstFactorVerification: vi.fn(), -}; -const environment = { - userSettings: { - instanceIsPasswordBased: true, - passwordSettings: { - min_length: 8, - max_length: 72, - show_zxcvbn: false, - min_zxcvbn_strength: 3, - require_uppercase: false, - require_numbers: false, - }, - }, - authConfig: { reverification: true }, - displayConfig: { preferredSignInStrategy: 'password', supportEmail: 'support@example.com' }, -}; -const clerk = { - user, - session, - __internal_environment: environment, - __internal_moduleManager: {}, - __internal_getOption: () => undefined, - setActive: vi.fn(), -}; -let isSessionLoaded = true; - -vi.mock('@clerk/shared/internal/clerk-js/passwords/loadZxcvbn', () => ({ - createLoadZxcvbn: () => ({ - loadZxcvbn: () => Promise.resolve(() => ({ score: 0, feedback: { suggestions: ['anotherWord'] } })), - }), -})); - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useClerk: () => clerk, - useUser: () => ({ isLoaded: true, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - }; -}); - -beforeEach(() => { - vi.clearAllMocks(); - isSessionLoaded = true; - user.updatePassword.mockReset(); - user.passwordEnabled = true; - user.enterpriseAccounts = []; - environment.authConfig.reverification = true; - environment.userSettings.passwordSettings.show_zxcvbn = false; - environment.userSettings.passwordSettings.require_uppercase = false; - environment.userSettings.passwordSettings.require_numbers = false; - environment.userSettings.instanceIsPasswordBased = true; - user.updatePassword.mockResolvedValue(user); - clerk.setActive.mockResolvedValue(undefined); - session.startVerification.mockResolvedValue({ - status: 'needs_first_factor', - supportedFirstFactors: [{ strategy: 'password' }], - }); - session.attemptFirstFactorVerification.mockResolvedValue({ status: 'complete' }); -}); - -function passwordTree() { - return ( - - - - - - ); -} - -function renderPassword() { - return render(passwordTree()); -} - -async function editPassword() { - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('Current password'), 'old-secret'); - await events.type(screen.getByLabelText('New password'), 'new-password-123'); - await events.type(screen.getByLabelText('Confirm password'), 'new-password-123'); - await events.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); - return events; -} - -describe('UserProfilePasswordSection', () => { - it('keeps the editor pending until the direct update finishes', async () => { - let finishUpdate: (value: unknown) => void = () => {}; - user.updatePassword.mockReturnValueOnce( - new Promise(resolve => { - finishUpdate = resolve; - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); - - expect(screen.getByLabelText('New password')).toBeVisible(); - expect(screen.getByLabelText('New password')).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); - expect(screen.queryByText('Verification required')).not.toBeInTheDocument(); - expect(session.startVerification).not.toHaveBeenCalled(); - - await act(() => { - finishUpdate(user); - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('shows a direct API error without verification or an automatic retry', async () => { - user.updatePassword.mockRejectedValue( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Verify'); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(user.updatePassword).toHaveBeenCalledOnce(); - expect(session.startVerification).not.toHaveBeenCalled(); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - }); - - it('hides the section when instance passwords are disabled', () => { - environment.userSettings.instanceIsPasswordBased = false; - renderPassword(); - expect(screen.queryByRole('region', { name: 'Authentication' })).not.toBeInTheDocument(); - }); - - it('keeps the password draft while session data briefly reloads', async () => { - const { rerender } = renderPassword(); - await editPassword(); - - isSessionLoaded = false; - rerender(passwordTree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - isSessionLoaded = true; - rerender(passwordTree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(screen.getByLabelText('Current password')).toHaveValue('old-secret'); - }); - - it('keeps an enterprise-managed password visible without offering a mutation', () => { - user.enterpriseAccounts = [{ active: true }]; - renderPassword(); - expect( - screen.getByText( - 'Your password can currently not be edited because you can sign in only via the enterprise connection.', - ), - ).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); - }); - - it('shows the configured password rule without making it a new submit restriction', async () => { - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('Current password'), 'old-secret'); - await events.type(screen.getByLabelText('New password'), 'short'); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); - expect( - screen - .getAllByRole('status') - .some(status => status.textContent?.includes('Your password must contain 8 or more characters.')), - ).toBe(true); - await events.type(screen.getByLabelText('Confirm password'), 'short'); - - expect(await screen.findByText('Your password must contain 8 or more characters.')).toBeVisible(); - expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); - await events.click(screen.getByLabelText('New password')); - expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); - expect(screen.getByRole('button', { name: 'Save changes' })).not.toHaveAttribute('aria-disabled', 'true'); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ - currentPassword: 'old-secret', - newPassword: 'short', - signOutOfOtherSessions: true, - }); - }); - - it('shows password API errors at the visible field and preserves the draft', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Invalid', { - status: 422, - data: [ - { - code: 'form_password_pwned', - message: 'Choose a different password.', - meta: { param_name: 'new_password' }, - }, - ], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'This password has been found as part of a breach and can not be used, please try another password instead.', - ), - ); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); - }); - - it('prioritizes the backend minimum-length error over an earlier complexity error', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Invalid', { - status: 422, - data: [ - { code: 'form_password_no_uppercase', message: 'Raw uppercase', meta: { param_name: 'new_password' } }, - { code: 'form_password_length_too_short', message: 'Raw minimum', meta: { param_name: 'new_password' } }, - ], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); - }); - - it('uses legacy wording and list formatting for live complexity feedback', async () => { - environment.userSettings.passwordSettings.require_uppercase = true; - environment.userSettings.passwordSettings.require_numbers = true; - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('New password'), 'longpassword'); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain a number and an uppercase letter.', - ), - ); - }); - - it('shows the minimum length as an error after the new password is left empty', async () => { - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.click(screen.getByLabelText('New password')); - expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ); - - await events.click(screen.getByLabelText('Confirm password')); - - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); - }); - - it('keeps the mismatch visible after the confirmation is cleared', async () => { - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('New password'), 'new-password-123'); - await events.type(screen.getByLabelText('Confirm password'), 'new-password-12'); - await events.click(screen.getByLabelText('New password')); - await waitFor(() => - expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), - ); - - await events.clear(screen.getByLabelText('Confirm password')); - - expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); - expect(screen.getByLabelText('Confirm password')).toHaveAttribute('aria-invalid', 'true'); - }); - - it('confirms that requirements are met when strength checking is disabled', async () => { - renderPassword(); - const events = await editPassword(); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password meets all the necessary requirements.', - ), - ); - await events.clear(screen.getByLabelText('New password')); - await events.type(screen.getByLabelText('New password'), 'short'); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - }); - - it('shows specific client strength suggestions without blocking submission', async () => { - environment.userSettings.passwordSettings.show_zxcvbn = true; - renderPassword(); - const events = await editPassword(); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password is not strong enough. Add more words that are less common.', - ), - ); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - expect(user.updatePassword).toHaveBeenCalled(); - }); - - it('keeps the draft and checkbox choice after a rejected update', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Update failed', { - status: 500, - data: [{ code: 'server_error', message: 'Update failed' }], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Update failed'); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); - expect(screen.getByRole('checkbox')).not.toBeChecked(); - expect(user.updatePassword).toHaveBeenCalledTimes(1); - expect(screen.getByRole('button', { name: 'Save changes' })).not.toBeDisabled(); - }); - - it('waits for the direct update before closing', async () => { - let finish: () => void = () => {}; - const update = new Promise(resolve => { - finish = resolve; - }); - user.updatePassword.mockImplementationOnce(() => update.then(() => user)); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); - expect(session.startVerification).not.toHaveBeenCalled(); - - await act(async () => { - finish(); - await update; - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('saves through the model and closes after the request succeeds', async () => { - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ - currentPassword: 'old-secret', - newPassword: 'new-password-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx new file mode 100644 index 00000000000..8b56d255d42 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -0,0 +1,261 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; + +const email = fapiEmailAddress({ id: 'idn_1', email_address: 'person@example.com' }); +const alice = fapiUser({ id: 'user_1', email_addresses: [email] }); + +async function renderPassword(user = alice, environment = fapiEnvironment()) { + const fapi = serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + return fapi; +} + +async function fillPassword() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + return user; +} + +describe('Changing a password', () => { + it('shows no password action when nobody is signed in', async () => { + serveFapi({ client: fapiClient() }); + await renderWithClerk(); + + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + expect(screen.queryByText('Password')).toBeNull(); + }); + + it('sends the update to Clerk and closes after it succeeds', async () => { + const fapi = await renderPassword(); + const user = await fillPassword(); + await user.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates[0]?.get('current_password')).toBe('old-secret'); + expect(fapi.passwordUpdates[0]?.get('new_password')).toBe('new-password-123'); + expect(fapi.passwordUpdates[0]?.get('sign_out_of_other_sessions')).toBe('false'); + }); + + it('stays busy until the update finishes and prevents duplicate saves', async () => { + await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + expect(screen.getByLabelText('New password')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + await user.keyboard('{Enter}'); + expect(update.requests).toHaveLength(1); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + update.release(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('shows a direct API error and keeps the draft without retrying automatically', async () => { + const fapi = await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('session_reverification_required'); + + expect(await screen.findByRole('alert')).toHaveTextContent('session_reverification_required'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.queryByText('Verification required')).toBeNull(); + expect(update.requests).toHaveLength(1); + serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates).toHaveLength(1); + }); + + it('shows a password error at the field and keeps the draft', async () => { + await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('form_password_pwned', undefined, 'new_password'); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ), + ); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('sets a first password without asking for the current one', async () => { + const fapi = await renderPassword(fapiUser({ ...alice, password_enabled: false })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Set password' })); + expect(screen.queryByLabelText('Current password')).toBeNull(); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates[0]?.has('current_password')).toBe(false); + await waitFor(() => expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument()); + }); + + it('hides the section when instance passwords are disabled', async () => { + const environment = fapiEnvironment(); + environment.user_settings.attributes.password.enabled = false; + await renderPassword(alice, environment); + + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + }); + + it('keeps an enterprise-managed password read-only', async () => { + await renderPassword(fapiUser({ ...alice, enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1' })] })); + + expect( + screen.getByText( + 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + ), + ).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + }); + + it('focuses the current password and clears the draft after cancellation', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + await user.type(screen.getByLabelText('New password'), 'draft-secret'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + expect(screen.getByLabelText('New password')).toHaveValue(''); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + }); + + it('keeps a confirmation mismatch visible after its field is cleared', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-12'); + await user.click(screen.getByLabelText('New password')); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); + await user.clear(screen.getByLabelText('Confirm password')); + + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('reveals and hides the current password on request', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + const current = screen.getByLabelText('Current password'); + expect(current).toHaveAttribute('type', 'password'); + + await user.click(screen.getAllByRole('button', { name: 'Show password' })[0]); + expect(current).toHaveAttribute('type', 'text'); + await user.click(screen.getByRole('button', { name: 'Hide password' })); + expect(current).toHaveAttribute('type', 'password'); + }); + + it('shows the password rule and leaves matching passwords eligible for a server check', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.min_length = 8; + const fapi = await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + await user.type(screen.getByLabelText('Confirm password'), 'short'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); + expect(fapi.passwordUpdates[0]?.get('new_password')).toBe('short'); + }); + + it('shows the minimum length as an error after an empty new password is left', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ); + + await user.click(screen.getByLabelText('Confirm password')); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('lists the configured complexity requirements beside the new password', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.require_uppercase = true; + environment.user_settings.password_settings.require_numbers = true; + await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('New password'), 'longpassword'); + + await waitFor( + () => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain a number and an uppercase letter.', + ), + { timeout: 2500 }, + ); + }); + + it('advises a weak password without blocking a server check', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.show_zxcvbn = true; + environment.user_settings.password_settings.min_zxcvbn_strength = 3; + const fapi = await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'password123'); + await user.type(screen.getByLabelText('Confirm password'), 'password123'); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription(/Your password is not strong enough/), + ); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 3728c08549e..fe120b52e31 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -2,9 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; -import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; function deferred() { let resolve: (result: T) => void = () => {}; @@ -14,40 +12,18 @@ function deferred() { return { promise, resolve }; } -function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), - requiresCurrentPassword = true, -) { - return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); -} - -type Controller = ReturnType['result']; - -function open(result: Controller) { - act(() => result.current.onOpenChange(true)); -} - -function fill(result: Controller, { current = 'old-secret', next = 'new-secret-123' } = {}) { - act(() => result.current.form.setValue('currentPassword', current)); - act(() => result.current.form.setValue('newPassword', next)); - act(() => result.current.form.setValue('confirmPassword', next)); -} - -describe('useUserProfileEditPasswordController', () => { +describe('useUserProfileEditPasswordController timing', () => { it('ignores older password feedback and clears it when the editor closes', async () => { const older = deferred(); const newer = deferred(); const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); const { result } = renderHook(() => - useUserProfileEditPasswordController({ - onSubmit: () => Promise.resolve(), - validatePassword, - }), + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), ); - open(result); - fill(result, { next: 'first password' }); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('first password')); - fill(result, { next: 'second password' }); + act(() => result.current.form.setValue('newPassword', 'second password')); await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('second password')); await act(async () => { @@ -64,384 +40,29 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.passwordFeedback).toBeUndefined(); }); - it('checks an empty new password only once it has been left', async () => { - const validatePassword = vi.fn(() => - Promise.resolve({ type: 'info', message: 'Your password must contain 8 or more characters.' }), - ); - const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), - ); - open(result); - await act(() => Promise.resolve()); - expect(validatePassword).not.toHaveBeenCalled(); - - act(() => result.current.form.touch('newPassword')); - - await waitFor(() => - expect(result.current.passwordFeedback).toEqual({ - type: 'info', - message: 'Your password must contain 8 or more characters.', - }), - ); - expect(validatePassword).toHaveBeenCalledWith(''); - }); - - it('can submit while an advisory password check is pending', async () => { - const check = deferred(); - const validatePassword = vi.fn(() => check.promise); - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit, validatePassword })); - open(result); - fill(result); - await waitFor(() => expect(validatePassword).toHaveBeenCalled()); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); - await act(async () => { - check.resolve({ type: 'error', message: 'Too short.' }); - await check.promise; - }); - expect(result.current.passwordFeedback).toBeUndefined(); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - it('ignores dismissal and duplicate saves in the same event as submission', async () => { const save = deferred(); const onSubmit = vi.fn(() => save.promise); - const { result } = renderController(onSubmit); - open(result); - fill(result); - - act(() => { - result.current.form.submit(); - result.current.onOpenChange(false); - result.current.form.submit(); - }); - - expect(result.current.isOpen).toBe(true); - expect(onSubmit).toHaveBeenCalledTimes(1); - await act(async () => { - save.resolve(undefined); - await save.promise; - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('shows password-rule feedback without blocking an otherwise valid submission', async () => { - const validatePassword = vi.fn(() => - Promise.resolve({ - type: 'warning', - message: 'Try a longer password.', - }), - ); - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => - useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit, validatePassword }), - ); - open(result); - fill(result, { next: ' new secret ' }); - - await waitFor(() => - expect(result.current.passwordFeedback).toEqual({ type: 'warning', message: 'Try a longer password.' }), - ); - expect(validatePassword).toHaveBeenCalledWith(' new secret '); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: ' new secret ', - signOutOfOtherSessions: true, - }); - }); - - it('keeps the draft and shows a direct save error', async () => { const { result } = renderHook(() => - useUserProfileEditPasswordController({ - requiresCurrentPassword: true, - onSubmit: () => Promise.reject(new Error('Save failed')), - }), + useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit }), ); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.isSubmitting).toBe(false)); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.error).toBe('Save failed'); - expect(result.current.form.values).toEqual({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - confirmPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - expect(result.current.form.canSubmit).toBe(true); - }); - - it('blocks direct submission with an empty confirmation', () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit); - open(result); - fill(result); - act(() => result.current.form.setValue('confirmPassword', '')); - - act(() => result.current.form.submit()); - - expect(result.current.form.canSubmit).toBe(false); - expect(onSubmit).not.toHaveBeenCalled(); - expect(result.current.isOpen).toBe(true); - }); - - it('opens with empty fields and sign-out of other devices on', () => { - const { result } = renderController(); - expect(result.current.isOpen).toBe(false); - - open(result); - - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values).toEqual({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - signOutOfOtherSessions: true, - }); - expect(result.current.form.error).toBeUndefined(); - expect(result.current.form.isSubmitting).toBe(false); - }); - - it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - - act(() => result.current.form.submit()); - - expect(result.current.isOpen).toBe(true); - expect(result.current.form.isSubmitting).toBe(true); - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('leaves the current password out when setting a first password', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit, false); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.submit()); - - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: undefined, - newPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('withholds the save until the halves match and the current password is in', () => { - const { result } = renderController(); - open(result); - expect(result.current.form.canSubmit).toBe(false); - - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.canSubmit).toBe(false); - + act(() => result.current.onOpenChange(true)); act(() => result.current.form.setValue('currentPassword', 'old-secret')); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-124')); - expect(result.current.form.canSubmit).toBe(false); - }); - - it('names the mismatch under the confirmation once it has been left', () => { - const { result } = renderController(); - open(result); act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - - act(() => result.current.form.touch('confirmPassword')); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - }); - - it('keeps the mismatch after the confirmation is cleared', () => { - const { result } = renderController(); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - act(() => result.current.form.touch('confirmPassword')); - - act(() => result.current.form.setValue('confirmPassword', '')); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - }); - - it('leaves an untouched empty confirmation quiet', () => { - const { result } = renderController(); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - }); - - it('forgets what was typed when the dialog is cancelled', () => { - const { result } = renderController(); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - act(() => result.current.form.touch('confirmPassword')); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - open(result); - expect(result.current.form.values).toEqual({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - signOutOfOtherSessions: true, + act(() => { + result.current.form.submit(); + result.current.onOpenChange(false); + result.current.form.submit(); }); - expect(result.current.form.fields.confirmPassword.touched).toBe(false); - }); - - it('starts fresh when opened again after a save', async () => { - const { result } = renderController(); - open(result); - fill(result); - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - - open(result); - - expect(result.current.form.values.newPassword).toBe(''); - }); - - it('stays open while the save runs', async () => { - const save = deferred(); - const { result } = renderController(() => save.promise); - open(result); - fill(result); - act(() => result.current.form.submit()); - - act(() => result.current.onOpenChange(false)); expect(result.current.isOpen).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); await act(async () => { save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - - it('keeps what was typed when the save fails, and carries field copy when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your password could not be updated.', { - newPassword: 'Your password must contain 8 or more characters.', - }); - const { result } = renderController(() => Promise.reject(failure)); - open(result); - fill(result); - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Your password could not be updated.')); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values.newPassword).toBe('new-secret-123'); - expect(result.current.form.fields.newPassword.feedback).toEqual({ - type: 'error', - message: 'Your password must contain 8 or more characters.', - }); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const { result } = renderController(() => Promise.reject('nope')); - open(result); - fill(result); - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Something went wrong. Please try again.')); - }); - - it('keeps a failed save visible next to a fresh mismatch', async () => { - const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); - open(result); - fill(result); - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.form.error).toBe('Incorrect password.')); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - - expect(result.current.form.error).toBe('Incorrect password.'); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - }); - - it('does not retry a rejected direct save', async () => { - const onSubmit = vi.fn(() => Promise.reject(new Error('Save failed'))); - const { result } = renderController(onSubmit); - open(result); - fill(result); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Save failed')); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values.newPassword).toBe('new-secret-123'); - expect(onSubmit).toHaveBeenCalledOnce(); - }); - - it('formats a failed save before the form shows it', async () => { - const failure = new Error('raw'); - const formatError = vi.fn(() => new UserProfileSaveError('Formatted.')); - const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(failure), formatError }), - ); - open(result); - fill(result); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Formatted.')); - expect(formatError).toHaveBeenCalledWith(failure); - }); - - it('closes after a failed direct save', async () => { - const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(new Error('Save failed')) }), - ); - open(result); - fill(result); - - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.form.error).toBe('Save failed')); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - expect(result.current.form.values.newPassword).toBe(''); - }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index 30ed4e570e2..c0dddc3f360 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -1,5 +1,4 @@ import type { PasswordSettingsData } from '@clerk/shared/types'; -import { createDeferredPromise } from '@clerk/shared/utils'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -28,17 +27,12 @@ const passwordSettings: PasswordSettingsData = { }; function createEnvironment() { - return { - userSettings: { instanceIsPasswordBased: true, passwordSettings }, - authConfig: { reverification: false }, - }; + return { userSettings: { instanceIsPasswordBased: true, passwordSettings } }; } -let isUserLoaded: boolean; -let isSessionLoaded: boolean; let user: TestUser | null; let session: TestSession | null; -let environment: ReturnType | undefined; +let environment: ReturnType; const clerk = { get user() { @@ -54,14 +48,12 @@ const clerk = { vi.mock('@clerk/shared/react', () => ({ useClerk: () => clerk, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: true, session }), })); beforeEach(() => { - isUserLoaded = true; - isSessionLoaded = true; - user = { id: 'user_1', passwordEnabled: false, enterpriseAccounts: [], updatePassword: vi.fn() }; + user = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword: vi.fn() }; session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; environment = createEnvironment(); }); @@ -69,37 +61,19 @@ beforeEach(() => { afterEach(cleanup); function ready(model: ReturnType) { - expect(model.status).toBe('ready'); if (model.status !== 'ready') { throw new Error('expected ready model'); } return model; } -describe('useUserProfilePasswordModel', () => { - it.each([undefined, ''])('rejects a missing required current password (%j)', async currentPassword => { - if (!user) { - throw new Error('expected user'); - } - user.passwordEnabled = true; - const { result } = renderHook(() => useUserProfilePasswordModel()); - await expect( - ready(result.current).updatePassword({ - currentPassword, - newPassword: 'new password', - signOutOfOtherSessions: true, - }), - ).rejects.toMatchObject({ code: 'current_password_required' }); - expect(user.updatePassword).not.toHaveBeenCalled(); - }); - +describe('useUserProfilePasswordModel context changes', () => { it.each(['signed out', 'different user', 'different session', 'no session', 'disabled', 'enterprise', 'mode'])( - 'rejects a captured action after the context changes: %s', + 'rejects a captured action after %s', async change => { - if (!user || !session || !environment) { + if (!user || !session) { throw new Error('expected loaded fixtures'); } - user.passwordEnabled = true; const updatePassword = user.updatePassword; const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); const action = ready(result.current).updatePassword; @@ -136,7 +110,7 @@ describe('useUserProfilePasswordModel', () => { }, ); - it('rejects updates when a loaded user has no active session', async () => { + it('rejects an update when a loaded user has no active session', async () => { if (!user) { throw new Error('expected user'); } @@ -147,144 +121,4 @@ describe('useUserProfilePasswordModel', () => { ).rejects.toMatchObject({ code: 'unavailable' }); expect(user.updatePassword).not.toHaveBeenCalled(); }); - - it.each([ - { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true }, - { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false }, - { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false }, - { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true }, - ])('sends the direct password payload for %j', async policy => { - if (!user || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = policy.passwordEnabled; - environment.authConfig.reverification = policy.reverification; - const { result } = renderHook(() => useUserProfilePasswordModel()); - const input = { - currentPassword: ' old secret ', - newPassword: ' new e\u0301 secret ', - confirmPassword: 'must never reach the SDK', - signOutOfOtherSessions: policy.signOutOfOtherSessions, - }; - - await ready(result.current).updatePassword(input); - - const expected = { - newPassword: ' new e\u0301 secret ', - signOutOfOtherSessions: policy.signOutOfOtherSessions, - }; - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith( - policy.passwordEnabled ? { ...expected, currentPassword: ' old secret ' } : expected, - ); - }); - - it('waits for the SDK resource and derives change mode only after hydration', async () => { - if (!user) { - throw new Error('expected user'); - } - const request = createDeferredPromise(); - user.updatePassword.mockReturnValueOnce(request.promise); - const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); - const promise = ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }); - expect(ready(result.current).mode).toBe('set'); - expect(user.passwordEnabled).toBe(false); - - user = { ...user, passwordEnabled: true }; - request.resolve(user); - await expect(promise).resolves.toBe(user); - rerender(); - expect(ready(result.current).mode).toBe('change'); - }); - - it.each([ - { errors: [{ code: 'form_password_incorrect', meta: { paramName: 'current_password' } }] }, - { errors: [{ code: 'session_reverification_required' }] }, - new Error('network failure'), - ])('preserves the SDK rejection for the caller', async error => { - if (!user) { - throw new Error('expected user'); - } - user.updatePassword.mockRejectedValueOnce(error); - const { result } = renderHook(() => useUserProfilePasswordModel()); - await expect( - ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), - ).rejects.toBe(error); - }); - - it.each([ - { passwordEnabled: false, reverification: false, mode: 'set', requiresCurrentPassword: false }, - { passwordEnabled: false, reverification: true, mode: 'set', requiresCurrentPassword: false }, - { passwordEnabled: true, reverification: false, mode: 'change', requiresCurrentPassword: true }, - { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: true }, - ])('derives $mode mode independently of reverification=$reverification', policy => { - if (!user || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = policy.passwordEnabled; - environment.authConfig.reverification = policy.reverification; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toMatchObject({ - status: 'ready', - mode: policy.mode, - requiresCurrentPassword: policy.requiresCurrentPassword, - userId: 'user_1', - sessionId: 'session_1', - identifier: 'person@example.com', - passwordSettings, - }); - }); - - it('keeps a loaded user visible when there is no session identifier', () => { - session = null; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toMatchObject({ status: 'ready', sessionId: null, identifier: '' }); - }); - - it.each([false, true])('makes active enterprise accounts readonly with passwordEnabled=%s', passwordEnabled => { - if (!user) { - throw new Error('expected user'); - } - user.passwordEnabled = passwordEnabled; - user.enterpriseAccounts = [{ active: false }, { active: true }, { active: true }]; - const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toEqual({ - status: 'readonly', - reason: 'enterprise_account', - mode: passwordEnabled ? 'change' : 'set', - }); - - user.enterpriseAccounts = [{ active: false }]; - rerender(); - expect(result.current.status).toBe('ready'); - }); - - it('hides the section when the loaded user is absent', () => { - user = null; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toEqual({ status: 'hidden', reason: 'no_user' }); - }); - - it.each([false, true])('hides disabled instance passwords when user passwordEnabled is %s', passwordEnabled => { - if (!user || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = passwordEnabled; - environment.userSettings.instanceIsPasswordBased = false; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toEqual({ status: 'hidden', reason: 'password_disabled' }); - }); - - it.each(['user', 'session', 'environment'])('waits for %s to load', resource => { - if (resource === 'user') { - isUserLoaded = false; - } else if (resource === 'session') { - isSessionLoaded = false; - } else { - environment = undefined; - } - - const { result } = renderHook(() => useUserProfilePasswordModel()); - - expect(result.current).toEqual({ status: 'loading' }); - }); }); From 0ef6cb79975b1d341175ee1635b15d39eded4c61 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:59:28 -0600 Subject: [PATCH 20/91] fix(swingset): parse password fixture as JSX and trim duplicate tests --- ...ser-profile-password-section.view.test.tsx | 131 ------------------ .../user-profile-password.feature.test.tsx | 2 + .../{user-profile.ts => user-profile.tsx} | 0 3 files changed, 2 insertions(+), 131 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx rename packages/swingset/src/stories/fixtures/{user-profile.ts => user-profile.tsx} (100%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx deleted file mode 100644 index 2a9b023d80a..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; - -function renderView(props: UserProfilePasswordSectionViewProps = {}) { - return render( - - - , - ); -} - -describe('UserProfilePasswordSectionView', () => { - it('changes a password and closes the dialog after saving', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ hasPassword: true, requiresCurrentPassword: true, onSubmitPassword }); - - expect(screen.getByText('••••••••••••••••••')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Change password' })); - const dialog = screen.getByRole('dialog', { name: 'Change password' }); - await user.type(within(dialog).getByLabelText('Current password'), 'old-secret'); - await user.type(within(dialog).getByLabelText('New password'), 'new-secret-123'); - await user.type(within(dialog).getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(within(dialog).getByRole('checkbox', { name: 'Sign out of all other devices' })); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitPassword).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Change password' })).not.toBeInTheDocument()); - }); - - it('offers to set a password when the instance takes one but the account has none', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ hasPassword: false, onSubmitPassword }); - - expect(screen.getByRole('group', { name: 'Password' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 2, name: 'Password' })).toHaveClass('cl-section-title'); - expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); - expect(screen.getByText('No password set')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Set password' })); - const dialog = screen.getByRole('dialog', { name: 'Set password' }); - expect(within(dialog).queryByLabelText('Current password')).not.toBeInTheDocument(); - await user.type(within(dialog).getByLabelText('New password'), 'new-secret-123'); - await user.type(within(dialog).getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitPassword).toHaveBeenCalledWith({ - currentPassword: undefined, - newPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }); - }); - - it('keeps entered values after a failure and closes after a corrected retry', async () => { - const user = userEvent.setup(); - const onSubmitPassword = vi - .fn() - .mockRejectedValueOnce( - new UserProfileSaveError('Your password could not be updated.', { - currentPassword: 'Incorrect password.', - }), - ) - .mockResolvedValue(undefined); - renderView({ hasPassword: true, requiresCurrentPassword: true, onSubmitPassword }); - - await user.click(screen.getByRole('button', { name: 'Change password' })); - await user.type(screen.getByLabelText('Current password'), 'incorrect-password'); - await user.type(screen.getByLabelText('New password'), 'new-secret-123'); - await user.type(screen.getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Your password could not be updated.'); - expect(screen.getByLabelText('Current password')).toHaveAccessibleDescription('Incorrect password.'); - expect(screen.getByLabelText('New password')).toHaveValue('new-secret-123'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-secret-123'); - - await user.clear(screen.getByLabelText('Current password')); - await user.type(screen.getByLabelText('Current password'), 'correct-password'); - await user.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Change password' })).toHaveFocus(); - }); - - it('hides the entire section when there is no password, manager, or action', () => { - const { container } = render( - - - , - ); - - expect(container).toBeEmptyDOMElement(); - }); - - it('shows an existing password without requiring an edit action', () => { - render( - - - , - ); - - expect(screen.getByRole('group', { name: 'Password' })).toBeVisible(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('shows the enterprise manager instead of password actions', () => { - render( - - Promise.resolve())} - /> - , - ); - - expect(screen.getByText('Managed by Okta')).toBeVisible(); - expect(screen.getByText('Okta')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index 8b56d255d42..76cb8bb1b0b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -208,6 +208,7 @@ describe('Changing a password', () => { await renderPassword(); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); await user.click(screen.getByLabelText('New password')); expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( 'Your password must contain 8 or more characters.', @@ -230,6 +231,7 @@ describe('Changing a password', () => { await renderPassword(alice, environment); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); await user.type(screen.getByLabelText('New password'), 'longpassword'); await waitFor( diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.tsx similarity index 100% rename from packages/swingset/src/stories/fixtures/user-profile.ts rename to packages/swingset/src/stories/fixtures/user-profile.tsx From 3b5eab5482086efa1403636b3914f0c2f1d8e1dc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 14:15:36 -0600 Subject: [PATCH 21/91] fix(mosaic): use managed password row for enterprise accounts --- .changeset/quiet-managed-password.md | 5 +++ .../user-profile-password.feature.test.tsx | 35 +++++++++++++++---- .../user-profile-password-section.messages.ts | 2 +- .../user-profile-password-section.model.ts | 25 ++++++++++--- .../user-profile-password-section.tsx | 6 ++-- 5 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 .changeset/quiet-managed-password.md diff --git a/.changeset/quiet-managed-password.md b/.changeset/quiet-managed-password.md new file mode 100644 index 00000000000..3c705a165b8 --- /dev/null +++ b/.changeset/quiet-managed-password.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Show the enterprise provider's name and logo for managed passwords instead of squeezing the password row with a long explanation. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index 76cb8bb1b0b..7a23e4b3aa9 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -130,14 +130,35 @@ describe('Changing a password', () => { expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); }); - it('keeps an enterprise-managed password read-only', async () => { - await renderPassword(fapiUser({ ...alice, enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1' })] })); + it.each([true, false])('shows the managed view when passwordEnabled is %s', async passwordEnabled => { + await renderPassword( + fapiUser({ + ...alice, + password_enabled: passwordEnabled, + enterprise_accounts: [ + fapiEnterpriseAccount({ id: 'inactive', active: false, enterprise_connection: null }), + fapiEnterpriseAccount({ id: 'ent_1' }), + ], + }), + ); - expect( - screen.getByText( - 'Your password can currently not be edited because you can sign in only via the enterprise connection.', - ), - ).toBeVisible(); + expect(screen.getByText(/Managed by|Your password can currently/)).toHaveTextContent('Managed by Company SSO'); + if (!passwordEnabled) { + expect(screen.getByText('No password set')).toBeVisible(); + } + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Set password' })).toBeNull(); + }); + + it('keeps the managed view when connection details are unavailable', async () => { + await renderPassword( + fapiUser({ + ...alice, + enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1', enterprise_connection: null })], + }), + ); + + expect(screen.getByText('Managed by your enterprise connection')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 85fd184af7b..2c79055865b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -3,7 +3,7 @@ export const userProfilePasswordSectionMessages = { masked: '••••••••••••••••••', noPasswordSet: 'No password set', managedBy: 'Managed by {name}', - readonly: 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + enterpriseConnection: 'your enterprise connection', change: 'Change password', set: 'Set password', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index eb386f7de62..4df60e0c07f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -2,7 +2,13 @@ import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/ import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; -import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; +import type { + EnterpriseAccountResource, + EnvironmentResource, + PasswordSettingsData, + PasswordValidation, + UserResource, +} from '@clerk/shared/types'; import { useCallback } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; @@ -15,7 +21,12 @@ type EditablePasswordPolicy = type UnavailablePasswordModel = | { status: 'hidden'; reason: 'no_user' | 'password_disabled' } - | { status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account' }; + | { + status: 'readonly'; + mode: 'set' | 'change'; + reason: 'enterprise_account'; + enterpriseConnection: EnterpriseAccountResource['enterpriseConnection']; + }; export type UserProfilePasswordModel = | { status: 'loading' } @@ -46,8 +57,14 @@ function getPasswordPolicy( ? { mode: 'change', requiresCurrentPassword: true } : { mode: 'set', requiresCurrentPassword: false }; - if (user.enterpriseAccounts.some(account => account.active)) { - return { status: 'readonly', mode: policy.mode, reason: 'enterprise_account' }; + const enterpriseAccount = user.enterpriseAccounts.find(account => account.active); + if (enterpriseAccount) { + return { + status: 'readonly', + mode: policy.mode, + reason: 'enterprise_account', + enterpriseConnection: enterpriseAccount.enterpriseConnection, + }; } return { status: 'ready', userId: user.id, ...policy }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 747486d5a02..1ce9e619eb6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -3,7 +3,6 @@ import { useCallback, useRef } from 'react'; import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; -import { Text } from '../../../components/text'; import { useLocale, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; @@ -35,7 +34,10 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw return ( {m.readonly}} + managedBy={{ + name: model.enterpriseConnection?.name || m.enterpriseConnection, + iconUrl: model.enterpriseConnection?.logoPublicUrl ?? undefined, + }} /> ); } From 9967dff5e32f3bc6749f8d7a709ee65874655ae2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:12:40 -0600 Subject: [PATCH 22/91] test(mosaic): reproduce managed password label overflow --- ...r-profile-password-layout.feature.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx new file mode 100644 index 00000000000..0839fd34e52 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx @@ -0,0 +1,39 @@ +import '../../../../dist/styles.css'; + +import { render, screen } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; + +it.each([ + { width: 320, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, + { width: 720, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, + { width: 320, name: 'EnterpriseConnection'.repeat(5) }, + { width: 720, name: 'EnterpriseConnection'.repeat(5) }, +])('keeps the enterprise name beside the password label at $width px ($name)', ({ width, name }) => { + const { container } = render( + +
+ +
+
, + ); + const label = screen.getByText('Password'); + const managed = screen.getByText(`Managed by ${name}`); + const icon = container.querySelector('img'); + if (!icon) { + throw new Error('Missing enterprise icon'); + } + const labelRange = document.createRange(); + labelRange.selectNodeContents(label); + const textRange = document.createRange(); + textRange.selectNodeContents(managed); + const host = screen.getByRole('region', { name: 'Authentication' }).getBoundingClientRect(); + + expect(host.width).toBe(width); + expect(labelRange.getBoundingClientRect().right).toBeLessThanOrEqual(icon.getBoundingClientRect().left); + expect(textRange.getBoundingClientRect().right).toBeLessThanOrEqual(host.right); +}); From 6816ca9caf8397d9c4e9919fce84ba847b8f2eee Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:14:01 -0600 Subject: [PATCH 23/91] fix(mosaic): wrap managed password provider names --- .changeset/quiet-phones-wrap.md | 5 +++++ .../user-profile-password-row.view.tsx | 4 ++-- .../user-profile-password-section.styles.ts | 7 +++++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-phones-wrap.md diff --git a/.changeset/quiet-phones-wrap.md b/.changeset/quiet-phones-wrap.md new file mode 100644 index 00000000000..34c92f6c205 --- /dev/null +++ b/.changeset/quiet-phones-wrap.md @@ -0,0 +1,5 @@ +--- +"@clerk/mosaic": patch +--- + +Keep long enterprise names from overlapping the password label in UserProfile on narrow screens. diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 345ab04cd43..0f39e1ffef9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -25,13 +25,13 @@ export function UserProfilePasswordRowView({ return ( - + {hasPassword ? m.masked : m.noPasswordSet} {action ? ( {action} ) : managedBy ? ( - + ) : onSubmitPassword ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts index 44d50260dc3..c6158cefa94 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -28,6 +28,12 @@ export const styles = stylex.create({ checkboxDescription: { color: colorVars['--cl-color-foreground-secondary'], }, + managedContent: { + flexShrink: 0, + }, + managedActions: { + flexShrink: 1, + }, managedBy: { gap: space['1.5'], alignItems: 'center', @@ -35,5 +41,6 @@ export const styles = stylex.create({ }, managedByText: { color: colorVars['--cl-color-foreground-secondary'], + overflowWrap: 'anywhere', }, }); From 98c1f24645d9cc54cfb30d3a7d17611b409506f0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:16:05 -0600 Subject: [PATCH 24/91] docs(swingset): show long managed password provider --- .../src/stories/user-profile-password-section.mdx | 9 +++++++++ .../user-profile-password-section.stories.tsx | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 1aa43a70301..420c5f6fa07 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -46,6 +46,15 @@ group width the label keeps only the name. storyModule={Stories} /> +### Long enterprise name + +The 320px example shows how the provider name wraps beside the password label. + + + ## Save error and retry The first save shows a form error and a current-password error. Correct the value and retry to diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 1ac0dfe0cfc..f6e583f4115 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -64,6 +64,20 @@ export function ManagedByEnterprise() { ); } +export function ManagedByEnterpriseLongName() { + return ( +
+ +
+ ); +} + /** The first save shows field and form errors; retrying succeeds. */ export function EditPasswordFails() { return ( From 0531bec57f9169ef4676e6ec6eb5ff55b8afed8a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:30:06 -0600 Subject: [PATCH 25/91] fix(mosaic): place managed provider below password --- .changeset/quiet-phones-wrap.md | 2 +- .../user-profile-password-layout.feature.test.tsx | 9 +++++---- .../user-profile-password-row.view.tsx | 9 +++------ .../user-profile-password-section.styles.ts | 7 +------ .../src/stories/user-profile-password-section.mdx | 2 +- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/.changeset/quiet-phones-wrap.md b/.changeset/quiet-phones-wrap.md index 34c92f6c205..272096f3e83 100644 --- a/.changeset/quiet-phones-wrap.md +++ b/.changeset/quiet-phones-wrap.md @@ -2,4 +2,4 @@ "@clerk/mosaic": patch --- -Keep long enterprise names from overlapping the password label in UserProfile on narrow screens. +Show enterprise password providers below the password in UserProfile, with room for long names to wrap. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx index 0839fd34e52..71a85e31822 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx @@ -11,11 +11,12 @@ it.each([ { width: 720, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, { width: 320, name: 'EnterpriseConnection'.repeat(5) }, { width: 720, name: 'EnterpriseConnection'.repeat(5) }, -])('keeps the enterprise name beside the password label at $width px ($name)', ({ width, name }) => { +])('places the enterprise name below the password at $width px ($name)', ({ width, name }) => { const { container } = render(
@@ -27,13 +28,13 @@ it.each([ if (!icon) { throw new Error('Missing enterprise icon'); } - const labelRange = document.createRange(); - labelRange.selectNodeContents(label); const textRange = document.createRange(); textRange.selectNodeContents(managed); const host = screen.getByRole('region', { name: 'Authentication' }).getBoundingClientRect(); + const description = screen.getByText('••••••••••••••••••').getBoundingClientRect(); expect(host.width).toBe(width); - expect(labelRange.getBoundingClientRect().right).toBeLessThanOrEqual(icon.getBoundingClientRect().left); + expect(icon.getBoundingClientRect().top).toBeGreaterThanOrEqual(description.bottom); + expect(icon.getBoundingClientRect().left).toBeGreaterThanOrEqual(label.getBoundingClientRect().left); expect(textRange.getBoundingClientRect().right).toBeLessThanOrEqual(host.right); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 0f39e1ffef9..a35286dcbe9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -25,16 +25,13 @@ export function UserProfilePasswordRowView({ return ( - + {hasPassword ? m.masked : m.noPasswordSet} + {!action && managedBy ? : null} {action ? ( {action} - ) : managedBy ? ( - - - - ) : onSubmitPassword ? ( + ) : !managedBy && onSubmitPassword ? ( Date: Tue, 29 Sep 2026 16:40:10 -0600 Subject: [PATCH 26/91] revert(mosaic): leave section layout for follow-up --- .changeset/quiet-phones-wrap.md | 5 --- ...r-profile-password-layout.feature.test.tsx | 40 ------------------- .../user-profile-password-row.view.tsx | 7 +++- .../user-profile-password-section.styles.ts | 2 - .../stories/user-profile-password-section.mdx | 9 ----- .../user-profile-password-section.stories.tsx | 14 ------- 6 files changed, 5 insertions(+), 72 deletions(-) delete mode 100644 .changeset/quiet-phones-wrap.md delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx diff --git a/.changeset/quiet-phones-wrap.md b/.changeset/quiet-phones-wrap.md deleted file mode 100644 index 272096f3e83..00000000000 --- a/.changeset/quiet-phones-wrap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@clerk/mosaic": patch ---- - -Show enterprise password providers below the password in UserProfile, with room for long names to wrap. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx deleted file mode 100644 index 71a85e31822..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import '../../../../dist/styles.css'; - -import { render, screen } from '@testing-library/react'; -import { expect, it } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; - -it.each([ - { width: 320, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, - { width: 720, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, - { width: 320, name: 'EnterpriseConnection'.repeat(5) }, - { width: 720, name: 'EnterpriseConnection'.repeat(5) }, -])('places the enterprise name below the password at $width px ($name)', ({ width, name }) => { - const { container } = render( - -
- -
-
, - ); - const label = screen.getByText('Password'); - const managed = screen.getByText(`Managed by ${name}`); - const icon = container.querySelector('img'); - if (!icon) { - throw new Error('Missing enterprise icon'); - } - const textRange = document.createRange(); - textRange.selectNodeContents(managed); - const host = screen.getByRole('region', { name: 'Authentication' }).getBoundingClientRect(); - const description = screen.getByText('••••••••••••••••••').getBoundingClientRect(); - - expect(host.width).toBe(width); - expect(icon.getBoundingClientRect().top).toBeGreaterThanOrEqual(description.bottom); - expect(icon.getBoundingClientRect().left).toBeGreaterThanOrEqual(label.getBoundingClientRect().left); - expect(textRange.getBoundingClientRect().right).toBeLessThanOrEqual(host.right); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index a35286dcbe9..345ab04cd43 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -27,11 +27,14 @@ export function UserProfilePasswordRowView({ {hasPassword ? m.masked : m.noPasswordSet} - {!action && managedBy ? : null} {action ? ( {action} - ) : !managedBy && onSubmitPassword ? ( + ) : managedBy ? ( + + + + ) : onSubmitPassword ? ( -### Long enterprise name - -The 320px example shows the provider name below the password, with enough room to wrap. - - - ## Save error and retry The first save shows a form error and a current-password error. Correct the value and retry to diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index f6e583f4115..1ac0dfe0cfc 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -64,20 +64,6 @@ export function ManagedByEnterprise() { ); } -export function ManagedByEnterpriseLongName() { - return ( -
- -
- ); -} - /** The first save shows field and form errors; retrying succeeds. */ export function EditPasswordFails() { return ( From bde1a0d3caa065e420369bd0b1f82a859a762a19 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 17:00:02 -0600 Subject: [PATCH 27/91] test(mosaic): await password dialog focus before typing --- .../__tests__/user-profile-password.feature.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index 7a23e4b3aa9..b18b7572ceb 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -180,6 +180,7 @@ describe('Changing a password', () => { await renderPassword(); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); await user.type(screen.getByLabelText('New password'), 'new-password-123'); await user.type(screen.getByLabelText('Confirm password'), 'new-password-12'); await user.click(screen.getByLabelText('New password')); @@ -188,7 +189,11 @@ describe('Changing a password', () => { ); await user.clear(screen.getByLabelText('Confirm password')); - expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Confirm password')).toHaveValue(''); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); }); From 1083ac80d1bf2dcf4a19398c513c61ac55ad1245 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 01:31:20 -0600 Subject: [PATCH 28/91] chore(mosaic): mark session reverification as deferred --- .../user-profile-password-section.model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 4df60e0c07f..8f75c0d4338 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -112,6 +112,7 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { identifier: session?.publicUserData.identifier ?? '', passwordSettings: environment.userSettings.passwordSettings, validatePassword, + // TODO: Add session reverification for password updates; surface API errors until then. updatePassword: async ({ currentPassword, newPassword, signOutOfOtherSessions }) => { const currentUser = clerk.user; const currentPolicy = getPasswordPolicy(currentUser, environment); From 6bce495c57ef6901b812ce9c223d6c77ec0995ac Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:26:06 -0600 Subject: [PATCH 29/91] chore: make password section changeset empty --- .changeset/quiet-managed-password.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/quiet-managed-password.md b/.changeset/quiet-managed-password.md index 3c705a165b8..a845151cc84 100644 --- a/.changeset/quiet-managed-password.md +++ b/.changeset/quiet-managed-password.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Show the enterprise provider's name and logo for managed passwords instead of squeezing the password row with a long explanation. From f91503a21c5bdcb6ad78dc491b5250acb25a9613 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:27:24 -0600 Subject: [PATCH 30/91] chore: remove duplicate empty changeset --- .changeset/calm-password-controller.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .changeset/calm-password-controller.md diff --git a/.changeset/calm-password-controller.md b/.changeset/calm-password-controller.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/calm-password-controller.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- From 2e68e351e95add31a014bd9d82da77292b7d130f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:52:33 -0600 Subject: [PATCH 31/91] refactor(mosaic): simplify password loading --- .../user-profile-password-loading.test.tsx | 16 +++++----------- .../user-profile-password-section.tsx | 9 ++------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx index b8759f6b0ad..b12456d5e47 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx @@ -2,7 +2,6 @@ import type * as SharedReact from '@clerk/shared/react'; import { ClerkInstanceContext } from '@clerk/shared/react'; import type { LoadedClerk } from '@clerk/shared/types'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; @@ -50,24 +49,19 @@ function tree() { return ( - + Loading password section} /> ); } -it('keeps the draft while session data briefly reloads', async () => { +it('shows the fallback when session data starts loading after the section is ready', () => { isSessionLoaded = true; const { rerender } = render(tree()); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('New password'), 'new-password-123'); + expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument(); isSessionLoaded = false; rerender(tree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - - isSessionLoaded = true; - rerender(tree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByText('Loading password section')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 1ce9e619eb6..c8833287275 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; @@ -20,12 +20,7 @@ export interface UserProfilePasswordSectionProps { } export function UserProfilePasswordSection({ fallback = null }: UserProfilePasswordSectionProps) { - const currentModel = useUserProfilePasswordModel(); - const settledModel = useRef(currentModel); - if (currentModel.status !== 'loading') { - settledModel.current = currentModel; - } - const model = currentModel.status === 'loading' ? settledModel.current : currentModel; + const model = useUserProfilePasswordModel(); const m = useMessages('userProfilePasswordSection'); if (model.status === 'loading') { return fallback; From 7601f003d7867e6d88e7654c47d7380e91666e3b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 18:28:41 -0600 Subject: [PATCH 32/91] fix(mosaic): localize user profile password errors --- .changeset/quiet-password-messages.md | 5 ++ .../user-profile-password-feedback.test.ts | 83 ++++++++++++++++--- .../user-profile-password-feedback.ts | 35 ++++++-- .../user-profile-password-section.messages.ts | 8 -- .../user-profile-password-section.tsx | 6 +- .../src/localization/errors.messages.ts | 12 +++ .../mosaic/src/localization/errors.test.tsx | 62 ++++++++++++++ packages/mosaic/src/localization/errors.ts | 20 +++++ packages/mosaic/src/localization/index.ts | 2 + packages/mosaic/src/localization/registry.ts | 2 + 10 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 .changeset/quiet-password-messages.md create mode 100644 packages/mosaic/src/localization/errors.messages.ts create mode 100644 packages/mosaic/src/localization/errors.test.tsx create mode 100644 packages/mosaic/src/localization/errors.ts diff --git a/.changeset/quiet-password-messages.md b/.changeset/quiet-password-messages.md new file mode 100644 index 00000000000..c3c021c45dc --- /dev/null +++ b/.changeset/quiet-password-messages.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Support localized password API errors, including current-password errors, while preserving server messages for unknown errors. diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index 88274166aeb..eeda0c0fceb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -1,8 +1,10 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { renderHook } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; import { FormSubmitError } from '../../../components/form'; -import { resolveLocalization } from '../../../localization'; +import { MosaicLocalizationProvider, resolveLocalization, useErrorText } from '../../../localization'; import { passwordFormError } from './user-profile-password-feedback'; import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; @@ -10,13 +12,21 @@ const settings = { min_length: 12, max_length: 64 }; const localization = resolveLocalization({ locale: 'en', overrides: { + errors: { + form_password_size_in_bytes_exceeded: 'Custom byte error.', + form_password_incorrect__current_password: 'Le mot de passe actuel est incorrect.', + }, userProfilePasswordSection: { suggestions: { anotherWord: 'Custom suggestion.' }, - passwordErrors: { form_password_size_in_bytes_exceeded: 'Custom byte error.' }, }, }, }); const messages = localization.messages.userProfilePasswordSection; +function resolver(value = localization) { + const wrapper = ({ children }: { children: ReactNode }) => + createElement(MosaicLocalizationProvider, { value }, children); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} function format(data: ConstructorParameters[1]['data'], current = false) { const result = passwordFormError( new ClerkAPIResponseError('Invalid', { status: 422, data }), @@ -24,6 +34,7 @@ function format(data: ConstructorParameters[1]['da settings, messages, localization.locale, + resolver(), ); expect(result).toBeInstanceOf(FormSubmitError); if (!(result instanceof FormSubmitError)) { @@ -33,6 +44,16 @@ function format(data: ConstructorParameters[1]['da } describe('password error feedback', () => { + it('keeps a breached-password error ahead of later complexity failures', () => { + expect( + format([ + { code: 'form_password_pwned', message: 'raw', meta: { param_name: 'new_password' } }, + { code: 'form_password_no_uppercase', message: 'raw', meta: { param_name: 'new_password' } }, + ]).fields?.newPassword, + ).toBe( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ); + }); it('combines recognized requirements in a localized list', () => { expect( format([ @@ -78,11 +99,27 @@ describe('password error feedback', () => { const errors = [ { code: 'form_password_incorrect', message: 'Incorrect', meta: { param_name: 'current_password' } }, ]; - expect(format(errors, true).fields?.currentPassword).toBe('Incorrect'); - expect(format(errors).banner).toBe('Incorrect'); + expect(format(errors, true).fields?.currentPassword).toBe('Le mot de passe actuel est incorrect.'); + expect(format(errors).banner).toBe('Le mot de passe actuel est incorrect.'); expect(format([{ code: 'unknown', message: 'Other', meta: { param_name: 'unmapped' } }]).banner).toBe('Other'); }); + it('retains the server fallback for unknown current-password errors', () => { + expect( + format( + [ + { + code: 'future_error', + message: 'short', + long_message: 'Detailed error', + meta: { param_name: 'current_password' }, + }, + ], + true, + ).fields?.currentPassword, + ).toBe('Detailed error'); + }); + it('uses the configured maximum length', () => { expect( format([{ code: 'form_password_length_too_long', message: 'raw', meta: { param_name: 'new_password' } }]).fields @@ -102,25 +139,42 @@ describe('password error feedback', () => { ).toBe('New password cannot be the same as the current password.'); }); - it('retains the server fallback for untranslated special codes', () => { + it.each([ + { + code: 'form_password_size_in_bytes_exceeded', + paramName: 'new_password', + field: 'newPassword', + current: false, + message: + 'Your password has exceeded the maximum number of bytes allowed, please shorten it or remove some special characters.', + }, + { + code: 'form_password_validation_failed', + paramName: 'current_password', + field: 'currentPassword', + current: true, + message: 'Incorrect Password', + }, + ])('uses the English catalog message for $code', ({ code, paramName, field, current, message }) => { const defaults = resolveLocalization({ locale: 'en' }); const result = passwordFormError( new ClerkAPIResponseError('Invalid', { status: 422, data: [ { - code: 'form_password_size_in_bytes_exceeded', - message: 'Password is too large', - meta: { param_name: 'new_password' }, + code, + message: 'Server fallback', + meta: { param_name: paramName }, }, ], }), - false, + current, settings, defaults.messages.userProfilePasswordSection, defaults.locale, + resolver(defaults), ); - expect(result).toMatchObject({ fields: { newPassword: 'Password is too large' } }); + expect(result).toMatchObject({ fields: { [field]: message } }); }); it('handles missing and unrecognized suggestion metadata without leaking codes', () => { @@ -144,7 +198,14 @@ describe('password error feedback', () => { it('localizes update errors raised before the request is sent', () => { const translate = (code: UserProfilePasswordUpdateError['code']) => - passwordFormError(new UserProfilePasswordUpdateError(code), true, settings, messages, localization.locale); + passwordFormError( + new UserProfilePasswordUpdateError(code), + true, + settings, + messages, + localization.locale, + resolver(), + ); expect(translate('unavailable')).toMatchObject({ banner: 'Password update is no longer available.' }); expect(translate('current_password_required')).toMatchObject({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index 5234c466186..fe2babd90ea 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -2,7 +2,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; import { FormSubmitError } from '../../../components/form'; -import type { MosaicMessages } from '../../../localization'; +import type { LocalizableError, MosaicMessages } from '../../../localization'; import { fill } from '../../../localization'; import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; @@ -17,15 +17,17 @@ export function passwordStrengthMessage(codes: string[], messages: Messages): st return [messages.rules.weak, ...codes.map(code => lookup(messages.suggestions, code))].filter(Boolean).join(' '); } -function passwordError(errors: ClerkAPIError[], settings: Settings, messages: Messages, locale: string) { +function passwordError( + errors: ClerkAPIError[], + settings: Settings, + messages: Messages, + locale: string, + errorText: (error: LocalizableError) => string, +) { const first = errors[0]; if (!first) { return undefined; } - const known = lookup(messages.passwordErrors, first.code); - if (known !== undefined) { - return known || first.message; - } if (first.code === 'form_password_not_strong_enough') { return passwordStrengthMessage(first.meta?.zxcvbn?.suggestions?.map(suggestion => suggestion.code) ?? [], messages); } @@ -37,11 +39,21 @@ function passwordError(errors: ClerkAPIError[], settings: Settings, messages: Me form_password_no_number: 'require_numbers', form_password_no_special_char: 'require_special_char', }; + if (!lookup(codes, first.code)) { + return errorText({ + code: first.code, + paramName: first.meta?.paramName, + message: first.longMessage || first.message, + }); + } const failures = errors.flatMap(error => { const code = lookup(codes, error.code); return code ? [code] : []; }); - return passwordComplexityMessage(failures, settings, messages, locale) || first.longMessage || first.message; + return ( + passwordComplexityMessage(failures, settings, messages, locale) || + errorText({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message }) + ); } export function passwordComplexityMessage(failures: string[], settings: Settings, messages: Messages, locale: string) { @@ -73,6 +85,7 @@ export function passwordFormError( settings: Settings, messages: Messages, locale: string, + errorText: (error: LocalizableError) => string, ): unknown { if (error instanceof UserProfilePasswordUpdateError) { return error.code === 'current_password_required' @@ -86,7 +99,11 @@ export function passwordFormError( const passwordErrors: ClerkAPIError[] = []; let message: string | undefined; for (const item of error.errors) { - const text = item.longMessage || item.message; + const text = errorText({ + code: item.code, + paramName: item.meta?.paramName, + message: item.longMessage || item.message, + }); const name = item.meta?.paramName; if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { fields.currentPassword ??= text; @@ -97,7 +114,7 @@ export function passwordFormError( } } if (passwordErrors.length) { - fields.newPassword = passwordError(passwordErrors, settings, messages, locale); + fields.newPassword = passwordError(passwordErrors, settings, messages, locale, errorText); } return new FormSubmitError({ message, fields }); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 2c79055865b..b9055babc0b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -36,14 +36,6 @@ export const userProfilePasswordSectionMessages = { number: 'a number', special: 'a special character', }, - passwordErrors: { - form_password_pwned: - 'This password has been found as part of a breach and can not be used, please try another password instead.', - form_password_matches_identifier: - 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', - form_password_size_in_bytes_exceeded: '', - form_new_password_matches_current: 'New password cannot be the same as the current password.', - }, suggestions: { allUppercase: 'Capitalize some, but not all letters.', anotherWord: 'Add more words that are less common.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index c8833287275..878dfb756b7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; -import { useLocale, useMessages } from '../../../localization'; +import { useErrorText, useLocale, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import { @@ -50,6 +50,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw function PasswordEditor({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const locale = useLocale(); + const errorText = useErrorText(); const { validatePassword, passwordSettings } = model; const feedback = useCallback( async (password: string): Promise => { @@ -75,7 +76,8 @@ function PasswordEditor({ model }: { model: Extract passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale), + formatError: error => + passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale, errorText), }); return ( diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts new file mode 100644 index 00000000000..09df4031b4b --- /dev/null +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -0,0 +1,12 @@ +export const errorMessages: { readonly generic: string } & Readonly> = { + generic: 'Something went wrong. Please try again.', + form_new_password_matches_current: 'New password cannot be the same as the current password.', + form_password_incorrect: 'Your current password is incorrect.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_password_size_in_bytes_exceeded: + 'Your password has exceeded the maximum number of bytes allowed, please shorten it or remove some special characters.', + form_password_validation_failed: 'Incorrect Password', +}; diff --git a/packages/mosaic/src/localization/errors.test.tsx b/packages/mosaic/src/localization/errors.test.tsx new file mode 100644 index 00000000000..475439a4e5e --- /dev/null +++ b/packages/mosaic/src/localization/errors.test.tsx @@ -0,0 +1,62 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicCatalog } from './catalog'; +import { MosaicLocalizationProvider, resolveLocalization } from './context'; +import { useErrorText } from './errors'; + +function errorText(overrides?: MosaicCatalog) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} + +describe('useErrorText', () => { + it('prefers the message for the code on that field', () => { + const text = errorText({ + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + 'errors.form_identifier_exists': 'Ya existe.', + }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe( + 'Nombre de usuario en uso.', + ); + }); + + it('falls back to the message for the code', () => { + const text = errorText({ 'errors.form_identifier_exists': 'Ya existe.' }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe('Ya existe.'); + }); + + it('ships English for known codes', () => { + expect(errorText()({ code: 'form_password_incorrect', message: 'api text' })).toBe( + 'Your current password is incorrect.', + ); + }); + + it('falls back to the message Clerk sent for a code it has no text for', () => { + expect(errorText()({ code: 'form_identifier_exists', message: 'That username is taken.' })).toBe( + 'That username is taken.', + ); + }); + + it('fills the values the error carries into its message', () => { + const text = errorText({ + 'errors.test_length': 'Your value must be between {min_length} and {max_length} characters long.', + }); + expect(text({ code: 'test_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Your value must be between 4 and 64 characters long.', + ); + }); + + it('fills an override the same way', () => { + const text = errorText({ 'errors.test_length': 'Entre {min_length} y {max_length} caracteres.' }); + expect(text({ code: 'test_length', params: { min_length: 4, max_length: 64 } })).toBe('Entre 4 y 64 caracteres.'); + }); + + it('falls back to the generic message when there is nothing else', () => { + expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); + expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts new file mode 100644 index 00000000000..172628b9e7c --- /dev/null +++ b/packages/mosaic/src/localization/errors.ts @@ -0,0 +1,20 @@ +import { useMessages } from './context'; +import type { MessageValues } from './messages'; +import { fill } from './messages'; + +export interface LocalizableError { + code?: string; + paramName?: string; + message?: string; + params?: MessageValues; +} + +export function useErrorText(): (error: LocalizableError) => string { + const messages = useMessages('errors'); + const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); + return ({ code, paramName, message, params }) => { + const template = + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return params ? fill(template, params) : template; + }; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index fbfc32e732c..8d5633e4bac 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -1,5 +1,7 @@ export type { MosaicCatalog, MosaicLocalization } from './catalog'; export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; +export type { LocalizableError } from './errors'; +export { useErrorText } from './errors'; export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 34a933c8880..6e4a6cae586 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -27,8 +27,10 @@ import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/ import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; +import { errorMessages } from './errors.messages'; export const mosaicMessages = { + errors: errorMessages, form: formMessages, membersTableTab: membersTableTabMessages, invitationsTableTab: invitationsTableTabMessages, From 5829c878afa0b2b65dc8a64faa56d513aa0db91d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 18:29:03 -0600 Subject: [PATCH 33/91] fix(mosaic): debounce user profile password validation --- .changeset/patient-password-validation.md | 5 ++++ ...r-profile-edit-password.controller.test.ts | 24 +++++++++++++++++++ .../user-profile-edit-password.controller.ts | 24 +++++++++++-------- 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 .changeset/patient-password-validation.md diff --git a/.changeset/patient-password-validation.md b/.changeset/patient-password-validation.md new file mode 100644 index 00000000000..9238351861f --- /dev/null +++ b/.changeset/patient-password-validation.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Wait 350 ms after typing before validating a new password in the user profile. diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index fe120b52e31..952d6b98645 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -13,6 +13,30 @@ function deferred() { } describe('useUserProfileEditPasswordController timing', () => { + it('debounces changes and cancels pending validation when closed', async () => { + vi.useFakeTimers(); + try { + const validatePassword = vi.fn(() => Promise.resolve(undefined)); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(validatePassword).not.toHaveBeenCalled(); + act(() => result.current.form.setValue('newPassword', 'latest password')); + await act(() => vi.advanceTimersByTimeAsync(349)); + expect(validatePassword).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(validatePassword).toHaveBeenCalledExactlyOnceWith('latest password'); + act(() => result.current.form.setValue('newPassword', 'cancelled password')); + act(() => result.current.onOpenChange(false)); + await act(() => vi.advanceTimersByTimeAsync(350)); + expect(validatePassword).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); it('ignores older password feedback and clears it when the editor closes', async () => { const older = deferred(); const newer = deferred(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 0219b5014d1..62240fdbcd1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,3 +1,4 @@ +import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants'; import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; @@ -79,18 +80,21 @@ export function useUserProfileEditPasswordController({ } let active = true; - void Promise.resolve() - .then(() => validatePassword(password)) - .then( - feedback => { - if (active) { - setPasswordFeedback(feedback); - } - }, - () => {}, - ); + const timeout = setTimeout(() => { + void Promise.resolve() + .then(() => validatePassword(password)) + .then( + feedback => { + if (active) { + setPasswordFeedback(feedback); + } + }, + () => {}, + ); + }, DEBOUNCE_MS); return () => { active = false; + clearTimeout(timeout); }; }, [isOpen, password, passwordLeft, validatePassword]); From d73f3a2edf1c9b4e741aedd480884f95031eed81 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 18:29:43 -0600 Subject: [PATCH 34/91] chore: consolidate password changesets --- .changeset/patient-password-validation.md | 5 ----- .changeset/quiet-password-messages.md | 3 --- 2 files changed, 8 deletions(-) delete mode 100644 .changeset/patient-password-validation.md diff --git a/.changeset/patient-password-validation.md b/.changeset/patient-password-validation.md deleted file mode 100644 index 9238351861f..00000000000 --- a/.changeset/patient-password-validation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/mosaic': patch ---- - -Wait 350 ms after typing before validating a new password in the user profile. diff --git a/.changeset/quiet-password-messages.md b/.changeset/quiet-password-messages.md index c3c021c45dc..a845151cc84 100644 --- a/.changeset/quiet-password-messages.md +++ b/.changeset/quiet-password-messages.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Support localized password API errors, including current-password errors, while preserving server messages for unknown errors. From 20cb44f7a025bb76e3ce287249c28a11656f3cd5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 19:52:03 -0600 Subject: [PATCH 35/91] chore(mosaic): note password loading skeleton follow-up --- .../user-profile-password-section.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 878dfb756b7..435460a345a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -23,6 +23,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw const model = useUserProfilePasswordModel(); const m = useMessages('userProfilePasswordSection'); if (model.status === 'loading') { + // TODO: Add a password section skeleton as the default loading fallback. return fallback; } if (model.status === 'readonly') { From e67e8093c7d6d5d32f5cc1cecd45cb5f4eda1173 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 19:53:19 -0600 Subject: [PATCH 36/91] refactor(mosaic): extract password field feedback mapping --- .../user-profile-password-feedback.test.ts | 51 ++++++++++++++++++- .../user-profile-password-feedback.ts | 25 ++++++++- .../user-profile-password-section.tsx | 23 ++------- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index eeda0c0fceb..bb1b220c7d1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -1,11 +1,12 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { PasswordStrength } from '@clerk/shared/types'; import { renderHook } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; import { FormSubmitError } from '../../../components/form'; import { MosaicLocalizationProvider, resolveLocalization, useErrorText } from '../../../localization'; -import { passwordFormError } from './user-profile-password-feedback'; +import { passwordFieldFeedback, passwordFormError } from './user-profile-password-feedback'; import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; const settings = { min_length: 12, max_length: 64 }; @@ -213,3 +214,51 @@ describe('password error feedback', () => { }); }); }); + +describe('password field feedback', () => { + const feedback = (validation: Parameters[0]) => + passwordFieldFeedback(validation, settings, messages, localization.locale); + const strength = (state: PasswordStrength['state'], suggestions: string[] = []): PasswordStrength => ({ + state, + keys: [], + result: { + feedback: { warning: null, suggestions }, + score: 0, + password: '', + guesses: 0, + guessesLog10: 0, + calcTime: 0, + }, + }); + + it('prefers complexity feedback over strength feedback', () => { + expect(feedback({ complexity: { require_numbers: true }, strength: strength('fail') })).toEqual({ + type: 'error', + message: 'Your password must contain a number.', + }); + }); + + it('reports minimum length as info and other complexity failures as errors', () => { + expect(feedback({ complexity: { min_length: true, require_numbers: true } })).toEqual({ + type: 'info', + message: 'Your password must contain 12 or more characters.', + }); + expect(feedback({ complexity: { require_uppercase: true } })).toMatchObject({ type: 'error' }); + }); + + it('maps strength results to error, warning, and success', () => { + expect(feedback({ complexity: {}, strength: strength('fail', ['anotherWord']) })).toEqual({ + type: 'error', + message: 'Your password is not strong enough. Custom suggestion.', + }); + expect(feedback({ complexity: {}, strength: strength('pass') })).toEqual({ + type: 'warning', + message: messages.rules.stronger, + }); + expect(feedback({ complexity: {}, strength: strength('excellent') })).toEqual({ + type: 'success', + message: messages.rules.strong, + }); + expect(feedback({ complexity: {} })).toMatchObject({ type: 'success' }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index fe2babd90ea..c15e3f1f0f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -1,6 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; +import type { ClerkAPIError, PasswordSettingsData, PasswordValidation } from '@clerk/shared/types'; +import type { FieldFeedback } from '../../../components/form'; import { FormSubmitError } from '../../../components/form'; import type { LocalizableError, MosaicMessages } from '../../../localization'; import { fill } from '../../../localization'; @@ -79,6 +80,28 @@ export function passwordComplexityMessage(failures: string[], settings: Settings return fill(messages.complexity.sentence, { requirements: list }); } +export function passwordFieldFeedback( + { complexity, strength }: PasswordValidation, + settings: Settings, + messages: Messages, + locale: string, +): FieldFeedback { + const failures = Object.entries(complexity ?? {}) + .filter(([, failed]) => failed) + .map(([code]) => code); + const message = passwordComplexityMessage(failures, settings, messages, locale); + if (message) { + return { type: complexity?.min_length ? 'info' : 'error', message }; + } + if (strength?.state === 'fail') { + return { type: 'error', message: passwordStrengthMessage(strength.result.feedback.suggestions, messages) }; + } + if (strength?.state === 'pass') { + return { type: 'warning', message: messages.rules.stronger }; + } + return { type: 'success', message: messages.rules.strong }; +} + export function passwordFormError( error: unknown, requiresCurrentPassword: boolean, diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 435460a345a..3ce2c44fa05 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -6,11 +6,7 @@ import type { FieldFeedback } from '../../../components/form'; import { useErrorText, useLocale, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { - passwordComplexityMessage, - passwordFormError, - passwordStrengthMessage, -} from './user-profile-password-feedback'; +import { passwordFieldFeedback, passwordFormError } from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; @@ -55,21 +51,8 @@ function PasswordEditor({ model }: { model: Extract => { - const { complexity, strength } = await validatePassword(password); - const failures = Object.entries(complexity ?? {}) - .filter(([, failed]) => failed) - .map(([code]) => code); - const message = passwordComplexityMessage(failures, passwordSettings, m, locale); - if (message) { - return { type: complexity?.min_length ? 'info' : 'error', message }; - } - if (strength?.state === 'fail') { - return { type: 'error', message: passwordStrengthMessage(strength.result.feedback.suggestions, m) }; - } - if (strength?.state === 'pass') { - return { type: 'warning', message: m.rules.stronger }; - } - return { type: 'success', message: m.rules.strong }; + const validation = await validatePassword(password); + return passwordFieldFeedback(validation, passwordSettings, m, locale); }, [validatePassword, passwordSettings, m, locale], ); From ac4287665bce1a00c06a2914427de2e7b1fa5edb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 20:13:53 -0600 Subject: [PATCH 37/91] refactor(mosaic): check hidden before readonly password status --- .../user-profile-password-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 3ce2c44fa05..eadf861389c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -22,6 +22,9 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw // TODO: Add a password section skeleton as the default loading fallback. return fallback; } + if (model.status === 'hidden') { + return null; + } if (model.status === 'readonly') { return ( ); } - if (model.status === 'hidden') { - return null; - } return ( Date: Wed, 30 Sep 2026 20:40:18 -0600 Subject: [PATCH 38/91] refactor(mosaic): require password settings before validating --- .../user-profile-password-section.model.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 8f75c0d4338..1acd2c12e2d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -9,7 +9,7 @@ import type { PasswordValidation, UserResource, } from '@clerk/shared/types'; -import { useCallback } from 'react'; +import { useMemo } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; @@ -77,11 +77,11 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { const environment = useMosaicEnvironment(); const passwordSettings = environment?.userSettings.passwordSettings; const moduleManager = clerk.__internal_moduleManager; - const validatePassword = useCallback( - async (password: string): Promise => { - if (!passwordSettings) { - return {}; - } + const validatePassword = useMemo(() => { + if (!passwordSettings) { + return undefined; + } + return async (password: string): Promise => { const complexity = validateComplexity(password, passwordSettings); if (Object.keys(complexity).length > 0 || !passwordSettings.show_zxcvbn || !moduleManager) { return { complexity }; @@ -89,11 +89,10 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { const { loadZxcvbn } = createLoadZxcvbn(moduleManager); const strength = createValidatePasswordStrength(passwordSettings)(await loadZxcvbn())(password); return { complexity, strength }; - }, - [passwordSettings, moduleManager], - ); + }; + }, [passwordSettings, moduleManager]); - if (!isUserLoaded || !isSessionLoaded || !environment) { + if (!isUserLoaded || !isSessionLoaded || !environment || !validatePassword) { return { status: 'loading' }; } @@ -107,7 +106,6 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { return { ...policy, - userId, sessionId, identifier: session?.publicUserData.identifier ?? '', passwordSettings: environment.userSettings.passwordSettings, From d3ac749617009f668f0565984a54ed8aa54bd0fa Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 20:49:21 -0600 Subject: [PATCH 39/91] refactor(mosaic): return plain data from password model --- ...ser-profile-password-section.model.test.ts | 30 ++++++++++++++++++- .../user-profile-password-section.model.ts | 19 +++++------- .../user-profile-password-section.tsx | 4 +-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index c0dddc3f360..3085b989ec1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -7,7 +7,7 @@ import { useUserProfilePasswordModel } from './user-profile-password-section.mod type TestUser = { id: string; passwordEnabled: boolean; - enterpriseAccounts: { active: boolean }[]; + enterpriseAccounts: { active: boolean; enterpriseConnection?: { name: string; logoPublicUrl: string | null } }[]; updatePassword: ReturnType; }; @@ -122,3 +122,31 @@ describe('useUserProfilePasswordModel context changes', () => { expect(user.updatePassword).not.toHaveBeenCalled(); }); }); + +describe('useUserProfilePasswordModel enterprise accounts', () => { + it('describes the managing connection as plain data', () => { + if (!user) { + throw new Error('expected user'); + } + user.enterpriseAccounts = [ + { active: false, enterpriseConnection: { name: 'Inactive', logoPublicUrl: null } }, + { active: true, enterpriseConnection: { name: 'Acme SSO', logoPublicUrl: 'https://example.com/acme.png' } }, + ]; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ + status: 'readonly', + mode: 'change', + reason: 'enterprise_account', + managedBy: { name: 'Acme SSO', iconUrl: 'https://example.com/acme.png' }, + }); + }); + + it('leaves a blank connection name and missing logo undefined', () => { + if (!user) { + throw new Error('expected user'); + } + user.enterpriseAccounts = [{ active: true, enterpriseConnection: { name: '', logoPublicUrl: null } }]; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ managedBy: { name: undefined, iconUrl: undefined } }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 1acd2c12e2d..41f0da549ce 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -2,13 +2,7 @@ import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/ import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; -import type { - EnterpriseAccountResource, - EnvironmentResource, - PasswordSettingsData, - PasswordValidation, - UserResource, -} from '@clerk/shared/types'; +import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; import { useMemo } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; @@ -25,7 +19,7 @@ type UnavailablePasswordModel = status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account'; - enterpriseConnection: EnterpriseAccountResource['enterpriseConnection']; + managedBy: { name?: string; iconUrl?: string }; }; export type UserProfilePasswordModel = @@ -38,7 +32,7 @@ export type UserProfilePasswordModel = identifier: string; passwordSettings: PasswordSettingsData; validatePassword: (password: string) => Promise; - updatePassword: (input: UserProfileEditPasswordValue) => Promise; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; }); function getPasswordPolicy( @@ -63,7 +57,10 @@ function getPasswordPolicy( status: 'readonly', mode: policy.mode, reason: 'enterprise_account', - enterpriseConnection: enterpriseAccount.enterpriseConnection, + managedBy: { + name: enterpriseAccount.enterpriseConnection?.name || undefined, + iconUrl: enterpriseAccount.enterpriseConnection?.logoPublicUrl ?? undefined, + }, }; } @@ -130,7 +127,7 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { throw new UserProfilePasswordUpdateError('current_password_required'); } - return currentUser.updatePassword({ + await currentUser.updatePassword({ newPassword, signOutOfOtherSessions, ...(policy.requiresCurrentPassword ? { currentPassword } : {}), diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index eadf861389c..91d33d7c7ad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -30,8 +30,8 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw ); From f3177c0838a83ec3f19c5430c35ca6d1c096a422 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 20:55:00 -0600 Subject: [PATCH 40/91] chore(mosaic): note current password reverification parity --- .../user-profile-password-section.model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 41f0da549ce..1af462153a8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -47,6 +47,7 @@ function getPasswordPolicy( return { status: 'hidden', reason: 'password_disabled' }; } + // TODO: When session reverification is supported, require the current password only when reverification is disabled. const policy: EditablePasswordPolicy = user.passwordEnabled ? { mode: 'change', requiresCurrentPassword: true } : { mode: 'set', requiresCurrentPassword: false }; From dcb61fd62974fb69f6a00b453c4e756c457c2925 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 21:04:02 -0600 Subject: [PATCH 41/91] refactor(mosaic): hide password section without a session --- .../user-profile-password-section.model.test.ts | 10 ++-------- .../user-profile-password-section.model.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index 3085b989ec1..dd43f63da5d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -110,16 +110,10 @@ describe('useUserProfilePasswordModel context changes', () => { }, ); - it('rejects an update when a loaded user has no active session', async () => { - if (!user) { - throw new Error('expected user'); - } + it('hides the section when a loaded user has no active session', () => { session = null; const { result } = renderHook(() => useUserProfilePasswordModel()); - await expect( - ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), - ).rejects.toMatchObject({ code: 'unavailable' }); - expect(user.updatePassword).not.toHaveBeenCalled(); + expect(result.current).toEqual({ status: 'hidden', reason: 'no_user' }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 1af462153a8..e6c0ae1ef05 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -28,7 +28,7 @@ export type UserProfilePasswordModel = | (EditablePasswordPolicy & { status: 'ready'; userId: string; - sessionId: string | null; + sessionId: string; identifier: string; passwordSettings: PasswordSettingsData; validatePassword: (password: string) => Promise; @@ -94,18 +94,22 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { return { status: 'loading' }; } + if (!session) { + return { status: 'hidden', reason: 'no_user' }; + } + const policy = getPasswordPolicy(user, environment); if (policy.status !== 'ready') { return policy; } const userId = policy.userId; - const sessionId = session?.id ?? null; + const sessionId = session.id; return { ...policy, sessionId, - identifier: session?.publicUserData.identifier ?? '', + identifier: session.publicUserData.identifier ?? '', passwordSettings: environment.userSettings.passwordSettings, validatePassword, // TODO: Add session reverification for password updates; surface API errors until then. @@ -115,7 +119,6 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { if ( !currentUser || currentUser.id !== userId || - !sessionId || clerk.session?.id !== sessionId || currentPolicy.status !== 'ready' || currentPolicy.mode !== policy.mode || From 3b119b56492984ff043e02cc30b8bd0c19dcf662 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 21:26:45 -0600 Subject: [PATCH 42/91] fix(mosaic): only report strong passwords after a strength check --- .../user-profile-password-feedback.test.ts | 10 ++++++++-- .../user-profile-password-feedback.ts | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index bb1b220c7d1..ecf1e5fa4bb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -217,7 +217,7 @@ describe('password error feedback', () => { describe('password field feedback', () => { const feedback = (validation: Parameters[0]) => - passwordFieldFeedback(validation, settings, messages, localization.locale); + passwordFieldFeedback(validation, { ...settings, show_zxcvbn: true }, messages, localization.locale); const strength = (state: PasswordStrength['state'], suggestions: string[] = []): PasswordStrength => ({ state, keys: [], @@ -259,6 +259,12 @@ describe('password field feedback', () => { type: 'success', message: messages.rules.strong, }); - expect(feedback({ complexity: {} })).toMatchObject({ type: 'success' }); + }); + + it('only reports a strong password when strength was checked or is disabled', () => { + expect(feedback({ complexity: {} })).toBeUndefined(); + expect( + passwordFieldFeedback({ complexity: {} }, { ...settings, show_zxcvbn: false }, messages, localization.locale), + ).toEqual({ type: 'success', message: messages.rules.strong }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index c15e3f1f0f4..66554aefaac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -82,10 +82,10 @@ export function passwordComplexityMessage(failures: string[], settings: Settings export function passwordFieldFeedback( { complexity, strength }: PasswordValidation, - settings: Settings, + settings: Settings & Pick, messages: Messages, locale: string, -): FieldFeedback { +): FieldFeedback | undefined { const failures = Object.entries(complexity ?? {}) .filter(([, failed]) => failed) .map(([code]) => code); @@ -99,6 +99,9 @@ export function passwordFieldFeedback( if (strength?.state === 'pass') { return { type: 'warning', message: messages.rules.stronger }; } + if (!strength && settings.show_zxcvbn) { + return undefined; + } return { type: 'success', message: messages.rules.strong }; } From d03939e6ab26d319384f8332e7e14522a1e708ba Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 21:52:21 -0600 Subject: [PATCH 43/91] fix(mosaic): drop managed-by logo removed on main --- .../__tests__/user-profile-password.feature.test.tsx | 2 +- .../user-profile-password-section.model.test.ts | 6 +++--- .../user-profile-password-section.model.ts | 7 ++----- .../user-profile-password-section.tsx | 5 +---- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index b18b7572ceb..9b69ddb21f4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -158,7 +158,7 @@ describe('Changing a password', () => { }), ); - expect(screen.getByText('Managed by your enterprise connection')).toBeVisible(); + expect(screen.getByText('Managed by your enterprise connection')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index dd43f63da5d..f06ed19a8b1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -131,16 +131,16 @@ describe('useUserProfilePasswordModel enterprise accounts', () => { status: 'readonly', mode: 'change', reason: 'enterprise_account', - managedBy: { name: 'Acme SSO', iconUrl: 'https://example.com/acme.png' }, + managedBy: { name: 'Acme SSO' }, }); }); - it('leaves a blank connection name and missing logo undefined', () => { + it('leaves a blank connection name undefined', () => { if (!user) { throw new Error('expected user'); } user.enterpriseAccounts = [{ active: true, enterpriseConnection: { name: '', logoPublicUrl: null } }]; const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toMatchObject({ managedBy: { name: undefined, iconUrl: undefined } }); + expect(result.current).toMatchObject({ managedBy: { name: undefined } }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index e6c0ae1ef05..e61dc92b51a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -19,7 +19,7 @@ type UnavailablePasswordModel = status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account'; - managedBy: { name?: string; iconUrl?: string }; + managedBy: { name?: string }; }; export type UserProfilePasswordModel = @@ -58,10 +58,7 @@ function getPasswordPolicy( status: 'readonly', mode: policy.mode, reason: 'enterprise_account', - managedBy: { - name: enterpriseAccount.enterpriseConnection?.name || undefined, - iconUrl: enterpriseAccount.enterpriseConnection?.logoPublicUrl ?? undefined, - }, + managedBy: { name: enterpriseAccount.enterpriseConnection?.name || undefined }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 91d33d7c7ad..62cc3b31d28 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -29,10 +29,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw return ( ); } From 4a550bdb5ddce143cd78f9cfa1f273eff717a548 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 12:39:13 -0600 Subject: [PATCH 44/91] test(mosaic): remove synthetic password loading test --- .../user-profile-password-loading.test.tsx | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx deleted file mode 100644 index b12456d5e47..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type * as SharedReact from '@clerk/shared/react'; -import { ClerkInstanceContext } from '@clerk/shared/react'; -import type { LoadedClerk } from '@clerk/shared/types'; -import { render, screen } from '@testing-library/react'; -import { expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; - -const user = { - id: 'user_1', - passwordEnabled: true, - enterpriseAccounts: [], - updatePassword: vi.fn(), -}; -const session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; -const clerk = { - user, - session, - __internal_environment: { - userSettings: { - instanceIsPasswordBased: true, - passwordSettings: { - min_length: 8, - max_length: 72, - show_zxcvbn: false, - min_zxcvbn_strength: 3, - require_uppercase: false, - require_numbers: false, - }, - }, - displayConfig: { preferredSignInStrategy: 'password' }, - }, - __internal_moduleManager: {}, -}; -let isSessionLoaded = true; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useClerk: () => clerk, - useUser: () => ({ isLoaded: true, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - }; -}); - -function tree() { - return ( - - - Loading password section} /> - - - ); -} - -it('shows the fallback when session data starts loading after the section is ready', () => { - isSessionLoaded = true; - const { rerender } = render(tree()); - expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument(); - - isSessionLoaded = false; - rerender(tree()); - expect(screen.getByText('Loading password section')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); -}); From e6a6153911f5e65571d8f7934c1cb0f0d255474f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 12:41:50 -0600 Subject: [PATCH 45/91] chore: remove redundant password changeset --- .changeset/quiet-password-messages.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .changeset/quiet-password-messages.md diff --git a/.changeset/quiet-password-messages.md b/.changeset/quiet-password-messages.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/quiet-password-messages.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- From f0e6f94882876befb654921504d87dc0c1daafe1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 12:47:58 -0600 Subject: [PATCH 46/91] test(mosaic): remove password setup from profile view tests --- .../user-profile-security-panel.view.test.tsx | 11 ++--------- .../user-profile/__tests__/user-profile.view.test.tsx | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 4cbe3ba7444..1b34b926232 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -4,12 +4,10 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; const props: UserProfileSecurityPanelViewProps = { - passwordSlot: , passkeys: [ { id: 'passkey_1', @@ -66,8 +64,6 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Active devices' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Danger zone' })).toBeInTheDocument(); - expect(within(authentication).getByRole('heading', { level: 3, name: 'Password' })).toBeInTheDocument(); - expect(within(authentication).getByRole('group', { name: 'Password' })).toBeInTheDocument(); expect(within(authentication).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeInTheDocument(); expect(within(authentication).getByRole('heading', { level: 3, name: '2-step verification' })).toBeInTheDocument(); expect(within(authentication).getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); @@ -163,7 +159,6 @@ describe('UserProfileSecurityPanelView', () => { it('keeps supported empty authentication methods actionable', () => { renderView({ - passwordSlot: undefined, passkeys: [], mfaMethods: [], devices: [], @@ -177,7 +172,6 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.getByRole('button', { name: 'Add passkey' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add verification method' })).toBeInTheDocument(); expect(screen.getByText('No current device available')).toBeInTheDocument(); - expect(screen.queryByText('Password')).not.toBeInTheDocument(); }); it('withholds sign out from the current device', async () => { @@ -191,7 +185,6 @@ describe('UserProfileSecurityPanelView', () => { it('keeps the authentication section on MFA when existing passkeys are hidden', () => { renderView({ - passwordSlot: undefined, passkeysVisible: false, onAddPasskey: vi.fn(), onRenamePasskey: vi.fn(), @@ -206,7 +199,7 @@ describe('UserProfileSecurityPanelView', () => { }); it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => { - renderView({ passwordSlot: undefined, passkeys: [], onAddPasskey: undefined }); + renderView({ passkeys: [], onAddPasskey: undefined }); const section = screen.getByRole('region', { name: 'Authentication' }); expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); @@ -221,7 +214,7 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(async () => { await removal.promise; }); - const { rerender } = renderView({ passwordSlot: undefined, mfaMethods: undefined, onRemovePasskey }); + const { rerender } = renderView({ mfaMethods: undefined, onRemovePasskey }); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index e37381e2a6f..d3f955cb90d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -6,11 +6,10 @@ import { Dialog } from '../../../components/dialog'; import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileViewProps } from '../user-profile.view'; import { UserProfileView } from '../user-profile.view'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; const pages: UserProfileViewProps['pages'] = { account: { name: 'Preston Booth', username: 'prestonxyz' }, - security: { passwordSlot: }, + security: {}, billing: { subscription: { planName: 'Basic Plan', From 8d43be09eb4648813343ef317f5b3dad058b547a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 24 Sep 2026 16:12:26 -0600 Subject: [PATCH 47/91] feat(mosaic): add account password model and policy tests --- ...ser-profile-password-section.model.test.ts | 299 ++++++++++++++++++ .../user-profile-password-section.model.ts | 145 +++++++++ 2 files changed, 444 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts new file mode 100644 index 00000000000..4274d235350 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -0,0 +1,299 @@ +import type { PasswordSettingsData } from '@clerk/shared/types'; +import { createDeferredPromise } from '@clerk/shared/utils'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfilePasswordModel } from './user-profile-password-section.model'; + +type TestUser = { + id: string; + passwordEnabled: boolean; + enterpriseAccounts: { active: boolean }[]; + updatePassword: ReturnType; +}; + +type TestSession = { id: string; publicUserData: { identifier: string | null } }; + +const passwordSettings: PasswordSettingsData = { + min_length: 8, + max_length: 72, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + require_special_char: false, + allowed_special_characters: '', + disable_hibp: false, + show_zxcvbn: true, + min_zxcvbn_strength: 2, +}; + +function createEnvironment() { + return { + userSettings: { instanceIsPasswordBased: true, passwordSettings }, + authConfig: { reverification: false }, + }; +} + +let isUserLoaded: boolean; +let isSessionLoaded: boolean; +let user: TestUser | null; +let session: TestSession | null; +let environment: ReturnType | undefined; + +const clerk = { + get user() { + return user; + }, + get session() { + return session; + }, + get __internal_environment() { + return environment; + }, +}; + +vi.mock('@clerk/shared/react', () => ({ + useClerk: () => clerk, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), +})); + +beforeEach(() => { + isUserLoaded = true; + isSessionLoaded = true; + user = { id: 'user_1', passwordEnabled: false, enterpriseAccounts: [], updatePassword: vi.fn() }; + session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; + environment = createEnvironment(); +}); + +afterEach(cleanup); + +function ready(model: ReturnType) { + expect(model.status).toBe('ready'); + if (model.status !== 'ready') { + throw new Error('expected ready model'); + } + return model; +} + +describe('useUserProfilePasswordModel', () => { + it.each([undefined, ''])('rejects a missing required current password (%j)', async currentPassword => { + if (!user) { + throw new Error('expected user'); + } + user.passwordEnabled = true; + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ + currentPassword, + newPassword: 'new password', + signOutOfOtherSessions: true, + }), + ).rejects.toThrow('Current password is required.'); + expect(user.updatePassword).not.toHaveBeenCalled(); + }); + + it.each([ + 'signed out', + 'different user', + 'different session', + 'no session', + 'disabled', + 'enterprise', + 'mode', + 'proof', + ])('rejects a captured action after the context changes: %s', async change => { + if (!user || !session || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = true; + const updatePassword = user.updatePassword; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const action = ready(result.current).updatePassword; + + switch (change) { + case 'signed out': + user = null; + break; + case 'different user': + user = { ...user, id: 'user_2' }; + break; + case 'different session': + session = { ...session, id: 'session_2' }; + break; + case 'no session': + session = null; + break; + case 'disabled': + environment.userSettings.instanceIsPasswordBased = false; + break; + case 'enterprise': + user.enterpriseAccounts = [{ active: true }]; + break; + case 'mode': + user.passwordEnabled = false; + break; + case 'proof': + environment.authConfig.reverification = true; + break; + } + + const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; + await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + rerender(); + await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + expect(updatePassword).not.toHaveBeenCalled(); + }); + + it('rejects updates when a loaded user has no active session', async () => { + if (!user) { + throw new Error('expected user'); + } + session = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), + ).rejects.toThrow('Password update is no longer available.'); + expect(user.updatePassword).not.toHaveBeenCalled(); + }); + + it.each([ + { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true, currentPassword: undefined }, + { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false, currentPassword: undefined }, + { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false, currentPassword: ' old secret ' }, + { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true, currentPassword: undefined }, + ])('sends the legacy payload for %j', async policy => { + if (!user || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = policy.passwordEnabled; + environment.authConfig.reverification = policy.reverification; + const { result } = renderHook(() => useUserProfilePasswordModel()); + const input = { + currentPassword: ' old secret ', + newPassword: ' new e\u0301 secret ', + confirmPassword: 'must never reach the SDK', + signOutOfOtherSessions: policy.signOutOfOtherSessions, + }; + + await ready(result.current).updatePassword(input); + + const expected = { + newPassword: ' new e\u0301 secret ', + signOutOfOtherSessions: policy.signOutOfOtherSessions, + }; + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith( + policy.currentPassword === undefined ? expected : { ...expected, currentPassword: ' old secret ' }, + ); + }); + + it('waits for the SDK resource and derives change mode only after hydration', async () => { + if (!user) { + throw new Error('expected user'); + } + const request = createDeferredPromise(); + user.updatePassword.mockReturnValueOnce(request.promise); + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const promise = ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }); + expect(ready(result.current).mode).toBe('set'); + expect(user.passwordEnabled).toBe(false); + + user = { ...user, passwordEnabled: true }; + request.resolve(user); + await expect(promise).resolves.toBe(user); + rerender(); + expect(ready(result.current).mode).toBe('change'); + }); + + it.each([ + { errors: [{ code: 'form_password_incorrect', meta: { paramName: 'current_password' } }] }, + { errors: [{ code: 'session_reverification_required' }] }, + new Error('network failure'), + ])('preserves the SDK rejection for the caller', async error => { + if (!user) { + throw new Error('expected user'); + } + user.updatePassword.mockRejectedValueOnce(error); + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), + ).rejects.toBe(error); + }); + + it.each([ + { passwordEnabled: false, reverification: false, mode: 'set', requiresCurrentPassword: false }, + { passwordEnabled: false, reverification: true, mode: 'set', requiresCurrentPassword: false }, + { passwordEnabled: true, reverification: false, mode: 'change', requiresCurrentPassword: true }, + { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: false }, + ])('derives $mode mode with reverification=$reverification', policy => { + if (!user || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = policy.passwordEnabled; + environment.authConfig.reverification = policy.reverification; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ + status: 'ready', + mode: policy.mode, + requiresCurrentPassword: policy.requiresCurrentPassword, + userId: 'user_1', + sessionId: 'session_1', + identifier: 'person@example.com', + passwordSettings, + }); + }); + + it('keeps a loaded user visible when there is no session identifier', () => { + session = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ status: 'ready', sessionId: null, identifier: '' }); + }); + + it.each([false, true])('makes active enterprise accounts readonly with passwordEnabled=%s', passwordEnabled => { + if (!user) { + throw new Error('expected user'); + } + user.passwordEnabled = passwordEnabled; + user.enterpriseAccounts = [{ active: false }, { active: true }, { active: true }]; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ + status: 'readonly', + reason: 'enterprise_account', + mode: passwordEnabled ? 'change' : 'set', + }); + + user.enterpriseAccounts = [{ active: false }]; + rerender(); + expect(result.current.status).toBe('ready'); + }); + + it('hides the section when the loaded user is absent', () => { + user = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ status: 'hidden', reason: 'no_user' }); + }); + + it.each([false, true])('hides disabled instance passwords when user passwordEnabled is %s', passwordEnabled => { + if (!user || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = passwordEnabled; + environment.userSettings.instanceIsPasswordBased = false; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ status: 'hidden', reason: 'password_disabled' }); + }); + + it.each(['user', 'session', 'environment'])('waits for %s to load', resource => { + if (resource === 'user') { + isUserLoaded = false; + } else if (resource === 'session') { + isSessionLoaded = false; + } else { + environment = undefined; + } + + const { result } = renderHook(() => useUserProfilePasswordModel()); + + expect(result.current).toEqual({ status: 'loading' }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts new file mode 100644 index 00000000000..25a0cbb57de --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -0,0 +1,145 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/passwords/complexity'; +import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; +import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; +import { useClerk, useSession, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; +import { useCallback } from 'react'; + +import { FormSubmitError } from '../../../components/form'; +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; + +export function passwordFormError(error: unknown, requiresCurrentPassword: boolean): unknown { + if (!isClerkAPIResponseError(error)) { + return error; + } + const fields: { currentPassword?: string; newPassword?: string } = {}; + let message: string | undefined; + for (const item of error.errors) { + const text = item.longMessage || item.message; + const name = item.meta?.paramName; + if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { + fields.currentPassword ??= text; + } else if (name === 'new_password' || name === 'newPassword' || name === 'password') { + fields.newPassword ??= text; + } else { + message ??= text; + } + } + return new FormSubmitError({ message, fields }); +} + +type EditablePasswordPolicy = + | { mode: 'set'; requiresCurrentPassword: false } + | { mode: 'change'; requiresCurrentPassword: boolean }; + +type UnavailablePasswordModel = + | { status: 'hidden'; reason: 'no_user' | 'password_disabled' } + | { status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account' }; + +export type UserProfilePasswordModel = + | { status: 'loading' } + | UnavailablePasswordModel + | (EditablePasswordPolicy & { + status: 'ready'; + userId: string; + sessionId: string | null; + identifier: string; + passwordSettings: PasswordSettingsData; + validatePassword: (password: string) => Promise; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; + }); + +function getPasswordPolicy( + user: UserResource | null | undefined, + environment: EnvironmentResource, +): UnavailablePasswordModel | (EditablePasswordPolicy & { status: 'ready'; userId: string }) { + if (!user) { + return { status: 'hidden', reason: 'no_user' }; + } + + if (!environment.userSettings.instanceIsPasswordBased) { + return { status: 'hidden', reason: 'password_disabled' }; + } + + const policy: EditablePasswordPolicy = user.passwordEnabled + ? { mode: 'change', requiresCurrentPassword: !environment.authConfig.reverification } + : { mode: 'set', requiresCurrentPassword: false }; + + if (user.enterpriseAccounts.some(account => account.active)) { + return { status: 'readonly', mode: policy.mode, reason: 'enterprise_account' }; + } + + return { status: 'ready', userId: user.id, ...policy }; +} + +export function useUserProfilePasswordModel(): UserProfilePasswordModel { + const clerk = useClerk(); + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + const passwordSettings = environment?.userSettings.passwordSettings; + const moduleManager = clerk.__internal_moduleManager; + const validatePassword = useCallback( + async (password: string): Promise => { + if (!passwordSettings) { + return {}; + } + const complexity = validateComplexity(password, passwordSettings); + if (Object.keys(complexity).length > 0 || !passwordSettings.show_zxcvbn || !moduleManager) { + return { complexity }; + } + const { loadZxcvbn } = createLoadZxcvbn(moduleManager); + const strength = createValidatePasswordStrength(passwordSettings)(await loadZxcvbn())(password); + return { complexity, strength }; + }, + [passwordSettings, moduleManager], + ); + + if (!isUserLoaded || !isSessionLoaded || !environment) { + return { status: 'loading' }; + } + + const policy = getPasswordPolicy(user, environment); + if (policy.status !== 'ready') { + return policy; + } + + const userId = policy.userId; + const sessionId = session?.id ?? null; + + return { + ...policy, + userId, + sessionId, + identifier: session?.publicUserData.identifier ?? '', + passwordSettings: environment.userSettings.passwordSettings, + validatePassword, + updatePassword: async ({ currentPassword, newPassword, signOutOfOtherSessions }) => { + const currentUser = clerk.user; + const currentPolicy = getPasswordPolicy(currentUser, environment); + if ( + !currentUser || + currentUser.id !== userId || + !sessionId || + clerk.session?.id !== sessionId || + currentPolicy.status !== 'ready' || + currentPolicy.mode !== policy.mode || + currentPolicy.requiresCurrentPassword !== policy.requiresCurrentPassword + ) { + throw new Error('Password update is no longer available.'); + } + + if (policy.requiresCurrentPassword && !currentPassword) { + throw new Error('Current password is required.'); + } + + return currentUser.updatePassword({ + newPassword, + signOutOfOtherSessions, + ...(policy.requiresCurrentPassword ? { currentPassword } : {}), + }); + }, + }; +} From 5278a9f089473101978f380675ad7e1075f885f9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 24 Sep 2026 16:12:43 -0600 Subject: [PATCH 48/91] feat(mosaic): support password feedback and verification in the editor --- ...r-profile-edit-password.controller.test.ts | 158 +++++++++++++- .../user-profile-edit-password.controller.ts | 63 +++++- .../user-profile-edit-password.dialog.tsx | 206 ++++++++++-------- .../user-profile-password-row.view.tsx | 10 +- .../user-profile-password-section.types.ts | 2 + .../user-profile-password-section.view.tsx | 4 +- 6 files changed, 331 insertions(+), 112 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index b82f79e1d5a..83f52e86d23 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,20 +1,23 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileEditPasswordSubmitResult } from './user-profile-edit-password.controller'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -function deferred() { - let resolve: () => void = () => {}; - const promise = new Promise(r => { +function deferred() { + let resolve: (result: T) => void = () => {}; + const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), + onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => + Promise.resolve({ status: 'saved' }), requiresCurrentPassword = true, ) { return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); @@ -33,6 +36,147 @@ function fill(result: Controller, { current = 'old-secret', next = 'new-secret-1 } describe('useUserProfileEditPasswordController', () => { + it('ignores older password feedback and clears it when the editor closes', async () => { + const older = deferred(); + const newer = deferred(); + const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ + onSubmit: () => Promise.resolve({ status: 'saved' }), + validatePassword, + }), + ); + open(result); + fill(result, { next: 'first password' }); + await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('first password')); + fill(result, { next: 'second password' }); + await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('second password')); + + await act(async () => { + newer.resolve({ type: 'success', message: 'Strong password.' }); + await newer.promise; + }); + await act(async () => { + older.resolve({ type: 'warning', message: 'Weak password.' }); + await older.promise; + }); + + expect(result.current.passwordFeedback).toEqual({ type: 'success', message: 'Strong password.' }); + act(() => result.current.onOpenChange(false)); + expect(result.current.passwordFeedback).toBeUndefined(); + }); + + it('can submit while an advisory password check is pending', async () => { + const check = deferred(); + const validatePassword = vi.fn(() => check.promise); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit, validatePassword })); + open(result); + fill(result); + await waitFor(() => expect(validatePassword).toHaveBeenCalled()); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + await act(async () => { + check.resolve({ type: 'error', message: 'Too short.' }); + await check.promise; + }); + expect(result.current.passwordFeedback).toBeUndefined(); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('ignores dismissal and duplicate saves in the same event as submission', async () => { + const save = deferred(); + const onSubmit = vi.fn(() => save.promise); + const { result } = renderController(onSubmit); + open(result); + fill(result); + + act(() => { + result.current.form.submit(); + result.current.onOpenChange(false); + result.current.form.submit(); + }); + + expect(result.current.isOpen).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); + await act(async () => { + save.resolve({ status: 'saved' }); + await save.promise; + }); + await waitFor(() => expect(result.current.isOpen).toBe(false)); + }); + + it('shows password-rule feedback without blocking an otherwise valid submission', async () => { + const validatePassword = vi.fn(() => + Promise.resolve({ + type: 'warning', + message: 'Try a longer password.', + }), + ); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit, validatePassword }), + ); + open(result); + fill(result, { next: ' new secret ' }); + + await waitFor(() => + expect(result.current.passwordFeedback).toEqual({ type: 'warning', message: 'Try a longer password.' }), + ); + expect(validatePassword).toHaveBeenCalledWith(' new secret '); + expect(result.current.form.canSubmit).toBe(true); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + expect(onSubmit).toHaveBeenCalledWith({ + currentPassword: 'old-secret', + newPassword: ' new secret ', + signOutOfOtherSessions: true, + }); + }); + + it('returns to editing without losing the draft when the flow cancels verification', async () => { + const { result } = renderHook(() => + useUserProfileEditPasswordController({ + requiresCurrentPassword: true, + onSubmit: () => Promise.resolve({ status: 'cancelled' }), + }), + ); + open(result); + fill(result); + act(() => result.current.form.setValue('signOutOfOtherSessions', false)); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.form.isSubmitting).toBe(false)); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.error).toBeUndefined(); + expect(result.current.form.values).toEqual({ + currentPassword: 'old-secret', + newPassword: 'new-secret-123', + confirmPassword: 'new-secret-123', + signOutOfOtherSessions: false, + }); + expect(result.current.form.canSubmit).toBe(true); + }); + + it('blocks direct submission with an empty confirmation', () => { + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const { result } = renderController(onSubmit); + open(result); + fill(result); + act(() => result.current.form.setValue('confirmPassword', '')); + + act(() => result.current.form.submit()); + + expect(result.current.form.canSubmit).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isOpen).toBe(true); + }); + it('opens with empty fields and sign-out of other devices on', () => { const { result } = renderController(); expect(result.current.isOpen).toBe(false); @@ -51,7 +195,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); const { result } = renderController(onSubmit); open(result); fill(result); @@ -70,7 +214,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('leaves the current password out when reverification stands in for it', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); const { result } = renderController(onSubmit, false); open(result); act(() => result.current.form.setValue('newPassword', 'new-secret-123')); @@ -163,7 +307,7 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.isOpen).toBe(true); await act(async () => { - save.resolve(); + save.resolve({ status: 'saved' }); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index eeb5ab7c25d..75ba6b5b65b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; +import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; import type { UserProfileEditPasswordValue, @@ -15,23 +16,30 @@ const initialValues: UserProfileEditPasswordValues = { signOutOfOtherSessions: true, }; +export type UserProfileEditPasswordSubmitResult = { status: 'saved' } | { status: 'cancelled' }; + export interface UserProfileEditPasswordControllerOptions { requiresCurrentPassword?: boolean; - onSubmit: (value: UserProfileEditPasswordValue) => Promise; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; + validatePassword?: (password: string) => Promise; } export interface UserProfileEditPasswordController { isOpen: boolean; onOpenChange: (open: boolean) => void; form: UseFormResult; + passwordFeedback: FieldFeedback | undefined; } export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, + validatePassword, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); + const [passwordFeedback, setPasswordFeedback] = useState(); + const submitting = useRef(false); const form = useForm({ initialValues, @@ -41,24 +49,57 @@ export function useUserProfileEditPasswordController({ value !== '' && value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, }, }, - canSubmit: values => values.newPassword !== '' && (!requiresCurrentPassword || values.currentPassword !== ''), + canSubmit: values => + values.newPassword !== '' && + values.confirmPassword === values.newPassword && + (!requiresCurrentPassword || values.currentPassword !== ''), onSubmit: async values => { - await onSubmit({ - currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, - newPassword: values.newPassword, - signOutOfOtherSessions: values.signOutOfOtherSessions, - }); - setIsOpen(false); + submitting.current = true; + try { + const result = await onSubmit({ + currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, + newPassword: values.newPassword, + signOutOfOtherSessions: values.signOutOfOtherSessions, + }); + if (result.status === 'saved') { + setIsOpen(false); + } + } finally { + submitting.current = false; + } }, }); + const password = form.values.newPassword; + useEffect(() => { + setPasswordFeedback(undefined); + if (!isOpen || password === '' || !validatePassword) { + return; + } + + let active = true; + void Promise.resolve() + .then(() => validatePassword(password)) + .then( + feedback => { + if (active) { + setPasswordFeedback(feedback); + } + }, + () => {}, + ); + return () => { + active = false; + }; + }, [isOpen, password, validatePassword]); + const onOpenChange = (open: boolean) => { - if (!open && form.isSubmitting) { + if (submitting.current || form.isSubmitting) { return; } form.reset(); setIsOpen(open); }; - return { isOpen, onOpenChange, form }; + return { isOpen, onOpenChange, form, passwordFeedback }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index f0fd1f94040..b88e792ecc3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,6 +1,6 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; @@ -9,7 +9,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import type { UseFormResult } from '../../../components/form'; +import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; @@ -21,6 +21,9 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { + children?: ReactNode; + passwordFeedback?: FieldFeedback; + identifier?: string; open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -30,6 +33,9 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ + children, + passwordFeedback, + identifier = '', open, onOpenChange, trigger, @@ -57,97 +63,109 @@ export function UserProfileEditPasswordDialog({ elevation='overlay' renderBranding={false} > - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - } - > - {form.error ? ( - + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } > - {form.error} - - ) : null} - {showCurrentPassword ? ( - - ) : null} - - -
- form.setValue('signOutOfOtherSessions', event.target.checked)} - /> -
- } - size='sm' - xstyle={styles.checkboxLabel} - > - {m.signOutOfOtherSessionsLabel} - - - {m.signOutOfOtherSessionsDescription} - -
-
-
- - + + )} @@ -160,12 +178,14 @@ function PasswordField({ form, inputRef, name, + advisoryFeedback, }: { label: string; autoComplete: 'current-password' | 'new-password'; form: UseFormResult; inputRef?: RefObject; name: UserProfileEditPasswordField; + advisoryFeedback?: FieldFeedback; }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); @@ -198,7 +218,11 @@ function PasswordField({ - {error ? {error} : null} + {error ? ( + {error} + ) : advisoryFeedback ? ( + {advisoryFeedback.message} + ) : null} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 45572922b09..09aff9ba276 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -15,6 +15,7 @@ import type { } from './user-profile-password-section.types'; export function UserProfilePasswordRowView({ + action, hasPassword = false, requiresCurrentPassword = false, managedBy, @@ -27,7 +28,9 @@ export function UserProfilePasswordRowView({ {hasPassword ? m.masked : m.noPasswordSet} - {managedBy ? ( + {action ? ( + {action} + ) : managedBy ? ( @@ -78,7 +81,10 @@ function EditPassword({ const m = useMessages('userProfilePasswordSection'); const controller = useUserProfileEditPasswordController({ requiresCurrentPassword: hasPassword && requiresCurrentPassword, - onSubmit, + onSubmit: async value => { + await onSubmit(value); + return { status: 'saved' }; + }, }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 44fc4da20c6..f4823a9080e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -18,9 +18,11 @@ export interface UserProfilePasswordManagedBy { } export interface UserProfilePasswordSectionViewProps { + action?: ReactNode; hasPassword?: boolean; requiresCurrentPassword?: boolean; /** Replaces the edit action with the enterprise provider’s name. */ managedBy?: UserProfilePasswordManagedBy; onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; } +import type { ReactNode } from 'react'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index f941a18e8f4..d453b40def7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -12,13 +12,14 @@ export type { } from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ + action, hasPassword = false, requiresCurrentPassword = false, managedBy, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { const m = useMessages('userProfilePasswordSection'); - if (!hasPassword && !managedBy && !onSubmitPassword) { + if (!hasPassword && !managedBy && !onSubmitPassword && !action) { return null; } @@ -29,6 +30,7 @@ export function UserProfilePasswordSectionView({ Date: Thu, 24 Sep 2026 16:12:59 -0600 Subject: [PATCH 49/91] feat(mosaic): connect password editing to account updates and reverification --- .changeset/calm-password-controller.md | 2 + .../src/components/field/field.test.tsx | 18 ++ .../mosaic/src/components/field/field.tsx | 30 +- packages/mosaic/src/components/field/index.ts | 1 + ...file-password-section.integration.test.tsx | 298 ++++++++++++++++++ .../user-profile-edit-password.dialog.tsx | 249 ++++++++------- .../user-profile-password-section.messages.ts | 14 + .../user-profile-password-section.tsx | 149 +++++++++ packages/mosaic/src/utils/feedback.styles.ts | 3 + 9 files changed, 650 insertions(+), 114 deletions(-) create mode 100644 .changeset/calm-password-controller.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx diff --git a/.changeset/calm-password-controller.md b/.changeset/calm-password-controller.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-password-controller.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index 22572b7b628..537d1b37a0d 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -37,6 +37,24 @@ function stubPrototype(target: object, name: string, descriptor: PropertyDescrip } describe('Mosaic Field', () => { + it('associates text-only info feedback with the control in a live region', () => { + render( + + Password + + + Try a longer password. + + , + ); + + expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Try a longer password.'); + expect(screen.getByRole('status')).toHaveTextContent('Try a longer password.'); + const info = screen.getByText('Try a longer password.').closest('p'); + expect(info).toHaveClass('cl-field-info'); + expect(info?.querySelector('svg')).toBeNull(); + }); + afterEach(() => { restores.splice(0).forEach(restore => restore()); }); diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 6f792681cda..6a5b55ea08f 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -202,11 +202,12 @@ const Message = React.forwardRef(function Mos return {rendered}; }); -type FieldFeedbackKind = 'error' | 'success'; +type FieldFeedbackKind = 'error' | 'success' | 'info'; -const FEEDBACK: Record = { +const FEEDBACK: Record = { error: { slot: 'field-error', icon: 'exclamation-circle', color: feedbackStyles.error }, success: { slot: 'field-success', icon: 'checkmark', color: feedbackStyles.success }, + info: { slot: 'field-info', color: feedbackStyles.info }, }; interface FieldFeedbackProps extends MosaicComponentProps<'p'> { @@ -240,7 +241,7 @@ const FieldFeedback = React.forwardRef rest, ), id, - children: {message}, + children: icon ? {message} : message, }, }); }); @@ -271,5 +272,26 @@ const FieldSuccess = React.forwardRef(f ); }); +export type FieldInfoProps = MosaicComponentProps<'p'>; + +const FieldInfo = React.forwardRef(function MosaicFieldInfo(props, ref) { + return ( + + ); +}); + /** Styled parts for composing an automatically associated single-control field. */ -export const Field = { Root, Label, Content, Description, Message, Error: FieldError, Success: FieldSuccess }; +export const Field = { + Root, + Label, + Content, + Description, + Message, + Error: FieldError, + Success: FieldSuccess, + Info: FieldInfo, +}; diff --git a/packages/mosaic/src/components/field/index.ts b/packages/mosaic/src/components/field/index.ts index 95609821d13..98780409ce4 100644 --- a/packages/mosaic/src/components/field/index.ts +++ b/packages/mosaic/src/components/field/index.ts @@ -2,6 +2,7 @@ export { Field } from './field'; export type { FieldDescriptionProps, FieldErrorProps, + FieldInfoProps, FieldLabelProps, FieldMessageProps, FieldRootProps, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx new file mode 100644 index 00000000000..48162056af0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -0,0 +1,298 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { ClerkInstanceContext } from '@clerk/shared/react'; +import type { LoadedClerk } from '@clerk/shared/types'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; +import type { UserProfileEditPasswordValue } from '../user-profile-password-section/user-profile-password-section.types'; + +const updatePassword = vi.fn<(input: UserProfileEditPasswordValue) => Promise>(); +const user: { + id: string; + passwordEnabled: boolean; + enterpriseAccounts: { active: boolean }[]; + updatePassword: typeof updatePassword; +} = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword }; +const session = { + id: 'session_1', + publicUserData: { identifier: 'person@example.com' }, + startVerification: vi.fn(), + attemptFirstFactorVerification: vi.fn(), +}; +const environment = { + userSettings: { + instanceIsPasswordBased: true, + passwordSettings: { min_length: 8, max_length: 72, show_zxcvbn: false }, + }, + authConfig: { reverification: true }, + displayConfig: { preferredSignInStrategy: 'password', supportEmail: 'support@example.com' }, +}; +const clerk = { + user, + session, + __internal_environment: environment, + __internal_getOption: () => undefined, + setActive: vi.fn(), +}; +let isSessionLoaded = true; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => clerk, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + isSessionLoaded = true; + user.updatePassword.mockReset(); + user.passwordEnabled = true; + user.enterpriseAccounts = []; + environment.authConfig.reverification = true; + environment.userSettings.instanceIsPasswordBased = true; + user.updatePassword.mockResolvedValue(user); + clerk.setActive.mockResolvedValue(undefined); + session.startVerification.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + session.attemptFirstFactorVerification.mockResolvedValue({ status: 'complete' }); +}); + +function passwordTree() { + return ( + + + + + + ); +} + +function renderPassword() { + return render(passwordTree()); +} + +async function editPassword() { + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'new-password-123'); + await events.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + await events.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); + return events; +} + +describe('UserProfilePasswordSection', () => { + it('keeps the editor pending until verification is ready and returns to the draft', async () => { + let finishVerification: (value: unknown) => void = () => {}; + session.startVerification.mockReturnValueOnce( + new Promise(resolve => { + finishVerification = resolve; + }), + ); + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(session.startVerification).toHaveBeenCalled()); + + expect(screen.getByLabelText('New password')).toBeVisible(); + expect(screen.getByLabelText('New password')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByText('Verification required')).not.toBeInTheDocument(); + + await act(() => { + finishVerification({ status: 'needs_first_factor', supportedFirstFactors: [{ strategy: 'password' }] }); + }); + await waitFor(() => expect(screen.getByLabelText('Password')).toBeVisible()); + const back = screen.getByRole('button', { name: 'Back', exact: true }); + await events.click(back); + await waitFor(() => expect(screen.getByLabelText('New password')).toBeVisible()); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + await waitFor(() => expect(screen.getByLabelText('New password')).toHaveFocus()); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('shows a failed retry instead of reporting a save when verification is required again', async () => { + user.updatePassword.mockRejectedValue( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await events.type(await screen.findByLabelText('Password'), 'current-password'); + await events.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your password was not saved. Please try verifying again.', + ); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(user.updatePassword).toHaveBeenCalledTimes(2); + }); + + it('hides the section when instance passwords are disabled', () => { + environment.userSettings.instanceIsPasswordBased = false; + renderPassword(); + expect(screen.queryByRole('region', { name: 'Authentication' })).not.toBeInTheDocument(); + }); + + it('keeps the active verification mounted while session data briefly reloads', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + const { rerender } = renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByLabelText('Password'); + + isSessionLoaded = false; + rerender(passwordTree()); + expect(screen.getByLabelText('Password')).toBeVisible(); + isSessionLoaded = true; + rerender(passwordTree()); + await events.click(screen.getByRole('button', { name: 'Back', exact: true })); + expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + }); + + it('keeps an enterprise-managed password visible without offering a mutation', () => { + user.enterpriseAccounts = [{ active: true }]; + renderPassword(); + expect(screen.getByText('Your organization manages your password.')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); + }); + + it('shows the configured password rule without making it a new submit restriction', async () => { + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); + expect( + screen + .getAllByRole('status') + .some(status => status.textContent?.includes('Your password must contain 8 or more characters.')), + ).toBe(true); + await events.type(screen.getByLabelText('Confirm password'), 'short'); + + expect(await screen.findByText('Your password must contain 8 or more characters.')).toBeVisible(); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + await events.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).not.toHaveAttribute('aria-disabled', 'true'); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ newPassword: 'short', signOutOfOtherSessions: true }); + }); + + it('shows password API errors at the visible field and preserves the draft', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [ + { + code: 'form_password_pwned', + message: 'Choose a different password.', + meta: { param_name: 'new_password' }, + }, + ], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription('Choose a different password.'), + ); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + }); + + it('returns from verification to the same draft without showing an error', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByLabelText('Password'); + await events.click(screen.getByRole('button', { name: 'Back', exact: true })); + + expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(user.updatePassword).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Save changes' })).not.toBeDisabled(); + }); + + it('verifies, retries the original values, and waits for the retry before closing', async () => { + let finish: () => void = () => {}; + const retry = new Promise(resolve => { + finish = resolve; + }); + user.updatePassword + .mockRejectedValueOnce( + new ClerkAPIResponseError('Verify', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Verify' }], + }), + ) + .mockImplementationOnce(() => retry.then(() => user)); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + + await events.type(await screen.findByLabelText('Password'), 'current-password'); + await events.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(user.updatePassword).toHaveBeenCalledTimes(2)); + expect(user.updatePassword.mock.calls[1]).toEqual(user.updatePassword.mock.calls[0]); + expect(clerk.setActive).toHaveBeenCalledWith({ session: 'session_1' }); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-disabled', 'true'); + + await act(async () => { + finish(); + await retry; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('saves through the model and closes after the request succeeds', async () => { + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ + newPassword: 'new-password-123', + signOutOfOtherSessions: false, + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index b88e792ecc3..59d4e76f5c1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,7 +1,7 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { ReactNode, RefObject } from 'react'; -import { useId, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -9,6 +9,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import { Flow } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; @@ -48,6 +49,16 @@ export function UserProfileEditPasswordDialog({ const signOutDescriptionId = useId(); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; + const restoreEditorFocus = useRef(false); + + useEffect(() => { + if (children) { + restoreEditorFocus.current = true; + } else if (restoreEditorFocus.current && !form.isSubmitting) { + restoreEditorFocus.current = false; + initialFocusRef.current?.focus({ preventScroll: true }); + } + }, [children, form.isSubmitting]); return ( - {children ?? ( - <> - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - } - > - - {form.error ? ( - - {form.error} - - ) : null} - {showCurrentPassword ? ( - - ) : null} - - -
- form.setValue('signOutOfOtherSessions', event.target.checked)} - /> -
- } - size='sm' - xstyle={styles.checkboxLabel} - > - {m.signOutOfOtherSessionsLabel} - - - {m.signOutOfOtherSessionsDescription} - -
-
-
- - + {() => ( + <> + + <> + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } > - {m.cancel} - - } - /> - - {m.save} - - - - )} + + {form.error ? ( + + {form.error} + + ) : null} + {showCurrentPassword ? ( + + ) : null} + + +
+ form.setValue('signOutOfOtherSessions', event.target.checked)} + /> +
+ } + size='sm' + xstyle={styles.checkboxLabel} + > + {m.signOutOfOtherSessionsLabel} + + + {m.signOutOfOtherSessionsDescription} + +
+
+ + + + {m.cancel} + + } + /> + + {m.save} + + + + + {children} + + )} +
@@ -189,15 +211,17 @@ function PasswordField({ }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); + const [focused, setFocused] = useState(false); const { feedback } = form.fields[name]; - const error = feedback?.type === 'error' ? feedback.message : undefined; + const message = feedback?.type === 'error' ? feedback : advisoryFeedback; + const feedbackType = message?.type === 'info' && !focused ? 'error' : message?.type; const { ref, ...control } = form.register(name); const mergedRef = useMergeRefs([ref, inputRef]); return ( {label} @@ -207,6 +231,11 @@ function PasswordField({ autoComplete={autoComplete} type={visible ? 'text' : 'password'} {...control} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + control.onBlur(); + }} /> + } + > + {verification.status !== 'idle' && verification.status !== 'loading' ? ( + <> + + + + + + ) : undefined} + + } + /> + ); +} diff --git a/packages/mosaic/src/utils/feedback.styles.ts b/packages/mosaic/src/utils/feedback.styles.ts index 92bcd4fbd4c..52af213cff4 100644 --- a/packages/mosaic/src/utils/feedback.styles.ts +++ b/packages/mosaic/src/utils/feedback.styles.ts @@ -46,6 +46,9 @@ export const feedbackStyles = stylex.create({ error: { color: colorVars['--cl-color-negative'], }, + info: { + color: colorVars['--cl-color-foreground-secondary'], + }, success: { color: colorVars['--cl-color-positive'], }, From 8f3884611d6e941a2a8259ac700f5aff04f456b2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 24 Sep 2026 16:13:16 -0600 Subject: [PATCH 50/91] feat(swingset): add live password editing demo --- .../swingset/src/app/(clerk)/live-sidebar.tsx | 1 + .../src/app/(clerk)/live/password/page.tsx | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/swingset/src/app/(clerk)/live/password/page.tsx diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index e428bb89584..5ab40cfaa2e 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -20,6 +20,7 @@ import { const flows = [ { title: 'API keys', href: '/live/api-keys' }, + { title: 'Password', href: '/live/password' }, { title: 'Delete account', href: '/live/delete-account' }, { title: 'Reverification', href: '/live/reverification' }, ]; diff --git a/packages/swingset/src/app/(clerk)/live/password/page.tsx b/packages/swingset/src/app/(clerk)/live/password/page.tsx new file mode 100644 index 00000000000..7cb0e53e3b6 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/password/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { UserProfilePasswordSection } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function PasswordLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Password

+

Set or change the password for your signed-in account.

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage your password. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} From fb3a26d8a74a162078688424cae5f20ef706bd3c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 09:42:18 -0600 Subject: [PATCH 51/91] refactor(mosaic): rename Field.Info to Field.Hint --- packages/mosaic/src/components/field/field.test.tsx | 2 +- packages/mosaic/src/components/field/field.tsx | 6 +++--- packages/mosaic/src/components/field/index.ts | 2 +- .../user-profile-edit-password.dialog.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index 537d1b37a0d..7950441cd30 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -43,7 +43,7 @@ describe('Mosaic Field', () => { Password - Try a longer password. + Try a longer password.
, ); diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 6a5b55ea08f..742f9de9a6c 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -272,9 +272,9 @@ const FieldSuccess = React.forwardRef(f ); }); -export type FieldInfoProps = MosaicComponentProps<'p'>; +export type FieldHintProps = MosaicComponentProps<'p'>; -const FieldInfo = React.forwardRef(function MosaicFieldInfo(props, ref) { +const FieldHint = React.forwardRef(function MosaicFieldHint(props, ref) { return ( {feedbackType === 'error' ? message?.message : null} - {feedbackType === 'info' || feedbackType === 'warning' ? message?.message : null} + {feedbackType === 'info' || feedbackType === 'warning' ? message?.message : null} {feedbackType === 'success' ? message?.message : null} From dae0a2121ce5126ec8023154b2724cdf641b572e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 10:23:54 -0600 Subject: [PATCH 52/91] fix(mosaic): restore password API error formatting --- ...file-password-section.integration.test.tsx | 26 +++- .../user-profile-password-feedback.test.ts | 122 ++++++++++++++++++ .../user-profile-password-feedback.ts | 97 ++++++++++++++ .../user-profile-password-section.messages.ts | 35 ++++- .../user-profile-password-section.model.ts | 22 ---- .../user-profile-password-section.tsx | 8 +- 6 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 48162056af0..bd189b4000c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -226,11 +226,35 @@ describe('UserProfilePasswordSection', () => { await events.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription('Choose a different password.'), + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ), ); expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); }); + it('prioritizes the backend minimum-length error over an earlier complexity error', async () => { + user.updatePassword.mockRejectedValueOnce( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [ + { code: 'form_password_no_uppercase', message: 'Raw uppercase', meta: { param_name: 'new_password' } }, + { code: 'form_password_length_too_short', message: 'Raw minimum', meta: { param_name: 'new_password' } }, + ], + }), + ); + renderPassword(); + const events = await editPassword(); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + }); + it('returns from verification to the same draft without showing an error', async () => { user.updatePassword.mockRejectedValueOnce( new ClerkAPIResponseError('Verify', { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts new file mode 100644 index 00000000000..7075a2f8271 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -0,0 +1,122 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it } from 'vitest'; + +import { FormSubmitError } from '../../../components/form'; +import { resolveLocalization } from '../../../localization'; +import { passwordFormError } from './user-profile-password-feedback'; + +const settings = { min_length: 12, max_length: 64 }; +const localization = resolveLocalization({ + locale: 'en', + overrides: { + userProfilePasswordSection: { + suggestions: { anotherWord: 'Custom suggestion.' }, + passwordErrors: { form_password_size_in_bytes_exceeded: 'Custom byte error.' }, + }, + }, +}); +const messages = localization.messages.userProfilePasswordSection; +function format(data: ConstructorParameters[1]['data'], current = false) { + const result = passwordFormError( + new ClerkAPIResponseError('Invalid', { status: 422, data }), + current, + settings, + messages, + localization.locale, + ); + expect(result).toBeInstanceOf(FormSubmitError); + if (!(result instanceof FormSubmitError)) { + throw new Error('Expected form error'); + } + return result; +} + +describe('password error feedback', () => { + it('combines recognized requirements in a localized list', () => { + expect( + format([ + { code: 'form_password_no_uppercase', message: 'raw', meta: { param_name: 'new_password' } }, + { code: 'form_password_no_number', message: 'raw', meta: { param_name: 'new_password' } }, + ]).fields?.newPassword, + ).toBe('Your password must contain an uppercase letter and a number.'); + }); + + it('formats backend strength suggestions using Mosaic overrides', () => { + expect( + format([ + { + code: 'form_password_not_strong_enough', + message: 'raw', + meta: { + param_name: 'new_password', + zxcvbn: { suggestions: [{ code: 'anotherWord', message: 'raw suggestion' }] }, + }, + }, + ]).fields?.newPassword, + ).toBe('Your password is not strong enough. Custom suggestion.'); + }); + + it('localizes byte errors and falls back for unknown errors', () => { + expect( + format([{ code: 'form_password_size_in_bytes_exceeded', message: 'raw', meta: { param_name: 'password' } }]) + .fields?.newPassword, + ).toBe('Custom byte error.'); + expect( + format([ + { + code: 'future_error', + message: 'short', + long_message: 'Detailed error', + meta: { param_name: 'new_password' }, + }, + ]).fields?.newPassword, + ).toBe('Detailed error'); + }); + + it('keeps current-password errors and banner fallbacks separate', () => { + const errors = [ + { code: 'form_password_incorrect', message: 'Incorrect', meta: { param_name: 'current_password' } }, + ]; + expect(format(errors, true).fields?.currentPassword).toBe('Incorrect'); + expect(format(errors).banner).toBe('Incorrect'); + expect(format([{ code: 'unknown', message: 'Other', meta: { param_name: 'unmapped' } }]).banner).toBe('Other'); + }); + + it('uses the configured maximum length', () => { + expect( + format([{ code: 'form_password_length_too_long', message: 'raw', meta: { param_name: 'new_password' } }]).fields + ?.newPassword, + ).toBe('Your password must contain less than 64 characters.'); + }); + + it('retains the server fallback for untranslated special codes', () => { + expect( + format([ + { + code: 'form_new_password_matches_current', + message: 'Use a different password', + meta: { param_name: 'new_password' }, + }, + ]).fields?.newPassword, + ).toBe('Use a different password'); + }); + + it('handles missing and unrecognized suggestion metadata without leaking codes', () => { + expect( + format([{ code: 'form_password_not_strong_enough', message: 'raw', meta: { param_name: 'new_password' } }]).fields + ?.newPassword, + ).toBe('Your password is not strong enough.'); + expect( + format([ + { + code: 'form_password_not_strong_enough', + message: 'raw', + meta: { + param_name: 'new_password', + zxcvbn: { suggestions: [{ code: 'futureSuggestion', message: 'raw' }] }, + }, + }, + ]).fields?.newPassword, + ).toBe('Your password is not strong enough.'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts new file mode 100644 index 00000000000..2136695613b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -0,0 +1,97 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; + +import { FormSubmitError } from '../../../components/form'; +import type { MosaicMessages } from '../../../localization'; +import { fill } from '../../../localization'; + +type Messages = MosaicMessages['userProfilePasswordSection']; +type Settings = Pick; + +function lookup(messages: Record, key: string): string | undefined { + return Object.hasOwn(messages, key) ? messages[key] : undefined; +} + +export function passwordStrengthMessage(codes: string[], messages: Messages): string { + return [messages.rules.weak, ...codes.map(code => lookup(messages.suggestions, code))].filter(Boolean).join(' '); +} + +function passwordError(errors: ClerkAPIError[], settings: Settings, messages: Messages, locale: string) { + const first = errors[0]; + if (!first) { + return undefined; + } + const known = lookup(messages.passwordErrors, first.code); + if (known !== undefined) { + return known || first.message; + } + if (first.code === 'form_password_not_strong_enough') { + return passwordStrengthMessage(first.meta?.zxcvbn?.suggestions?.map(suggestion => suggestion.code) ?? [], messages); + } + const codes: Record = { + form_password_length_too_short: 'min_length', + form_password_length_too_long: 'max_length', + form_password_no_uppercase: 'require_uppercase', + form_password_no_lowercase: 'require_lowercase', + form_password_no_number: 'require_numbers', + form_password_no_special_char: 'require_special_char', + }; + const failures = errors.flatMap(error => { + const code = lookup(codes, error.code); + return code ? [code] : []; + }); + return passwordComplexityMessage(failures, settings, messages, locale) || first.longMessage || first.message; +} + +export function passwordComplexityMessage(failures: string[], settings: Settings, messages: Messages, locale: string) { + const rules: Record = { + min_length: fill(messages.complexity.minimumLength, { length: settings.min_length }), + max_length: fill(messages.complexity.maximumLength, { length: settings.max_length }), + require_uppercase: messages.complexity.uppercase, + require_lowercase: messages.complexity.lowercase, + require_numbers: messages.complexity.number, + require_special_char: messages.complexity.special, + }; + const requirements = (failures.includes('min_length') ? ['min_length'] : failures).flatMap(code => { + const text = lookup(rules, code); + return text ? [text] : []; + }); + if (!requirements.length) { + return undefined; + } + const list = + typeof Intl.ListFormat === 'function' + ? new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(requirements) + : requirements.join(', '); + return fill(messages.complexity.sentence, { requirements: list }); +} + +export function passwordFormError( + error: unknown, + requiresCurrentPassword: boolean, + settings: Settings, + messages: Messages, + locale: string, +): unknown { + if (!isClerkAPIResponseError(error)) { + return error; + } + const fields: { currentPassword?: string; newPassword?: string } = {}; + const passwordErrors: ClerkAPIError[] = []; + let message: string | undefined; + for (const item of error.errors) { + const text = item.longMessage || item.message; + const name = item.meta?.paramName; + if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { + fields.currentPassword ??= text; + } else if (name === 'new_password' || name === 'newPassword' || name === 'password') { + passwordErrors.push(item); + } else { + message ??= text; + } + } + if (passwordErrors.length) { + fields.newPassword = passwordError(passwordErrors, settings, messages, locale); + } + return new FormSubmitError({ message, fields }); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 41dcba08b47..1c2d8e06ac1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -29,10 +29,43 @@ export const userProfilePasswordSectionMessages = { uppercase: 'Add an uppercase letter.', number: 'Add a number.', special: 'Add a special character.', - weak: 'Choose a stronger password.', + weak: 'Your password is not strong enough.', stronger: 'Your password could be stronger.', strong: 'Strong password.', }, + complexity: { + sentence: 'Your password must contain {requirements}.', + minimumLength: '{length} or more characters', + maximumLength: 'less than {length} characters', + lowercase: 'a lowercase letter', + uppercase: 'an uppercase letter', + number: 'a number', + special: 'a special character', + }, + passwordErrors: { + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_size_in_bytes_exceeded: '', + form_new_password_matches_current: '', + }, + suggestions: { + allUppercase: 'Capitalize some, but not all letters.', + anotherWord: 'Add more words that are less common.', + associatedYears: 'Avoid years that are associated with you.', + capitalization: 'Capitalize more than the first letter.', + dates: 'Avoid dates and years that are associated with you.', + l33t: "Avoid predictable letter substitutions like '@' for 'a'.", + longerKeyboardPattern: 'Use longer keyboard patterns and change typing direction multiple times.', + noNeed: 'You can create strong passwords without using symbols, numbers, or uppercase letters.', + pwned: 'If you use this password elsewhere, you should change it.', + recentYears: 'Avoid recent years.', + repeated: 'Avoid repeated words and characters.', + reverseWords: 'Avoid reversed spellings of common words.', + sequences: 'Avoid common character sequences.', + useWords: 'Use multiple words, but avoid common phrases.', + }, errors: { verificationIncomplete: 'Your password was not saved. Please try verifying again.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 25a0cbb57de..a1ed7040e48 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -1,4 +1,3 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/passwords/complexity'; import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; @@ -6,30 +5,9 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; import { useCallback } from 'react'; -import { FormSubmitError } from '../../../components/form'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -export function passwordFormError(error: unknown, requiresCurrentPassword: boolean): unknown { - if (!isClerkAPIResponseError(error)) { - return error; - } - const fields: { currentPassword?: string; newPassword?: string } = {}; - let message: string | undefined; - for (const item of error.errors) { - const text = item.longMessage || item.message; - const name = item.meta?.paramName; - if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { - fields.currentPassword ??= text; - } else if (name === 'new_password' || name === 'newPassword' || name === 'password') { - fields.newPassword ??= text; - } else { - message ??= text; - } - } - return new FormSubmitError({ message, fields }); -} - type EditablePasswordPolicy = | { mode: 'set'; requiresCurrentPassword: false } | { mode: 'change'; requiresCurrentPassword: boolean }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 9c207c9aa25..00dd13dd319 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -7,12 +7,13 @@ import { Button } from '../../../components/button'; import { Card } from '../../../components/card'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; -import { fill, useMessages } from '../../../localization'; +import { fill, useLocale, useMessages } from '../../../localization'; import { Reverification, useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; +import { passwordFormError } from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; -import { passwordFormError, useUserProfilePasswordModel } from './user-profile-password-section.model'; +import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; export interface UserProfilePasswordSectionProps { @@ -51,6 +52,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw function PasswordFlow({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); + const locale = useLocale(); const [updatePassword, verification] = useReverificationFlow(model.updatePassword); const { validatePassword, passwordSettings } = model; const feedback = useCallback( @@ -93,7 +95,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 10:24:17 -0600 Subject: [PATCH 53/91] fix(mosaic): show password strength suggestions --- ...file-password-section.integration.test.tsx | 32 ++++++++++++++++++- .../user-profile-password-section.messages.ts | 4 +-- .../user-profile-password-section.tsx | 4 +-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index bd189b4000c..cd44e6e90ce 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -26,7 +26,14 @@ const session = { const environment = { userSettings: { instanceIsPasswordBased: true, - passwordSettings: { min_length: 8, max_length: 72, show_zxcvbn: false }, + passwordSettings: { + min_length: 8, + max_length: 72, + show_zxcvbn: false, + min_zxcvbn_strength: 3, + require_uppercase: false, + require_numbers: false, + }, }, authConfig: { reverification: true }, displayConfig: { preferredSignInStrategy: 'password', supportEmail: 'support@example.com' }, @@ -35,11 +42,18 @@ const clerk = { user, session, __internal_environment: environment, + __internal_moduleManager: {}, __internal_getOption: () => undefined, setActive: vi.fn(), }; let isSessionLoaded = true; +vi.mock('@clerk/shared/internal/clerk-js/passwords/loadZxcvbn', () => ({ + createLoadZxcvbn: () => ({ + loadZxcvbn: () => Promise.resolve(() => ({ score: 0, feedback: { suggestions: ['anotherWord'] } })), + }), +})); + vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); return { @@ -57,6 +71,9 @@ beforeEach(() => { user.passwordEnabled = true; user.enterpriseAccounts = []; environment.authConfig.reverification = true; + environment.userSettings.passwordSettings.show_zxcvbn = false; + environment.userSettings.passwordSettings.require_uppercase = false; + environment.userSettings.passwordSettings.require_numbers = false; environment.userSettings.instanceIsPasswordBased = true; user.updatePassword.mockResolvedValue(user); clerk.setActive.mockResolvedValue(undefined); @@ -255,6 +272,19 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); }); + it('shows specific client strength suggestions without blocking submission', async () => { + environment.userSettings.passwordSettings.show_zxcvbn = true; + renderPassword(); + const events = await editPassword(); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password is not strong enough. Add more words that are less common.', + ), + ); + await events.click(screen.getByRole('button', { name: 'Save changes' })); + expect(user.updatePassword).toHaveBeenCalled(); + }); + it('returns from verification to the same draft without showing an error', async () => { user.updatePassword.mockRejectedValueOnce( new ClerkAPIResponseError('Verify', { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 1c2d8e06ac1..7a0b464bde9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -30,8 +30,8 @@ export const userProfilePasswordSectionMessages = { number: 'Add a number.', special: 'Add a special character.', weak: 'Your password is not strong enough.', - stronger: 'Your password could be stronger.', - strong: 'Strong password.', + stronger: 'Your password works, but could be stronger. Try adding more characters.', + strong: 'Your password meets all the necessary requirements.', }, complexity: { sentence: 'Your password must contain {requirements}.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 00dd13dd319..50f6acc6479 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -11,7 +11,7 @@ import { fill, useLocale, useMessages } from '../../../localization'; import { Reverification, useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { passwordFormError } from './user-profile-password-feedback'; +import { passwordFormError, passwordStrengthMessage } from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; @@ -72,7 +72,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 10:24:37 -0600 Subject: [PATCH 54/91] fix(mosaic): match legacy password hint wording --- ...file-password-section.integration.test.tsx | 14 ++++++++++ .../user-profile-password-section.messages.ts | 7 +---- .../user-profile-password-section.tsx | 28 +++++++++---------- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index cd44e6e90ce..7d4d5b7c667 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -272,6 +272,20 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); }); + it('uses legacy wording and list formatting for live complexity feedback', async () => { + environment.userSettings.passwordSettings.require_uppercase = true; + environment.userSettings.passwordSettings.require_numbers = true; + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'longpassword'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain a number and an uppercase letter.', + ), + ); + }); + it('shows specific client strength suggestions without blocking submission', async () => { environment.userSettings.passwordSettings.show_zxcvbn = true; renderPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 7a0b464bde9..794ac7a8707 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -23,16 +23,11 @@ export const userProfilePasswordSectionMessages = { back: 'Back', save: 'Save changes', rules: { - minLength: 'Your password must contain {length} or more characters.', - maxLength: 'Use fewer than {length} characters.', - lowercase: 'Add a lowercase letter.', - uppercase: 'Add an uppercase letter.', - number: 'Add a number.', - special: 'Add a special character.', weak: 'Your password is not strong enough.', stronger: 'Your password works, but could be stronger. Try adding more characters.', strong: 'Your password meets all the necessary requirements.', }, + complexity: { sentence: 'Your password must contain {requirements}.', minimumLength: '{length} or more characters', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 50f6acc6479..43ecb4d1e0c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -7,11 +7,15 @@ import { Button } from '../../../components/button'; import { Card } from '../../../components/card'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; -import { fill, useLocale, useMessages } from '../../../localization'; +import { useLocale, useMessages } from '../../../localization'; import { Reverification, useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { passwordFormError, passwordStrengthMessage } from './user-profile-password-feedback'; +import { + passwordComplexityMessage, + passwordFormError, + passwordStrengthMessage, +} from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; @@ -58,18 +62,12 @@ function PasswordFlow({ model }: { model: Extract => { const { complexity, strength } = await validatePassword(password); - if (complexity?.min_length) { - return { type: 'info', message: fill(m.rules.minLength, { length: passwordSettings.min_length }) }; - } - const messages = [ - complexity?.max_length && fill(m.rules.maxLength, { length: passwordSettings.max_length }), - complexity?.require_lowercase && m.rules.lowercase, - complexity?.require_uppercase && m.rules.uppercase, - complexity?.require_numbers && m.rules.number, - complexity?.require_special_char && m.rules.special, - ].filter(Boolean); - if (messages.length > 0) { - return { type: 'error', message: messages.join(' ') }; + const failures = Object.entries(complexity ?? {}) + .filter(([, failed]) => failed) + .map(([code]) => code); + const message = passwordComplexityMessage(failures, passwordSettings, m, locale); + if (message) { + return { type: complexity?.min_length ? 'info' : 'error', message }; } if (strength?.state === 'fail') { return { type: 'error', message: passwordStrengthMessage(strength.result.feedback.suggestions, m) }; @@ -79,7 +77,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 12:31:34 -0600 Subject: [PATCH 55/91] refactor(mosaic): take the password section as a security panel slot --- .../user-profile-security-panel.view.test.tsx | 11 ++--- .../__tests__/user-profile.view.test.tsx | 3 +- .../user-profile-security-panel.view.tsx | 44 +++++-------------- .../src/stories/fixtures/user-profile.tsx | 3 +- 4 files changed, 20 insertions(+), 41 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index f7035959062..08f64bc309b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; @@ -15,7 +16,7 @@ function DeleteAccount() { } const props: UserProfileSecurityPanelViewProps = { - hasPassword: true, + passwordSlot: , passkeys: [ { id: 'passkey_1', @@ -160,7 +161,7 @@ describe('UserProfileSecurityPanelView', () => { it('keeps supported empty authentication methods actionable', () => { renderView({ - hasPassword: false, + passwordSlot: undefined, passkeys: [], mfaMethods: [], devices: [], @@ -188,7 +189,7 @@ describe('UserProfileSecurityPanelView', () => { it('keeps the authentication section on MFA when existing passkeys are hidden', () => { renderView({ - hasPassword: false, + passwordSlot: undefined, passkeysVisible: false, onAddPasskey: vi.fn(), onRenamePasskey: vi.fn(), @@ -203,7 +204,7 @@ describe('UserProfileSecurityPanelView', () => { }); it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => { - renderView({ hasPassword: false, passkeys: [], onAddPasskey: undefined }); + renderView({ passwordSlot: undefined, passkeys: [], onAddPasskey: undefined }); const section = screen.getByRole('region', { name: 'Authentication' }); expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); @@ -218,7 +219,7 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(async () => { await removal.promise; }); - const { rerender } = renderView({ hasPassword: false, mfaMethods: undefined, onRemovePasskey }); + const { rerender } = renderView({ passwordSlot: undefined, mfaMethods: undefined, onRemovePasskey }); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index d3a041d5292..41631a2b05e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -6,10 +6,11 @@ import { Dialog } from '../../../components/dialog'; import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileViewProps } from '../user-profile.view'; import { UserProfileView } from '../user-profile.view'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; const pages: UserProfileViewProps['pages'] = { account: { name: 'Preston Booth', username: 'prestonxyz' }, - security: { hasPassword: true }, + security: { passwordSlot: }, billing: { subscription: { planName: 'Basic Plan', diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 9f1fe8b237d..07e36788787 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -12,29 +12,15 @@ import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-p import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; -import type { - UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, - UserProfilePasswordSectionViewProps, -} from './user-profile-password-section/user-profile-password-section.view'; -import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; -export type { - UserProfileDevice, - UserProfileEditPasswordValue, - UserProfileMfaAddableMethod, - UserProfileMfaMethod, - UserProfilePasskey, - UserProfilePasswordManagedBy, -}; +export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; -export interface UserProfileSecurityPanelViewProps - extends - Omit, - Pick< - UserProfilePasswordSectionViewProps, - 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' - > { +export interface UserProfileSecurityPanelViewProps extends Omit { + /** + * The password section. Omit it when passwords are unavailable rather than passing a section that renders + * nothing, so the Authentication heading stays correct. + */ + passwordSlot?: ReactNode; passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; @@ -54,16 +40,13 @@ export interface UserProfileSecurityPanelViewProps } export function UserProfileSecurityPanelView({ - hasPassword = false, - requiresCurrentPassword, - managedBy, + passwordSlot, passkeys, passkeysVisible = true, mfaMethods, addableMfaMethods, mfaAddControl, devices, - onSubmitPassword, onAddPasskey, addPasskeyError, onRenamePasskey, @@ -76,7 +59,7 @@ export function UserProfileSecurityPanelView({ onSignOutAllOtherDevices, deleteAccountSlot, }: UserProfileSecurityPanelViewProps): ReactElement { - const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); + const showPassword = Boolean(passwordSlot); const showPasskeys = passkeys !== undefined && passkeysVisible; const hasAuthentication = showPassword || showPasskeys || mfaMethods !== undefined; @@ -86,14 +69,7 @@ export function UserProfileSecurityPanelView({ {hasAuthentication ? ( - {showPassword ? ( - - ) : null} + {passwordSlot} {showPasskeys ? ( current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), }, security: { - ...editPassword, + passwordSlot: , passkeys: passkeys.passkeys, addPasskeyError: passkeys.addError, onRenamePasskey: passkeys.onRename, From 6a541c390699124767ade6fb0b2a91cdd9183d73 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:32:37 -0600 Subject: [PATCH 56/91] fix(mosaic): confirm met password requirements when strength checks are off --- ...rofile-password-section.integration.test.tsx | 17 +++++++++++++++++ .../user-profile-password-section.tsx | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 7d4d5b7c667..4cf1efa631d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -286,6 +286,23 @@ describe('UserProfilePasswordSection', () => { ); }); + it('confirms that requirements are met when strength checking is disabled', async () => { + renderPassword(); + const events = await editPassword(); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password meets all the necessary requirements.', + ), + ); + await events.clear(screen.getByLabelText('New password')); + await events.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + }); + it('shows specific client strength suggestions without blocking submission', async () => { environment.userSettings.passwordSettings.show_zxcvbn = true; renderPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 43ecb4d1e0c..8996dd66380 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -75,7 +75,7 @@ function PasswordFlow({ model }: { model: Extract Date: Fri, 25 Sep 2026 12:32:44 -0600 Subject: [PATCH 57/91] fix(mosaic): use the legacy read-only password copy --- .../user-profile-password-section.integration.test.tsx | 6 +++++- .../user-profile-password-section.messages.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 4cf1efa631d..c8fbb3e9f7d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -194,7 +194,11 @@ describe('UserProfilePasswordSection', () => { it('keeps an enterprise-managed password visible without offering a mutation', () => { user.enterpriseAccounts = [{ active: true }]; renderPassword(); - expect(screen.getByText('Your organization manages your password.')).toBeVisible(); + expect( + screen.getByText( + 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + ), + ).toBeVisible(); expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 794ac7a8707..1419b590bff 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -3,7 +3,7 @@ export const userProfilePasswordSectionMessages = { masked: '••••••••••••••••••', noPasswordSet: 'No password set', managedBy: 'Managed by {name}', - readonly: 'Your organization manages your password.', + readonly: 'Your password can currently not be edited because you can sign in only via the enterprise connection.', change: 'Change password', set: 'Set password', From b9f3181630d2be05807714d0fe8850c674646783 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:32:57 -0600 Subject: [PATCH 58/91] fix(mosaic): localize password update errors raised by the model --- .../user-profile-password-feedback.test.ts | 11 +++++++++++ .../user-profile-password-feedback.ts | 6 ++++++ .../user-profile-password-section.messages.ts | 2 ++ .../user-profile-password-section.model.test.ts | 8 ++++---- .../user-profile-password-section.model.ts | 5 +++-- .../user-profile-password-section.types.ts | 7 +++++++ 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index 7075a2f8271..b7ed3089ffa 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { FormSubmitError } from '../../../components/form'; import { resolveLocalization } from '../../../localization'; import { passwordFormError } from './user-profile-password-feedback'; +import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; const settings = { min_length: 12, max_length: 64 }; const localization = resolveLocalization({ @@ -119,4 +120,14 @@ describe('password error feedback', () => { ]).fields?.newPassword, ).toBe('Your password is not strong enough.'); }); + + it('localizes update errors raised before the request is sent', () => { + const translate = (code: UserProfilePasswordUpdateError['code']) => + passwordFormError(new UserProfilePasswordUpdateError(code), true, settings, messages, localization.locale); + + expect(translate('unavailable')).toMatchObject({ banner: 'Password update is no longer available.' }); + expect(translate('current_password_required')).toMatchObject({ + fields: { currentPassword: 'Current password is required.' }, + }); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index 2136695613b..5234c466186 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -4,6 +4,7 @@ import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; import { FormSubmitError } from '../../../components/form'; import type { MosaicMessages } from '../../../localization'; import { fill } from '../../../localization'; +import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; type Messages = MosaicMessages['userProfilePasswordSection']; type Settings = Pick; @@ -73,6 +74,11 @@ export function passwordFormError( messages: Messages, locale: string, ): unknown { + if (error instanceof UserProfilePasswordUpdateError) { + return error.code === 'current_password_required' + ? new FormSubmitError({ fields: { currentPassword: messages.errors.currentPasswordRequired } }) + : new FormSubmitError({ message: messages.errors.unavailable }); + } if (!isClerkAPIResponseError(error)) { return error; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 1419b590bff..da7c6e15f69 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -64,6 +64,8 @@ export const userProfilePasswordSectionMessages = { errors: { verificationIncomplete: 'Your password was not saved. Please try verifying again.', + unavailable: 'Password update is no longer available.', + currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index 4274d235350..b87d80e54c0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -89,7 +89,7 @@ describe('useUserProfilePasswordModel', () => { newPassword: 'new password', signOutOfOtherSessions: true, }), - ).rejects.toThrow('Current password is required.'); + ).rejects.toMatchObject({ code: 'current_password_required' }); expect(user.updatePassword).not.toHaveBeenCalled(); }); @@ -139,9 +139,9 @@ describe('useUserProfilePasswordModel', () => { } const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; - await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); rerender(); - await expect(action(input)).rejects.toThrow('Password update is no longer available.'); + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); expect(updatePassword).not.toHaveBeenCalled(); }); @@ -153,7 +153,7 @@ describe('useUserProfilePasswordModel', () => { const { result } = renderHook(() => useUserProfilePasswordModel()); await expect( ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), - ).rejects.toThrow('Password update is no longer available.'); + ).rejects.toMatchObject({ code: 'unavailable' }); expect(user.updatePassword).not.toHaveBeenCalled(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index a1ed7040e48..e92874edeea 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; +import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; type EditablePasswordPolicy = | { mode: 'set'; requiresCurrentPassword: false } @@ -106,11 +107,11 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { currentPolicy.mode !== policy.mode || currentPolicy.requiresCurrentPassword !== policy.requiresCurrentPassword ) { - throw new Error('Password update is no longer available.'); + throw new UserProfilePasswordUpdateError('unavailable'); } if (policy.requiresCurrentPassword && !currentPassword) { - throw new Error('Current password is required.'); + throw new UserProfilePasswordUpdateError('current_password_required'); } return currentUser.updatePassword({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index f4823a9080e..bda9a4d4862 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,3 +1,10 @@ +export class UserProfilePasswordUpdateError extends Error { + constructor(readonly code: 'unavailable' | 'current_password_required') { + super(code); + this.name = 'UserProfilePasswordUpdateError'; + } +} + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { From 6465ca034d62c70f2251637e763c0c3cd823ea30 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:33:02 -0600 Subject: [PATCH 59/91] fix(mosaic): use the legacy copy when the new password matches the current one --- .../user-profile-password-feedback.test.ts | 25 +++++++++++++++++-- .../user-profile-password-section.messages.ts | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index b7ed3089ffa..88274166aeb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -90,7 +90,7 @@ describe('password error feedback', () => { ).toBe('Your password must contain less than 64 characters.'); }); - it('retains the server fallback for untranslated special codes', () => { + it('uses the legacy wording when the new password matches the current one', () => { expect( format([ { @@ -99,7 +99,28 @@ describe('password error feedback', () => { meta: { param_name: 'new_password' }, }, ]).fields?.newPassword, - ).toBe('Use a different password'); + ).toBe('New password cannot be the same as the current password.'); + }); + + it('retains the server fallback for untranslated special codes', () => { + const defaults = resolveLocalization({ locale: 'en' }); + const result = passwordFormError( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [ + { + code: 'form_password_size_in_bytes_exceeded', + message: 'Password is too large', + meta: { param_name: 'new_password' }, + }, + ], + }), + false, + settings, + defaults.messages.userProfilePasswordSection, + defaults.locale, + ); + expect(result).toMatchObject({ fields: { newPassword: 'Password is too large' } }); }); it('handles missing and unrecognized suggestion metadata without leaking codes', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index da7c6e15f69..b18d07c4bd5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -43,7 +43,7 @@ export const userProfilePasswordSectionMessages = { form_password_matches_identifier: 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', form_password_size_in_bytes_exceeded: '', - form_new_password_matches_current: '', + form_new_password_matches_current: 'New password cannot be the same as the current password.', }, suggestions: { allUppercase: 'Capitalize some, but not all letters.', From f4588d07fe5c83b54fc9b7a68f9ac8b0b2b941f3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:33:03 -0600 Subject: [PATCH 60/91] refactor(mosaic): move password reverification into the editor controller and dialog --- ...r-profile-edit-password.controller.test.ts | 72 +++++++++++++++---- .../user-profile-edit-password.controller.ts | 28 ++++++-- .../user-profile-edit-password.dialog.tsx | 42 ++++++++--- .../user-profile-password-row.view.tsx | 5 +- .../user-profile-password-section.tsx | 52 +++----------- 5 files changed, 123 insertions(+), 76 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 83f52e86d23..2c232fa0903 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,13 +1,15 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; +import type { ReverificationController } from '../../reverification'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditPasswordSubmitResult } from './user-profile-edit-password.controller'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -function deferred() { +function deferred() { let resolve: (result: T) => void = () => {}; const promise = new Promise(r => { resolve = r; @@ -16,8 +18,7 @@ function deferred() { } function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => - Promise.resolve({ status: 'saved' }), + onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), requiresCurrentPassword = true, ) { return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); @@ -42,7 +43,7 @@ describe('useUserProfileEditPasswordController', () => { const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); const { result } = renderHook(() => useUserProfileEditPasswordController({ - onSubmit: () => Promise.resolve({ status: 'saved' }), + onSubmit: () => Promise.resolve(), validatePassword, }), ); @@ -69,7 +70,7 @@ describe('useUserProfileEditPasswordController', () => { it('can submit while an advisory password check is pending', async () => { const check = deferred(); const validatePassword = vi.fn(() => check.promise); - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit, validatePassword })); open(result); fill(result); @@ -102,7 +103,7 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.isOpen).toBe(true); expect(onSubmit).toHaveBeenCalledTimes(1); await act(async () => { - save.resolve({ status: 'saved' }); + save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); @@ -115,7 +116,7 @@ describe('useUserProfileEditPasswordController', () => { message: 'Try a longer password.', }), ); - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit, validatePassword }), ); @@ -142,7 +143,7 @@ describe('useUserProfileEditPasswordController', () => { const { result } = renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword: true, - onSubmit: () => Promise.resolve({ status: 'cancelled' }), + onSubmit: () => Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })), }), ); open(result); @@ -164,7 +165,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('blocks direct submission with an empty confirmation', () => { - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit); open(result); fill(result); @@ -195,7 +196,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit); open(result); fill(result); @@ -214,7 +215,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('leaves the current password out when reverification stands in for it', async () => { - const onSubmit = vi.fn(() => Promise.resolve({ status: 'saved' })); + const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit, false); open(result); act(() => result.current.form.setValue('newPassword', 'new-secret-123')); @@ -307,7 +308,7 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.isOpen).toBe(true); await act(async () => { - save.resolve({ status: 'saved' }); + save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); @@ -356,4 +357,49 @@ describe('useUserProfileEditPasswordController', () => { message: "Passwords don't match.", }); }); + + it('keeps the draft and reports a failed retry when verification is required again', async () => { + const onSubmit = vi.fn(() => Promise.resolve(reverificationError())); + const { result } = renderController(onSubmit); + open(result); + fill(result); + + act(() => result.current.form.submit()); + + await waitFor(() => + expect(result.current.form.error).toBe('Your password was not saved. Please try verifying again.'), + ); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.newPassword).toBe('new-secret-123'); + }); + + it('formats a failed save before the form shows it', async () => { + const failure = new Error('raw'); + const formatError = vi.fn(() => new UserProfileSaveError('Formatted.')); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(failure), formatError }), + ); + open(result); + fill(result); + + act(() => result.current.form.submit()); + + await waitFor(() => expect(result.current.form.error).toBe('Formatted.')); + expect(formatError).toHaveBeenCalledWith(failure); + }); + + it('cancels an active verification instead of closing the editor', () => { + const onCancel = vi.fn(); + const reverification = { status: 'unavailable', phase: 'active', onCancel } as ReverificationController; + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), reverification }), + ); + open(result); + + act(() => result.current.onOpenChange(false)); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(result.current.isOpen).toBe(true); + expect(result.current.reverification).toBe(reverification); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 75ba6b5b65b..ee45483bbd5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,9 +1,12 @@ +import { isReverificationHint } from '@clerk/shared/authorization-errors'; +import { isReverificationCancelledError } from '@clerk/shared/error'; import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; +import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordValue, UserProfileEditPasswordValues, @@ -16,12 +19,12 @@ const initialValues: UserProfileEditPasswordValues = { signOutOfOtherSessions: true, }; -export type UserProfileEditPasswordSubmitResult = { status: 'saved' } | { status: 'cancelled' }; - export interface UserProfileEditPasswordControllerOptions { requiresCurrentPassword?: boolean; - onSubmit: (value: UserProfileEditPasswordValue) => Promise; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; validatePassword?: (password: string) => Promise; + formatError?: (error: unknown) => unknown; + reverification?: ReverificationController; } export interface UserProfileEditPasswordController { @@ -29,12 +32,15 @@ export interface UserProfileEditPasswordController { onOpenChange: (open: boolean) => void; form: UseFormResult; passwordFeedback: FieldFeedback | undefined; + reverification?: ReverificationController; } export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, validatePassword, + formatError = error => error, + reverification, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); @@ -61,9 +67,15 @@ export function useUserProfileEditPasswordController({ newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); - if (result.status === 'saved') { - setIsOpen(false); + if (isReverificationHint(result)) { + throw new Error(m.errors.verificationIncomplete); + } + setIsOpen(false); + } catch (error) { + if (isReverificationCancelledError(error)) { + return; } + throw formatError(error); } finally { submitting.current = false; } @@ -94,6 +106,10 @@ export function useUserProfileEditPasswordController({ }, [isOpen, password, validatePassword]); const onOpenChange = (open: boolean) => { + if (!open && reverification && reverification.status !== 'idle') { + reverification.onCancel?.(); + return; + } if (submitting.current || form.isSubmitting) { return; } @@ -101,5 +117,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback }; + return { isOpen, onOpenChange, form, passwordFeedback, reverification }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 2df58f13529..d7fb84d9cca 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,6 +1,6 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { ReactNode, RefObject } from 'react'; +import type { RefObject } from 'react'; import { useEffect, useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; @@ -15,6 +15,8 @@ import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; import { useMessages } from '../../../localization'; +import type { ReverificationController } from '../../reverification'; +import { Reverification } from '../../reverification'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordField, @@ -22,7 +24,7 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { - children?: ReactNode; + reverification?: ReverificationController; passwordFeedback?: FieldFeedback; identifier?: string; open: boolean; @@ -34,7 +36,7 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ - children, + reverification, passwordFeedback, identifier = '', open, @@ -50,15 +52,20 @@ export function UserProfileEditPasswordDialog({ const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; const restoreEditorFocus = useRef(false); + const verification = + reverification && reverification.status !== 'idle' && reverification.status !== 'loading' + ? reverification + : undefined; + const verifying = verification !== undefined; useEffect(() => { - if (children) { + if (verifying) { restoreEditorFocus.current = true; } else if (restoreEditorFocus.current && !form.isSubmitting) { restoreEditorFocus.current = false; initialFocusRef.current?.focus({ preventScroll: true }); } - }, [children, form.isSubmitting]); + }, [verifying, form.isSubmitting]); return ( {() => ( <> @@ -184,7 +191,24 @@ export function UserProfileEditPasswordDialog({ - {children} + + {verification ? ( + <> + + + + + + ) : null} + )} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 09aff9ba276..345ab04cd43 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -81,10 +81,7 @@ function EditPassword({ const m = useMessages('userProfilePasswordSection'); const controller = useUserProfileEditPasswordController({ requiresCurrentPassword: hasPassword && requiresCurrentPassword, - onSubmit: async value => { - await onSubmit(value); - return { status: 'saved' }; - }, + onSubmit, }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 8996dd66380..579c1a8c633 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -1,14 +1,11 @@ -import { isReverificationHint } from '@clerk/shared/authorization-errors'; -import { isReverificationCancelledError } from '@clerk/shared/error'; import type { ReactNode } from 'react'; import { useCallback, useRef } from 'react'; import { Button } from '../../../components/button'; -import { Card } from '../../../components/card'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; import { useLocale, useMessages } from '../../../localization'; -import { Reverification, useReverificationFlow } from '../../reverification'; +import { useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import { @@ -57,7 +54,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw function PasswordFlow({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const locale = useLocale(); - const [updatePassword, verification] = useReverificationFlow(model.updatePassword); + const [updatePassword, reverification] = useReverificationFlow(model.updatePassword); const { validatePassword, passwordSettings } = model; const feedback = useCallback( async (password: string): Promise => { @@ -82,20 +79,9 @@ function PasswordFlow({ model }: { model: Extract { - try { - const result = await updatePassword(value); - if (isReverificationHint(result)) { - throw new Error(m.errors.verificationIncomplete); - } - return { status: 'saved' }; - } catch (error) { - if (isReverificationCancelledError(error)) { - return { status: 'cancelled' }; - } - throw passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale); - } - }, + onSubmit: updatePassword, + formatError: error => passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale), + reverification, }); return ( @@ -107,15 +93,10 @@ function PasswordFlow({ model }: { model: Extract { - if (!open && verification.status !== 'idle') { - verification.onCancel?.(); - return; - } - controller.onOpenChange(open); - }} + onOpenChange={controller.onOpenChange} hasPassword={model.mode === 'change'} requiresCurrentPassword={model.requiresCurrentPassword} + reverification={controller.reverification} trigger={ - - - ) : undefined} - + /> } /> ); From 949b26f48842eea291a939677ee384f6a0cc5e50 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 12:51:39 -0600 Subject: [PATCH 61/91] fix(mosaic): show password requirements after the new password is left empty --- ...file-password-section.integration.test.tsx | 19 ++++++++++++++++ ...r-profile-edit-password.controller.test.ts | 22 +++++++++++++++++++ .../user-profile-edit-password.controller.ts | 5 +++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index c8fbb3e9f7d..785ad11c1e8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -290,6 +290,25 @@ describe('UserProfilePasswordSection', () => { ); }); + it('shows the minimum length as an error after the new password is left empty', async () => { + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ); + + await events.click(screen.getByLabelText('Confirm password')); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + }); + it('confirms that requirements are met when strength checking is disabled', async () => { renderPassword(); const events = await editPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 2c232fa0903..6bc498b824b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -67,6 +67,28 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.passwordFeedback).toBeUndefined(); }); + it('checks an empty new password only once it has been left', async () => { + const validatePassword = vi.fn(() => + Promise.resolve({ type: 'info', message: 'Your password must contain 8 or more characters.' }), + ); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), + ); + open(result); + await act(() => Promise.resolve()); + expect(validatePassword).not.toHaveBeenCalled(); + + act(() => result.current.form.touch('newPassword')); + + await waitFor(() => + expect(result.current.passwordFeedback).toEqual({ + type: 'info', + message: 'Your password must contain 8 or more characters.', + }), + ); + expect(validatePassword).toHaveBeenCalledWith(''); + }); + it('can submit while an advisory password check is pending', async () => { const check = deferred(); const validatePassword = vi.fn(() => check.promise); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index ee45483bbd5..de8278b9601 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -83,9 +83,10 @@ export function useUserProfileEditPasswordController({ }); const password = form.values.newPassword; + const passwordLeft = form.fields.newPassword.touched; useEffect(() => { setPasswordFeedback(undefined); - if (!isOpen || password === '' || !validatePassword) { + if (!isOpen || (password === '' && !passwordLeft) || !validatePassword) { return; } @@ -103,7 +104,7 @@ export function useUserProfileEditPasswordController({ return () => { active = false; }; - }, [isOpen, password, validatePassword]); + }, [isOpen, password, passwordLeft, validatePassword]); const onOpenChange = (open: boolean) => { if (!open && reverification && reverification.status !== 'idle') { From c180e8f72a8c8c80fa10433bb585148935d81f52 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 25 Sep 2026 13:11:12 -0600 Subject: [PATCH 62/91] fix(mosaic): keep the password mismatch after the confirmation is cleared --- ...file-password-section.integration.test.tsx | 17 ++++++++++++++ ...r-profile-edit-password.controller.test.ts | 23 +++++++++++++++++++ .../user-profile-edit-password.controller.ts | 2 +- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index 785ad11c1e8..ed3360c6b40 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -309,6 +309,23 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); }); + it('keeps the mismatch visible after the confirmation is cleared', async () => { + renderPassword(); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'new-password-123'); + await events.type(screen.getByLabelText('Confirm password'), 'new-password-12'); + await events.click(screen.getByLabelText('New password')); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); + + await events.clear(screen.getByLabelText('Confirm password')); + + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); + expect(screen.getByLabelText('Confirm password')).toHaveAttribute('aria-invalid', 'true'); + }); + it('confirms that requirements are met when strength checking is disabled', async () => { renderPassword(); const events = await editPassword(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 6bc498b824b..f8e1018481b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -287,6 +287,29 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); }); + it('keeps the mismatch after the confirmation is cleared', () => { + const { result } = renderController(); + open(result); + act(() => result.current.form.setValue('newPassword', 'new-secret-123')); + act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); + act(() => result.current.form.touch('confirmPassword')); + + act(() => result.current.form.setValue('confirmPassword', '')); + + expect(result.current.form.fields.confirmPassword.feedback).toEqual({ + type: 'error', + message: "Passwords don't match.", + }); + }); + + it('leaves an untouched empty confirmation quiet', () => { + const { result } = renderController(); + open(result); + act(() => result.current.form.setValue('newPassword', 'new-secret-123')); + + expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); + }); + it('forgets what was typed when the dialog is cancelled', () => { const { result } = renderController(); open(result); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index de8278b9601..c88cbb3573a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -52,7 +52,7 @@ export function useUserProfileEditPasswordController({ fields: { confirmPassword: { validate: (value, values) => - value !== '' && value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, + value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, }, }, canSubmit: values => From d162913807cc2e91c2c2dcca4b8bca30bf58ba79 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:18:17 -0600 Subject: [PATCH 63/91] fix(mosaic): align password reverification with controller status --- ...user-profile-edit-password.dialog.test.tsx | 41 +++++++++++++++++++ ...r-profile-edit-password.controller.test.ts | 2 +- .../user-profile-edit-password.dialog.tsx | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx index c4bbc9a52c1..db8aed9287f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { FormField, UseFormResult } from '../../../components/form'; import { MosaicProvider } from '../../../MosaicProvider'; +import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; @@ -68,7 +69,47 @@ const confirmPasswordField = () => screen.getByLabelText('Confirm password'); const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); +function verification(status: 'ready' | 'retrying', onCancel?: () => void): ReverificationController { + const view = { + step: 'password', + value: '', + onValueChange: vi.fn(), + isPending: status === 'retrying', + onSubmit: vi.fn(), + onShowMethods: vi.fn(), + onShowHelp: vi.fn(), + onBack: vi.fn(), + onEmailSupport: vi.fn(), + onResend: vi.fn(), + canResend: true, + methods: [], + onSelectMethod: vi.fn(), + }; + return status === 'ready' ? { ...view, status, onCancel } : { ...view, status }; +} + describe('UserProfileEditPasswordDialog', () => { + it('allows Back while verification can be cancelled and disables it during the retry', async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + const { rerender, props } = renderView({ reverification: verification('ready', onCancel) }); + const back = screen.getByRole('button', { name: 'Back', exact: true }); + + expect(back).not.toBeDisabled(); + await user.click(back); + expect(onCancel).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); + }); + it('names the dialog for a change and masks every field', () => { renderView( {}, diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index f8e1018481b..4f312d423e9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -435,7 +435,7 @@ describe('useUserProfileEditPasswordController', () => { it('cancels an active verification instead of closing the editor', () => { const onCancel = vi.fn(); - const reverification = { status: 'unavailable', phase: 'active', onCancel } as ReverificationController; + const reverification: ReverificationController = { status: 'unavailable', onCancel }; const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), reverification }), ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index d7fb84d9cca..3f077d97cb0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -200,7 +200,7 @@ export function UserProfileEditPasswordDialog({ variant='outline' color='neutral' fullWidth - disabled={verification.phase === 'retrying'} + disabled={!verification.onCancel} onClick={verification.onCancel} > {m.back} From a1f1ca2752b498794be56dcb1711a4daaa512c5c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:40:48 -0600 Subject: [PATCH 64/91] refactor(mosaic): remove password reverification flow --- ...user-profile-edit-password.dialog.test.tsx | 42 +-- ...file-password-section.integration.test.tsx | 103 +++----- ...r-profile-edit-password.controller.test.ts | 35 ++- .../user-profile-edit-password.controller.ts | 20 +- .../user-profile-edit-password.dialog.tsx | 250 +++++++----------- .../user-profile-password-section.messages.ts | 2 - ...ser-profile-password-section.model.test.ts | 105 ++++---- .../user-profile-password-section.model.ts | 2 +- .../user-profile-password-section.tsx | 10 +- 9 files changed, 212 insertions(+), 357 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx index db8aed9287f..302407d1462 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -4,7 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { FormField, UseFormResult } from '../../../components/form'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; @@ -69,45 +68,14 @@ const confirmPasswordField = () => screen.getByLabelText('Confirm password'); const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); -function verification(status: 'ready' | 'retrying', onCancel?: () => void): ReverificationController { - const view = { - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: status === 'retrying', - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - onResend: vi.fn(), - canResend: true, - methods: [], - onSelectMethod: vi.fn(), - }; - return status === 'ready' ? { ...view, status, onCancel } : { ...view, status }; -} - describe('UserProfileEditPasswordDialog', () => { - it('allows Back while verification can be cancelled and disables it during the retry', async () => { - const onCancel = vi.fn(); + it('closes directly when Cancel is selected', async () => { const user = userEvent.setup(); - const { rerender, props } = renderView({ reverification: verification('ready', onCancel) }); - const back = screen.getByRole('button', { name: 'Back', exact: true }); + const { props } = renderView(); - expect(back).not.toBeDisabled(); - await user.click(back); - expect(onCancel).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); - rerender( - - - , - ); - expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); }); it('names the dialog for a change and masks every field', () => { @@ -178,7 +146,7 @@ describe('UserProfileEditPasswordDialog', () => { expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); }); - it('skips the current password when reverification stands in for it', async () => { + it('skips the current password when it is not required', async () => { renderView({ requiresCurrentPassword: false }); expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx index ed3360c6b40..4a33f9a9965 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx @@ -101,6 +101,7 @@ function renderPassword() { async function editPassword() { const events = userEvent.setup(); await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('Current password'), 'old-secret'); await events.type(screen.getByLabelText('New password'), 'new-password-123'); await events.type(screen.getByLabelText('Confirm password'), 'new-password-123'); await events.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); @@ -108,43 +109,31 @@ async function editPassword() { } describe('UserProfilePasswordSection', () => { - it('keeps the editor pending until verification is ready and returns to the draft', async () => { - let finishVerification: (value: unknown) => void = () => {}; - session.startVerification.mockReturnValueOnce( + it('keeps the editor pending until the direct update finishes', async () => { + let finishUpdate: (value: unknown) => void = () => {}; + user.updatePassword.mockReturnValueOnce( new Promise(resolve => { - finishVerification = resolve; - }), - ); - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], + finishUpdate = resolve; }), ); renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await waitFor(() => expect(session.startVerification).toHaveBeenCalled()); + await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); expect(screen.getByLabelText('New password')).toBeVisible(); expect(screen.getByLabelText('New password')).toBeDisabled(); expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); expect(screen.queryByText('Verification required')).not.toBeInTheDocument(); + expect(session.startVerification).not.toHaveBeenCalled(); await act(() => { - finishVerification({ status: 'needs_first_factor', supportedFirstFactors: [{ strategy: 'password' }] }); + finishUpdate(user); }); - await waitFor(() => expect(screen.getByLabelText('Password')).toBeVisible()); - const back = screen.getByRole('button', { name: 'Back', exact: true }); - await events.click(back); - await waitFor(() => expect(screen.getByLabelText('New password')).toBeVisible()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - await waitFor(() => expect(screen.getByLabelText('New password')).toHaveFocus()); - await events.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - it('shows a failed retry instead of reporting a save when verification is required again', async () => { + it('shows a direct API error without verification or an automatic retry', async () => { user.updatePassword.mockRejectedValue( new ClerkAPIResponseError('Verify', { status: 403, @@ -154,14 +143,12 @@ describe('UserProfilePasswordSection', () => { renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await events.type(await screen.findByLabelText('Password'), 'current-password'); - await events.click(screen.getByRole('button', { name: 'Continue' })); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'Your password was not saved. Please try verifying again.', - ); + expect(await screen.findByRole('alert')).toHaveTextContent('Verify'); expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(user.updatePassword).toHaveBeenCalledTimes(2); + expect(user.updatePassword).toHaveBeenCalledOnce(); + expect(session.startVerification).not.toHaveBeenCalled(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); }); it('hides the section when instance passwords are disabled', () => { @@ -170,25 +157,17 @@ describe('UserProfilePasswordSection', () => { expect(screen.queryByRole('region', { name: 'Authentication' })).not.toBeInTheDocument(); }); - it('keeps the active verification mounted while session data briefly reloads', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], - }), - ); + it('keeps the password draft while session data briefly reloads', async () => { const { rerender } = renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - await screen.findByLabelText('Password'); + await editPassword(); isSessionLoaded = false; rerender(passwordTree()); - expect(screen.getByLabelText('Password')).toBeVisible(); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); isSessionLoaded = true; rerender(passwordTree()); - await events.click(screen.getByRole('button', { name: 'Back', exact: true })); - expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Current password')).toHaveValue('old-secret'); }); it('keeps an enterprise-managed password visible without offering a mutation', () => { @@ -206,6 +185,7 @@ describe('UserProfilePasswordSection', () => { renderPassword(); const events = userEvent.setup(); await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('Current password'), 'old-secret'); await events.type(screen.getByLabelText('New password'), 'short'); await waitFor(() => expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( @@ -226,7 +206,11 @@ describe('UserProfilePasswordSection', () => { expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); expect(screen.getByRole('button', { name: 'Save changes' })).not.toHaveAttribute('aria-disabled', 'true'); await events.click(screen.getByRole('button', { name: 'Save changes' })); - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ newPassword: 'short', signOutOfOtherSessions: true }); + expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ + currentPassword: 'old-secret', + newPassword: 'short', + signOutOfOtherSessions: true, + }); }); it('shows password API errors at the visible field and preserves the draft', async () => { @@ -356,55 +340,43 @@ describe('UserProfilePasswordSection', () => { expect(user.updatePassword).toHaveBeenCalled(); }); - it('returns from verification to the same draft without showing an error', async () => { + it('keeps the draft and checkbox choice after a rejected update', async () => { user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], + new ClerkAPIResponseError('Update failed', { + status: 500, + data: [{ code: 'server_error', message: 'Update failed' }], }), ); renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await screen.findByLabelText('Password'); - await events.click(screen.getByRole('button', { name: 'Back', exact: true })); - expect(await screen.findByLabelText('New password')).toHaveValue('new-password-123'); + expect(await screen.findByRole('alert')).toHaveTextContent('Update failed'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); expect(screen.getByRole('checkbox')).not.toBeChecked(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(user.updatePassword).toHaveBeenCalledTimes(1); expect(screen.getByRole('button', { name: 'Save changes' })).not.toBeDisabled(); }); - it('verifies, retries the original values, and waits for the retry before closing', async () => { + it('waits for the direct update before closing', async () => { let finish: () => void = () => {}; - const retry = new Promise(resolve => { + const update = new Promise(resolve => { finish = resolve; }); - user.updatePassword - .mockRejectedValueOnce( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], - }), - ) - .mockImplementationOnce(() => retry.then(() => user)); + user.updatePassword.mockImplementationOnce(() => update.then(() => user)); renderPassword(); const events = await editPassword(); await events.click(screen.getByRole('button', { name: 'Save changes' })); - await events.type(await screen.findByLabelText('Password'), 'current-password'); - await events.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => expect(user.updatePassword).toHaveBeenCalledTimes(2)); - expect(user.updatePassword.mock.calls[1]).toEqual(user.updatePassword.mock.calls[0]); - expect(clerk.setActive).toHaveBeenCalledWith({ session: 'session_1' }); + await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(session.startVerification).not.toHaveBeenCalled(); await act(async () => { finish(); - await retry; + await update; }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); @@ -415,6 +387,7 @@ describe('UserProfilePasswordSection', () => { await events.click(screen.getByRole('button', { name: 'Save changes' })); expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ + currentPassword: 'old-secret', newPassword: 'new-password-123', signOutOfOtherSessions: false, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 4f312d423e9..3728c08549e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,10 +1,7 @@ -import { reverificationError } from '@clerk/shared/authorization-errors'; -import { ClerkRuntimeError } from '@clerk/shared/error'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; -import type { ReverificationController } from '../../reverification'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; @@ -161,11 +158,11 @@ describe('useUserProfileEditPasswordController', () => { }); }); - it('returns to editing without losing the draft when the flow cancels verification', async () => { + it('keeps the draft and shows a direct save error', async () => { const { result } = renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword: true, - onSubmit: () => Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })), + onSubmit: () => Promise.reject(new Error('Save failed')), }), ); open(result); @@ -176,7 +173,7 @@ describe('useUserProfileEditPasswordController', () => { await waitFor(() => expect(result.current.form.isSubmitting).toBe(false)); expect(result.current.isOpen).toBe(true); - expect(result.current.form.error).toBeUndefined(); + expect(result.current.form.error).toBe('Save failed'); expect(result.current.form.values).toEqual({ currentPassword: 'old-secret', newPassword: 'new-secret-123', @@ -236,7 +233,7 @@ describe('useUserProfileEditPasswordController', () => { await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('leaves the current password out when reverification stands in for it', async () => { + it('leaves the current password out when setting a first password', async () => { const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderController(onSubmit, false); open(result); @@ -403,19 +400,18 @@ describe('useUserProfileEditPasswordController', () => { }); }); - it('keeps the draft and reports a failed retry when verification is required again', async () => { - const onSubmit = vi.fn(() => Promise.resolve(reverificationError())); + it('does not retry a rejected direct save', async () => { + const onSubmit = vi.fn(() => Promise.reject(new Error('Save failed'))); const { result } = renderController(onSubmit); open(result); fill(result); act(() => result.current.form.submit()); - await waitFor(() => - expect(result.current.form.error).toBe('Your password was not saved. Please try verifying again.'), - ); + await waitFor(() => expect(result.current.form.error).toBe('Save failed')); expect(result.current.isOpen).toBe(true); expect(result.current.form.values.newPassword).toBe('new-secret-123'); + expect(onSubmit).toHaveBeenCalledOnce(); }); it('formats a failed save before the form shows it', async () => { @@ -433,18 +429,19 @@ describe('useUserProfileEditPasswordController', () => { expect(formatError).toHaveBeenCalledWith(failure); }); - it('cancels an active verification instead of closing the editor', () => { - const onCancel = vi.fn(); - const reverification: ReverificationController = { status: 'unavailable', onCancel }; + it('closes after a failed direct save', async () => { const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), reverification }), + useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(new Error('Save failed')) }), ); open(result); + fill(result); + + act(() => result.current.form.submit()); + await waitFor(() => expect(result.current.form.error).toBe('Save failed')); act(() => result.current.onOpenChange(false)); - expect(onCancel).toHaveBeenCalledOnce(); - expect(result.current.isOpen).toBe(true); - expect(result.current.reverification).toBe(reverification); + expect(result.current.isOpen).toBe(false); + expect(result.current.form.values.newPassword).toBe(''); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index c88cbb3573a..0219b5014d1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,12 +1,9 @@ -import { isReverificationHint } from '@clerk/shared/authorization-errors'; -import { isReverificationCancelledError } from '@clerk/shared/error'; import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; -import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordValue, UserProfileEditPasswordValues, @@ -24,7 +21,6 @@ export interface UserProfileEditPasswordControllerOptions { onSubmit: (value: UserProfileEditPasswordValue) => Promise; validatePassword?: (password: string) => Promise; formatError?: (error: unknown) => unknown; - reverification?: ReverificationController; } export interface UserProfileEditPasswordController { @@ -32,7 +28,6 @@ export interface UserProfileEditPasswordController { onOpenChange: (open: boolean) => void; form: UseFormResult; passwordFeedback: FieldFeedback | undefined; - reverification?: ReverificationController; } export function useUserProfileEditPasswordController({ @@ -40,7 +35,6 @@ export function useUserProfileEditPasswordController({ onSubmit, validatePassword, formatError = error => error, - reverification, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); @@ -62,19 +56,13 @@ export function useUserProfileEditPasswordController({ onSubmit: async values => { submitting.current = true; try { - const result = await onSubmit({ + await onSubmit({ currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); - if (isReverificationHint(result)) { - throw new Error(m.errors.verificationIncomplete); - } setIsOpen(false); } catch (error) { - if (isReverificationCancelledError(error)) { - return; - } throw formatError(error); } finally { submitting.current = false; @@ -107,10 +95,6 @@ export function useUserProfileEditPasswordController({ }, [isOpen, password, passwordLeft, validatePassword]); const onOpenChange = (open: boolean) => { - if (!open && reverification && reverification.status !== 'idle') { - reverification.onCancel?.(); - return; - } if (submitting.current || form.isSubmitting) { return; } @@ -118,5 +102,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback, reverification }; + return { isOpen, onOpenChange, form, passwordFeedback }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 3f077d97cb0..538eec96d74 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,7 +1,7 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { RefObject } from 'react'; -import { useEffect, useId, useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -9,14 +9,11 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import { Flow } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; import { useMessages } from '../../../localization'; -import type { ReverificationController } from '../../reverification'; -import { Reverification } from '../../reverification'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordField, @@ -24,7 +21,6 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { - reverification?: ReverificationController; passwordFeedback?: FieldFeedback; identifier?: string; open: boolean; @@ -36,7 +32,6 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ - reverification, passwordFeedback, identifier = '', open, @@ -51,21 +46,6 @@ export function UserProfileEditPasswordDialog({ const signOutDescriptionId = useId(); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; - const restoreEditorFocus = useRef(false); - const verification = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' - ? reverification - : undefined; - const verifying = verification !== undefined; - - useEffect(() => { - if (verifying) { - restoreEditorFocus.current = true; - } else if (restoreEditorFocus.current && !form.isSubmitting) { - restoreEditorFocus.current = false; - initialFocusRef.current?.focus({ preventScroll: true }); - } - }, [verifying, form.isSubmitting]); return ( - + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } > - {() => ( - <> - - <> - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - } - > - - {form.error ? ( - - {form.error} - - ) : null} - {showCurrentPassword ? ( - - ) : null} - - -
- form.setValue('signOutOfOtherSessions', event.target.checked)} - /> -
- } - size='sm' - xstyle={styles.checkboxLabel} - > - {m.signOutOfOtherSessionsLabel} - - - {m.signOutOfOtherSessionsDescription} - -
-
-
- - - {m.cancel} - - } - /> - - {m.save} - - - -
- - {verification ? ( - <> - - - - - - ) : null} - - - )} -
+ + {form.error ? ( + + {form.error} + + ) : null} + {showCurrentPassword ? ( + + ) : null} + + +
+ form.setValue('signOutOfOtherSessions', event.target.checked)} + /> +
+ } + size='sm' + xstyle={styles.checkboxLabel} + > + {m.signOutOfOtherSessionsLabel} + + + {m.signOutOfOtherSessionsDescription} + +
+
+ + + + {m.cancel} + + } + /> + + {m.save} + +
diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index b18d07c4bd5..85fd184af7b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -20,7 +20,6 @@ export const userProfilePasswordSectionMessages = { signOutOfOtherSessionsDescription: 'It is recommended to sign out of all other devices which may have used your old password.', cancel: 'Cancel', - back: 'Back', save: 'Save changes', rules: { weak: 'Your password is not strong enough.', @@ -63,7 +62,6 @@ export const userProfilePasswordSectionMessages = { }, errors: { - verificationIncomplete: 'Your password was not saved. Please try verifying again.', unavailable: 'Password update is no longer available.', currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index b87d80e54c0..30ed4e570e2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -93,57 +93,48 @@ describe('useUserProfilePasswordModel', () => { expect(user.updatePassword).not.toHaveBeenCalled(); }); - it.each([ - 'signed out', - 'different user', - 'different session', - 'no session', - 'disabled', - 'enterprise', - 'mode', - 'proof', - ])('rejects a captured action after the context changes: %s', async change => { - if (!user || !session || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = true; - const updatePassword = user.updatePassword; - const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); - const action = ready(result.current).updatePassword; + it.each(['signed out', 'different user', 'different session', 'no session', 'disabled', 'enterprise', 'mode'])( + 'rejects a captured action after the context changes: %s', + async change => { + if (!user || !session || !environment) { + throw new Error('expected loaded fixtures'); + } + user.passwordEnabled = true; + const updatePassword = user.updatePassword; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const action = ready(result.current).updatePassword; - switch (change) { - case 'signed out': - user = null; - break; - case 'different user': - user = { ...user, id: 'user_2' }; - break; - case 'different session': - session = { ...session, id: 'session_2' }; - break; - case 'no session': - session = null; - break; - case 'disabled': - environment.userSettings.instanceIsPasswordBased = false; - break; - case 'enterprise': - user.enterpriseAccounts = [{ active: true }]; - break; - case 'mode': - user.passwordEnabled = false; - break; - case 'proof': - environment.authConfig.reverification = true; - break; - } + switch (change) { + case 'signed out': + user = null; + break; + case 'different user': + user = { ...user, id: 'user_2' }; + break; + case 'different session': + session = { ...session, id: 'session_2' }; + break; + case 'no session': + session = null; + break; + case 'disabled': + environment.userSettings.instanceIsPasswordBased = false; + break; + case 'enterprise': + user.enterpriseAccounts = [{ active: true }]; + break; + case 'mode': + user.passwordEnabled = false; + break; + } - const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; - await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); - rerender(); - await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); - expect(updatePassword).not.toHaveBeenCalled(); - }); + const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); + rerender(); + await expect(action(input)).rejects.toMatchObject({ code: 'unavailable' }); + expect(updatePassword).not.toHaveBeenCalled(); + }, + ); it('rejects updates when a loaded user has no active session', async () => { if (!user) { @@ -158,11 +149,11 @@ describe('useUserProfilePasswordModel', () => { }); it.each([ - { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true, currentPassword: undefined }, - { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false, currentPassword: undefined }, - { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false, currentPassword: ' old secret ' }, - { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true, currentPassword: undefined }, - ])('sends the legacy payload for %j', async policy => { + { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true }, + { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false }, + { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false }, + { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true }, + ])('sends the direct password payload for %j', async policy => { if (!user || !environment) { throw new Error('expected loaded fixtures'); } @@ -183,7 +174,7 @@ describe('useUserProfilePasswordModel', () => { signOutOfOtherSessions: policy.signOutOfOtherSessions, }; expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith( - policy.currentPassword === undefined ? expected : { ...expected, currentPassword: ' old secret ' }, + policy.passwordEnabled ? { ...expected, currentPassword: ' old secret ' } : expected, ); }); @@ -224,8 +215,8 @@ describe('useUserProfilePasswordModel', () => { { passwordEnabled: false, reverification: false, mode: 'set', requiresCurrentPassword: false }, { passwordEnabled: false, reverification: true, mode: 'set', requiresCurrentPassword: false }, { passwordEnabled: true, reverification: false, mode: 'change', requiresCurrentPassword: true }, - { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: false }, - ])('derives $mode mode with reverification=$reverification', policy => { + { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: true }, + ])('derives $mode mode independently of reverification=$reverification', policy => { if (!user || !environment) { throw new Error('expected loaded fixtures'); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index e92874edeea..eb386f7de62 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -43,7 +43,7 @@ function getPasswordPolicy( } const policy: EditablePasswordPolicy = user.passwordEnabled - ? { mode: 'change', requiresCurrentPassword: !environment.authConfig.reverification } + ? { mode: 'change', requiresCurrentPassword: true } : { mode: 'set', requiresCurrentPassword: false }; if (user.enterpriseAccounts.some(account => account.active)) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 579c1a8c633..747486d5a02 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -5,7 +5,6 @@ import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; import { Text } from '../../../components/text'; import { useLocale, useMessages } from '../../../localization'; -import { useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import { @@ -44,17 +43,16 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw return null; } return ( - ); } -function PasswordFlow({ model }: { model: Extract }) { +function PasswordEditor({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const locale = useLocale(); - const [updatePassword, reverification] = useReverificationFlow(model.updatePassword); const { validatePassword, passwordSettings } = model; const feedback = useCallback( async (password: string): Promise => { @@ -79,9 +77,8 @@ function PasswordFlow({ model }: { model: Extract passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale), - reverification, }); return ( @@ -96,7 +93,6 @@ function PasswordFlow({ model }: { model: Extract Date: Tue, 29 Sep 2026 11:02:04 -0600 Subject: [PATCH 65/91] test(mosaic): cover password editing with real Clerk --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 34 +- packages/mosaic/src/__tests__/feature/fapi.ts | 39 ++ ...user-profile-edit-password.dialog.test.tsx | 242 ----------- .../user-profile-password-loading.test.tsx | 73 ++++ ...file-password-section.integration.test.tsx | 396 ----------------- .../user-profile-password.feature.test.tsx | 261 ++++++++++++ ...r-profile-edit-password.controller.test.ts | 403 +----------------- ...ser-profile-password-section.model.test.ts | 184 +------- 8 files changed, 426 insertions(+), 1206 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index b71c2b9f5f1..a7a4ba2dea0 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -39,6 +39,7 @@ export interface FakeFapiState { suggestions: OrganizationSuggestionJSON[]; apiKeys: ApiKeyJSON[]; verification: FakeVerificationState; + passwordUpdates: URLSearchParams[]; } export type FakeFapiSeed = Partial> & { @@ -100,6 +101,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { invitations: [], suggestions: [], apiKeys: [], + passwordUpdates: [], ...rest, verification: createVerificationState(verification), }; @@ -144,6 +146,19 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null }; return envelope({ ...session, status: 'removed' }, state.client); }), + http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => { + const session = findSession(state, state.client.last_active_session_id); + if (!session) { + return missing(); + } + state.passwordUpdates.push(new URLSearchParams(await request.text())); + const updatedUser = { ...session.user, password_enabled: true }; + state.client = { + ...state.client, + sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user: updatedUser } : item)), + }; + return envelope(updatedUser, state.client); + }), http.post(fapiUrl('/v1/client/sessions'), ({ request }) => { if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { return undefined; @@ -224,7 +239,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { export interface HeldRequests { requests: Request[]; release: () => void; - fail: (code?: string) => void; + fail: (code?: string, longMessage?: string, paramName?: string) => void; } interface Hold { @@ -266,6 +281,21 @@ export function holdRequests(method: 'get' | 'post', path: string): HeldRequests return { requests, release: () => settle(undefined), - fail: (code = 'form_param_invalid') => settle(error(code)), + fail: (code = 'form_param_invalid', longMessage = code, paramName?: string) => + settle( + HttpResponse.json( + { + errors: [ + { + code, + message: code, + long_message: longMessage, + ...(paramName ? { meta: { param_name: paramName } } : {}), + }, + ], + }, + { status: 400 }, + ), + ), }; } diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index b8575289d0e..81faadddb17 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -7,6 +7,7 @@ import type { ClientJSON, DisplayConfigJSON, EmailAddressJSON, + EnterpriseAccountJSON, EnvironmentJSON, OAuthProviders, OrganizationJSON, @@ -271,6 +272,44 @@ export function fapiUser(overrides: Partial & Pick): U }; } +export function fapiEnterpriseAccount( + overrides: Partial & Pick, +): EnterpriseAccountJSON { + return { + object: 'enterprise_account', + active: true, + email_address: 'sso@example.com', + enterprise_connection: { + object: 'enterprise_connection', + id: 'sso_1', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: true, + domain: 'example.com', + logo_public_url: null, + name: 'Company SSO', + protocol: 'saml', + provider: 'saml_okta', + sync_user_attributes: false, + allow_organization_account_linking: false, + created_at: createdAt, + updated_at: createdAt, + enterprise_connection_id: 'sso_1', + }, + first_name: null, + last_name: null, + protocol: 'saml', + provider: 'saml_okta', + provider_user_id: null, + public_metadata: {}, + verification: null, + last_authenticated_at: null, + enterprise_connection_id: 'sso_1', + ...overrides, + }; +} + export function fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx deleted file mode 100644 index 302407d1462..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import type { FormField, UseFormResult } from '../../../components/form'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; -import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; -import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; - -type Form = UseFormResult; - -const untouched: FormField = { feedback: undefined, isValidating: false, touched: false, isDirty: false }; - -function stubForm(overrides: Partial = {}): Form { - const form: Form = { - id: 'edit-password', - values: { currentPassword: '', newPassword: '', confirmPassword: '', signOutOfOtherSessions: true }, - fields: { - currentPassword: untouched, - newPassword: untouched, - confirmPassword: untouched, - signOutOfOtherSessions: untouched, - }, - error: undefined, - isSubmitting: false, - isDirty: false, - canSubmit: true, - register: name => ({ - name, - value: form.values[name], - onChange: event => form.setValue(name, event.target.value), - onBlur: () => form.touch(name), - ref: () => undefined, - }), - setValue: vi.fn(), - touch: vi.fn(), - submit: vi.fn(), - handleSubmit: vi.fn((event: { preventDefault: () => void }) => event.preventDefault()), - reset: vi.fn(), - ...overrides, - }; - return form; -} - -function renderView(overrides: Partial = {}, form: Partial = {}) { - const props: UserProfileEditPasswordDialogProps = { - open: true, - onOpenChange: vi.fn(), - hasPassword: true, - requiresCurrentPassword: true, - form: stubForm(form), - ...overrides, - }; - return { - props, - ...render( - - - , - ), - }; -} - -const currentPasswordField = () => screen.getByLabelText('Current password'); -const newPasswordField = () => screen.getByLabelText('New password'); -const confirmPasswordField = () => screen.getByLabelText('Confirm password'); -const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); -const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); - -describe('UserProfileEditPasswordDialog', () => { - it('closes directly when Cancel is selected', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - }); - - it('names the dialog for a change and masks every field', () => { - renderView( - {}, - { values: { currentPassword: 'old', newPassword: 'new', confirmPassword: 'new', signOutOfOtherSessions: true } }, - ); - - expect(screen.getByRole('dialog', { name: 'Change password' })).toBeInTheDocument(); - expect(currentPasswordField()).toHaveAttribute('type', 'password'); - expect(currentPasswordField()).toHaveValue('old'); - expect(newPasswordField()).toHaveAttribute('type', 'password'); - expect(newPasswordField()).toHaveAttribute('autocomplete', 'new-password'); - expect(confirmPasswordField()).toHaveAttribute('type', 'password'); - expect(signOutCheckbox()).toBeChecked(); - expect(signOutCheckbox()).toHaveAccessibleDescription( - 'It is recommended to sign out of all other devices which may have used your old password.', - ); - }); - - it('writes typing and leaving a field back to the form by name', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - await user.type(newPasswordField(), 'a'); - await user.tab(); - await user.click(signOutCheckbox()); - - expect(props.form.setValue).toHaveBeenCalledWith('newPassword', 'a'); - expect(props.form.touch).toHaveBeenCalledWith('newPassword'); - expect(props.form.setValue).toHaveBeenCalledWith('signOutOfOtherSessions', false); - }); - - it('reveals a password from its own eye toggle and hides it again', async () => { - const user = userEvent.setup(); - renderView( - {}, - { - values: { - currentPassword: '', - newPassword: 'new-secret-123', - confirmPassword: '', - signOutOfOtherSessions: true, - }, - }, - ); - const [, newPasswordToggle] = screen.getAllByRole('button', { name: 'Show password' }); - if (!newPasswordToggle) { - throw new Error('New password visibility toggle is missing'); - } - - await user.click(newPasswordToggle); - - expect(newPasswordField()).toHaveAttribute('type', 'text'); - expect(newPasswordField()).toHaveValue('new-secret-123'); - expect(currentPasswordField()).toHaveAttribute('type', 'password'); - expect(confirmPasswordField()).toHaveAttribute('type', 'password'); - - await user.click(screen.getByRole('button', { name: 'Hide password' })); - - expect(newPasswordField()).toHaveAttribute('type', 'password'); - }); - - it('names the dialog for a first password and skips the current one', () => { - renderView({ hasPassword: false }); - - expect(screen.getByRole('dialog', { name: 'Set password' })).toBeInTheDocument(); - expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); - }); - - it('skips the current password when it is not required', async () => { - renderView({ requiresCurrentPassword: false }); - - expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); - await waitFor(() => expect(newPasswordField()).toHaveFocus()); - }); - - it('opens on the current password rather than the corner dismiss', async () => { - renderView(); - - await waitFor(() => expect(currentPasswordField()).toHaveFocus()); - }); - - it('announces the failure in a negative banner', () => { - renderView({}, { error: 'Your password could not be updated.' }); - - const banner = screen.getByRole('alert'); - expect(banner).toHaveTextContent('Your password could not be updated.'); - expect(newPasswordField()).not.toHaveAttribute('aria-invalid', 'true'); - }); - - it('renders field errors under their controls with no banner', () => { - const errored = (message: string): FormField => ({ - feedback: { type: 'error', message }, - isValidating: false, - touched: true, - }); - renderView( - {}, - { - fields: { - currentPassword: errored('Incorrect password.'), - newPassword: errored('Your password must contain 8 or more characters.'), - confirmPassword: errored("Passwords don't match."), - signOutOfOtherSessions: untouched, - }, - }, - ); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(currentPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(currentPasswordField()).toHaveAccessibleDescription('Incorrect password.'); - expect(newPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(newPasswordField()).toHaveAccessibleDescription('Your password must contain 8 or more characters.'); - expect(confirmPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(confirmPasswordField()).toHaveAccessibleDescription("Passwords don't match."); - }); - - it('withholds the save while the form says it cannot submit', async () => { - const user = userEvent.setup(); - const { props } = renderView({}, { canSubmit: false }); - - expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); - await user.click(saveButton()); - - expect(props.form.handleSubmit).not.toHaveBeenCalled(); - }); - - it('submits the form from the save button once every required field is filled', async () => { - const user = userEvent.setup(); - const { props } = renderView( - {}, - { - values: { - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - confirmPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }, - }, - ); - - await user.click(saveButton()); - - expect(props.form.handleSubmit).toHaveBeenCalledTimes(1); - }); - - it('stays inert while the save runs', async () => { - const user = userEvent.setup(); - const { props } = renderView({}, { isSubmitting: true }); - - await user.type(newPasswordField(), 'abc'); - - expect(currentPasswordField()).toBeDisabled(); - expect(newPasswordField()).toBeDisabled(); - expect(confirmPasswordField()).toBeDisabled(); - expect(signOutCheckbox()).toBeDisabled(); - screen.getAllByRole('button', { name: 'Show password' }).forEach(toggle => expect(toggle).toBeDisabled()); - expect(props.form.setValue).not.toHaveBeenCalled(); - expect(saveButton()).toHaveAttribute('aria-busy', 'true'); - await user.click(saveButton()); - expect(props.form.handleSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx new file mode 100644 index 00000000000..b8759f6b0ad --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx @@ -0,0 +1,73 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { ClerkInstanceContext } from '@clerk/shared/react'; +import type { LoadedClerk } from '@clerk/shared/types'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; + +const user = { + id: 'user_1', + passwordEnabled: true, + enterpriseAccounts: [], + updatePassword: vi.fn(), +}; +const session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; +const clerk = { + user, + session, + __internal_environment: { + userSettings: { + instanceIsPasswordBased: true, + passwordSettings: { + min_length: 8, + max_length: 72, + show_zxcvbn: false, + min_zxcvbn_strength: 3, + require_uppercase: false, + require_numbers: false, + }, + }, + displayConfig: { preferredSignInStrategy: 'password' }, + }, + __internal_moduleManager: {}, +}; +let isSessionLoaded = true; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => clerk, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + }; +}); + +function tree() { + return ( + + + + + + ); +} + +it('keeps the draft while session data briefly reloads', async () => { + isSessionLoaded = true; + const { rerender } = render(tree()); + const events = userEvent.setup(); + await events.click(screen.getByRole('button', { name: 'Change password' })); + await events.type(screen.getByLabelText('New password'), 'new-password-123'); + + isSessionLoaded = false; + rerender(tree()); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + + isSessionLoaded = true; + rerender(tree()); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx deleted file mode 100644 index 4a33f9a9965..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.integration.test.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type * as SharedReact from '@clerk/shared/react'; -import { ClerkInstanceContext } from '@clerk/shared/react'; -import type { LoadedClerk } from '@clerk/shared/types'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; -import type { UserProfileEditPasswordValue } from '../user-profile-password-section/user-profile-password-section.types'; - -const updatePassword = vi.fn<(input: UserProfileEditPasswordValue) => Promise>(); -const user: { - id: string; - passwordEnabled: boolean; - enterpriseAccounts: { active: boolean }[]; - updatePassword: typeof updatePassword; -} = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword }; -const session = { - id: 'session_1', - publicUserData: { identifier: 'person@example.com' }, - startVerification: vi.fn(), - attemptFirstFactorVerification: vi.fn(), -}; -const environment = { - userSettings: { - instanceIsPasswordBased: true, - passwordSettings: { - min_length: 8, - max_length: 72, - show_zxcvbn: false, - min_zxcvbn_strength: 3, - require_uppercase: false, - require_numbers: false, - }, - }, - authConfig: { reverification: true }, - displayConfig: { preferredSignInStrategy: 'password', supportEmail: 'support@example.com' }, -}; -const clerk = { - user, - session, - __internal_environment: environment, - __internal_moduleManager: {}, - __internal_getOption: () => undefined, - setActive: vi.fn(), -}; -let isSessionLoaded = true; - -vi.mock('@clerk/shared/internal/clerk-js/passwords/loadZxcvbn', () => ({ - createLoadZxcvbn: () => ({ - loadZxcvbn: () => Promise.resolve(() => ({ score: 0, feedback: { suggestions: ['anotherWord'] } })), - }), -})); - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useClerk: () => clerk, - useUser: () => ({ isLoaded: true, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - }; -}); - -beforeEach(() => { - vi.clearAllMocks(); - isSessionLoaded = true; - user.updatePassword.mockReset(); - user.passwordEnabled = true; - user.enterpriseAccounts = []; - environment.authConfig.reverification = true; - environment.userSettings.passwordSettings.show_zxcvbn = false; - environment.userSettings.passwordSettings.require_uppercase = false; - environment.userSettings.passwordSettings.require_numbers = false; - environment.userSettings.instanceIsPasswordBased = true; - user.updatePassword.mockResolvedValue(user); - clerk.setActive.mockResolvedValue(undefined); - session.startVerification.mockResolvedValue({ - status: 'needs_first_factor', - supportedFirstFactors: [{ strategy: 'password' }], - }); - session.attemptFirstFactorVerification.mockResolvedValue({ status: 'complete' }); -}); - -function passwordTree() { - return ( - - - - - - ); -} - -function renderPassword() { - return render(passwordTree()); -} - -async function editPassword() { - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('Current password'), 'old-secret'); - await events.type(screen.getByLabelText('New password'), 'new-password-123'); - await events.type(screen.getByLabelText('Confirm password'), 'new-password-123'); - await events.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); - return events; -} - -describe('UserProfilePasswordSection', () => { - it('keeps the editor pending until the direct update finishes', async () => { - let finishUpdate: (value: unknown) => void = () => {}; - user.updatePassword.mockReturnValueOnce( - new Promise(resolve => { - finishUpdate = resolve; - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); - - expect(screen.getByLabelText('New password')).toBeVisible(); - expect(screen.getByLabelText('New password')).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); - expect(screen.queryByText('Verification required')).not.toBeInTheDocument(); - expect(session.startVerification).not.toHaveBeenCalled(); - - await act(() => { - finishUpdate(user); - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('shows a direct API error without verification or an automatic retry', async () => { - user.updatePassword.mockRejectedValue( - new ClerkAPIResponseError('Verify', { - status: 403, - data: [{ code: 'session_reverification_required', message: 'Verify' }], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Verify'); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(user.updatePassword).toHaveBeenCalledOnce(); - expect(session.startVerification).not.toHaveBeenCalled(); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - }); - - it('hides the section when instance passwords are disabled', () => { - environment.userSettings.instanceIsPasswordBased = false; - renderPassword(); - expect(screen.queryByRole('region', { name: 'Authentication' })).not.toBeInTheDocument(); - }); - - it('keeps the password draft while session data briefly reloads', async () => { - const { rerender } = renderPassword(); - await editPassword(); - - isSessionLoaded = false; - rerender(passwordTree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - isSessionLoaded = true; - rerender(passwordTree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(screen.getByLabelText('Current password')).toHaveValue('old-secret'); - }); - - it('keeps an enterprise-managed password visible without offering a mutation', () => { - user.enterpriseAccounts = [{ active: true }]; - renderPassword(); - expect( - screen.getByText( - 'Your password can currently not be edited because you can sign in only via the enterprise connection.', - ), - ).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); - }); - - it('shows the configured password rule without making it a new submit restriction', async () => { - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('Current password'), 'old-secret'); - await events.type(screen.getByLabelText('New password'), 'short'); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); - expect( - screen - .getAllByRole('status') - .some(status => status.textContent?.includes('Your password must contain 8 or more characters.')), - ).toBe(true); - await events.type(screen.getByLabelText('Confirm password'), 'short'); - - expect(await screen.findByText('Your password must contain 8 or more characters.')).toBeVisible(); - expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); - await events.click(screen.getByLabelText('New password')); - expect(screen.getByLabelText('New password')).not.toHaveAttribute('aria-invalid', 'true'); - expect(screen.getByRole('button', { name: 'Save changes' })).not.toHaveAttribute('aria-disabled', 'true'); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ - currentPassword: 'old-secret', - newPassword: 'short', - signOutOfOtherSessions: true, - }); - }); - - it('shows password API errors at the visible field and preserves the draft', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Invalid', { - status: 422, - data: [ - { - code: 'form_password_pwned', - message: 'Choose a different password.', - meta: { param_name: 'new_password' }, - }, - ], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'This password has been found as part of a breach and can not be used, please try another password instead.', - ), - ); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); - }); - - it('prioritizes the backend minimum-length error over an earlier complexity error', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Invalid', { - status: 422, - data: [ - { code: 'form_password_no_uppercase', message: 'Raw uppercase', meta: { param_name: 'new_password' } }, - { code: 'form_password_length_too_short', message: 'Raw minimum', meta: { param_name: 'new_password' } }, - ], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); - }); - - it('uses legacy wording and list formatting for live complexity feedback', async () => { - environment.userSettings.passwordSettings.require_uppercase = true; - environment.userSettings.passwordSettings.require_numbers = true; - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('New password'), 'longpassword'); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain a number and an uppercase letter.', - ), - ); - }); - - it('shows the minimum length as an error after the new password is left empty', async () => { - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.click(screen.getByLabelText('New password')); - expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ); - - await events.click(screen.getByLabelText('Confirm password')); - - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); - }); - - it('keeps the mismatch visible after the confirmation is cleared', async () => { - renderPassword(); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('New password'), 'new-password-123'); - await events.type(screen.getByLabelText('Confirm password'), 'new-password-12'); - await events.click(screen.getByLabelText('New password')); - await waitFor(() => - expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), - ); - - await events.clear(screen.getByLabelText('Confirm password')); - - expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); - expect(screen.getByLabelText('Confirm password')).toHaveAttribute('aria-invalid', 'true'); - }); - - it('confirms that requirements are met when strength checking is disabled', async () => { - renderPassword(); - const events = await editPassword(); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password meets all the necessary requirements.', - ), - ); - await events.clear(screen.getByLabelText('New password')); - await events.type(screen.getByLabelText('New password'), 'short'); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password must contain 8 or more characters.', - ), - ); - }); - - it('shows specific client strength suggestions without blocking submission', async () => { - environment.userSettings.passwordSettings.show_zxcvbn = true; - renderPassword(); - const events = await editPassword(); - await waitFor(() => - expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( - 'Your password is not strong enough. Add more words that are less common.', - ), - ); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - expect(user.updatePassword).toHaveBeenCalled(); - }); - - it('keeps the draft and checkbox choice after a rejected update', async () => { - user.updatePassword.mockRejectedValueOnce( - new ClerkAPIResponseError('Update failed', { - status: 500, - data: [{ code: 'server_error', message: 'Update failed' }], - }), - ); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Update failed'); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); - expect(screen.getByRole('checkbox')).not.toBeChecked(); - expect(user.updatePassword).toHaveBeenCalledTimes(1); - expect(screen.getByRole('button', { name: 'Save changes' })).not.toBeDisabled(); - }); - - it('waits for the direct update before closing', async () => { - let finish: () => void = () => {}; - const update = new Promise(resolve => { - finish = resolve; - }); - user.updatePassword.mockImplementationOnce(() => update.then(() => user)); - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(user.updatePassword).toHaveBeenCalledOnce()); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); - expect(session.startVerification).not.toHaveBeenCalled(); - - await act(async () => { - finish(); - await update; - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('saves through the model and closes after the request succeeds', async () => { - renderPassword(); - const events = await editPassword(); - await events.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith({ - currentPassword: 'old-secret', - newPassword: 'new-password-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx new file mode 100644 index 00000000000..8b56d255d42 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -0,0 +1,261 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; + +const email = fapiEmailAddress({ id: 'idn_1', email_address: 'person@example.com' }); +const alice = fapiUser({ id: 'user_1', email_addresses: [email] }); + +async function renderPassword(user = alice, environment = fapiEnvironment()) { + const fapi = serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + return fapi; +} + +async function fillPassword() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + return user; +} + +describe('Changing a password', () => { + it('shows no password action when nobody is signed in', async () => { + serveFapi({ client: fapiClient() }); + await renderWithClerk(); + + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + expect(screen.queryByText('Password')).toBeNull(); + }); + + it('sends the update to Clerk and closes after it succeeds', async () => { + const fapi = await renderPassword(); + const user = await fillPassword(); + await user.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates[0]?.get('current_password')).toBe('old-secret'); + expect(fapi.passwordUpdates[0]?.get('new_password')).toBe('new-password-123'); + expect(fapi.passwordUpdates[0]?.get('sign_out_of_other_sessions')).toBe('false'); + }); + + it('stays busy until the update finishes and prevents duplicate saves', async () => { + await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + expect(screen.getByLabelText('New password')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + await user.keyboard('{Enter}'); + expect(update.requests).toHaveLength(1); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + update.release(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('shows a direct API error and keeps the draft without retrying automatically', async () => { + const fapi = await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('session_reverification_required'); + + expect(await screen.findByRole('alert')).toHaveTextContent('session_reverification_required'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.queryByText('Verification required')).toBeNull(); + expect(update.requests).toHaveLength(1); + serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates).toHaveLength(1); + }); + + it('shows a password error at the field and keeps the draft', async () => { + await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('form_password_pwned', undefined, 'new_password'); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ), + ); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('sets a first password without asking for the current one', async () => { + const fapi = await renderPassword(fapiUser({ ...alice, password_enabled: false })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Set password' })); + expect(screen.queryByLabelText('Current password')).toBeNull(); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates[0]?.has('current_password')).toBe(false); + await waitFor(() => expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument()); + }); + + it('hides the section when instance passwords are disabled', async () => { + const environment = fapiEnvironment(); + environment.user_settings.attributes.password.enabled = false; + await renderPassword(alice, environment); + + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + }); + + it('keeps an enterprise-managed password read-only', async () => { + await renderPassword(fapiUser({ ...alice, enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1' })] })); + + expect( + screen.getByText( + 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + ), + ).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + }); + + it('focuses the current password and clears the draft after cancellation', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + await user.type(screen.getByLabelText('New password'), 'draft-secret'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + expect(screen.getByLabelText('New password')).toHaveValue(''); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + }); + + it('keeps a confirmation mismatch visible after its field is cleared', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-12'); + await user.click(screen.getByLabelText('New password')); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); + await user.clear(screen.getByLabelText('Confirm password')); + + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('reveals and hides the current password on request', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + const current = screen.getByLabelText('Current password'); + expect(current).toHaveAttribute('type', 'password'); + + await user.click(screen.getAllByRole('button', { name: 'Show password' })[0]); + expect(current).toHaveAttribute('type', 'text'); + await user.click(screen.getByRole('button', { name: 'Hide password' })); + expect(current).toHaveAttribute('type', 'password'); + }); + + it('shows the password rule and leaves matching passwords eligible for a server check', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.min_length = 8; + const fapi = await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + await user.type(screen.getByLabelText('Confirm password'), 'short'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); + expect(fapi.passwordUpdates[0]?.get('new_password')).toBe('short'); + }); + + it('shows the minimum length as an error after an empty new password is left', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ); + + await user.click(screen.getByLabelText('Confirm password')); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('lists the configured complexity requirements beside the new password', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.require_uppercase = true; + environment.user_settings.password_settings.require_numbers = true; + await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('New password'), 'longpassword'); + + await waitFor( + () => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain a number and an uppercase letter.', + ), + { timeout: 2500 }, + ); + }); + + it('advises a weak password without blocking a server check', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.show_zxcvbn = true; + environment.user_settings.password_settings.min_zxcvbn_strength = 3; + const fapi = await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'password123'); + await user.type(screen.getByLabelText('Confirm password'), 'password123'); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription(/Your password is not strong enough/), + ); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 3728c08549e..fe120b52e31 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -2,9 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; -import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; function deferred() { let resolve: (result: T) => void = () => {}; @@ -14,40 +12,18 @@ function deferred() { return { promise, resolve }; } -function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), - requiresCurrentPassword = true, -) { - return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); -} - -type Controller = ReturnType['result']; - -function open(result: Controller) { - act(() => result.current.onOpenChange(true)); -} - -function fill(result: Controller, { current = 'old-secret', next = 'new-secret-123' } = {}) { - act(() => result.current.form.setValue('currentPassword', current)); - act(() => result.current.form.setValue('newPassword', next)); - act(() => result.current.form.setValue('confirmPassword', next)); -} - -describe('useUserProfileEditPasswordController', () => { +describe('useUserProfileEditPasswordController timing', () => { it('ignores older password feedback and clears it when the editor closes', async () => { const older = deferred(); const newer = deferred(); const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); const { result } = renderHook(() => - useUserProfileEditPasswordController({ - onSubmit: () => Promise.resolve(), - validatePassword, - }), + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), ); - open(result); - fill(result, { next: 'first password' }); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('first password')); - fill(result, { next: 'second password' }); + act(() => result.current.form.setValue('newPassword', 'second password')); await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('second password')); await act(async () => { @@ -64,384 +40,29 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.passwordFeedback).toBeUndefined(); }); - it('checks an empty new password only once it has been left', async () => { - const validatePassword = vi.fn(() => - Promise.resolve({ type: 'info', message: 'Your password must contain 8 or more characters.' }), - ); - const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), - ); - open(result); - await act(() => Promise.resolve()); - expect(validatePassword).not.toHaveBeenCalled(); - - act(() => result.current.form.touch('newPassword')); - - await waitFor(() => - expect(result.current.passwordFeedback).toEqual({ - type: 'info', - message: 'Your password must contain 8 or more characters.', - }), - ); - expect(validatePassword).toHaveBeenCalledWith(''); - }); - - it('can submit while an advisory password check is pending', async () => { - const check = deferred(); - const validatePassword = vi.fn(() => check.promise); - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileEditPasswordController({ onSubmit, validatePassword })); - open(result); - fill(result); - await waitFor(() => expect(validatePassword).toHaveBeenCalled()); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); - await act(async () => { - check.resolve({ type: 'error', message: 'Too short.' }); - await check.promise; - }); - expect(result.current.passwordFeedback).toBeUndefined(); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - it('ignores dismissal and duplicate saves in the same event as submission', async () => { const save = deferred(); const onSubmit = vi.fn(() => save.promise); - const { result } = renderController(onSubmit); - open(result); - fill(result); - - act(() => { - result.current.form.submit(); - result.current.onOpenChange(false); - result.current.form.submit(); - }); - - expect(result.current.isOpen).toBe(true); - expect(onSubmit).toHaveBeenCalledTimes(1); - await act(async () => { - save.resolve(undefined); - await save.promise; - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('shows password-rule feedback without blocking an otherwise valid submission', async () => { - const validatePassword = vi.fn(() => - Promise.resolve({ - type: 'warning', - message: 'Try a longer password.', - }), - ); - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => - useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit, validatePassword }), - ); - open(result); - fill(result, { next: ' new secret ' }); - - await waitFor(() => - expect(result.current.passwordFeedback).toEqual({ type: 'warning', message: 'Try a longer password.' }), - ); - expect(validatePassword).toHaveBeenCalledWith(' new secret '); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: ' new secret ', - signOutOfOtherSessions: true, - }); - }); - - it('keeps the draft and shows a direct save error', async () => { const { result } = renderHook(() => - useUserProfileEditPasswordController({ - requiresCurrentPassword: true, - onSubmit: () => Promise.reject(new Error('Save failed')), - }), + useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit }), ); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.isSubmitting).toBe(false)); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.error).toBe('Save failed'); - expect(result.current.form.values).toEqual({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - confirmPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - expect(result.current.form.canSubmit).toBe(true); - }); - - it('blocks direct submission with an empty confirmation', () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit); - open(result); - fill(result); - act(() => result.current.form.setValue('confirmPassword', '')); - - act(() => result.current.form.submit()); - - expect(result.current.form.canSubmit).toBe(false); - expect(onSubmit).not.toHaveBeenCalled(); - expect(result.current.isOpen).toBe(true); - }); - - it('opens with empty fields and sign-out of other devices on', () => { - const { result } = renderController(); - expect(result.current.isOpen).toBe(false); - - open(result); - - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values).toEqual({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - signOutOfOtherSessions: true, - }); - expect(result.current.form.error).toBeUndefined(); - expect(result.current.form.isSubmitting).toBe(false); - }); - - it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - - act(() => result.current.form.submit()); - - expect(result.current.isOpen).toBe(true); - expect(result.current.form.isSubmitting).toBe(true); - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('leaves the current password out when setting a first password', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit, false); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.submit()); - - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: undefined, - newPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('withholds the save until the halves match and the current password is in', () => { - const { result } = renderController(); - open(result); - expect(result.current.form.canSubmit).toBe(false); - - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.canSubmit).toBe(false); - + act(() => result.current.onOpenChange(true)); act(() => result.current.form.setValue('currentPassword', 'old-secret')); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-124')); - expect(result.current.form.canSubmit).toBe(false); - }); - - it('names the mismatch under the confirmation once it has been left', () => { - const { result } = renderController(); - open(result); act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - - act(() => result.current.form.touch('confirmPassword')); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - }); - - it('keeps the mismatch after the confirmation is cleared', () => { - const { result } = renderController(); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - act(() => result.current.form.touch('confirmPassword')); - - act(() => result.current.form.setValue('confirmPassword', '')); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - }); - - it('leaves an untouched empty confirmation quiet', () => { - const { result } = renderController(); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - }); - - it('forgets what was typed when the dialog is cancelled', () => { - const { result } = renderController(); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - act(() => result.current.form.touch('confirmPassword')); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - open(result); - expect(result.current.form.values).toEqual({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - signOutOfOtherSessions: true, + act(() => { + result.current.form.submit(); + result.current.onOpenChange(false); + result.current.form.submit(); }); - expect(result.current.form.fields.confirmPassword.touched).toBe(false); - }); - - it('starts fresh when opened again after a save', async () => { - const { result } = renderController(); - open(result); - fill(result); - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - - open(result); - - expect(result.current.form.values.newPassword).toBe(''); - }); - - it('stays open while the save runs', async () => { - const save = deferred(); - const { result } = renderController(() => save.promise); - open(result); - fill(result); - act(() => result.current.form.submit()); - - act(() => result.current.onOpenChange(false)); expect(result.current.isOpen).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); await act(async () => { save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - - it('keeps what was typed when the save fails, and carries field copy when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your password could not be updated.', { - newPassword: 'Your password must contain 8 or more characters.', - }); - const { result } = renderController(() => Promise.reject(failure)); - open(result); - fill(result); - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Your password could not be updated.')); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values.newPassword).toBe('new-secret-123'); - expect(result.current.form.fields.newPassword.feedback).toEqual({ - type: 'error', - message: 'Your password must contain 8 or more characters.', - }); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const { result } = renderController(() => Promise.reject('nope')); - open(result); - fill(result); - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Something went wrong. Please try again.')); - }); - - it('keeps a failed save visible next to a fresh mismatch', async () => { - const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); - open(result); - fill(result); - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.form.error).toBe('Incorrect password.')); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - - expect(result.current.form.error).toBe('Incorrect password.'); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - }); - - it('does not retry a rejected direct save', async () => { - const onSubmit = vi.fn(() => Promise.reject(new Error('Save failed'))); - const { result } = renderController(onSubmit); - open(result); - fill(result); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Save failed')); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values.newPassword).toBe('new-secret-123'); - expect(onSubmit).toHaveBeenCalledOnce(); - }); - - it('formats a failed save before the form shows it', async () => { - const failure = new Error('raw'); - const formatError = vi.fn(() => new UserProfileSaveError('Formatted.')); - const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(failure), formatError }), - ); - open(result); - fill(result); - - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Formatted.')); - expect(formatError).toHaveBeenCalledWith(failure); - }); - - it('closes after a failed direct save', async () => { - const { result } = renderHook(() => - useUserProfileEditPasswordController({ onSubmit: () => Promise.reject(new Error('Save failed')) }), - ); - open(result); - fill(result); - - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.form.error).toBe('Save failed')); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - expect(result.current.form.values.newPassword).toBe(''); - }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index 30ed4e570e2..c0dddc3f360 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -1,5 +1,4 @@ import type { PasswordSettingsData } from '@clerk/shared/types'; -import { createDeferredPromise } from '@clerk/shared/utils'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -28,17 +27,12 @@ const passwordSettings: PasswordSettingsData = { }; function createEnvironment() { - return { - userSettings: { instanceIsPasswordBased: true, passwordSettings }, - authConfig: { reverification: false }, - }; + return { userSettings: { instanceIsPasswordBased: true, passwordSettings } }; } -let isUserLoaded: boolean; -let isSessionLoaded: boolean; let user: TestUser | null; let session: TestSession | null; -let environment: ReturnType | undefined; +let environment: ReturnType; const clerk = { get user() { @@ -54,14 +48,12 @@ const clerk = { vi.mock('@clerk/shared/react', () => ({ useClerk: () => clerk, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: true, session }), })); beforeEach(() => { - isUserLoaded = true; - isSessionLoaded = true; - user = { id: 'user_1', passwordEnabled: false, enterpriseAccounts: [], updatePassword: vi.fn() }; + user = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword: vi.fn() }; session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; environment = createEnvironment(); }); @@ -69,37 +61,19 @@ beforeEach(() => { afterEach(cleanup); function ready(model: ReturnType) { - expect(model.status).toBe('ready'); if (model.status !== 'ready') { throw new Error('expected ready model'); } return model; } -describe('useUserProfilePasswordModel', () => { - it.each([undefined, ''])('rejects a missing required current password (%j)', async currentPassword => { - if (!user) { - throw new Error('expected user'); - } - user.passwordEnabled = true; - const { result } = renderHook(() => useUserProfilePasswordModel()); - await expect( - ready(result.current).updatePassword({ - currentPassword, - newPassword: 'new password', - signOutOfOtherSessions: true, - }), - ).rejects.toMatchObject({ code: 'current_password_required' }); - expect(user.updatePassword).not.toHaveBeenCalled(); - }); - +describe('useUserProfilePasswordModel context changes', () => { it.each(['signed out', 'different user', 'different session', 'no session', 'disabled', 'enterprise', 'mode'])( - 'rejects a captured action after the context changes: %s', + 'rejects a captured action after %s', async change => { - if (!user || !session || !environment) { + if (!user || !session) { throw new Error('expected loaded fixtures'); } - user.passwordEnabled = true; const updatePassword = user.updatePassword; const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); const action = ready(result.current).updatePassword; @@ -136,7 +110,7 @@ describe('useUserProfilePasswordModel', () => { }, ); - it('rejects updates when a loaded user has no active session', async () => { + it('rejects an update when a loaded user has no active session', async () => { if (!user) { throw new Error('expected user'); } @@ -147,144 +121,4 @@ describe('useUserProfilePasswordModel', () => { ).rejects.toMatchObject({ code: 'unavailable' }); expect(user.updatePassword).not.toHaveBeenCalled(); }); - - it.each([ - { passwordEnabled: false, reverification: false, signOutOfOtherSessions: true }, - { passwordEnabled: false, reverification: true, signOutOfOtherSessions: false }, - { passwordEnabled: true, reverification: false, signOutOfOtherSessions: false }, - { passwordEnabled: true, reverification: true, signOutOfOtherSessions: true }, - ])('sends the direct password payload for %j', async policy => { - if (!user || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = policy.passwordEnabled; - environment.authConfig.reverification = policy.reverification; - const { result } = renderHook(() => useUserProfilePasswordModel()); - const input = { - currentPassword: ' old secret ', - newPassword: ' new e\u0301 secret ', - confirmPassword: 'must never reach the SDK', - signOutOfOtherSessions: policy.signOutOfOtherSessions, - }; - - await ready(result.current).updatePassword(input); - - const expected = { - newPassword: ' new e\u0301 secret ', - signOutOfOtherSessions: policy.signOutOfOtherSessions, - }; - expect(user.updatePassword).toHaveBeenCalledExactlyOnceWith( - policy.passwordEnabled ? { ...expected, currentPassword: ' old secret ' } : expected, - ); - }); - - it('waits for the SDK resource and derives change mode only after hydration', async () => { - if (!user) { - throw new Error('expected user'); - } - const request = createDeferredPromise(); - user.updatePassword.mockReturnValueOnce(request.promise); - const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); - const promise = ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }); - expect(ready(result.current).mode).toBe('set'); - expect(user.passwordEnabled).toBe(false); - - user = { ...user, passwordEnabled: true }; - request.resolve(user); - await expect(promise).resolves.toBe(user); - rerender(); - expect(ready(result.current).mode).toBe('change'); - }); - - it.each([ - { errors: [{ code: 'form_password_incorrect', meta: { paramName: 'current_password' } }] }, - { errors: [{ code: 'session_reverification_required' }] }, - new Error('network failure'), - ])('preserves the SDK rejection for the caller', async error => { - if (!user) { - throw new Error('expected user'); - } - user.updatePassword.mockRejectedValueOnce(error); - const { result } = renderHook(() => useUserProfilePasswordModel()); - await expect( - ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), - ).rejects.toBe(error); - }); - - it.each([ - { passwordEnabled: false, reverification: false, mode: 'set', requiresCurrentPassword: false }, - { passwordEnabled: false, reverification: true, mode: 'set', requiresCurrentPassword: false }, - { passwordEnabled: true, reverification: false, mode: 'change', requiresCurrentPassword: true }, - { passwordEnabled: true, reverification: true, mode: 'change', requiresCurrentPassword: true }, - ])('derives $mode mode independently of reverification=$reverification', policy => { - if (!user || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = policy.passwordEnabled; - environment.authConfig.reverification = policy.reverification; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toMatchObject({ - status: 'ready', - mode: policy.mode, - requiresCurrentPassword: policy.requiresCurrentPassword, - userId: 'user_1', - sessionId: 'session_1', - identifier: 'person@example.com', - passwordSettings, - }); - }); - - it('keeps a loaded user visible when there is no session identifier', () => { - session = null; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toMatchObject({ status: 'ready', sessionId: null, identifier: '' }); - }); - - it.each([false, true])('makes active enterprise accounts readonly with passwordEnabled=%s', passwordEnabled => { - if (!user) { - throw new Error('expected user'); - } - user.passwordEnabled = passwordEnabled; - user.enterpriseAccounts = [{ active: false }, { active: true }, { active: true }]; - const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toEqual({ - status: 'readonly', - reason: 'enterprise_account', - mode: passwordEnabled ? 'change' : 'set', - }); - - user.enterpriseAccounts = [{ active: false }]; - rerender(); - expect(result.current.status).toBe('ready'); - }); - - it('hides the section when the loaded user is absent', () => { - user = null; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toEqual({ status: 'hidden', reason: 'no_user' }); - }); - - it.each([false, true])('hides disabled instance passwords when user passwordEnabled is %s', passwordEnabled => { - if (!user || !environment) { - throw new Error('expected loaded fixtures'); - } - user.passwordEnabled = passwordEnabled; - environment.userSettings.instanceIsPasswordBased = false; - const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toEqual({ status: 'hidden', reason: 'password_disabled' }); - }); - - it.each(['user', 'session', 'environment'])('waits for %s to load', resource => { - if (resource === 'user') { - isUserLoaded = false; - } else if (resource === 'session') { - isSessionLoaded = false; - } else { - environment = undefined; - } - - const { result } = renderHook(() => useUserProfilePasswordModel()); - - expect(result.current).toEqual({ status: 'loading' }); - }); }); From 5603fde93f5d058b0a840d3c9699ee9c639ba84e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:59:28 -0600 Subject: [PATCH 66/91] fix(swingset): parse password fixture as JSX and trim duplicate tests --- ...ser-profile-password-section.view.test.tsx | 131 ------------------ .../user-profile-password.feature.test.tsx | 2 + 2 files changed, 2 insertions(+), 131 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx deleted file mode 100644 index 2a9b023d80a..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; - -function renderView(props: UserProfilePasswordSectionViewProps = {}) { - return render( - - - , - ); -} - -describe('UserProfilePasswordSectionView', () => { - it('changes a password and closes the dialog after saving', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ hasPassword: true, requiresCurrentPassword: true, onSubmitPassword }); - - expect(screen.getByText('••••••••••••••••••')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Change password' })); - const dialog = screen.getByRole('dialog', { name: 'Change password' }); - await user.type(within(dialog).getByLabelText('Current password'), 'old-secret'); - await user.type(within(dialog).getByLabelText('New password'), 'new-secret-123'); - await user.type(within(dialog).getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(within(dialog).getByRole('checkbox', { name: 'Sign out of all other devices' })); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitPassword).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Change password' })).not.toBeInTheDocument()); - }); - - it('offers to set a password when the instance takes one but the account has none', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ hasPassword: false, onSubmitPassword }); - - expect(screen.getByRole('group', { name: 'Password' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 2, name: 'Password' })).toHaveClass('cl-section-title'); - expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); - expect(screen.getByText('No password set')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Set password' })); - const dialog = screen.getByRole('dialog', { name: 'Set password' }); - expect(within(dialog).queryByLabelText('Current password')).not.toBeInTheDocument(); - await user.type(within(dialog).getByLabelText('New password'), 'new-secret-123'); - await user.type(within(dialog).getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitPassword).toHaveBeenCalledWith({ - currentPassword: undefined, - newPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }); - }); - - it('keeps entered values after a failure and closes after a corrected retry', async () => { - const user = userEvent.setup(); - const onSubmitPassword = vi - .fn() - .mockRejectedValueOnce( - new UserProfileSaveError('Your password could not be updated.', { - currentPassword: 'Incorrect password.', - }), - ) - .mockResolvedValue(undefined); - renderView({ hasPassword: true, requiresCurrentPassword: true, onSubmitPassword }); - - await user.click(screen.getByRole('button', { name: 'Change password' })); - await user.type(screen.getByLabelText('Current password'), 'incorrect-password'); - await user.type(screen.getByLabelText('New password'), 'new-secret-123'); - await user.type(screen.getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Your password could not be updated.'); - expect(screen.getByLabelText('Current password')).toHaveAccessibleDescription('Incorrect password.'); - expect(screen.getByLabelText('New password')).toHaveValue('new-secret-123'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-secret-123'); - - await user.clear(screen.getByLabelText('Current password')); - await user.type(screen.getByLabelText('Current password'), 'correct-password'); - await user.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Change password' })).toHaveFocus(); - }); - - it('hides the entire section when there is no password, manager, or action', () => { - const { container } = render( - - - , - ); - - expect(container).toBeEmptyDOMElement(); - }); - - it('shows an existing password without requiring an edit action', () => { - render( - - - , - ); - - expect(screen.getByRole('group', { name: 'Password' })).toBeVisible(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('shows the enterprise manager instead of password actions', () => { - render( - - Promise.resolve())} - /> - , - ); - - expect(screen.getByText('Managed by Okta')).toBeVisible(); - expect(screen.getByText('Okta')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index 8b56d255d42..76cb8bb1b0b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -208,6 +208,7 @@ describe('Changing a password', () => { await renderPassword(); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); await user.click(screen.getByLabelText('New password')); expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( 'Your password must contain 8 or more characters.', @@ -230,6 +231,7 @@ describe('Changing a password', () => { await renderPassword(alice, environment); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); await user.type(screen.getByLabelText('New password'), 'longpassword'); await waitFor( From c99eb122c93880b64311d0ac4e5d6d671624ce21 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 14:15:36 -0600 Subject: [PATCH 67/91] fix(mosaic): use managed password row for enterprise accounts --- .changeset/quiet-managed-password.md | 5 +++ .../user-profile-password.feature.test.tsx | 35 +++++++++++++++---- .../user-profile-password-section.messages.ts | 2 +- .../user-profile-password-section.model.ts | 25 ++++++++++--- .../user-profile-password-section.tsx | 6 ++-- 5 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 .changeset/quiet-managed-password.md diff --git a/.changeset/quiet-managed-password.md b/.changeset/quiet-managed-password.md new file mode 100644 index 00000000000..3c705a165b8 --- /dev/null +++ b/.changeset/quiet-managed-password.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Show the enterprise provider's name and logo for managed passwords instead of squeezing the password row with a long explanation. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index 76cb8bb1b0b..7a23e4b3aa9 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -130,14 +130,35 @@ describe('Changing a password', () => { expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); }); - it('keeps an enterprise-managed password read-only', async () => { - await renderPassword(fapiUser({ ...alice, enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1' })] })); + it.each([true, false])('shows the managed view when passwordEnabled is %s', async passwordEnabled => { + await renderPassword( + fapiUser({ + ...alice, + password_enabled: passwordEnabled, + enterprise_accounts: [ + fapiEnterpriseAccount({ id: 'inactive', active: false, enterprise_connection: null }), + fapiEnterpriseAccount({ id: 'ent_1' }), + ], + }), + ); - expect( - screen.getByText( - 'Your password can currently not be edited because you can sign in only via the enterprise connection.', - ), - ).toBeVisible(); + expect(screen.getByText(/Managed by|Your password can currently/)).toHaveTextContent('Managed by Company SSO'); + if (!passwordEnabled) { + expect(screen.getByText('No password set')).toBeVisible(); + } + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Set password' })).toBeNull(); + }); + + it('keeps the managed view when connection details are unavailable', async () => { + await renderPassword( + fapiUser({ + ...alice, + enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1', enterprise_connection: null })], + }), + ); + + expect(screen.getByText('Managed by your enterprise connection')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 85fd184af7b..2c79055865b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -3,7 +3,7 @@ export const userProfilePasswordSectionMessages = { masked: '••••••••••••••••••', noPasswordSet: 'No password set', managedBy: 'Managed by {name}', - readonly: 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + enterpriseConnection: 'your enterprise connection', change: 'Change password', set: 'Set password', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index eb386f7de62..4df60e0c07f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -2,7 +2,13 @@ import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/ import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; -import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; +import type { + EnterpriseAccountResource, + EnvironmentResource, + PasswordSettingsData, + PasswordValidation, + UserResource, +} from '@clerk/shared/types'; import { useCallback } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; @@ -15,7 +21,12 @@ type EditablePasswordPolicy = type UnavailablePasswordModel = | { status: 'hidden'; reason: 'no_user' | 'password_disabled' } - | { status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account' }; + | { + status: 'readonly'; + mode: 'set' | 'change'; + reason: 'enterprise_account'; + enterpriseConnection: EnterpriseAccountResource['enterpriseConnection']; + }; export type UserProfilePasswordModel = | { status: 'loading' } @@ -46,8 +57,14 @@ function getPasswordPolicy( ? { mode: 'change', requiresCurrentPassword: true } : { mode: 'set', requiresCurrentPassword: false }; - if (user.enterpriseAccounts.some(account => account.active)) { - return { status: 'readonly', mode: policy.mode, reason: 'enterprise_account' }; + const enterpriseAccount = user.enterpriseAccounts.find(account => account.active); + if (enterpriseAccount) { + return { + status: 'readonly', + mode: policy.mode, + reason: 'enterprise_account', + enterpriseConnection: enterpriseAccount.enterpriseConnection, + }; } return { status: 'ready', userId: user.id, ...policy }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 747486d5a02..1ce9e619eb6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -3,7 +3,6 @@ import { useCallback, useRef } from 'react'; import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; -import { Text } from '../../../components/text'; import { useLocale, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; @@ -35,7 +34,10 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw return ( {m.readonly}} + managedBy={{ + name: model.enterpriseConnection?.name || m.enterpriseConnection, + iconUrl: model.enterpriseConnection?.logoPublicUrl ?? undefined, + }} /> ); } From 0d49e8070214018a7f2f8c186d0bd4237fd21d27 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:12:40 -0600 Subject: [PATCH 68/91] test(mosaic): reproduce managed password label overflow --- ...r-profile-password-layout.feature.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx new file mode 100644 index 00000000000..0839fd34e52 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx @@ -0,0 +1,39 @@ +import '../../../../dist/styles.css'; + +import { render, screen } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; + +it.each([ + { width: 320, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, + { width: 720, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, + { width: 320, name: 'EnterpriseConnection'.repeat(5) }, + { width: 720, name: 'EnterpriseConnection'.repeat(5) }, +])('keeps the enterprise name beside the password label at $width px ($name)', ({ width, name }) => { + const { container } = render( + +
+ +
+
, + ); + const label = screen.getByText('Password'); + const managed = screen.getByText(`Managed by ${name}`); + const icon = container.querySelector('img'); + if (!icon) { + throw new Error('Missing enterprise icon'); + } + const labelRange = document.createRange(); + labelRange.selectNodeContents(label); + const textRange = document.createRange(); + textRange.selectNodeContents(managed); + const host = screen.getByRole('region', { name: 'Authentication' }).getBoundingClientRect(); + + expect(host.width).toBe(width); + expect(labelRange.getBoundingClientRect().right).toBeLessThanOrEqual(icon.getBoundingClientRect().left); + expect(textRange.getBoundingClientRect().right).toBeLessThanOrEqual(host.right); +}); From 07d1d43ead53886980e0f960d3367479809490cd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:14:01 -0600 Subject: [PATCH 69/91] fix(mosaic): wrap managed password provider names --- .changeset/quiet-phones-wrap.md | 5 +++++ .../user-profile-password-row.view.tsx | 4 ++-- .../user-profile-password-section.styles.ts | 7 +++++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-phones-wrap.md diff --git a/.changeset/quiet-phones-wrap.md b/.changeset/quiet-phones-wrap.md new file mode 100644 index 00000000000..34c92f6c205 --- /dev/null +++ b/.changeset/quiet-phones-wrap.md @@ -0,0 +1,5 @@ +--- +"@clerk/mosaic": patch +--- + +Keep long enterprise names from overlapping the password label in UserProfile on narrow screens. diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 345ab04cd43..0f39e1ffef9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -25,13 +25,13 @@ export function UserProfilePasswordRowView({ return ( - + {hasPassword ? m.masked : m.noPasswordSet} {action ? ( {action} ) : managedBy ? ( - + ) : onSubmitPassword ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts index 44d50260dc3..c6158cefa94 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -28,6 +28,12 @@ export const styles = stylex.create({ checkboxDescription: { color: colorVars['--cl-color-foreground-secondary'], }, + managedContent: { + flexShrink: 0, + }, + managedActions: { + flexShrink: 1, + }, managedBy: { gap: space['1.5'], alignItems: 'center', @@ -35,5 +41,6 @@ export const styles = stylex.create({ }, managedByText: { color: colorVars['--cl-color-foreground-secondary'], + overflowWrap: 'anywhere', }, }); From 5afc13b776a1baf256ce15c4b2abef164a667dfe Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:16:05 -0600 Subject: [PATCH 70/91] docs(swingset): show long managed password provider --- .../src/stories/user-profile-password-section.mdx | 9 +++++++++ .../user-profile-password-section.stories.tsx | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 1aa43a70301..420c5f6fa07 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -46,6 +46,15 @@ group width the label keeps only the name. storyModule={Stories} /> +### Long enterprise name + +The 320px example shows how the provider name wraps beside the password label. + + + ## Save error and retry The first save shows a form error and a current-password error. Correct the value and retry to diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 1ac0dfe0cfc..f6e583f4115 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -64,6 +64,20 @@ export function ManagedByEnterprise() { ); } +export function ManagedByEnterpriseLongName() { + return ( +
+ +
+ ); +} + /** The first save shows field and form errors; retrying succeeds. */ export function EditPasswordFails() { return ( From 889efe1816f2705ff1bc389cb2e0abf71158006a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 16:30:06 -0600 Subject: [PATCH 71/91] fix(mosaic): place managed provider below password --- .changeset/quiet-phones-wrap.md | 2 +- .../user-profile-password-layout.feature.test.tsx | 9 +++++---- .../user-profile-password-row.view.tsx | 9 +++------ .../user-profile-password-section.styles.ts | 7 +------ .../src/stories/user-profile-password-section.mdx | 2 +- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/.changeset/quiet-phones-wrap.md b/.changeset/quiet-phones-wrap.md index 34c92f6c205..272096f3e83 100644 --- a/.changeset/quiet-phones-wrap.md +++ b/.changeset/quiet-phones-wrap.md @@ -2,4 +2,4 @@ "@clerk/mosaic": patch --- -Keep long enterprise names from overlapping the password label in UserProfile on narrow screens. +Show enterprise password providers below the password in UserProfile, with room for long names to wrap. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx index 0839fd34e52..71a85e31822 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx @@ -11,11 +11,12 @@ it.each([ { width: 720, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, { width: 320, name: 'EnterpriseConnection'.repeat(5) }, { width: 720, name: 'EnterpriseConnection'.repeat(5) }, -])('keeps the enterprise name beside the password label at $width px ($name)', ({ width, name }) => { +])('places the enterprise name below the password at $width px ($name)', ({ width, name }) => { const { container } = render(
@@ -27,13 +28,13 @@ it.each([ if (!icon) { throw new Error('Missing enterprise icon'); } - const labelRange = document.createRange(); - labelRange.selectNodeContents(label); const textRange = document.createRange(); textRange.selectNodeContents(managed); const host = screen.getByRole('region', { name: 'Authentication' }).getBoundingClientRect(); + const description = screen.getByText('••••••••••••••••••').getBoundingClientRect(); expect(host.width).toBe(width); - expect(labelRange.getBoundingClientRect().right).toBeLessThanOrEqual(icon.getBoundingClientRect().left); + expect(icon.getBoundingClientRect().top).toBeGreaterThanOrEqual(description.bottom); + expect(icon.getBoundingClientRect().left).toBeGreaterThanOrEqual(label.getBoundingClientRect().left); expect(textRange.getBoundingClientRect().right).toBeLessThanOrEqual(host.right); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 0f39e1ffef9..a35286dcbe9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -25,16 +25,13 @@ export function UserProfilePasswordRowView({ return ( - + {hasPassword ? m.masked : m.noPasswordSet} + {!action && managedBy ? : null} {action ? ( {action} - ) : managedBy ? ( - - - - ) : onSubmitPassword ? ( + ) : !managedBy && onSubmitPassword ? ( Date: Tue, 29 Sep 2026 16:40:10 -0600 Subject: [PATCH 72/91] revert(mosaic): leave section layout for follow-up --- .changeset/quiet-phones-wrap.md | 5 --- ...r-profile-password-layout.feature.test.tsx | 40 ------------------- .../user-profile-password-row.view.tsx | 7 +++- .../user-profile-password-section.styles.ts | 2 - .../stories/user-profile-password-section.mdx | 9 ----- .../user-profile-password-section.stories.tsx | 14 ------- 6 files changed, 5 insertions(+), 72 deletions(-) delete mode 100644 .changeset/quiet-phones-wrap.md delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx diff --git a/.changeset/quiet-phones-wrap.md b/.changeset/quiet-phones-wrap.md deleted file mode 100644 index 272096f3e83..00000000000 --- a/.changeset/quiet-phones-wrap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@clerk/mosaic": patch ---- - -Show enterprise password providers below the password in UserProfile, with room for long names to wrap. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx deleted file mode 100644 index 71a85e31822..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-layout.feature.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import '../../../../dist/styles.css'; - -import { render, screen } from '@testing-library/react'; -import { expect, it } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; - -it.each([ - { width: 320, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, - { width: 720, name: 'Acme International Enterprise Identity and Access Management Production Organization' }, - { width: 320, name: 'EnterpriseConnection'.repeat(5) }, - { width: 720, name: 'EnterpriseConnection'.repeat(5) }, -])('places the enterprise name below the password at $width px ($name)', ({ width, name }) => { - const { container } = render( - -
- -
-
, - ); - const label = screen.getByText('Password'); - const managed = screen.getByText(`Managed by ${name}`); - const icon = container.querySelector('img'); - if (!icon) { - throw new Error('Missing enterprise icon'); - } - const textRange = document.createRange(); - textRange.selectNodeContents(managed); - const host = screen.getByRole('region', { name: 'Authentication' }).getBoundingClientRect(); - const description = screen.getByText('••••••••••••••••••').getBoundingClientRect(); - - expect(host.width).toBe(width); - expect(icon.getBoundingClientRect().top).toBeGreaterThanOrEqual(description.bottom); - expect(icon.getBoundingClientRect().left).toBeGreaterThanOrEqual(label.getBoundingClientRect().left); - expect(textRange.getBoundingClientRect().right).toBeLessThanOrEqual(host.right); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index a35286dcbe9..345ab04cd43 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -27,11 +27,14 @@ export function UserProfilePasswordRowView({ {hasPassword ? m.masked : m.noPasswordSet} - {!action && managedBy ? : null} {action ? ( {action} - ) : !managedBy && onSubmitPassword ? ( + ) : managedBy ? ( + + + + ) : onSubmitPassword ? ( -### Long enterprise name - -The 320px example shows the provider name below the password, with enough room to wrap. - - - ## Save error and retry The first save shows a form error and a current-password error. Correct the value and retry to diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index f6e583f4115..1ac0dfe0cfc 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -64,20 +64,6 @@ export function ManagedByEnterprise() { ); } -export function ManagedByEnterpriseLongName() { - return ( -
- -
- ); -} - /** The first save shows field and form errors; retrying succeeds. */ export function EditPasswordFails() { return ( From ef6fbe99284489a48064e94b9253eba489ef8009 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 17:00:02 -0600 Subject: [PATCH 73/91] test(mosaic): await password dialog focus before typing --- .../__tests__/user-profile-password.feature.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index 7a23e4b3aa9..b18b7572ceb 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -180,6 +180,7 @@ describe('Changing a password', () => { await renderPassword(); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); await user.type(screen.getByLabelText('New password'), 'new-password-123'); await user.type(screen.getByLabelText('Confirm password'), 'new-password-12'); await user.click(screen.getByLabelText('New password')); @@ -188,7 +189,11 @@ describe('Changing a password', () => { ); await user.clear(screen.getByLabelText('Confirm password')); - expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Confirm password')).toHaveValue(''); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); }); From b1e7ec81636084f813d75e1130200cdacc85b55d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 01:31:20 -0600 Subject: [PATCH 74/91] chore(mosaic): mark session reverification as deferred --- .../user-profile-password-section.model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 4df60e0c07f..8f75c0d4338 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -112,6 +112,7 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { identifier: session?.publicUserData.identifier ?? '', passwordSettings: environment.userSettings.passwordSettings, validatePassword, + // TODO: Add session reverification for password updates; surface API errors until then. updatePassword: async ({ currentPassword, newPassword, signOutOfOtherSessions }) => { const currentUser = clerk.user; const currentPolicy = getPasswordPolicy(currentUser, environment); From 24cbdc74b356447c6feda2842f06bf09656db671 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:26:06 -0600 Subject: [PATCH 75/91] chore: make password section changeset empty --- .changeset/quiet-managed-password.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/quiet-managed-password.md b/.changeset/quiet-managed-password.md index 3c705a165b8..a845151cc84 100644 --- a/.changeset/quiet-managed-password.md +++ b/.changeset/quiet-managed-password.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Show the enterprise provider's name and logo for managed passwords instead of squeezing the password row with a long explanation. From 4f13837aebb4167385f4ca147a63d52d95a7398a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:27:24 -0600 Subject: [PATCH 76/91] chore: remove duplicate empty changeset --- .changeset/calm-password-controller.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .changeset/calm-password-controller.md diff --git a/.changeset/calm-password-controller.md b/.changeset/calm-password-controller.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/calm-password-controller.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- From 505bcd3d5e8c968879ddf86835d2083a0c76ac7d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:52:33 -0600 Subject: [PATCH 77/91] refactor(mosaic): simplify password loading --- .../user-profile-password-loading.test.tsx | 16 +++++----------- .../user-profile-password-section.tsx | 9 ++------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx index b8759f6b0ad..b12456d5e47 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-loading.test.tsx @@ -2,7 +2,6 @@ import type * as SharedReact from '@clerk/shared/react'; import { ClerkInstanceContext } from '@clerk/shared/react'; import type { LoadedClerk } from '@clerk/shared/types'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; @@ -50,24 +49,19 @@ function tree() { return ( - + Loading password section} /> ); } -it('keeps the draft while session data briefly reloads', async () => { +it('shows the fallback when session data starts loading after the section is ready', () => { isSessionLoaded = true; const { rerender } = render(tree()); - const events = userEvent.setup(); - await events.click(screen.getByRole('button', { name: 'Change password' })); - await events.type(screen.getByLabelText('New password'), 'new-password-123'); + expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument(); isSessionLoaded = false; rerender(tree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - - isSessionLoaded = true; - rerender(tree()); - expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByText('Loading password section')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 1ce9e619eb6..c8833287275 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; @@ -20,12 +20,7 @@ export interface UserProfilePasswordSectionProps { } export function UserProfilePasswordSection({ fallback = null }: UserProfilePasswordSectionProps) { - const currentModel = useUserProfilePasswordModel(); - const settledModel = useRef(currentModel); - if (currentModel.status !== 'loading') { - settledModel.current = currentModel; - } - const model = currentModel.status === 'loading' ? settledModel.current : currentModel; + const model = useUserProfilePasswordModel(); const m = useMessages('userProfilePasswordSection'); if (model.status === 'loading') { return fallback; From e3c77c5166e96d3c00fae7578b629a74785f2d9b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 18:28:41 -0600 Subject: [PATCH 78/91] fix(mosaic): localize user profile password errors --- .changeset/quiet-password-messages.md | 5 ++ .../user-profile-password-feedback.test.ts | 83 ++++++++++++++++--- .../user-profile-password-feedback.ts | 35 ++++++-- .../user-profile-password-section.messages.ts | 8 -- .../user-profile-password-section.tsx | 6 +- .../src/localization/errors.messages.ts | 12 +++ .../mosaic/src/localization/errors.test.tsx | 62 ++++++++++++++ packages/mosaic/src/localization/errors.ts | 20 +++++ packages/mosaic/src/localization/index.ts | 2 + packages/mosaic/src/localization/registry.ts | 2 + 10 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 .changeset/quiet-password-messages.md create mode 100644 packages/mosaic/src/localization/errors.messages.ts create mode 100644 packages/mosaic/src/localization/errors.test.tsx create mode 100644 packages/mosaic/src/localization/errors.ts diff --git a/.changeset/quiet-password-messages.md b/.changeset/quiet-password-messages.md new file mode 100644 index 00000000000..c3c021c45dc --- /dev/null +++ b/.changeset/quiet-password-messages.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Support localized password API errors, including current-password errors, while preserving server messages for unknown errors. diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index 88274166aeb..eeda0c0fceb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -1,8 +1,10 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { renderHook } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; import { FormSubmitError } from '../../../components/form'; -import { resolveLocalization } from '../../../localization'; +import { MosaicLocalizationProvider, resolveLocalization, useErrorText } from '../../../localization'; import { passwordFormError } from './user-profile-password-feedback'; import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; @@ -10,13 +12,21 @@ const settings = { min_length: 12, max_length: 64 }; const localization = resolveLocalization({ locale: 'en', overrides: { + errors: { + form_password_size_in_bytes_exceeded: 'Custom byte error.', + form_password_incorrect__current_password: 'Le mot de passe actuel est incorrect.', + }, userProfilePasswordSection: { suggestions: { anotherWord: 'Custom suggestion.' }, - passwordErrors: { form_password_size_in_bytes_exceeded: 'Custom byte error.' }, }, }, }); const messages = localization.messages.userProfilePasswordSection; +function resolver(value = localization) { + const wrapper = ({ children }: { children: ReactNode }) => + createElement(MosaicLocalizationProvider, { value }, children); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} function format(data: ConstructorParameters[1]['data'], current = false) { const result = passwordFormError( new ClerkAPIResponseError('Invalid', { status: 422, data }), @@ -24,6 +34,7 @@ function format(data: ConstructorParameters[1]['da settings, messages, localization.locale, + resolver(), ); expect(result).toBeInstanceOf(FormSubmitError); if (!(result instanceof FormSubmitError)) { @@ -33,6 +44,16 @@ function format(data: ConstructorParameters[1]['da } describe('password error feedback', () => { + it('keeps a breached-password error ahead of later complexity failures', () => { + expect( + format([ + { code: 'form_password_pwned', message: 'raw', meta: { param_name: 'new_password' } }, + { code: 'form_password_no_uppercase', message: 'raw', meta: { param_name: 'new_password' } }, + ]).fields?.newPassword, + ).toBe( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ); + }); it('combines recognized requirements in a localized list', () => { expect( format([ @@ -78,11 +99,27 @@ describe('password error feedback', () => { const errors = [ { code: 'form_password_incorrect', message: 'Incorrect', meta: { param_name: 'current_password' } }, ]; - expect(format(errors, true).fields?.currentPassword).toBe('Incorrect'); - expect(format(errors).banner).toBe('Incorrect'); + expect(format(errors, true).fields?.currentPassword).toBe('Le mot de passe actuel est incorrect.'); + expect(format(errors).banner).toBe('Le mot de passe actuel est incorrect.'); expect(format([{ code: 'unknown', message: 'Other', meta: { param_name: 'unmapped' } }]).banner).toBe('Other'); }); + it('retains the server fallback for unknown current-password errors', () => { + expect( + format( + [ + { + code: 'future_error', + message: 'short', + long_message: 'Detailed error', + meta: { param_name: 'current_password' }, + }, + ], + true, + ).fields?.currentPassword, + ).toBe('Detailed error'); + }); + it('uses the configured maximum length', () => { expect( format([{ code: 'form_password_length_too_long', message: 'raw', meta: { param_name: 'new_password' } }]).fields @@ -102,25 +139,42 @@ describe('password error feedback', () => { ).toBe('New password cannot be the same as the current password.'); }); - it('retains the server fallback for untranslated special codes', () => { + it.each([ + { + code: 'form_password_size_in_bytes_exceeded', + paramName: 'new_password', + field: 'newPassword', + current: false, + message: + 'Your password has exceeded the maximum number of bytes allowed, please shorten it or remove some special characters.', + }, + { + code: 'form_password_validation_failed', + paramName: 'current_password', + field: 'currentPassword', + current: true, + message: 'Incorrect Password', + }, + ])('uses the English catalog message for $code', ({ code, paramName, field, current, message }) => { const defaults = resolveLocalization({ locale: 'en' }); const result = passwordFormError( new ClerkAPIResponseError('Invalid', { status: 422, data: [ { - code: 'form_password_size_in_bytes_exceeded', - message: 'Password is too large', - meta: { param_name: 'new_password' }, + code, + message: 'Server fallback', + meta: { param_name: paramName }, }, ], }), - false, + current, settings, defaults.messages.userProfilePasswordSection, defaults.locale, + resolver(defaults), ); - expect(result).toMatchObject({ fields: { newPassword: 'Password is too large' } }); + expect(result).toMatchObject({ fields: { [field]: message } }); }); it('handles missing and unrecognized suggestion metadata without leaking codes', () => { @@ -144,7 +198,14 @@ describe('password error feedback', () => { it('localizes update errors raised before the request is sent', () => { const translate = (code: UserProfilePasswordUpdateError['code']) => - passwordFormError(new UserProfilePasswordUpdateError(code), true, settings, messages, localization.locale); + passwordFormError( + new UserProfilePasswordUpdateError(code), + true, + settings, + messages, + localization.locale, + resolver(), + ); expect(translate('unavailable')).toMatchObject({ banner: 'Password update is no longer available.' }); expect(translate('current_password_required')).toMatchObject({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index 5234c466186..fe2babd90ea 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -2,7 +2,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; import { FormSubmitError } from '../../../components/form'; -import type { MosaicMessages } from '../../../localization'; +import type { LocalizableError, MosaicMessages } from '../../../localization'; import { fill } from '../../../localization'; import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; @@ -17,15 +17,17 @@ export function passwordStrengthMessage(codes: string[], messages: Messages): st return [messages.rules.weak, ...codes.map(code => lookup(messages.suggestions, code))].filter(Boolean).join(' '); } -function passwordError(errors: ClerkAPIError[], settings: Settings, messages: Messages, locale: string) { +function passwordError( + errors: ClerkAPIError[], + settings: Settings, + messages: Messages, + locale: string, + errorText: (error: LocalizableError) => string, +) { const first = errors[0]; if (!first) { return undefined; } - const known = lookup(messages.passwordErrors, first.code); - if (known !== undefined) { - return known || first.message; - } if (first.code === 'form_password_not_strong_enough') { return passwordStrengthMessage(first.meta?.zxcvbn?.suggestions?.map(suggestion => suggestion.code) ?? [], messages); } @@ -37,11 +39,21 @@ function passwordError(errors: ClerkAPIError[], settings: Settings, messages: Me form_password_no_number: 'require_numbers', form_password_no_special_char: 'require_special_char', }; + if (!lookup(codes, first.code)) { + return errorText({ + code: first.code, + paramName: first.meta?.paramName, + message: first.longMessage || first.message, + }); + } const failures = errors.flatMap(error => { const code = lookup(codes, error.code); return code ? [code] : []; }); - return passwordComplexityMessage(failures, settings, messages, locale) || first.longMessage || first.message; + return ( + passwordComplexityMessage(failures, settings, messages, locale) || + errorText({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message }) + ); } export function passwordComplexityMessage(failures: string[], settings: Settings, messages: Messages, locale: string) { @@ -73,6 +85,7 @@ export function passwordFormError( settings: Settings, messages: Messages, locale: string, + errorText: (error: LocalizableError) => string, ): unknown { if (error instanceof UserProfilePasswordUpdateError) { return error.code === 'current_password_required' @@ -86,7 +99,11 @@ export function passwordFormError( const passwordErrors: ClerkAPIError[] = []; let message: string | undefined; for (const item of error.errors) { - const text = item.longMessage || item.message; + const text = errorText({ + code: item.code, + paramName: item.meta?.paramName, + message: item.longMessage || item.message, + }); const name = item.meta?.paramName; if ((name === 'current_password' || name === 'currentPassword') && requiresCurrentPassword) { fields.currentPassword ??= text; @@ -97,7 +114,7 @@ export function passwordFormError( } } if (passwordErrors.length) { - fields.newPassword = passwordError(passwordErrors, settings, messages, locale); + fields.newPassword = passwordError(passwordErrors, settings, messages, locale, errorText); } return new FormSubmitError({ message, fields }); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 2c79055865b..b9055babc0b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -36,14 +36,6 @@ export const userProfilePasswordSectionMessages = { number: 'a number', special: 'a special character', }, - passwordErrors: { - form_password_pwned: - 'This password has been found as part of a breach and can not be used, please try another password instead.', - form_password_matches_identifier: - 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', - form_password_size_in_bytes_exceeded: '', - form_new_password_matches_current: 'New password cannot be the same as the current password.', - }, suggestions: { allUppercase: 'Capitalize some, but not all letters.', anotherWord: 'Add more words that are less common.', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index c8833287275..878dfb756b7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { Button } from '../../../components/button'; import type { FieldFeedback } from '../../../components/form'; -import { useLocale, useMessages } from '../../../localization'; +import { useErrorText, useLocale, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import { @@ -50,6 +50,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw function PasswordEditor({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const locale = useLocale(); + const errorText = useErrorText(); const { validatePassword, passwordSettings } = model; const feedback = useCallback( async (password: string): Promise => { @@ -75,7 +76,8 @@ function PasswordEditor({ model }: { model: Extract passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale), + formatError: error => + passwordFormError(error, model.requiresCurrentPassword, passwordSettings, m, locale, errorText), }); return ( diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts new file mode 100644 index 00000000000..09df4031b4b --- /dev/null +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -0,0 +1,12 @@ +export const errorMessages: { readonly generic: string } & Readonly> = { + generic: 'Something went wrong. Please try again.', + form_new_password_matches_current: 'New password cannot be the same as the current password.', + form_password_incorrect: 'Your current password is incorrect.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_password_size_in_bytes_exceeded: + 'Your password has exceeded the maximum number of bytes allowed, please shorten it or remove some special characters.', + form_password_validation_failed: 'Incorrect Password', +}; diff --git a/packages/mosaic/src/localization/errors.test.tsx b/packages/mosaic/src/localization/errors.test.tsx new file mode 100644 index 00000000000..475439a4e5e --- /dev/null +++ b/packages/mosaic/src/localization/errors.test.tsx @@ -0,0 +1,62 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicCatalog } from './catalog'; +import { MosaicLocalizationProvider, resolveLocalization } from './context'; +import { useErrorText } from './errors'; + +function errorText(overrides?: MosaicCatalog) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} + +describe('useErrorText', () => { + it('prefers the message for the code on that field', () => { + const text = errorText({ + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + 'errors.form_identifier_exists': 'Ya existe.', + }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe( + 'Nombre de usuario en uso.', + ); + }); + + it('falls back to the message for the code', () => { + const text = errorText({ 'errors.form_identifier_exists': 'Ya existe.' }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe('Ya existe.'); + }); + + it('ships English for known codes', () => { + expect(errorText()({ code: 'form_password_incorrect', message: 'api text' })).toBe( + 'Your current password is incorrect.', + ); + }); + + it('falls back to the message Clerk sent for a code it has no text for', () => { + expect(errorText()({ code: 'form_identifier_exists', message: 'That username is taken.' })).toBe( + 'That username is taken.', + ); + }); + + it('fills the values the error carries into its message', () => { + const text = errorText({ + 'errors.test_length': 'Your value must be between {min_length} and {max_length} characters long.', + }); + expect(text({ code: 'test_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Your value must be between 4 and 64 characters long.', + ); + }); + + it('fills an override the same way', () => { + const text = errorText({ 'errors.test_length': 'Entre {min_length} y {max_length} caracteres.' }); + expect(text({ code: 'test_length', params: { min_length: 4, max_length: 64 } })).toBe('Entre 4 y 64 caracteres.'); + }); + + it('falls back to the generic message when there is nothing else', () => { + expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); + expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts new file mode 100644 index 00000000000..172628b9e7c --- /dev/null +++ b/packages/mosaic/src/localization/errors.ts @@ -0,0 +1,20 @@ +import { useMessages } from './context'; +import type { MessageValues } from './messages'; +import { fill } from './messages'; + +export interface LocalizableError { + code?: string; + paramName?: string; + message?: string; + params?: MessageValues; +} + +export function useErrorText(): (error: LocalizableError) => string { + const messages = useMessages('errors'); + const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); + return ({ code, paramName, message, params }) => { + const template = + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return params ? fill(template, params) : template; + }; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index fbfc32e732c..8d5633e4bac 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -1,5 +1,7 @@ export type { MosaicCatalog, MosaicLocalization } from './catalog'; export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; +export type { LocalizableError } from './errors'; +export { useErrorText } from './errors'; export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 34a933c8880..6e4a6cae586 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -27,8 +27,10 @@ import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/ import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; +import { errorMessages } from './errors.messages'; export const mosaicMessages = { + errors: errorMessages, form: formMessages, membersTableTab: membersTableTabMessages, invitationsTableTab: invitationsTableTabMessages, From f0270350e7f173b41bd5ceab088346361e2d4654 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 18:29:03 -0600 Subject: [PATCH 79/91] fix(mosaic): debounce user profile password validation --- .changeset/patient-password-validation.md | 5 ++++ ...r-profile-edit-password.controller.test.ts | 24 +++++++++++++++++++ .../user-profile-edit-password.controller.ts | 24 +++++++++++-------- 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 .changeset/patient-password-validation.md diff --git a/.changeset/patient-password-validation.md b/.changeset/patient-password-validation.md new file mode 100644 index 00000000000..9238351861f --- /dev/null +++ b/.changeset/patient-password-validation.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Wait 350 ms after typing before validating a new password in the user profile. diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index fe120b52e31..952d6b98645 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -13,6 +13,30 @@ function deferred() { } describe('useUserProfileEditPasswordController timing', () => { + it('debounces changes and cancels pending validation when closed', async () => { + vi.useFakeTimers(); + try { + const validatePassword = vi.fn(() => Promise.resolve(undefined)); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(validatePassword).not.toHaveBeenCalled(); + act(() => result.current.form.setValue('newPassword', 'latest password')); + await act(() => vi.advanceTimersByTimeAsync(349)); + expect(validatePassword).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(validatePassword).toHaveBeenCalledExactlyOnceWith('latest password'); + act(() => result.current.form.setValue('newPassword', 'cancelled password')); + act(() => result.current.onOpenChange(false)); + await act(() => vi.advanceTimersByTimeAsync(350)); + expect(validatePassword).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); it('ignores older password feedback and clears it when the editor closes', async () => { const older = deferred(); const newer = deferred(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 0219b5014d1..62240fdbcd1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,3 +1,4 @@ +import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants'; import { useEffect, useRef, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; @@ -79,18 +80,21 @@ export function useUserProfileEditPasswordController({ } let active = true; - void Promise.resolve() - .then(() => validatePassword(password)) - .then( - feedback => { - if (active) { - setPasswordFeedback(feedback); - } - }, - () => {}, - ); + const timeout = setTimeout(() => { + void Promise.resolve() + .then(() => validatePassword(password)) + .then( + feedback => { + if (active) { + setPasswordFeedback(feedback); + } + }, + () => {}, + ); + }, DEBOUNCE_MS); return () => { active = false; + clearTimeout(timeout); }; }, [isOpen, password, passwordLeft, validatePassword]); From 3a893714a29603825d4db9030bcb467f90629e6e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 18:29:43 -0600 Subject: [PATCH 80/91] chore: consolidate password changesets --- .changeset/patient-password-validation.md | 5 ----- .changeset/quiet-password-messages.md | 3 --- 2 files changed, 8 deletions(-) delete mode 100644 .changeset/patient-password-validation.md diff --git a/.changeset/patient-password-validation.md b/.changeset/patient-password-validation.md deleted file mode 100644 index 9238351861f..00000000000 --- a/.changeset/patient-password-validation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/mosaic': patch ---- - -Wait 350 ms after typing before validating a new password in the user profile. diff --git a/.changeset/quiet-password-messages.md b/.changeset/quiet-password-messages.md index c3c021c45dc..a845151cc84 100644 --- a/.changeset/quiet-password-messages.md +++ b/.changeset/quiet-password-messages.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Support localized password API errors, including current-password errors, while preserving server messages for unknown errors. From 15726d61faad331e32c1ec922bc2f8e0fe33409c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 19:52:03 -0600 Subject: [PATCH 81/91] chore(mosaic): note password loading skeleton follow-up --- .../user-profile-password-section.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 878dfb756b7..435460a345a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -23,6 +23,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw const model = useUserProfilePasswordModel(); const m = useMessages('userProfilePasswordSection'); if (model.status === 'loading') { + // TODO: Add a password section skeleton as the default loading fallback. return fallback; } if (model.status === 'readonly') { From 2aca9466c4ff3bdb0c58b049f99206323e81fd21 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 19:53:19 -0600 Subject: [PATCH 82/91] refactor(mosaic): extract password field feedback mapping --- .../user-profile-password-feedback.test.ts | 51 ++++++++++++++++++- .../user-profile-password-feedback.ts | 25 ++++++++- .../user-profile-password-section.tsx | 23 ++------- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index eeda0c0fceb..bb1b220c7d1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -1,11 +1,12 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { PasswordStrength } from '@clerk/shared/types'; import { renderHook } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; import { FormSubmitError } from '../../../components/form'; import { MosaicLocalizationProvider, resolveLocalization, useErrorText } from '../../../localization'; -import { passwordFormError } from './user-profile-password-feedback'; +import { passwordFieldFeedback, passwordFormError } from './user-profile-password-feedback'; import { UserProfilePasswordUpdateError } from './user-profile-password-section.types'; const settings = { min_length: 12, max_length: 64 }; @@ -213,3 +214,51 @@ describe('password error feedback', () => { }); }); }); + +describe('password field feedback', () => { + const feedback = (validation: Parameters[0]) => + passwordFieldFeedback(validation, settings, messages, localization.locale); + const strength = (state: PasswordStrength['state'], suggestions: string[] = []): PasswordStrength => ({ + state, + keys: [], + result: { + feedback: { warning: null, suggestions }, + score: 0, + password: '', + guesses: 0, + guessesLog10: 0, + calcTime: 0, + }, + }); + + it('prefers complexity feedback over strength feedback', () => { + expect(feedback({ complexity: { require_numbers: true }, strength: strength('fail') })).toEqual({ + type: 'error', + message: 'Your password must contain a number.', + }); + }); + + it('reports minimum length as info and other complexity failures as errors', () => { + expect(feedback({ complexity: { min_length: true, require_numbers: true } })).toEqual({ + type: 'info', + message: 'Your password must contain 12 or more characters.', + }); + expect(feedback({ complexity: { require_uppercase: true } })).toMatchObject({ type: 'error' }); + }); + + it('maps strength results to error, warning, and success', () => { + expect(feedback({ complexity: {}, strength: strength('fail', ['anotherWord']) })).toEqual({ + type: 'error', + message: 'Your password is not strong enough. Custom suggestion.', + }); + expect(feedback({ complexity: {}, strength: strength('pass') })).toEqual({ + type: 'warning', + message: messages.rules.stronger, + }); + expect(feedback({ complexity: {}, strength: strength('excellent') })).toEqual({ + type: 'success', + message: messages.rules.strong, + }); + expect(feedback({ complexity: {} })).toMatchObject({ type: 'success' }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index fe2babd90ea..c15e3f1f0f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -1,6 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; +import type { ClerkAPIError, PasswordSettingsData, PasswordValidation } from '@clerk/shared/types'; +import type { FieldFeedback } from '../../../components/form'; import { FormSubmitError } from '../../../components/form'; import type { LocalizableError, MosaicMessages } from '../../../localization'; import { fill } from '../../../localization'; @@ -79,6 +80,28 @@ export function passwordComplexityMessage(failures: string[], settings: Settings return fill(messages.complexity.sentence, { requirements: list }); } +export function passwordFieldFeedback( + { complexity, strength }: PasswordValidation, + settings: Settings, + messages: Messages, + locale: string, +): FieldFeedback { + const failures = Object.entries(complexity ?? {}) + .filter(([, failed]) => failed) + .map(([code]) => code); + const message = passwordComplexityMessage(failures, settings, messages, locale); + if (message) { + return { type: complexity?.min_length ? 'info' : 'error', message }; + } + if (strength?.state === 'fail') { + return { type: 'error', message: passwordStrengthMessage(strength.result.feedback.suggestions, messages) }; + } + if (strength?.state === 'pass') { + return { type: 'warning', message: messages.rules.stronger }; + } + return { type: 'success', message: messages.rules.strong }; +} + export function passwordFormError( error: unknown, requiresCurrentPassword: boolean, diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 435460a345a..3ce2c44fa05 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -6,11 +6,7 @@ import type { FieldFeedback } from '../../../components/form'; import { useErrorText, useLocale, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { - passwordComplexityMessage, - passwordFormError, - passwordStrengthMessage, -} from './user-profile-password-feedback'; +import { passwordFieldFeedback, passwordFormError } from './user-profile-password-feedback'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; import { useUserProfilePasswordModel } from './user-profile-password-section.model'; import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; @@ -55,21 +51,8 @@ function PasswordEditor({ model }: { model: Extract => { - const { complexity, strength } = await validatePassword(password); - const failures = Object.entries(complexity ?? {}) - .filter(([, failed]) => failed) - .map(([code]) => code); - const message = passwordComplexityMessage(failures, passwordSettings, m, locale); - if (message) { - return { type: complexity?.min_length ? 'info' : 'error', message }; - } - if (strength?.state === 'fail') { - return { type: 'error', message: passwordStrengthMessage(strength.result.feedback.suggestions, m) }; - } - if (strength?.state === 'pass') { - return { type: 'warning', message: m.rules.stronger }; - } - return { type: 'success', message: m.rules.strong }; + const validation = await validatePassword(password); + return passwordFieldFeedback(validation, passwordSettings, m, locale); }, [validatePassword, passwordSettings, m, locale], ); From d06c22b6902d846d9023239f1de038f0a9b2100a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 20:13:53 -0600 Subject: [PATCH 83/91] refactor(mosaic): check hidden before readonly password status --- .../user-profile-password-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 3ce2c44fa05..eadf861389c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -22,6 +22,9 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw // TODO: Add a password section skeleton as the default loading fallback. return fallback; } + if (model.status === 'hidden') { + return null; + } if (model.status === 'readonly') { return ( ); } - if (model.status === 'hidden') { - return null; - } return ( Date: Wed, 30 Sep 2026 20:40:18 -0600 Subject: [PATCH 84/91] refactor(mosaic): require password settings before validating --- .../user-profile-password-section.model.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 8f75c0d4338..1acd2c12e2d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -9,7 +9,7 @@ import type { PasswordValidation, UserResource, } from '@clerk/shared/types'; -import { useCallback } from 'react'; +import { useMemo } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; @@ -77,11 +77,11 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { const environment = useMosaicEnvironment(); const passwordSettings = environment?.userSettings.passwordSettings; const moduleManager = clerk.__internal_moduleManager; - const validatePassword = useCallback( - async (password: string): Promise => { - if (!passwordSettings) { - return {}; - } + const validatePassword = useMemo(() => { + if (!passwordSettings) { + return undefined; + } + return async (password: string): Promise => { const complexity = validateComplexity(password, passwordSettings); if (Object.keys(complexity).length > 0 || !passwordSettings.show_zxcvbn || !moduleManager) { return { complexity }; @@ -89,11 +89,10 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { const { loadZxcvbn } = createLoadZxcvbn(moduleManager); const strength = createValidatePasswordStrength(passwordSettings)(await loadZxcvbn())(password); return { complexity, strength }; - }, - [passwordSettings, moduleManager], - ); + }; + }, [passwordSettings, moduleManager]); - if (!isUserLoaded || !isSessionLoaded || !environment) { + if (!isUserLoaded || !isSessionLoaded || !environment || !validatePassword) { return { status: 'loading' }; } @@ -107,7 +106,6 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { return { ...policy, - userId, sessionId, identifier: session?.publicUserData.identifier ?? '', passwordSettings: environment.userSettings.passwordSettings, From 6475bc647ad32784a0ea34f33fdf7a970e13ebcd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 20:49:21 -0600 Subject: [PATCH 85/91] refactor(mosaic): return plain data from password model --- ...ser-profile-password-section.model.test.ts | 30 ++++++++++++++++++- .../user-profile-password-section.model.ts | 19 +++++------- .../user-profile-password-section.tsx | 4 +-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index c0dddc3f360..3085b989ec1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -7,7 +7,7 @@ import { useUserProfilePasswordModel } from './user-profile-password-section.mod type TestUser = { id: string; passwordEnabled: boolean; - enterpriseAccounts: { active: boolean }[]; + enterpriseAccounts: { active: boolean; enterpriseConnection?: { name: string; logoPublicUrl: string | null } }[]; updatePassword: ReturnType; }; @@ -122,3 +122,31 @@ describe('useUserProfilePasswordModel context changes', () => { expect(user.updatePassword).not.toHaveBeenCalled(); }); }); + +describe('useUserProfilePasswordModel enterprise accounts', () => { + it('describes the managing connection as plain data', () => { + if (!user) { + throw new Error('expected user'); + } + user.enterpriseAccounts = [ + { active: false, enterpriseConnection: { name: 'Inactive', logoPublicUrl: null } }, + { active: true, enterpriseConnection: { name: 'Acme SSO', logoPublicUrl: 'https://example.com/acme.png' } }, + ]; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ + status: 'readonly', + mode: 'change', + reason: 'enterprise_account', + managedBy: { name: 'Acme SSO', iconUrl: 'https://example.com/acme.png' }, + }); + }); + + it('leaves a blank connection name and missing logo undefined', () => { + if (!user) { + throw new Error('expected user'); + } + user.enterpriseAccounts = [{ active: true, enterpriseConnection: { name: '', logoPublicUrl: null } }]; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ managedBy: { name: undefined, iconUrl: undefined } }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 1acd2c12e2d..41f0da549ce 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -2,13 +2,7 @@ import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/ import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; -import type { - EnterpriseAccountResource, - EnvironmentResource, - PasswordSettingsData, - PasswordValidation, - UserResource, -} from '@clerk/shared/types'; +import type { EnvironmentResource, PasswordSettingsData, PasswordValidation, UserResource } from '@clerk/shared/types'; import { useMemo } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; @@ -25,7 +19,7 @@ type UnavailablePasswordModel = status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account'; - enterpriseConnection: EnterpriseAccountResource['enterpriseConnection']; + managedBy: { name?: string; iconUrl?: string }; }; export type UserProfilePasswordModel = @@ -38,7 +32,7 @@ export type UserProfilePasswordModel = identifier: string; passwordSettings: PasswordSettingsData; validatePassword: (password: string) => Promise; - updatePassword: (input: UserProfileEditPasswordValue) => Promise; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; }); function getPasswordPolicy( @@ -63,7 +57,10 @@ function getPasswordPolicy( status: 'readonly', mode: policy.mode, reason: 'enterprise_account', - enterpriseConnection: enterpriseAccount.enterpriseConnection, + managedBy: { + name: enterpriseAccount.enterpriseConnection?.name || undefined, + iconUrl: enterpriseAccount.enterpriseConnection?.logoPublicUrl ?? undefined, + }, }; } @@ -130,7 +127,7 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { throw new UserProfilePasswordUpdateError('current_password_required'); } - return currentUser.updatePassword({ + await currentUser.updatePassword({ newPassword, signOutOfOtherSessions, ...(policy.requiresCurrentPassword ? { currentPassword } : {}), diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index eadf861389c..91d33d7c7ad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -30,8 +30,8 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw ); From 94dae093c4ff862692612ae0d69f3decf479e908 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 20:55:00 -0600 Subject: [PATCH 86/91] chore(mosaic): note current password reverification parity --- .../user-profile-password-section.model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 41f0da549ce..1af462153a8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -47,6 +47,7 @@ function getPasswordPolicy( return { status: 'hidden', reason: 'password_disabled' }; } + // TODO: When session reverification is supported, require the current password only when reverification is disabled. const policy: EditablePasswordPolicy = user.passwordEnabled ? { mode: 'change', requiresCurrentPassword: true } : { mode: 'set', requiresCurrentPassword: false }; From 00f60d746e2bc5cc11e99ab02956b14da26e2de1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 21:04:02 -0600 Subject: [PATCH 87/91] refactor(mosaic): hide password section without a session --- .../user-profile-password-section.model.test.ts | 10 ++-------- .../user-profile-password-section.model.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index 3085b989ec1..dd43f63da5d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -110,16 +110,10 @@ describe('useUserProfilePasswordModel context changes', () => { }, ); - it('rejects an update when a loaded user has no active session', async () => { - if (!user) { - throw new Error('expected user'); - } + it('hides the section when a loaded user has no active session', () => { session = null; const { result } = renderHook(() => useUserProfilePasswordModel()); - await expect( - ready(result.current).updatePassword({ newPassword: 'new password', signOutOfOtherSessions: true }), - ).rejects.toMatchObject({ code: 'unavailable' }); - expect(user.updatePassword).not.toHaveBeenCalled(); + expect(result.current).toEqual({ status: 'hidden', reason: 'no_user' }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 1af462153a8..e6c0ae1ef05 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -28,7 +28,7 @@ export type UserProfilePasswordModel = | (EditablePasswordPolicy & { status: 'ready'; userId: string; - sessionId: string | null; + sessionId: string; identifier: string; passwordSettings: PasswordSettingsData; validatePassword: (password: string) => Promise; @@ -94,18 +94,22 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { return { status: 'loading' }; } + if (!session) { + return { status: 'hidden', reason: 'no_user' }; + } + const policy = getPasswordPolicy(user, environment); if (policy.status !== 'ready') { return policy; } const userId = policy.userId; - const sessionId = session?.id ?? null; + const sessionId = session.id; return { ...policy, sessionId, - identifier: session?.publicUserData.identifier ?? '', + identifier: session.publicUserData.identifier ?? '', passwordSettings: environment.userSettings.passwordSettings, validatePassword, // TODO: Add session reverification for password updates; surface API errors until then. @@ -115,7 +119,6 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { if ( !currentUser || currentUser.id !== userId || - !sessionId || clerk.session?.id !== sessionId || currentPolicy.status !== 'ready' || currentPolicy.mode !== policy.mode || From b1d57a98b189f584bc98afe6edd62d3c81ab81b6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 21:26:45 -0600 Subject: [PATCH 88/91] fix(mosaic): only report strong passwords after a strength check --- .../user-profile-password-feedback.test.ts | 10 ++++++++-- .../user-profile-password-feedback.ts | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts index bb1b220c7d1..ecf1e5fa4bb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.test.ts @@ -217,7 +217,7 @@ describe('password error feedback', () => { describe('password field feedback', () => { const feedback = (validation: Parameters[0]) => - passwordFieldFeedback(validation, settings, messages, localization.locale); + passwordFieldFeedback(validation, { ...settings, show_zxcvbn: true }, messages, localization.locale); const strength = (state: PasswordStrength['state'], suggestions: string[] = []): PasswordStrength => ({ state, keys: [], @@ -259,6 +259,12 @@ describe('password field feedback', () => { type: 'success', message: messages.rules.strong, }); - expect(feedback({ complexity: {} })).toMatchObject({ type: 'success' }); + }); + + it('only reports a strong password when strength was checked or is disabled', () => { + expect(feedback({ complexity: {} })).toBeUndefined(); + expect( + passwordFieldFeedback({ complexity: {} }, { ...settings, show_zxcvbn: false }, messages, localization.locale), + ).toEqual({ type: 'success', message: messages.rules.strong }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts index c15e3f1f0f4..66554aefaac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-feedback.ts @@ -82,10 +82,10 @@ export function passwordComplexityMessage(failures: string[], settings: Settings export function passwordFieldFeedback( { complexity, strength }: PasswordValidation, - settings: Settings, + settings: Settings & Pick, messages: Messages, locale: string, -): FieldFeedback { +): FieldFeedback | undefined { const failures = Object.entries(complexity ?? {}) .filter(([, failed]) => failed) .map(([code]) => code); @@ -99,6 +99,9 @@ export function passwordFieldFeedback( if (strength?.state === 'pass') { return { type: 'warning', message: messages.rules.stronger }; } + if (!strength && settings.show_zxcvbn) { + return undefined; + } return { type: 'success', message: messages.rules.strong }; } From c7d8253f7b4c4e42f7e6cd3d6fbacad96b54eaf9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 21:52:21 -0600 Subject: [PATCH 89/91] fix(mosaic): drop managed-by logo removed on main --- .../__tests__/user-profile-password.feature.test.tsx | 2 +- .../user-profile-password-section.model.test.ts | 6 +++--- .../user-profile-password-section.model.ts | 7 ++----- .../user-profile-password-section.tsx | 5 +---- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index b18b7572ceb..9b69ddb21f4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -158,7 +158,7 @@ describe('Changing a password', () => { }), ); - expect(screen.getByText('Managed by your enterprise connection')).toBeVisible(); + expect(screen.getByText('Managed by your enterprise connection')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts index dd43f63da5d..f06ed19a8b1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -131,16 +131,16 @@ describe('useUserProfilePasswordModel enterprise accounts', () => { status: 'readonly', mode: 'change', reason: 'enterprise_account', - managedBy: { name: 'Acme SSO', iconUrl: 'https://example.com/acme.png' }, + managedBy: { name: 'Acme SSO' }, }); }); - it('leaves a blank connection name and missing logo undefined', () => { + it('leaves a blank connection name undefined', () => { if (!user) { throw new Error('expected user'); } user.enterpriseAccounts = [{ active: true, enterpriseConnection: { name: '', logoPublicUrl: null } }]; const { result } = renderHook(() => useUserProfilePasswordModel()); - expect(result.current).toMatchObject({ managedBy: { name: undefined, iconUrl: undefined } }); + expect(result.current).toMatchObject({ managedBy: { name: undefined } }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index e6c0ae1ef05..e61dc92b51a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -19,7 +19,7 @@ type UnavailablePasswordModel = status: 'readonly'; mode: 'set' | 'change'; reason: 'enterprise_account'; - managedBy: { name?: string; iconUrl?: string }; + managedBy: { name?: string }; }; export type UserProfilePasswordModel = @@ -58,10 +58,7 @@ function getPasswordPolicy( status: 'readonly', mode: policy.mode, reason: 'enterprise_account', - managedBy: { - name: enterpriseAccount.enterpriseConnection?.name || undefined, - iconUrl: enterpriseAccount.enterpriseConnection?.logoPublicUrl ?? undefined, - }, + managedBy: { name: enterpriseAccount.enterpriseConnection?.name || undefined }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 91d33d7c7ad..62cc3b31d28 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -29,10 +29,7 @@ export function UserProfilePasswordSection({ fallback = null }: UserProfilePassw return ( ); } From 053902d1aefab50c915669291090f4ad50aaf32b Mon Sep 17 00:00:00 2001 From: Austin Calvelage Date: Thu, 1 Oct 2026 12:33:25 -0600 Subject: [PATCH 90/91] Delete .changeset/quiet-password-messages.md --- .changeset/quiet-password-messages.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .changeset/quiet-password-messages.md diff --git a/.changeset/quiet-password-messages.md b/.changeset/quiet-password-messages.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/quiet-password-messages.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- From e664a4148ee1e8bf2b00ddf1fd0eeaaab352b466 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 14:12:44 -0600 Subject: [PATCH 91/91] test(mosaic): document deferred password behavior --- .../__tests__/user-profile-password.feature.test.tsx | 6 ++++++ .../user-profile-edit-password.controller.ts | 3 +++ .../user-profile-edit-password.dialog.tsx | 1 + 3 files changed, 10 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx index d00f817f55f..f28ecf6a9a3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -336,3 +336,9 @@ describe('Changing a password', () => { await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); }); }); + +describe('Deferred password behavior', () => { + it.todo('reverifies the session and retries the password update when Clerk requires verification'); + it.todo('omits the current password when session reverification is enabled'); + it.todo('shows a password section skeleton while loading without a custom fallback'); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index c8b9d798849..f28707b9884 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -44,6 +44,7 @@ export function useUserProfileEditPasswordController({ initialValues, fields: { confirmPassword: { + // TODO: Discuss showing success feedback when the confirmation matches, as legacy does. https://github.com/clerk/javascript/pull/9930#discussion_r4150406791 validate: (value, values) => value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, }, @@ -58,6 +59,7 @@ export function useUserProfileEditPasswordController({ newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); + // TODO: Discuss confirming the password was set or updated and other devices were signed out with a success page or toast. https://github.com/clerk/javascript/pull/9930#discussion_r4151641473 setIsOpen(false); }, }); @@ -65,6 +67,7 @@ export function useUserProfileEditPasswordController({ const password = form.values.newPassword; const passwordLeft = form.fields.newPassword.touched; useEffect(() => { + // TODO: Discuss keeping the password hint hidden on open or showing it immediately when the field autofocuses. https://github.com/clerk/javascript/pull/9930#discussion_r4150863181 if (!isOpen || (password === '' && !passwordLeft) || !validatePassword) { setPasswordFeedback(undefined); return; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index f6d61074b14..072c8788a23 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -190,6 +190,7 @@ function PasswordField({ const { ref, ...control } = form.register(name); const mergedRef = useMergeRefs([ref, inputRef]); + // TODO: Discuss enforcing the configured minimum length on the new password input or keeping the hint advisory and letting the server validate. https://github.com/clerk/javascript/pull/9930#discussion_r4151734254 return (