Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/27857-create-user-email-refusal.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
11 changes: 10 additions & 1 deletion ui/packages/app-shell/src/console/RemediationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@object-ui/auth')>()),
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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RemediationOverlay />
</I18nProvider>,
);
}

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();
});
});
58 changes: 58 additions & 0 deletions ui/packages/auth/src/__tests__/changePassword-code-27869.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 4 additions & 4 deletions ui/packages/auth/src/createAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3064,6 +3064,7 @@ const ar = {
submitting: "جارٍ التحديث…",
mismatch: "كلمتا المرور الجديدتان غير متطابقتين.",
sameAsTemporary: "اختر كلمة مرور جديدة تختلف عن كلمة المرور المؤقتة.",
wrongPassword: "كلمة المرور التي أدخلتها غير صحيحة. تحقق منها وحاول مرة أخرى.",
failed: "تعذّر تغيير كلمة المرور.",
},
mfa: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2828,6 +2828,7 @@ const ja = {
submitting: "更新しています…",
mismatch: "新しいパスワードが一致しません。",
sameAsTemporary: "仮パスワードとは異なる新しいパスワードを設定してください。",
wrongPassword: "入力したパスワードが正しくありません。確認してもう一度お試しください。",
failed: "パスワードを変更できませんでした。",
},
mfa: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2826,6 +2826,7 @@ const ko = {
submitting: "업데이트 중…",
mismatch: "새 비밀번호가 일치하지 않습니다.",
sameAsTemporary: "임시 비밀번호와 다른 새 비밀번호를 설정하세요.",
wrongPassword: "입력한 비밀번호가 올바르지 않습니다. 확인 후 다시 시도하세요.",
failed: "비밀번호를 변경할 수 없습니다.",
},
mfa: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,7 @@ const ru = {
submitting: "Обновление…",
mismatch: "Новые пароли не совпадают.",
sameAsTemporary: "Выберите новый пароль, отличный от временного.",
wrongPassword: "Введён неверный пароль. Проверьте его и попробуйте снова.",
failed: "Не удалось изменить пароль.",
},
mfa: {
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2903,6 +2903,7 @@ const zh = {
submitting: '正在更新…',
mismatch: '两次输入的新密码不一致。',
sameAsTemporary: '请设置一个与临时密码不同的新密码。',
wrongPassword: '你输入的密码不正确,请检查后重试。',
failed: '无法修改密码。',
},
mfa: {
Expand Down
Loading