diff --git a/.changeset/27857-create-user-email-refusal.md b/.changeset/27857-create-user-email-refusal.md new file mode 100644 index 00000000000..3d5c8756d13 --- /dev/null +++ b/.changeset/27857-create-user-email-refusal.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': patch +'@object-ui/app-shell': patch +'@object-ui/auth': patch +'@object-ui/i18n': patch +--- + +fix(plugin-auth): Create User with no email names Email, and a wrong password on the password-change screen reads in the user's language + +Clause-②: no +Metadata-protocol: none + +- **The refusal.** Create User with neither an email nor a phone number used to answer "Either email or phoneNumber is required", naming a request key the dialog does not show. It now answers "Email is required", or "Email or phone number is required" where phone sign-in is on. The status (400) and code (`INVALID_REQUEST`) are unchanged. +- **The password-change screen.** A wrong current or temporary password is refused with the code `INVALID_PASSWORD` and the English message "Invalid password", but `createAuthClient`'s `changePassword` threw a bare `Error` that dropped the code, and the screen showed the message as it came. `changePassword` now keeps the code on the thrown error, and the screen shows a translated sentence for that code in every console language pack. Any other refusal still shows the server's message. diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index ce652ebe0a6..6e67eeb4a9b 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -267,6 +267,21 @@ describe('runAdminCreateUser', () => { expectNoPasswordLeak(m, (res.body.data as any).temporaryPassword); }); + // #27857: the refusal names what the dialog shows. `phoneNumber` is a body + // key, and the dialog has a Phone Number field only where phone sign-in is + // on. The code and status are the endpoint's contract and do not move. + for (const phone of [false, true]) { + const expected = phone ? 'Email or phone number is required' : 'Email is required'; + it(`phone sign-in ${phone ? 'on' : 'off'}: neither email nor phone answers 400 INVALID_REQUEST "${expected}"`, async () => { + const m = makeDeps({ phoneNumberEnabled: () => phone }); + const res = await runAdminCreateUser(m.deps, makeRequest({ name: 'x', generatePassword: true }), ACTOR); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('INVALID_REQUEST'); + expect(res.body.error?.message).toBe(expected); + expect(m.createUser).not.toHaveBeenCalled(); + }); + } + // ── #2766 V1.5: phone-only users ──────────────────────────────────────── it('rejects phone-only creation when the phoneNumber plugin is off', async () => { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 2d4c87e9643..c5a27a7c252 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -278,7 +278,7 @@ export function generateTemporaryPassword(length = 16): string { /** * [#24032] The body, or the refusal message for a declared-JSON body that does * not parse: it was read as `{}`, which answered "userId is required" (or - * "Either email or phoneNumber is required") for a body that carried the field + * "Email is required") for a body that carried the field * (see request-json-body.ts). A well-formed body that is not an object still * reads as no fields, as before. */ @@ -694,7 +694,11 @@ export async function runAdminCreateUser( } } if (!hasEmail && !phoneNumber) { - return badRequest('Either email or phoneNumber is required'); + // Named in the dialog's words, not the body's keys: an administrator sees + // an Email field, and a Phone Number field only where phone sign-in is on. + return badRequest( + deps.phoneNumberEnabled?.() ? 'Email or phone number is required' : 'Email is required', + ); } const email = hasEmail ? (rawEmail as string).trim() : generatePlaceholderEmail(); const name = diff --git a/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts b/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts index 1ad0a50e5f6..359a7c65980 100644 --- a/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts @@ -407,7 +407,7 @@ const SECOND_SPELLING: Array<{ reader: string; routes: SecondSpellingRoute[] }> { reader: 'G. admin-user-endpoints.ts parseJson', routes: [ - { path: '/admin/create-user', truncated: '{"email": "pin-new@example.test", "name": "Pin New"', echo: 'pin-new@example.test', empty: 'Either email or phoneNumber is required' }, + { path: '/admin/create-user', truncated: '{"email": "pin-new@example.test", "name": "Pin New"', echo: 'pin-new@example.test', empty: 'Email is required' }, { path: '/admin/set-user-password', truncated: '{"userId": "usr_pin_target", "newPassword": "Pin-Passw0rd!"', echo: 'usr_pin_target', empty: 'userId is required' }, ], }, diff --git a/ui/packages/app-shell/src/console/RemediationOverlay.tsx b/ui/packages/app-shell/src/console/RemediationOverlay.tsx index 06c55e63820..7e02d0c92e7 100644 --- a/ui/packages/app-shell/src/console/RemediationOverlay.tsx +++ b/ui/packages/app-shell/src/console/RemediationOverlay.tsx @@ -165,7 +165,16 @@ function ChangePasswordForm({ forcedReset, message }: { forcedReset: boolean; me setRemediationRequired(null); window.location.reload(); } catch (err) { - setError(err instanceof Error ? err.message : t('auth.remediation.password.failed')); + // A wrong current (or temporary) password is refused with the code + // `INVALID_PASSWORD` and the English sentence "Invalid password", so the + // code is what is translated; any other refusal still shows the server's + // own message. + const code = (err as { code?: unknown } | null)?.code; + setError( + code === 'INVALID_PASSWORD' + ? t('auth.remediation.password.wrongPassword') + : err instanceof Error ? err.message : t('auth.remediation.password.failed'), + ); setBusy(false); } }; diff --git a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.wrongPassword-27869.test.tsx b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.wrongPassword-27869.test.tsx new file mode 100644 index 00000000000..fae5c416318 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.wrongPassword-27869.test.tsx @@ -0,0 +1,87 @@ +/** + * The password-change screen translates a wrong current or temporary password + * (objectstack#27869). + * + * The server refuses it with the code `INVALID_PASSWORD` and the English + * message "Invalid password", and the screen used to show that message as it + * came, so a French user read English. The code is now mapped to a translated + * sentence; any other refusal keeps the server's message. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; + +type Gate = { code: string; message: string }; +let gate: Gate | null = null; +let refusal: Error & { code?: string } = new Error('Invalid password'); +const changePassword = vi.fn(async () => { throw refusal; }); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ + remediationRequired: gate, + setRemediationRequired: vi.fn(), + changePassword, + enrollTotp: vi.fn(), + verifyTotp: vi.fn(), + signOut: vi.fn(), + }), +})); + +import { RemediationOverlay } from '../RemediationOverlay'; + +function renderWith(next: Gate, language: string) { + gate = next; + return render( + + + , + ); +} + +function submit(labels: { current: string; next: string; confirm: string }) { + fireEvent.change(screen.getByLabelText(labels.current), { target: { value: 'Wr0ng!Temporary' } }); + fireEvent.change(screen.getByLabelText(labels.next), { target: { value: 'Ch0sen!ByThe-User' } }); + fireEvent.change(screen.getByLabelText(labels.confirm), { target: { value: 'Ch0sen!ByThe-User' } }); + fireEvent.submit(screen.getByLabelText(labels.next).closest('form') as HTMLFormElement); +} + +const coded = (code: string, message: string) => Object.assign(new Error(message), { code }); + +const EN = { current: 'Temporary password', next: 'New password', confirm: 'Confirm new password' }; +const FR = { + current: 'Mot de passe temporaire', + next: 'Nouveau mot de passe', + confirm: 'Confirmer le nouveau mot de passe', +}; +const FORCED: Gate = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' }; + +beforeEach(() => { + changePassword.mockClear(); +}); +afterEach(cleanup); + +describe('RemediationOverlay — a wrong password reads in the user’s language (#27869)', () => { + it('fr: INVALID_PASSWORD shows the French sentence, not the server’s English', async () => { + refusal = coded('INVALID_PASSWORD', 'Invalid password'); + renderWith(FORCED, 'fr'); + submit(FR); + expect(await screen.findByText('Le mot de passe saisi est incorrect. Vérifiez-le et réessayez.')).toBeTruthy(); + expect(screen.queryByText('Invalid password')).toBeNull(); + }); + + it('en: INVALID_PASSWORD shows the English sentence', async () => { + refusal = coded('INVALID_PASSWORD', 'Invalid password'); + renderWith(FORCED, 'en'); + submit(EN); + expect(await screen.findByText('The password you entered is incorrect. Check it and try again.')).toBeTruthy(); + }); + + it('CONTROL: another code keeps the server’s message', async () => { + refusal = coded('PASSWORD_REUSE', 'Choose a password you have not used recently.'); + renderWith(FORCED, 'en'); + submit(EN); + expect(await screen.findByText('Choose a password you have not used recently.')).toBeTruthy(); + }); +}); diff --git a/ui/packages/auth/src/__tests__/changePassword-code-27869.test.ts b/ui/packages/auth/src/__tests__/changePassword-code-27869.test.ts new file mode 100644 index 00000000000..214506dbfdb --- /dev/null +++ b/ui/packages/auth/src/__tests__/changePassword-code-27869.test.ts @@ -0,0 +1,58 @@ +/** + * objectstack#27869 — a wrong current password keeps its machine `code` + * through `changePassword`. + * + * The console's ChangePasswordForm maps `INVALID_PASSWORD` to a translated + * sentence, and its test mocks `useAuth` with a coded error, so it is blind to + * this boundary. `changePassword` used to throw `new Error(error.message)`, + * which dropped the code: the mapping never fired and a French user still read + * better-auth's English "Invalid password". This drives the real better-auth + * client through a mocked `fetch`, as `org-error-code-4474.test.ts` does, and + * asserts on `.code`, because `.message` was already right before the fix. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createAuthClient } from '../createAuthClient'; + +function rejectingFetch(pathFragment: string, status: number, body: unknown) { + return vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes(pathFragment)) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); +} + +describe('#27869 — changePassword rejections carry the machine code', () => { + it('a wrong current password rejects with code INVALID_PASSWORD', async () => { + // better-auth's /change-password refuses a wrong current password with + // APIError.from('BAD_REQUEST', BASE_ERROR_CODES.INVALID_PASSWORD). + const fetchFn = rejectingFetch('/change-password', 400, { + message: 'Invalid password', + code: 'INVALID_PASSWORD', + }); + const client = createAuthClient({ + baseURL: 'http://localhost/api/auth', + fetchFn: fetchFn as unknown as typeof fetch, + }); + + const rejection = await client.changePassword('wrong-temp', 'N3w-password!').then( + () => { + throw new Error('expected the call to reject, but it resolved'); + }, + (err: unknown) => err as Error & { code?: string }, + ); + + expect(rejection.code).toBe('INVALID_PASSWORD'); + // The message is unchanged: this keeps the code, it rewords nothing. + expect(rejection.message).toBe('Invalid password'); + }); +}); diff --git a/ui/packages/auth/src/createAuthClient.ts b/ui/packages/auth/src/createAuthClient.ts index ac788c1dca9..d8a850b983f 100644 --- a/ui/packages/auth/src/createAuthClient.ts +++ b/ui/packages/auth/src/createAuthClient.ts @@ -739,7 +739,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // `changePassword`. The runtime method exists but its TS type is // sometimes missing depending on plugin order; cast through unknown. type ChangePwFn = (opts: { currentPassword: string; newPassword: string; revokeOtherSessions?: boolean }) => - Promise<{ error: { message?: string; status: number } | null }>; + Promise<{ error: BetterAuthErrorLike | null }>; const fn = (betterAuth as unknown as { changePassword: ChangePwFn }).changePassword; if (typeof fn !== 'function') { throw new Error('change-password is not available on this auth backend'); @@ -749,9 +749,9 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { newPassword, ...(options?.revokeOtherSessions != null ? { revokeOtherSessions: options.revokeOtherSessions } : {}), }); - if (error) { - throw new Error(error.message ?? `Auth request failed with status ${error.status}`); - } + // toAuthError keeps better-auth's `code` (a wrong current password is + // INVALID_PASSWORD) so the console can localise it (#27869). + if (error) throw toAuthError(error); }, // ADR-0069 — enforced-MFA enrollment. Returns the otpauth:// URI (for a QR) diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2122b9dff6c..6857014429e 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3064,6 +3064,7 @@ const ar = { submitting: "جارٍ التحديث…", mismatch: "كلمتا المرور الجديدتان غير متطابقتين.", sameAsTemporary: "اختر كلمة مرور جديدة تختلف عن كلمة المرور المؤقتة.", + wrongPassword: "كلمة المرور التي أدخلتها غير صحيحة. تحقق منها وحاول مرة أخرى.", failed: "تعذّر تغيير كلمة المرور.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8a9803d2ff4..593cb6128d9 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2826,6 +2826,7 @@ const de = { submitting: "Wird aktualisiert…", mismatch: "Die neuen Passwörter stimmen nicht überein.", sameAsTemporary: "Wählen Sie ein neues Passwort, das sich von Ihrem temporären Passwort unterscheidet.", + wrongPassword: "Das eingegebene Passwort ist falsch. Prüfen Sie es und versuchen Sie es erneut.", failed: "Passwort konnte nicht geändert werden.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index fc159b3d4fa..25ac6c4e552 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3383,6 +3383,7 @@ const en = { submitting: 'Updating…', mismatch: 'New passwords do not match.', sameAsTemporary: 'Choose a new password that is different from your temporary password.', + wrongPassword: 'The password you entered is incorrect. Check it and try again.', failed: 'Could not change password.', }, mfa: { diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f68aff15eb1..80b616763ff 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2887,6 +2887,7 @@ const es = { submitting: "Actualizando…", mismatch: "Las nuevas contraseñas no coinciden.", sameAsTemporary: "Elija una nueva contraseña distinta de su contraseña temporal.", + wrongPassword: "La contraseña que ha introducido es incorrecta. Revísela e inténtelo de nuevo.", failed: "No se pudo cambiar la contraseña.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 274c0725279..39903e00166 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2883,6 +2883,7 @@ const fr = { submitting: "Mise à jour…", mismatch: "Les nouveaux mots de passe ne correspondent pas.", sameAsTemporary: "Choisissez un nouveau mot de passe différent de votre mot de passe temporaire.", + wrongPassword: "Le mot de passe saisi est incorrect. Vérifiez-le et réessayez.", failed: "Impossible de changer le mot de passe.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 3e0c055e100..da814fd1390 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2828,6 +2828,7 @@ const ja = { submitting: "更新しています…", mismatch: "新しいパスワードが一致しません。", sameAsTemporary: "仮パスワードとは異なる新しいパスワードを設定してください。", + wrongPassword: "入力したパスワードが正しくありません。確認してもう一度お試しください。", failed: "パスワードを変更できませんでした。", }, mfa: { diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index a9de46c5142..140fe31406c 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2826,6 +2826,7 @@ const ko = { submitting: "업데이트 중…", mismatch: "새 비밀번호가 일치하지 않습니다.", sameAsTemporary: "임시 비밀번호와 다른 새 비밀번호를 설정하세요.", + wrongPassword: "입력한 비밀번호가 올바르지 않습니다. 확인 후 다시 시도하세요.", failed: "비밀번호를 변경할 수 없습니다.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 1ba5dab8ea3..8242341210c 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2882,6 +2882,7 @@ const pt = { submitting: "Atualizando…", mismatch: "As novas senhas não coincidem.", sameAsTemporary: "Escolha uma nova senha diferente da sua senha temporária.", + wrongPassword: "A senha que você digitou está incorreta. Verifique-a e tente novamente.", failed: "Não foi possível alterar a senha.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index b6928304408..3371504bc18 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2953,6 +2953,7 @@ const ru = { submitting: "Обновление…", mismatch: "Новые пароли не совпадают.", sameAsTemporary: "Выберите новый пароль, отличный от временного.", + wrongPassword: "Введён неверный пароль. Проверьте его и попробуйте снова.", failed: "Не удалось изменить пароль.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 9ef5be1a8b8..8fdfa8eeafe 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2903,6 +2903,7 @@ const zh = { submitting: '正在更新…', mismatch: '两次输入的新密码不一致。', sameAsTemporary: '请设置一个与临时密码不同的新密码。', + wrongPassword: '你输入的密码不正确,请检查后重试。', failed: '无法修改密码。', }, mfa: {