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
18 changes: 18 additions & 0 deletions .changeset/28289-create-user-email-field-refusal.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: one refusal of `POST /api/v1/auth/admin/create-user` answers an existing code from the plugin's ledger row with the existing `fields` envelope. Nothing an author writes changes, so there is nothing for a migration to rewrite and no tombstone. -->

**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.
34 changes: 31 additions & 3 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Expand Down
30 changes: 27 additions & 3 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
13 changes: 11 additions & 2 deletions packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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' },
],
},
Expand Down Expand Up @@ -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();
});
}
Expand Down
Loading