From 3a05184bee764e72f267633df9a98c971b0663a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:21:34 +0000 Subject: [PATCH 1/4] fix(plugin-auth): Create User with no email names Email, and the dialog marks Email required when phone sign-in is off The refusal read "Either email or phoneNumber is required", naming a body key the dialog does not show. It now reads "Email is required", or "Email or phone number is required" where phone sign-in is on; the code and status are unchanged. The create_user dialog backs Email with two params, a required one shown while phone sign-in is off and an optional one behind the phoneNumber feature, so the required mark appears exactly when Email is required. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwXFwjU2YZW2dRuZZt --- .../create-user-email-required.test.ts | 53 +++++++++++++++++++ .../src/identity/sys-user.object.ts | 9 +++- .../src/admin-user-endpoints.test.ts | 15 ++++++ .../plugin-auth/src/admin-user-endpoints.ts | 8 ++- .../src/request-json-body-refusal.test.ts | 2 +- 5 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 packages/platform-objects/src/identity/create-user-email-required.test.ts diff --git a/packages/platform-objects/src/identity/create-user-email-required.test.ts b/packages/platform-objects/src/identity/create-user-email-required.test.ts new file mode 100644 index 00000000000..81c566fd5f1 --- /dev/null +++ b/packages/platform-objects/src/identity/create-user-email-required.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_user.create_user` — the dialog marks Email required exactly when phone + * sign-in is off (#27857). + * + * The endpoint requires an email or a phone number, and the dialog offers a + * phone field only where `features.phoneNumber` is on. With one optional Email + * param, the dialog drew no required mark, and an empty submit came back as a + * server refusal. Two params back the one field: a required one gated on phone + * sign-in being off, and an optional one behind the `phoneNumber` feature. The + * dialog collects and submits only the visible params, so exactly one is shown + * and submitted for any value of the flag. + */ + +import { describe, expect, it } from 'vitest'; +import { SysUser } from './index.js'; + +type Param = { field?: string; name?: string; required?: boolean; visible?: unknown }; + +const params = (): Param[] => + ((SysUser.actions ?? []).find((a) => a.name === 'create_user')?.params ?? []) as Param[]; + +const source = (v: unknown): string => + typeof v === 'string' ? v : String((v as { source?: unknown } | undefined)?.source ?? ''); + +/** Evaluate the two gate shapes this action uses against a `features` map. */ +const shown = (p: Param, phoneNumber: boolean | undefined): boolean => { + const s = source(p.visible); + if (s === '') return true; + if (s === 'features.phoneNumber != true') return phoneNumber !== true; + if (s === 'features.phoneNumber == true') return phoneNumber === true; + throw new Error(`unexpected email gate: ${s}`); +}; + +describe('create_user — Email is required when phone sign-in is off (#27857)', () => { + const email = () => params().filter((p) => p.field === 'email'); + + it('declares two Email params, the required one first', () => { + expect(email().map((p) => [p.required === true, source(p.visible)])).toEqual([ + [true, 'features.phoneNumber != true'], + [false, 'features.phoneNumber == true'], + ]); + }); + + for (const flag of [false, true, undefined]) { + it(`phone sign-in ${String(flag)}: exactly one Email param is shown, required iff phone is off`, () => { + const visible = email().filter((p) => shown(p, flag)); + expect(visible).toHaveLength(1); + expect(visible[0]!.required === true).toBe(flag !== true); + }); + } +}); diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 5b87039d20d..249a7ac4d13 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -283,8 +283,13 @@ export const SysUser = ObjectSchema.create({ bodyExtra: { generatePassword: true }, params: [ // The endpoint requires email OR phone (phone-only users get a - // placeholder address; requires auth.plugins.phoneNumber). - { field: 'email', required: false }, + // placeholder address; requires auth.plugins.phoneNumber). So Email is + // required exactly when phone sign-in is off: two params on the one + // field, of which the dialog shows one (it collects and submits only + // the visible params). The required one's gate is `phoneNumber`'s + // opt-in gate negated, so the two predicates never both hold. + { field: 'email', required: true, visible: 'features.phoneNumber != true' }, + { field: 'email', required: false, requiresFeature: 'phoneNumber' }, { name: 'phoneNumber', label: 'Phone Number', 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' }, ], }, From 25fb437790234fb195669832b0bbeded0443d12e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:23:45 +0000 Subject: [PATCH 2/4] fix(app-shell): the password-change screen translates a wrong current or temporary password The screen showed the server's English "Invalid password" for the INVALID_PASSWORD refusal in every language. The code now maps to a translated sentence, one new key in every console language pack; any other refusal keeps the server's message. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwXFwjU2YZW2dRuZZt --- .changeset/27857-create-user-email-refusal.md | 15 ++++ .../src/console/RemediationOverlay.tsx | 11 ++- ...iationOverlay.wrongPassword-27869.test.tsx | 87 +++++++++++++++++++ ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 13 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 .changeset/27857-create-user-email-refusal.md create mode 100644 ui/packages/app-shell/src/console/__tests__/RemediationOverlay.wrongPassword-27869.test.tsx diff --git a/.changeset/27857-create-user-email-refusal.md b/.changeset/27857-create-user-email-refusal.md new file mode 100644 index 00000000000..3cea52f005b --- /dev/null +++ b/.changeset/27857-create-user-email-refusal.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(plugin-auth): Create User with no email names Email, the dialog marks Email required, 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 required mark.** The `sys_user.create_user` dialog backs its Email field with two params: a required one shown while phone sign-in is off, and an optional one behind the `phoneNumber` feature. The dialog shows and submits one of them, so Email carries a required mark exactly when it is required. +- **The password-change screen.** A wrong current or temporary password is refused with the code `INVALID_PASSWORD` and the English message "Invalid password", and the screen showed that message as it came. It now shows a translated sentence for that code in every console language pack. Any other refusal still shows the server's message. 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/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 4ccca061d8a..8a26ba9351d 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3052,6 +3052,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 8e6353c864b..54bb679108c 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2814,6 +2814,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 a2324caddf7..1936d29a55e 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3368,6 +3368,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 90cd1116165..d77ffb80a34 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2875,6 +2875,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 1bbebc6b61a..20a750a9903 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2871,6 +2871,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 f5e300d1830..f3d3d07d2dd 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2816,6 +2816,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 e032d8f22c5..311557a19cd 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2814,6 +2814,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 bc957e8bb0e..4b144ee47cd 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2870,6 +2870,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 1803b819744..e51fdb32e0d 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2941,6 +2941,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 951a99de304..cf9238c5ecf 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2891,6 +2891,7 @@ const zh = { submitting: '正在更新…', mismatch: '两次输入的新密码不一致。', sameAsTemporary: '请设置一个与临时密码不同的新密码。', + wrongPassword: '你输入的密码不正确,请检查后重试。', failed: '无法修改密码。', }, mfa: { From 41f07447b867c6d873b5091df0b35c422e1528de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:39:43 +0000 Subject: [PATCH 3/4] revert(platform-objects): keep create_user's single optional Email param The two visibility-gated Email params trip the feature-gate registry guard: the negated phoneNumber gate is not a shape PUBLIC_AUTH_FEATURES books, and booking it means editing the spec registry. The required mark is carried separately; this change keeps the refusal wording. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwXFwjU2YZW2dRuZZt --- .changeset/27857-create-user-email-refusal.md | 4 +- .../create-user-email-required.test.ts | 53 ------------------- .../src/identity/sys-user.object.ts | 9 +--- 3 files changed, 3 insertions(+), 63 deletions(-) delete mode 100644 packages/platform-objects/src/identity/create-user-email-required.test.ts diff --git a/.changeset/27857-create-user-email-refusal.md b/.changeset/27857-create-user-email-refusal.md index 3cea52f005b..e10fad9e369 100644 --- a/.changeset/27857-create-user-email-refusal.md +++ b/.changeset/27857-create-user-email-refusal.md @@ -1,15 +1,13 @@ --- '@objectstack/plugin-auth': patch -'@objectstack/platform-objects': patch '@object-ui/app-shell': patch '@object-ui/i18n': patch --- -fix(plugin-auth): Create User with no email names Email, the dialog marks Email required, and a wrong password on the password-change screen reads in the user's language +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 required mark.** The `sys_user.create_user` dialog backs its Email field with two params: a required one shown while phone sign-in is off, and an optional one behind the `phoneNumber` feature. The dialog shows and submits one of them, so Email carries a required mark exactly when it is required. - **The password-change screen.** A wrong current or temporary password is refused with the code `INVALID_PASSWORD` and the English message "Invalid password", and the screen showed that message as it came. It now 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/platform-objects/src/identity/create-user-email-required.test.ts b/packages/platform-objects/src/identity/create-user-email-required.test.ts deleted file mode 100644 index 81c566fd5f1..00000000000 --- a/packages/platform-objects/src/identity/create-user-email-required.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `sys_user.create_user` — the dialog marks Email required exactly when phone - * sign-in is off (#27857). - * - * The endpoint requires an email or a phone number, and the dialog offers a - * phone field only where `features.phoneNumber` is on. With one optional Email - * param, the dialog drew no required mark, and an empty submit came back as a - * server refusal. Two params back the one field: a required one gated on phone - * sign-in being off, and an optional one behind the `phoneNumber` feature. The - * dialog collects and submits only the visible params, so exactly one is shown - * and submitted for any value of the flag. - */ - -import { describe, expect, it } from 'vitest'; -import { SysUser } from './index.js'; - -type Param = { field?: string; name?: string; required?: boolean; visible?: unknown }; - -const params = (): Param[] => - ((SysUser.actions ?? []).find((a) => a.name === 'create_user')?.params ?? []) as Param[]; - -const source = (v: unknown): string => - typeof v === 'string' ? v : String((v as { source?: unknown } | undefined)?.source ?? ''); - -/** Evaluate the two gate shapes this action uses against a `features` map. */ -const shown = (p: Param, phoneNumber: boolean | undefined): boolean => { - const s = source(p.visible); - if (s === '') return true; - if (s === 'features.phoneNumber != true') return phoneNumber !== true; - if (s === 'features.phoneNumber == true') return phoneNumber === true; - throw new Error(`unexpected email gate: ${s}`); -}; - -describe('create_user — Email is required when phone sign-in is off (#27857)', () => { - const email = () => params().filter((p) => p.field === 'email'); - - it('declares two Email params, the required one first', () => { - expect(email().map((p) => [p.required === true, source(p.visible)])).toEqual([ - [true, 'features.phoneNumber != true'], - [false, 'features.phoneNumber == true'], - ]); - }); - - for (const flag of [false, true, undefined]) { - it(`phone sign-in ${String(flag)}: exactly one Email param is shown, required iff phone is off`, () => { - const visible = email().filter((p) => shown(p, flag)); - expect(visible).toHaveLength(1); - expect(visible[0]!.required === true).toBe(flag !== true); - }); - } -}); diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 249a7ac4d13..5b87039d20d 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -283,13 +283,8 @@ export const SysUser = ObjectSchema.create({ bodyExtra: { generatePassword: true }, params: [ // The endpoint requires email OR phone (phone-only users get a - // placeholder address; requires auth.plugins.phoneNumber). So Email is - // required exactly when phone sign-in is off: two params on the one - // field, of which the dialog shows one (it collects and submits only - // the visible params). The required one's gate is `phoneNumber`'s - // opt-in gate negated, so the two predicates never both hold. - { field: 'email', required: true, visible: 'features.phoneNumber != true' }, - { field: 'email', required: false, requiresFeature: 'phoneNumber' }, + // placeholder address; requires auth.plugins.phoneNumber). + { field: 'email', required: false }, { name: 'phoneNumber', label: 'Phone Number', From d5b0a9e4beee79dbe7b26b3b98632aff0ac5e521 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:31:08 +0000 Subject: [PATCH 4/4] fix(auth): changePassword keeps better-auth's error code changePassword threw a bare Error built from error.message, so the INVALID_PASSWORD code a wrong current password carries never reached ChangePasswordForm and its mapping to the translated sentence never fired. Route the refusal through toAuthError, as sign-in and the organization methods already do, and pin the code at the client boundary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016WV4cwXFwjU2YZW2dRuZZt --- .changeset/27857-create-user-email-refusal.md | 3 +- .../changePassword-code-27869.test.ts | 58 +++++++++++++++++++ ui/packages/auth/src/createAuthClient.ts | 8 +-- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 ui/packages/auth/src/__tests__/changePassword-code-27869.test.ts diff --git a/.changeset/27857-create-user-email-refusal.md b/.changeset/27857-create-user-email-refusal.md index e10fad9e369..3d5c8756d13 100644 --- a/.changeset/27857-create-user-email-refusal.md +++ b/.changeset/27857-create-user-email-refusal.md @@ -1,6 +1,7 @@ --- '@objectstack/plugin-auth': patch '@object-ui/app-shell': patch +'@object-ui/auth': patch '@object-ui/i18n': patch --- @@ -10,4 +11,4 @@ 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", and the screen showed that message as it came. It now shows a translated sentence for that code in every console language pack. Any other refusal still shows the server's message. +- **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/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)