diff --git a/.changeset/28289-create-user-email-field-refusal.md b/.changeset/28289-create-user-email-field-refusal.md new file mode 100644 index 00000000000..d84b02658bb --- /dev/null +++ b/.changeset/28289-create-user-email-field-refusal.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): Create User with no email answers the field-level refusal, so a client can mark the Email input and word it in its own language (#28289) + +Clause-②: yes (widening: the refusal gains `error.fields`, one `{ field, code, message }` entry per input that would satisfy it, code `required`; its `error.code` moves from `INVALID_REQUEST` to `VALIDATION_FAILED`, the code the same endpoint's permission-set refusal and the data doors already answer with `fields`) +Metadata-protocol: none + + + +**BREAKING** wire answer of one refusal at `POST /api/v1/auth/admin/create-user` (`@objectstack/plugin-auth`). + +- **Before.** With neither an email nor a phone number, the endpoint answered `400` `{ code: 'INVALID_REQUEST', message: 'Email is required' }` ("Email or phone number is required" where phone sign-in is on). The code is the endpoint's generic one, so a client could only show the English sentence. +- **Now.** The same `400` answers `{ code: 'VALIDATION_FAILED', message, fields }`. `fields` holds `{ field: 'email', code: 'required', message }`, plus `{ field: 'phoneNumber', code: 'required', message }` where phone sign-in is on. The English message is unchanged. +- **Unchanged.** Every other refusal of the endpoint keeps `INVALID_REQUEST` and its message: a malformed email, a phone number without phone sign-in, a malformed phone number, a `permissionSet` that is not a name, the password rules. A valid Create User still succeeds. +- **Supersedes** the line "The status (400) and code (`INVALID_REQUEST`) are unchanged" in this release's earlier Create User changeset: the status is still 400, and the code is now `VALIDATION_FAILED`. +- **Who sees it.** A client that read `INVALID_REQUEST` for this one refusal. The console shows the refusal's message as before; marking the Email input from `fields` is the console's own follow-up. 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 6e67eeb4a9b..56edda02d23 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -269,15 +269,43 @@ describe('runAdminCreateUser', () => { // #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. + // on. #28289: it is the field-level refusal, `VALIDATION_FAILED` with one + // `fields[]` entry per input that can satisfy it, code `required`, so a + // client can mark the input and word it in its own language. 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 fields = phone + ? [ + { field: 'email', code: 'required', message: expected }, + { field: 'phoneNumber', code: 'required', message: expected }, + ] + : [{ field: 'email', code: 'required', message: expected }]; + it(`phone sign-in ${phone ? 'on' : 'off'}: neither email nor phone answers 400 VALIDATION_FAILED "${expected}" at ${fields.map((f) => f.field).join(' + ')}`, 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?.code).toBe('VALIDATION_FAILED'); expect(res.body.error?.message).toBe(expected); + expect(res.body.error?.fields).toEqual(fields); + expect(m.createUser).not.toHaveBeenCalled(); + }); + } + + // #28289 CONTROLS: the refusals beside it keep their answer exactly. + for (const [shape, body, phone, message] of [ + ['a malformed email', { email: 'not-an-email' }, false, 'A valid email is required'], + ['a phone number with phone sign-in off', { phoneNumber: '+8613800000000' }, false, + 'Phone numbers require the phoneNumber auth plugin (auth.plugins.phoneNumber)'], + ['a malformed phone number', { phoneNumber: 'abc' }, true, + 'phoneNumber must be a valid phone number (E.164 recommended, e.g. +8613800000000)'], + ['a permissionSet that is not a name', { email: 'a@b.co', permissionSet: 7 }, false, + 'permissionSet must be the machine name of a permission set'], + ] as const) { + it(`CONTROL: ${shape} still answers 400 INVALID_REQUEST with no fields`, async () => { + const m = makeDeps({ phoneNumberEnabled: () => phone }); + const res = await runAdminCreateUser(m.deps, makeRequest({ ...body, generatePassword: true }), ACTOR); + expect(res.status).toBe(400); + expect(res.body.error).toEqual({ code: 'INVALID_REQUEST', message }); expect(m.createUser).not.toHaveBeenCalled(); }); } diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index c5a27a7c252..1715c535928 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -234,6 +234,7 @@ import { cloudManagedRefusal } from './cloud-managed.js'; import { temporaryPasswordLength } from './temporary-password-length.js'; import { isCloudManaged } from '@objectstack/core'; import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types'; +import type { FieldErrorCode } from '@objectstack/spec/api'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; @@ -295,6 +296,26 @@ function badRequest(message: string): EndpointResult { return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message } } }; } +/** + * [#28289] A refusal for inputs left empty: `400 VALIDATION_FAILED` with one + * `fields[]` entry per input, code `required` (the closed ADR-0114 catalog) — + * the envelope the grant refusal passes through and the data doors answer, so + * the console's field-error reader needs no case for this door. + */ +function requiredFieldsRefusal(message: string, fields: string[]): EndpointResult { + return { + status: 400, + body: { + success: false, + error: { + code: 'VALIDATION_FAILED', + message, + fields: fields.map((field) => ({ field, code: 'required' satisfies FieldErrorCode, message })), + }, + }, + }; +} + /** * Linear-time email plausibility check (local@domain with a dotted domain). * Deliberately not a regex: the obvious `\S+@\S+\.\S+` is polynomially @@ -696,9 +717,12 @@ export async function runAdminCreateUser( if (!hasEmail && !phoneNumber) { // 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', - ); + // [#28289] Field-level, as the grant refusal below is: one `fields[]` + // entry per input that would satisfy it, so a client marks the input and + // words it in its own language instead of showing this sentence. + const phoneSignIn = deps.phoneNumberEnabled?.() === true; + const message = phoneSignIn ? 'Email or phone number is required' : 'Email is required'; + return requiredFieldsRefusal(message, phoneSignIn ? ['email', 'phoneNumber'] : ['email']); } 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 359a7c65980..212e6b4398e 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 @@ -380,6 +380,10 @@ interface SecondSpellingRoute { truncated: string; echo: string; empty: string; + /** The empty body's code, where it is not `INVALID_REQUEST` (#28289: Create User's field-level refusal). */ + emptyCode?: string; + /** The empty body's `error.fields`, where the refusal is field-level. */ + emptyFields?: unknown[]; /** `admin-set-user-manager.ts` discriminates its refusals by `error.details.reason`. */ reason?: string; } @@ -407,7 +411,8 @@ 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: 'Email is required' }, + { path: '/admin/create-user', truncated: '{"email": "pin-new@example.test", "name": "Pin New"', echo: 'pin-new@example.test', empty: 'Email is required', + emptyCode: 'VALIDATION_FAILED', emptyFields: [{ field: 'email', code: 'required', message: 'Email is required' }] }, { path: '/admin/set-user-password', truncated: '{"userId": "usr_pin_target", "newPassword": "Pin-Passw0rd!"', echo: 'usr_pin_target', empty: 'userId is required' }, ], }, @@ -445,7 +450,11 @@ for (const { reader, routes } of SECOND_SPELLING) { const peek = (await res.clone().json()) as { error?: { details?: { reason?: string } } }; expect(peek.error?.details?.reason).toBe(route.reason); } - await expectAnswer(res, 400, 'INVALID_REQUEST', route.empty); + if (route.emptyFields) { + const peek = (await res.clone().json()) as { error?: { fields?: unknown[] } }; + expect(peek.error?.fields).toEqual(route.emptyFields); + } + await expectAnswer(res, 400, route.emptyCode ?? 'INVALID_REQUEST', route.empty); nothingActed(); }); }