Skip to content

Commit b339465

Browse files
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 (#28288)
domain:server#1 · os-elon-musk · session_016WV4cwXFwjU2YZW2dRuZZt <!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DB7otGmwMKQJqKhPxGv4D83)_ Fixes #27857 Part of #27869 Clause-②: no Metadata-protocol: none Skills-docs: none — `git grep` of `skills/**` and `content/docs/**` for "Either email or phoneNumber is required", "Invalid password" and `INVALID_PASSWORD` has zero hits. ## 维护者速读 - 改了什么:设置 → 创建用户:不填邮箱时,报错从「Either email or phoneNumber is required」改为「Email is required」(开启手机号登录时为「Email or phone number is required」);强制改密页面输错当前/临时密码时,按用户语言显示提示,不再显示英文「Invalid password」。 - 为什么改:HotCRM 18.2.0 设置走查:报错点名了界面上不存在的 `phoneNumber`;法语用户改密输错时看到英文。邮箱必填标记另立卡(见下文)。 - 风险与回滚:错误码 `INVALID_REQUEST` 与状态 400 不变,只改措辞;`createAuthClient` 的 `changePassword` 抛错时保留 better-auth 的错误码(原先丢失,映射永不触发);界面只多一个翻译键;元数据未改。回滚即还原本 PR。 - 先看:`packages/plugins/plugin-auth/src/admin-user-endpoints.ts`(报错措辞)、`ui/packages/auth/src/createAuthClient.ts`(保留错误码)、`ui/packages/app-shell/src/console/RemediationOverlay.tsx`(错误码映射)、`ui/packages/i18n/src/locales/fr.ts`。 - 两分钟验证:设置 → 用户 → 创建用户,邮箱留空提交:提示「Email is required」;以法语用户登录强制改密页,临时密码输错:显示法语提示。 - 需要你做的:无,席位落地。 ## What changes One commit per card, plus a revert commit, a merge of `origin/main`, and the seat-review fix for #27869 (a): 1. **#27857 — Create User with no email.** `plugin-auth` `admin-user-endpoints.ts`: with neither an email nor a phone number, the refusal reads "Email is required", or "Email or phone number is required" when phone sign-in is on (`deps.phoneNumberEnabled()`). It used to read "Either email or phoneNumber is required", naming a body key. Status 400 and code `INVALID_REQUEST` are unchanged (dispatch: wording only). 2. **#27869 (a) — "Invalid password" on the password-change screen.** better-auth's `/change-password` refuses a wrong current or temporary password with `APIError.from('BAD_REQUEST', BASE_ERROR_CODES.INVALID_PASSWORD)`, so the body is `{ message: "Invalid password", code: "INVALID_PASSWORD" }` (better-auth 1.7.12, `api/routes/update-user.mjs`). Two links were broken: - `createAuthClient`'s `changePassword` threw `new Error(error.message)` and dropped the code. It now throws through `toAuthError`, as sign-in and the organization methods already do, so the thrown error carries `code`. The message is unchanged. - `ChangePasswordForm` in `RemediationOverlay.tsx` showed `err.message` as it came. It now maps `INVALID_PASSWORD` to `auth.remediation.password.wrongPassword`, a new key in all ten console packs (ar, de, en, es, fr, ja, ko, pt, ru, zh). Any other refusal keeps the server's message. ### What this PR does not carry - **The Email required mark (#27857's dialog half).** The measured shape was two visibility-gated Email params on the one field. It trips `feature-gate-guard.test.ts`, whose registry is `packages/spec/src/kernel/public-auth-features.ts`, outside this PR's surface: the negated `phoneNumber` gate is not a shape `gatedInputs` books, and both params resolve to one path. Seat ruling B: it was committed (3a05184), then taken out again by the revert commit, and the seat files it as its own card. #27857's refusal row still closes here, as the seat ruled. - **#27857's acceptance line "the refusal carries a code the console can localise".** A new code is a `Clause-②: yes` change, and the dispatch fixes the code as `INVALID_REQUEST`, so it is reported as a finding for the seat. - **#27869 stays open (`Part of`).** Its (b) and "loudly" halves are #28107 and #28109. This PR leaves #27869 open; the seat shuts the card once all its rows are carried. ## Tests - New: `changePassword-code-27869.test.ts` (drives the real better-auth client through a mocked `fetch`; a 400 `{ message: 'Invalid password', code: 'INVALID_PASSWORD' }` from `/change-password` rejects with `.code === 'INVALID_PASSWORD'` and the message unchanged). New: `RemediationOverlay.wrongPassword-27869.test.tsx` (the fr and en sentences, plus a control: another code keeps the server message; it mocks `useAuth`, so the client pin above covers the first link). Extended: `admin-user-endpoints.test.ts` (the refusal wording with phone sign-in off and on, code and status pinned). Updated: `request-json-body-refusal.test.ts` (the empty-body control reads the new wording). - `pnpm --filter @objectstack/plugin-auth test`: Test Files 205 passed (205), Tests 3917 passed, 10 skipped. `typecheck`: exit 0. `ui` vitest over `packages/auth/`, `packages/app-shell/src/console/` and `packages/i18n/`: Test Files 354 passed (354), Tests 3299 passed, 13 skipped. `@object-ui/auth` `type-check`: exit 0. - Ablations (each through `scripts/ablation-replace.mjs`, restored to the HEAD blob, `git status` clean after): - Old refusal wording put back → `admin-user-endpoints.test.ts` 2 failed / 93 passed. - The `INVALID_PASSWORD` mapping disabled → `RemediationOverlay.wrongPassword-27869.test.tsx` 2 failed / 1 passed (the control stays green). - `changePassword` back to `throw new Error(error.message ?? …)` → `changePassword-code-27869.test.ts` 1 failed (`expected undefined to be 'INVALID_PASSWORD'`). ## Acceptance notes - The "code the console can localise" half of #27857 and the Email required mark are in the card report as findings. ## Verification The test and ablation readings above are on head `d5b0a9e4`. The gate readings below are on `9248a860`; `d5b0a9e4` adds only the `ui/packages/auth` commit, `dispatch-gates --commands` derives the same 66 families on it, and their rerun on `d5b0a9e4` is recorded in the card report on #27857. <details><summary>Derived gate families: 66 of 66 exit 0</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derived 66 families. They ran after builds of `@objectstack/cli` + `platform-objects` (with the i18n-bundle packages `check:i18n` names), the `@objectstack/dogfood^...` and `@objectstack/runtime^...` closures, and `@objectstack/plugin-auth...` (all exit 0). - All 66 exit 0, including `check:i18n`, `check:dual-build-cjs-loads`, `check:closing-keyword-parity`, `check:nul-bytes`, `check:published-files` and `pnpm --dir ui exec vitest run scripts/`. - `dispatch-gates --ran`: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED", exit 0. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - Not run locally: `ui` type-check and the full `ui` test shards. CI's Type Check UI and Test UI own them. </details> CI check-runs on this PR are the authority for every family CI runs. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (the seat widened #27857's claim to carry #27869 (a)) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cwXFwjU2YZW2dRuZZt --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cwXFwjU2YZW2dRuZZt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85a1b9d commit b339465

18 files changed

Lines changed: 205 additions & 8 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/auth': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
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
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
- **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.
14+
- **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.

‎packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,21 @@ describe('runAdminCreateUser', () => {
267267
expectNoPasswordLeak(m, (res.body.data as any).temporaryPassword);
268268
});
269269

270+
// #27857: the refusal names what the dialog shows. `phoneNumber` is a body
271+
// key, and the dialog has a Phone Number field only where phone sign-in is
272+
// on. The code and status are the endpoint's contract and do not move.
273+
for (const phone of [false, true]) {
274+
const expected = phone ? 'Email or phone number is required' : 'Email is required';
275+
it(`phone sign-in ${phone ? 'on' : 'off'}: neither email nor phone answers 400 INVALID_REQUEST "${expected}"`, async () => {
276+
const m = makeDeps({ phoneNumberEnabled: () => phone });
277+
const res = await runAdminCreateUser(m.deps, makeRequest({ name: 'x', generatePassword: true }), ACTOR);
278+
expect(res.status).toBe(400);
279+
expect(res.body.error?.code).toBe('INVALID_REQUEST');
280+
expect(res.body.error?.message).toBe(expected);
281+
expect(m.createUser).not.toHaveBeenCalled();
282+
});
283+
}
284+
270285
// ── #2766 V1.5: phone-only users ────────────────────────────────────────
271286

272287
it('rejects phone-only creation when the phoneNumber plugin is off', async () => {

‎packages/plugins/plugin-auth/src/admin-user-endpoints.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export function generateTemporaryPassword(length = 16): string {
278278
/**
279279
* [#24032] The body, or the refusal message for a declared-JSON body that does
280280
* not parse: it was read as `{}`, which answered "userId is required" (or
281-
* "Either email or phoneNumber is required") for a body that carried the field
281+
* "Email is required") for a body that carried the field
282282
* (see request-json-body.ts). A well-formed body that is not an object still
283283
* reads as no fields, as before.
284284
*/
@@ -694,7 +694,11 @@ export async function runAdminCreateUser(
694694
}
695695
}
696696
if (!hasEmail && !phoneNumber) {
697-
return badRequest('Either email or phoneNumber is required');
697+
// Named in the dialog's words, not the body's keys: an administrator sees
698+
// an Email field, and a Phone Number field only where phone sign-in is on.
699+
return badRequest(
700+
deps.phoneNumberEnabled?.() ? 'Email or phone number is required' : 'Email is required',
701+
);
698702
}
699703
const email = hasEmail ? (rawEmail as string).trim() : generatePlaceholderEmail();
700704
const name =

‎packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ const SECOND_SPELLING: Array<{ reader: string; routes: SecondSpellingRoute[] }>
407407
{
408408
reader: 'G. admin-user-endpoints.ts parseJson',
409409
routes: [
410-
{ 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' },
410+
{ path: '/admin/create-user', truncated: '{"email": "pin-new@example.test", "name": "Pin New"', echo: 'pin-new@example.test', empty: 'Email is required' },
411411
{ path: '/admin/set-user-password', truncated: '{"userId": "usr_pin_target", "newPassword": "Pin-Passw0rd!"', echo: 'usr_pin_target', empty: 'userId is required' },
412412
],
413413
},

‎ui/packages/app-shell/src/console/RemediationOverlay.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,16 @@ function ChangePasswordForm({ forcedReset, message }: { forcedReset: boolean; me
165165
setRemediationRequired(null);
166166
window.location.reload();
167167
} catch (err) {
168-
setError(err instanceof Error ? err.message : t('auth.remediation.password.failed'));
168+
// A wrong current (or temporary) password is refused with the code
169+
// `INVALID_PASSWORD` and the English sentence "Invalid password", so the
170+
// code is what is translated; any other refusal still shows the server's
171+
// own message.
172+
const code = (err as { code?: unknown } | null)?.code;
173+
setError(
174+
code === 'INVALID_PASSWORD'
175+
? t('auth.remediation.password.wrongPassword')
176+
: err instanceof Error ? err.message : t('auth.remediation.password.failed'),
177+
);
169178
setBusy(false);
170179
}
171180
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* The password-change screen translates a wrong current or temporary password
3+
* (objectstack#27869).
4+
*
5+
* The server refuses it with the code `INVALID_PASSWORD` and the English
6+
* message "Invalid password", and the screen used to show that message as it
7+
* came, so a French user read English. The code is now mapped to a translated
8+
* sentence; any other refusal keeps the server's message.
9+
*/
10+
11+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
13+
import { I18nProvider } from '@object-ui/i18n';
14+
15+
type Gate = { code: string; message: string };
16+
let gate: Gate | null = null;
17+
let refusal: Error & { code?: string } = new Error('Invalid password');
18+
const changePassword = vi.fn(async () => { throw refusal; });
19+
20+
vi.mock('@object-ui/auth', async (importOriginal) => ({
21+
...(await importOriginal<typeof import('@object-ui/auth')>()),
22+
useAuth: () => ({
23+
remediationRequired: gate,
24+
setRemediationRequired: vi.fn(),
25+
changePassword,
26+
enrollTotp: vi.fn(),
27+
verifyTotp: vi.fn(),
28+
signOut: vi.fn(),
29+
}),
30+
}));
31+
32+
import { RemediationOverlay } from '../RemediationOverlay';
33+
34+
function renderWith(next: Gate, language: string) {
35+
gate = next;
36+
return render(
37+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
38+
<RemediationOverlay />
39+
</I18nProvider>,
40+
);
41+
}
42+
43+
function submit(labels: { current: string; next: string; confirm: string }) {
44+
fireEvent.change(screen.getByLabelText(labels.current), { target: { value: 'Wr0ng!Temporary' } });
45+
fireEvent.change(screen.getByLabelText(labels.next), { target: { value: 'Ch0sen!ByThe-User' } });
46+
fireEvent.change(screen.getByLabelText(labels.confirm), { target: { value: 'Ch0sen!ByThe-User' } });
47+
fireEvent.submit(screen.getByLabelText(labels.next).closest('form') as HTMLFormElement);
48+
}
49+
50+
const coded = (code: string, message: string) => Object.assign(new Error(message), { code });
51+
52+
const EN = { current: 'Temporary password', next: 'New password', confirm: 'Confirm new password' };
53+
const FR = {
54+
current: 'Mot de passe temporaire',
55+
next: 'Nouveau mot de passe',
56+
confirm: 'Confirmer le nouveau mot de passe',
57+
};
58+
const FORCED: Gate = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' };
59+
60+
beforeEach(() => {
61+
changePassword.mockClear();
62+
});
63+
afterEach(cleanup);
64+
65+
describe('RemediationOverlay — a wrong password reads in the user’s language (#27869)', () => {
66+
it('fr: INVALID_PASSWORD shows the French sentence, not the server’s English', async () => {
67+
refusal = coded('INVALID_PASSWORD', 'Invalid password');
68+
renderWith(FORCED, 'fr');
69+
submit(FR);
70+
expect(await screen.findByText('Le mot de passe saisi est incorrect. Vérifiez-le et réessayez.')).toBeTruthy();
71+
expect(screen.queryByText('Invalid password')).toBeNull();
72+
});
73+
74+
it('en: INVALID_PASSWORD shows the English sentence', async () => {
75+
refusal = coded('INVALID_PASSWORD', 'Invalid password');
76+
renderWith(FORCED, 'en');
77+
submit(EN);
78+
expect(await screen.findByText('The password you entered is incorrect. Check it and try again.')).toBeTruthy();
79+
});
80+
81+
it('CONTROL: another code keeps the server’s message', async () => {
82+
refusal = coded('PASSWORD_REUSE', 'Choose a password you have not used recently.');
83+
renderWith(FORCED, 'en');
84+
submit(EN);
85+
expect(await screen.findByText('Choose a password you have not used recently.')).toBeTruthy();
86+
});
87+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* objectstack#27869 — a wrong current password keeps its machine `code`
3+
* through `changePassword`.
4+
*
5+
* The console's ChangePasswordForm maps `INVALID_PASSWORD` to a translated
6+
* sentence, and its test mocks `useAuth` with a coded error, so it is blind to
7+
* this boundary. `changePassword` used to throw `new Error(error.message)`,
8+
* which dropped the code: the mapping never fired and a French user still read
9+
* better-auth's English "Invalid password". This drives the real better-auth
10+
* client through a mocked `fetch`, as `org-error-code-4474.test.ts` does, and
11+
* asserts on `.code`, because `.message` was already right before the fix.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { createAuthClient } from '../createAuthClient';
16+
17+
function rejectingFetch(pathFragment: string, status: number, body: unknown) {
18+
return vi.fn(async (input: string | URL | Request) => {
19+
const url =
20+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
21+
if (url.includes(pathFragment)) {
22+
return new Response(JSON.stringify(body), {
23+
status,
24+
headers: { 'Content-Type': 'application/json' },
25+
});
26+
}
27+
return new Response(JSON.stringify({}), {
28+
status: 200,
29+
headers: { 'Content-Type': 'application/json' },
30+
});
31+
});
32+
}
33+
34+
describe('#27869 — changePassword rejections carry the machine code', () => {
35+
it('a wrong current password rejects with code INVALID_PASSWORD', async () => {
36+
// better-auth's /change-password refuses a wrong current password with
37+
// APIError.from('BAD_REQUEST', BASE_ERROR_CODES.INVALID_PASSWORD).
38+
const fetchFn = rejectingFetch('/change-password', 400, {
39+
message: 'Invalid password',
40+
code: 'INVALID_PASSWORD',
41+
});
42+
const client = createAuthClient({
43+
baseURL: 'http://localhost/api/auth',
44+
fetchFn: fetchFn as unknown as typeof fetch,
45+
});
46+
47+
const rejection = await client.changePassword('wrong-temp', 'N3w-password!').then(
48+
() => {
49+
throw new Error('expected the call to reject, but it resolved');
50+
},
51+
(err: unknown) => err as Error & { code?: string },
52+
);
53+
54+
expect(rejection.code).toBe('INVALID_PASSWORD');
55+
// The message is unchanged: this keeps the code, it rewords nothing.
56+
expect(rejection.message).toBe('Invalid password');
57+
});
58+
});

‎ui/packages/auth/src/createAuthClient.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
739739
// `changePassword`. The runtime method exists but its TS type is
740740
// sometimes missing depending on plugin order; cast through unknown.
741741
type ChangePwFn = (opts: { currentPassword: string; newPassword: string; revokeOtherSessions?: boolean }) =>
742-
Promise<{ error: { message?: string; status: number } | null }>;
742+
Promise<{ error: BetterAuthErrorLike | null }>;
743743
const fn = (betterAuth as unknown as { changePassword: ChangePwFn }).changePassword;
744744
if (typeof fn !== 'function') {
745745
throw new Error('change-password is not available on this auth backend');
@@ -749,9 +749,9 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
749749
newPassword,
750750
...(options?.revokeOtherSessions != null ? { revokeOtherSessions: options.revokeOtherSessions } : {}),
751751
});
752-
if (error) {
753-
throw new Error(error.message ?? `Auth request failed with status ${error.status}`);
754-
}
752+
// toAuthError keeps better-auth's `code` (a wrong current password is
753+
// INVALID_PASSWORD) so the console can localise it (#27869).
754+
if (error) throw toAuthError(error);
755755
},
756756

757757
// ADR-0069 — enforced-MFA enrollment. Returns the otpauth:// URI (for a QR)

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3064,6 +3064,7 @@ const ar = {
30643064
submitting: "جارٍ التحديث…",
30653065
mismatch: "كلمتا المرور الجديدتان غير متطابقتين.",
30663066
sameAsTemporary: "اختر كلمة مرور جديدة تختلف عن كلمة المرور المؤقتة.",
3067+
wrongPassword: "كلمة المرور التي أدخلتها غير صحيحة. تحقق منها وحاول مرة أخرى.",
30673068
failed: "تعذّر تغيير كلمة المرور.",
30683069
},
30693070
mfa: {

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2826,6 +2826,7 @@ const de = {
28262826
submitting: "Wird aktualisiert…",
28272827
mismatch: "Die neuen Passwörter stimmen nicht überein.",
28282828
sameAsTemporary: "Wählen Sie ein neues Passwort, das sich von Ihrem temporären Passwort unterscheidet.",
2829+
wrongPassword: "Das eingegebene Passwort ist falsch. Prüfen Sie es und versuchen Sie es erneut.",
28292830
failed: "Passwort konnte nicht geändert werden.",
28302831
},
28312832
mfa: {

0 commit comments

Comments
 (0)