From bf9504713f7f6ad7a4c22194a7634d3aaaca5f59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:49:45 +0000 Subject: [PATCH 01/10] fix(plugin-form,permissions,core): a create form asks its fields the create question, through one affordance-to-grant map (objectui#12082) A create form gated its fields on checkField(..., 'write'), whose fallback for a field the permission set does not mention is allowEdit, so a create-only role met a create form with every field disabled and a save that posted an empty body. - core: AFFORDANCE_GRANTS + resolveAffordance / resolveFieldAffordance / formFieldsAffordance, the one map every affordance reads. - permissions: checkField accepts 'create' (explicit entry, else allowCreate), the server's insert rule. - plugin-form: every layout's field gate, outbound filter and form-wide lock read the form's row in the map. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/12082-affordance-grant-map.md | 48 ++++ packages/core/src/index.ts | 3 + packages/core/src/utils/affordanceGrants.ts | 246 ++++++++++++++++++ .../permissions/src/MePermissionsProvider.tsx | 14 +- packages/permissions/src/PermissionContext.ts | 13 +- .../permissions/src/PermissionProvider.tsx | 7 +- packages/plugin-form/src/DrawerForm.tsx | 2 +- packages/plugin-form/src/ModalForm.tsx | 2 +- .../src/ObjectForm.effectiveOps.test.tsx | 3 + packages/plugin-form/src/ObjectForm.tsx | 2 +- packages/plugin-form/src/SplitForm.tsx | 2 +- packages/plugin-form/src/TabbedForm.tsx | 2 +- packages/plugin-form/src/WizardForm.tsx | 2 +- .../src/createFormGrant-12082.test.tsx | 220 ++++++++++++++++ packages/plugin-form/src/fieldWriteGate.ts | 126 ++++++--- .../src/formChrome.i18n-11071.test.tsx | 6 +- packages/plugin-form/src/sanitize.ts | 3 +- 17 files changed, 643 insertions(+), 58 deletions(-) create mode 100644 .changeset/12082-affordance-grant-map.md create mode 100644 packages/core/src/utils/affordanceGrants.ts create mode 100644 packages/plugin-form/src/createFormGrant-12082.test.tsx diff --git a/.changeset/12082-affordance-grant-map.md b/.changeset/12082-affordance-grant-map.md new file mode 100644 index 0000000000..d11216835d --- /dev/null +++ b/.changeset/12082-affordance-grant-map.md @@ -0,0 +1,48 @@ +--- +'@object-ui/core': minor +'@object-ui/permissions': minor +'@object-ui/plugin-form': patch +--- + +A create form asks its fields the create question, so a role that may create +records but not edit them can fill and submit the form (objectui#12082). Every +console affordance that offers a write now reads the grant it exercises from +one map. + +**The defect.** A create form gated each field on `checkField(object, field, +'write')`, whose fallback for a field the permission set does not mention is +the object's `allowEdit`. Under a grant of `allowCreate: true, allowEdit: false` +every field of the create form rendered disabled, the outbound filter stripped +every field from the body, and the save posted an empty record that the server +refused for its required fields — while the server accepts the same create. + +**The server's insert rule, which the create question follows.** The server's +field-level write step refuses a write that names a field whose explicit +field-level entry has `editable: false`; a field with no entry passes it, and +object admission decides the operation (`allowCreate` for an insert, +`allowEdit` for an update). So a create-form field now reads its explicit entry +when there is one and the object's create grant when there is none. A field the +permission set marks `editable: false` stays disabled and out of the body. + +**Clause-②: yes (widening)** + +- `@object-ui/core` exports the affordance-to-grant map: `AFFORDANCE_GRANTS` + (one row per affordance: the CRUD-affordance bit it needs, the object grant it + exercises and, for an affordance that offers fields, the field question it + asks), `resolveAffordance` (managed-object policy ∧ the server's effective API + operation set ∧ the caller's grant, with the row's `userActions` predicates + surfaced only when all three allow it), `resolveFieldAffordance`, + `formFieldsAffordance`, and their types (`ConsoleAffordance`, + `FieldAffordance`, `AffordanceGrant`, `FieldAffordanceGrant`, + `AffordanceGrantRow`, `AffordanceGrantPrincipal`, `FieldAffordancePrincipal`, + `AffordanceSource`, `AffordanceVerdict`). +- `@object-ui/permissions`: `checkField`'s action accepts `'create'` beside + `'read'` and `'write'`. `MePermissionsProvider` answers it from the explicit + field entry when there is one and from `allowCreate` otherwise; the + role-based `PermissionProvider` answers it as it answers `'write'`. + +**Behaviour (plugin-form).** Every `ObjectForm` layout reads the map: a create +form's fields and its outbound filter ask the create question, and the +form-wide lock (with its "You don't have permission to …" notice) also engages +when the caller's object grant for the form's mode is denied, not only when the +managed-object policy or the effective API operation set closes it. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98cbc64e71..cd0f047489 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,9 @@ export * from './builder/schema-builder.js'; export * from './utils/dom-props.js'; export * from './utils/filter-converter.js'; export * from './utils/managedBy.js'; +// The affordance-to-grant map (objectui#12082): every console affordance that +// offers a write reads the grant it exercises from this one table. +export * from './utils/affordanceGrants.js'; export * from './utils/extract-records.js'; // The non-grid row ceiling (objectui#7210), homed beside the `extractRecords` // it wraps by objectui#7508's ruling A′. `@object-ui/react` re-exports three of diff --git a/packages/core/src/utils/affordanceGrants.ts b/packages/core/src/utils/affordanceGrants.ts new file mode 100644 index 0000000000..58b60c8b7a --- /dev/null +++ b/packages/core/src/utils/affordanceGrants.ts @@ -0,0 +1,246 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The affordance-to-grant map (objectui#12082): ONE table naming, for every + * console affordance that offers a write, the grant the caller must hold for + * it — and ONE resolver every such affordance reads. + * + * ## The family this closes + * + * Each console surface used to decide on its own which grant its affordance + * reads, and each surface that decided wrongly was found and fixed alone + * (objectui#4296, #10107, #11000, #12047). objectui#12082 is the member that + * hurt most: a create form gated its fields on the EDIT grant, so a + * create-only role could not fill the form it was allowed to submit. The same + * pass found the record header not reading the update grant at all, and a + * lookup offering "Create new" with no grant read either (objectui#12081). The + * cause is one shape — an affordance spelling its own permission logic — so + * the fix is one table every affordance reads, not another one-off repair. + * + * ## What a row says + * + * `crud` — the resolved CRUD-affordance bit the affordance also needs: the + * object's managed-object policy (ADR-0103, the spec's + * `resolveCrudAffordances`) intersected with the server's effective API + * operation set (`/me/permissions` `apiOperations`, objectstack#3391), both + * through {@link resolveEffectiveCrudAffordances}. `null` for an affordance + * whose write does not go through the object's generic data door (an + * attachment upload goes through the storage route; the import template has + * its own endpoint), so that door's operation set says nothing about it. + * + * `grant` — the caller's object grant it exercises, asked as `can(object, + * grant)` (`MePermissionsProvider` maps `create` → `allowCreate`, `update` → + * `allowEdit`, `delete` → `allowDelete`). + * + * `field` — for an affordance that offers FIELDS to write, the field-level + * question asked of each one: `create` for an insert, `write` for an update. + * They differ only for a field the permission set does not mention, which the + * server's field step lets through so that object admission decides: on an + * insert that is `allowCreate`, on an update `allowEdit`. A field the set marks + * `editable: false` is refused on both, and the resolver answers that the same + * way for both questions. So a create form follows what the server enforces on + * insert, and adds no rule the server does not have. + * + * ## The verdict + * + * An affordance shows when the managed-object policy, the effective API + * operation set AND the caller's grant all allow it ({@link resolveAffordance}). + * Its `userActions` predicates (the #2614 object form) are surfaced only then: + * a predicate narrows, and never re-opens what policy, operation set or grant + * closed. + * + * ## Fail-open, as before + * + * With no permission provider mounted, `usePermissions()` answers `can` with + * `true` and `isLoaded` with `false`, so every grant here reads open and the + * field question is not asked at all — a standalone embed, a designer preview + * and a public surface behave as they did before permissions existed, and the + * server still enforces (the `fieldWriteGate.ts` contract in + * `@object-ui/plugin-form`). The policy half is not a per-principal answer and + * does not fail open with it, exactly as `resolveEffectiveCrudAffordances` + * never did. + * + * ## Adding an affordance + * + * Add its row here and read it through {@link resolveAffordance} (or + * {@link resolveFieldAffordance} for fields). The enumeration pin in + * `@object-ui/plugin-form` (`affordanceGrantMap-12082.test.tsx`) runs every row + * against four grant shapes and holds the row set to its own expectation table, + * and its census refuses a console source file that reads a CRUD grant without + * this map. + */ + +import { + resolveEffectiveCrudAffordances, + type CrudAffordances, + type RowCrudPredicates, + type SchemaLike, +} from './managedBy.js'; + +/** The caller's object grant an affordance exercises. */ +export type AffordanceGrant = 'create' | 'update' | 'delete'; + +/** + * The field-level question a field affordance asks: `create` follows the + * server's insert rule, `write` its update rule. + */ +export type FieldAffordanceGrant = 'create' | 'write'; + +/** One row of {@link AFFORDANCE_GRANTS}. */ +export interface AffordanceGrantRow { + /** The resolved CRUD-affordance bit it also needs, or `null` for none. */ + readonly crud: 'create' | 'import' | 'edit' | 'delete' | null; + /** The caller's object grant it exercises. */ + readonly grant: AffordanceGrant; + /** For an affordance that offers fields to write: the field-level question. */ + readonly field?: FieldAffordanceGrant; +} + +/** + * Every console affordance that offers a write → the grant it reads. Each + * row's comment names the affordance and the object the grant is asked of; + * where no object is named, it is the object the surface shows. + */ +export const AFFORDANCE_GRANTS = { + /** A create form's fields (every `ObjectForm` layout) and its form-wide lock. */ + createFormFields: { crud: 'create', grant: 'create', field: 'create' }, + /** An edit form's fields (every `ObjectForm` layout) and its form-wide lock. */ + editFormFields: { crud: 'edit', grant: 'update', field: 'write' }, + /** The record page's Edit — the header CTA and the record body's in-place editing. */ + recordEdit: { crud: 'edit', grant: 'update' }, + /** The record page's Delete. */ + recordDelete: { crud: 'delete', grant: 'delete' }, + /** An object list's New (toolbar button and the phone "+"). */ + listNew: { crud: 'create', grant: 'create' }, + /** An object list's Import, and the import wizard's writable target fields. */ + listImport: { crud: 'import', grant: 'create', field: 'create' }, + /** The import wizard's template download — its endpoint answers 403 without the create grant. */ + importTemplate: { crud: null, grant: 'create' }, + /** A list's inline (in-cell) editing. */ + listInlineEdit: { crud: 'edit', grant: 'update' }, + /** A list's bulk Delete. */ + listBulkDelete: { crud: 'delete', grant: 'delete' }, + /** A grid row's Edit. */ + rowEdit: { crud: 'edit', grant: 'update' }, + /** A grid row's Delete. */ + rowDelete: { crud: 'delete', grant: 'delete' }, + /** A grid's inline add-record row. */ + gridAddRow: { crud: 'create', grant: 'create' }, + /** A related list's "+ New", asked of the CHILD object. */ + relatedNew: { crud: 'create', grant: 'create' }, + /** A related list row's Edit, asked of the child object. */ + relatedRowEdit: { crud: 'edit', grant: 'update' }, + /** A related list row's Delete, asked of the child object. */ + relatedRowDelete: { crud: 'delete', grant: 'delete' }, + /** A lookup picker's "Create new", asked of the TARGET object. */ + lookupCreateNew: { crud: 'create', grant: 'create' }, + /** The record Attachments panel's Upload, asked of `sys_attachment` (storage route, not the data door). */ + attachmentUpload: { crud: null, grant: 'create' }, + /** The record Attachments panel's per-row delete, asked of `sys_attachment`. */ + attachmentDelete: { crud: null, grant: 'delete' }, +} as const satisfies Record; + +/** A console affordance with a row in {@link AFFORDANCE_GRANTS}. */ +export type ConsoleAffordance = keyof typeof AFFORDANCE_GRANTS; + +/** The affordances that offer fields to write. */ +export type FieldAffordance = { + [K in ConsoleAffordance]: (typeof AFFORDANCE_GRANTS)[K] extends { field: FieldAffordanceGrant } ? K : never; +}[ConsoleAffordance]; + +/** + * The principal surface {@link resolveAffordance} reads — structurally the + * subset of `usePermissions()` that answers it, declared here so this module + * stays React-free and binds to no provider. + */ +export interface AffordanceGrantPrincipal { + /** `usePermissions().can` — `true` for every grant with no provider mounted. */ + can(object: string, grant: AffordanceGrant): boolean; + /** The server's effective API operation set for an object; `undefined` leaves the policy as it is. */ + getObjectApiOperations?(object: string): readonly string[] | undefined; +} + +/** The principal surface {@link resolveFieldAffordance} reads. */ +export interface FieldAffordancePrincipal { + /** `false` with no provider mounted: no field question is asked. */ + isLoaded: boolean; + checkField(object: string, field: string, action: FieldAffordanceGrant): boolean; +} + +/** What an affordance is resolved against. */ +export interface AffordanceSource { + /** The schema of the object the grant is asked of; `null` reads as the default bucket. */ + objectSchema?: SchemaLike | null; + /** That object's name. Absent: no grant and no operation set can be asked, so both read open. */ + objectName?: string | null; + /** The caller's permissions; absent reads open, as no provider does. */ + perms?: AffordanceGrantPrincipal | null; +} + +/** One affordance's verdict. */ +export interface AffordanceVerdict { + /** Policy ∧ effective API operation set ∧ the caller's grant. */ + readonly allowed: boolean; + /** The row's `userActions` predicate envelope — only when {@link allowed}. */ + readonly predicates?: RowCrudPredicates; +} + +const PREDICATES: Record, keyof CrudAffordances> = { + create: 'createPredicates', + import: 'importPredicates', + edit: 'editPredicates', + delete: 'deletePredicates', +}; + +/** + * Resolve one affordance: its row's CRUD-affordance bit (managed-object policy + * ∧ the effective API operation set) AND the caller's grant. + */ +export function resolveAffordance( + affordance: ConsoleAffordance, + { objectSchema, objectName, perms }: AffordanceSource = {}, +): AffordanceVerdict { + const row: AffordanceGrantRow = AFFORDANCE_GRANTS[affordance]; + let predicates: RowCrudPredicates | undefined; + if (row.crud !== null) { + const ops = objectName ? perms?.getObjectApiOperations?.(objectName) : undefined; + const crud = resolveEffectiveCrudAffordances(objectSchema, ops); + if (!crud[row.crud]) return { allowed: false }; + predicates = crud[PREDICATES[row.crud]] as RowCrudPredicates | undefined; + } + if (objectName && perms && !perms.can(objectName, row.grant)) return { allowed: false }; + return predicates ? { allowed: true, predicates } : { allowed: true }; +} + +/** + * The field-level half of a field affordance: may the caller write `field` + * through it? Asks the resolver the row's question (`create` or `write`); with + * no provider mounted (`isLoaded` false) it asks nothing and answers `true`. + */ +export function resolveFieldAffordance( + affordance: FieldAffordance, + perms: FieldAffordancePrincipal | null | undefined, + objectName: string, + field: string, +): boolean { + if (!perms?.isLoaded) return true; + return perms.checkField(objectName, field, AFFORDANCE_GRANTS[affordance].field); +} + +/** + * The form row for a form's mode: a create form's fields ask the create + * question, every other form that writes asks the edit question, and a `view` + * form writes nothing (`undefined`). + */ +export function formFieldsAffordance( + mode: string | undefined, +): 'createFormFields' | 'editFormFields' | undefined { + if (mode === 'view') return undefined; + return mode === 'create' ? 'createFormFields' : 'editFormFields'; +} diff --git a/packages/permissions/src/MePermissionsProvider.tsx b/packages/permissions/src/MePermissionsProvider.tsx index b9d4ceab10..0fe29ed70d 100644 --- a/packages/permissions/src/MePermissionsProvider.tsx +++ b/packages/permissions/src/MePermissionsProvider.tsx @@ -300,19 +300,24 @@ export function MePermissionsProvider({ const dataKey: object = data ?? NO_DATA; const checkField = CHECK_FIELD([dataKey], () => - (object: string, field: string, action: 'read' | 'write'): boolean => { + (object: string, field: string, action: 'read' | 'write' | 'create'): boolean => { if (!data) return false; // fail-closed // Normalize casing — backend stores keys lowercase but callers may // pass schema.objectName as "Account" / "account" interchangeably. const objKey = (object ?? '').toLowerCase(); const key = `${objKey}.${field}`; const fieldPerm = data.fields?.[key] ?? data.fields?.[`${object}.${field}`]; + // An explicit entry answers `write` and `create` alike: the server's + // field step refuses a non-editable field on insert and update both. if (fieldPerm) { return action === 'read' ? fieldPerm.readable !== false : fieldPerm.editable !== false; } - // No explicit field-level override → defer to object-level perms. + // No explicit field-level override → defer to object-level perms: the + // field step lets the field through and object admission decides, so + // the answer is the grant of the operation asked about (objectui#12082) + // — `allowCreate` on an insert, `allowEdit` on an update. const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*']; if (!objPerm) { // [objectstack-ai/objectstack#2926 ④] Unknown-object default is authentication-gated: @@ -326,8 +331,9 @@ export function MePermissionsProvider({ // would brick public forms. return data.authenticated !== true; } - return action === 'read' - ? objPerm.allowRead !== false + if (action === 'read') return objPerm.allowRead !== false; + return action === 'create' + ? objPerm.allowCreate !== false : objPerm.allowEdit !== false; }, ); diff --git a/packages/permissions/src/PermissionContext.ts b/packages/permissions/src/PermissionContext.ts index c3b066eaf5..9dff8b1f80 100644 --- a/packages/permissions/src/PermissionContext.ts +++ b/packages/permissions/src/PermissionContext.ts @@ -12,8 +12,17 @@ import type { PermissionAction, PermissionCheckResult, FieldLevelPermission } fr export interface PermissionContextValue { /** Check if action is allowed on object */ check: (object: string, action: PermissionAction, record?: Record) => PermissionCheckResult; - /** Check field-level permissions */ - checkField: (object: string, field: string, action: 'read' | 'write') => boolean; + /** + * Check field-level permissions. + * + * `write` asks whether the caller may change the field on an existing record + * (the server's update rule); `create` whether they may set it on a new one + * (its insert rule, objectui#12082). The two differ only for a field the + * permission set does not mention: the server's field step lets it through + * and object admission decides — `allowEdit` on an update, `allowCreate` on + * an insert. An explicit field-level entry answers both the same way. + */ + checkField: (object: string, field: string, action: 'read' | 'write' | 'create') => boolean; /** Get field permissions for an object */ getFieldPermissions: (object: string) => FieldLevelPermission[]; /** Get row filter for an object */ diff --git a/packages/permissions/src/PermissionProvider.tsx b/packages/permissions/src/PermissionProvider.tsx index 9bdddca471..900cb10b56 100644 --- a/packages/permissions/src/PermissionProvider.tsx +++ b/packages/permissions/src/PermissionProvider.tsx @@ -100,10 +100,15 @@ export function PermissionProvider({ ); const checkField = CHECK_FIELD([permissions, userRoles], () => - (object: string, field: string, action: 'read' | 'write'): boolean => { + (object: string, field: string, action: 'read' | 'write' | 'create'): boolean => { const objectConfig = permissions.find((p) => p.object === object); if (!objectConfig) return true; // No config means no restrictions + // A role's field permission has one write bit, `write`, and no + // per-operation split: setting a field on a new record (`create`) reads + // it exactly as changing it on an existing one does, and a field the + // config does not mention keeps this provider's default allow for both. + // Same guard as `evaluator.ts` (objectui#4812): a config that omits the // required `roles` must deny or fall through, never throw. Here the // fall-through lands on this function's own documented default (allow), diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 0a99730198..772bde0e93 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -549,7 +549,7 @@ export const DrawerForm: React.FC = ({ // retains state for unmounted/disabled fields, so the render gate above // is not enough on its own (`80c54122e`). const payload = sanitizeFormData(data, objectSchema, { - canEdit: fieldWriteGate(perms, schema.objectName), + canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), }); // Omit the fields the producer owns (#4069) — see // `omitServerResolvedDefaults` for why an empty key is not the same as diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 78db783957..d3d9bb567a 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -635,7 +635,7 @@ export const ModalForm: React.FC = ({ // enough on its own — but the verdict is the same resolver's, adapted by // `fieldWriteGate` rather than copied here (`80c54122e`). const payload = sanitizeFormData(data, objectSchema, { - canEdit: fieldWriteGate(perms, schema.objectName), + canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), }); // Omit the fields the producer owns (#4069) — see // `omitServerResolvedDefaults` for why an empty key is not the same as diff --git a/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx b/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx index f038a11a2e..65d1d691a8 100644 --- a/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx +++ b/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx @@ -44,6 +44,9 @@ const { permsStub, state } = vi.hoisted(() => { permsStub: { isLoaded: false, checkField: () => true, + // The caller's object grant (objectui#12082) holds every verb, so the + // effective set stays the only variable, as the comment above says. + can: () => true, getObjectApiOperations: () => state.effectiveOps, }, }; diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 855efdbfb4..6863214488 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -1285,7 +1285,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // the record as the form now holds it, whether or not a field was written. const { payload, writePayload } = formWritePayload(formData, schema, { objectSchema, - canEdit: fieldWriteGate(perms, schema.objectName), + canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), snapshot: loadedRecordRef.current, }); diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index b15d023603..c4943fb461 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -384,7 +384,7 @@ export const SplitForm: React.FC = ({ // the identical payload. const { writePayload } = formWritePayload(data, schema, { objectSchema, - canEdit: fieldWriteGate(perms, schema.objectName), + canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), snapshot: loadedRecordRef.current, }); diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 407edd9fa1..a4c89660ff 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -483,7 +483,7 @@ export const TabbedForm: React.FC = ({ // the identical payload. const { writePayload } = formWritePayload(data, schema, { objectSchema, - canEdit: fieldWriteGate(perms, schema.objectName), + canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), snapshot: loadedRecordRef.current, }); diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 084f5bb88b..04bb23e5fc 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -959,7 +959,7 @@ export const WizardForm: React.FC = ({ // payload. A simple form's mobile `stepper` renders through here too. const { writePayload } = formWritePayload(mergedData, schema, { objectSchema, - canEdit: fieldWriteGate(perms, schema.objectName), + canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), snapshot: loadedRecordRef.current, }); diff --git a/packages/plugin-form/src/createFormGrant-12082.test.tsx b/packages/plugin-form/src/createFormGrant-12082.test.tsx new file mode 100644 index 0000000000..5def570441 --- /dev/null +++ b/packages/plugin-form/src/createFormGrant-12082.test.tsx @@ -0,0 +1,220 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A create form asks the CREATE question of its fields, so a create-only role + * can fill the form it is allowed to submit (objectui#12082). + * + * ## The defect + * + * The card's role holds `allowCreate: true, allowEdit: false` on + * `clm_contract_version`: requesters create versions and never edit them. The + * form's field gate asked `checkField(object, field, 'write')` in every mode + * but `view`, and with no field-level entry that question falls back to the + * object's `allowEdit`. So every field of the CREATE form rendered disabled, + * and the outbound filter, which asks the same question, stripped every field + * from the body: the save posted `{}` and the server answered + * `400 VALIDATION_FAILED` for the required fields. The server accepts the same + * create (`201`) under the role's real `allowCreate`. + * + * ## The server's insert rule, which the create question follows + * + * The security middleware's field-level write step refuses an insert that + * names a field whose explicit field-level entry has `editable: false`; a + * field with no entry passes it, and object admission (`insert` → + * `allowCreate`) decides the create. So a create-form field reads: explicit + * field entry → its `editable`; no entry → the object's create grant. That is + * the `createFormFields` row of the affordance-to-grant map in + * `@object-ui/core`, which every form layout reads. + * + * ## The rows + * + * - the card's repro, on every layout `ObjectForm` routes to: a create-only + * grant draws every field enabled, with no notice; + * - the payload, on the three containers that submit in one step: the body + * carries what was typed; + * - CONTROL, the same grant on an EDIT form: every field stays disabled — the + * edit question still reads `allowEdit`; + * - CONTROL, an explicit field-level `editable: false` stays disabled in the + * create form and is absent from the body — the field rule the server has on + * insert is kept, and no rule it lacks is added. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import React from 'react'; + +import { MePermissionsProvider } from '@object-ui/permissions'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from './ObjectForm'; + +registerAllFields(); +afterEach(cleanup); + +const OBJECT = 'clm_contract_version'; + +const FIELDS = { + contract: { type: 'text', label: 'Contract' }, + version_no: { type: 'number', label: 'Version No.' }, + notes: { type: 'text', label: 'Notes' }, +}; +const RECORD = { id: 'v1', contract: 'C-1', version_no: 1, notes: 'first' }; + +/** `/me/permissions` for the card's requester: create, never edit. */ +const createOnly = (fields: Record = {}): any => ({ + authenticated: true, + userId: 'u-requester', + tenantId: null, + roles: ['clm_requester'], + permissionSets: ['clm_requester'], + objects: { + [OBJECT]: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, + }, + fields, +}); + +const sections = (names: string[]) => [{ name: 'main', label: 'Main', fields: names }]; + +const LAYOUTS: Array<{ layout: string; schema: (names: string[]) => Record }> = [ + { layout: 'simple / sections (default arm)', schema: (n) => ({ sections: sections(n) }) }, + { layout: 'drawer / sections (DrawerForm)', schema: (n) => ({ formType: 'drawer', sections: sections(n) }) }, + { layout: 'modal / sections (ModalForm)', schema: (n) => ({ formType: 'modal', sections: sections(n) }) }, + { layout: 'tabbed / sections (TabbedForm)', schema: (n) => ({ formType: 'tabbed', sections: sections(n) }) }, + { layout: 'split / sections (SplitForm)', schema: (n) => ({ formType: 'split', sections: sections(n) }) }, + { layout: 'wizard / sections (WizardForm)', schema: (n) => ({ formType: 'wizard', sections: sections(n) }) }, + { layout: 'simple / flat (default arm)', schema: (n) => ({ fields: n }) }, + { layout: 'drawer / flat (DrawerForm)', schema: (n) => ({ formType: 'drawer', fields: n }) }, + { layout: 'modal / flat (ModalForm)', schema: (n) => ({ formType: 'modal', fields: n }) }, +]; + +/** The layouts that submit in one step, through the form element. */ +const SUBMITTING = LAYOUTS.filter((l) => l.layout.includes('/ flat')); + +function makeDS() { + return { + getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT, label: 'Contract Version', fields: FIELDS }), + findOne: vi.fn().mockResolvedValue({ ...RECORD }), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn(async (_o: string, d: Record) => ({ id: 'v2', ...d })), + update: vi.fn(async (_o: string, _id: string, d: Record) => ({ ...RECORD, ...d })), + }; +} + +function mount( + layout: (names: string[]) => Record, + mode: 'create' | 'edit', + perms: any, + ds: ReturnType, +) { + render( + + + , + ); +} + +/** Every drawn field → `true` when it kept NO enabled control (locked). */ +function lockedByField(): Record { + const out: Record = {}; + for (const item of document.body.querySelectorAll('[data-field]')) { + const name = item.getAttribute('data-field') as string; + out[name] = item.querySelector('input:not([disabled]), textarea:not([disabled])') === null; + } + return out; +} + +const notices = () => Array.from(document.body.querySelectorAll('[data-testid="closed-affordance-notice"]')); + +async function formOnScreen(mode: 'create' | 'edit') { + await waitFor(() => { + const el = document.body.querySelector('input[name="contract"]') as HTMLInputElement | null; + if (!el) throw new Error('form not drawn yet'); + if (mode === 'edit' && el.value !== RECORD.contract) throw new Error('record not on screen yet'); + }); +} + +function type(name: string, value: string) { + const el = document.body.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; + if (!el) throw new Error(`${name} not drawn`); + fireEvent.change(el, { target: { value } }); +} + +async function submit(ds: ReturnType) { + const form = document.body.querySelector('form'); + if (!form) throw new Error('no form element'); + fireEvent.submit(form); + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const [object, body] = ds.create.mock.calls[0]; + return { object, body: body as Record }; +} + +describe.each(LAYOUTS)('ObjectForm $layout under a create-only grant (objectui#12082)', ({ schema }) => { + it('the create form draws every field enabled, with no closed-affordance notice', async () => { + mount(schema, 'create', createOnly(), makeDS()); + await formOnScreen('create'); + expect(lockedByField()).toEqual({ contract: false, version_no: false, notes: false }); + expect(notices()).toHaveLength(0); + }); + + it('CONTROL — an edit form under the same grant stays disabled', async () => { + mount(schema, 'edit', createOnly(), makeDS()); + await formOnScreen('edit'); + expect(lockedByField()).toEqual({ contract: true, version_no: true, notes: true }); + }); + + it('CONTROL — an explicit field-level editable: false stays disabled in the create form', async () => { + mount( + schema, + 'create', + createOnly({ [`${OBJECT}.notes`]: { readable: true, editable: false } }), + makeDS(), + ); + await formOnScreen('create'); + // Only the field the entry names: whether its siblings are open is the + // repro row above, so this row stays a control on both sides of the fix. + expect(lockedByField().notes).toBe(true); + }); +}); + +describe.each(SUBMITTING)('ObjectForm $layout — the create-only body (objectui#12082)', ({ schema }) => { + it('the submitted create body carries the typed fields', async () => { + const ds = makeDS(); + mount(schema, 'create', createOnly(), ds); + await formOnScreen('create'); + type('contract', 'C-7'); + type('version_no', '3'); + type('notes', 'signed copy'); + const { object, body } = await submit(ds); + expect(object).toBe(OBJECT); + expect(body).toMatchObject({ contract: 'C-7', version_no: 3, notes: 'signed copy' }); + }); + + // The lit half (`contract`, `version_no` on the wire) keeps "absent" meaning + // "the field rule fired" rather than "the form sent nothing", so this row is + // red wherever the repro row is. + it('CONTROL — an explicit field-level editable: false never reaches the create body', async () => { + const ds = makeDS(); + mount(schema, 'create', createOnly({ [`${OBJECT}.notes`]: { readable: true, editable: false } }), ds); + await formOnScreen('create'); + type('contract', 'C-7'); + type('version_no', '3'); + const { body } = await submit(ds); + expect(body).toMatchObject({ contract: 'C-7', version_no: 3 }); + expect(body).not.toHaveProperty('notes'); + }); +}); diff --git a/packages/plugin-form/src/fieldWriteGate.ts b/packages/plugin-form/src/fieldWriteGate.ts index b8c7725bfb..a24e28796c 100644 --- a/packages/plugin-form/src/fieldWriteGate.ts +++ b/packages/plugin-form/src/fieldWriteGate.ts @@ -7,17 +7,26 @@ */ /** - * 「May this caller edit this field?」 — asked once, of the resolver that owns - * the answer (`80c54122e`). + * 「May this caller write this field through this form?」 — asked once, of the + * resolver that owns the answer (`80c54122e`), with the question the form's + * mode asks (objectui#12082). * * ## The one answer, and where it lives * - * `checkField(object, field, 'write')` in `@object-ui/permissions` IS that + * `checkField(object, field, action)` in `@object-ui/permissions` IS that * resolver: `MePermissionsProvider` reads the server's `/me/permissions` * envelope, looks the caller's field-level grant up by `"."`, - * and falls back to the object-level `allowEdit` for a field the permission - * set never mentions. ⛔ Nothing in this module re-derives any rung of it. It - * only ADAPTS that one verdict into the two shapes a form container needs: + * and falls back to the object-level grant for a field the permission set + * never mentions. WHICH question a form asks is not this module's to decide + * either: it is the form's row in the affordance-to-grant map + * (`AFFORDANCE_GRANTS` in `@object-ui/core`, read through + * `formFieldsAffordance` / `resolveFieldAffordance`). A create form asks + * `create` — the server's insert rule, whose fallback is `allowCreate` — and + * every other form that writes asks `write`, whose fallback is `allowEdit`. + * Asking `write` in create mode was objectui#12082: a create-only role met a + * create form with every field disabled and a save that posted `{}`. + * ⛔ Nothing in this module re-derives any rung of it. It only ADAPTS that one + * verdict into the shapes a form container needs: * * - {@link fieldWriteGate} — the predicate `sanitizeFormData` takes, so the * OUTBOUND payload never carries a field the caller may read but not edit; @@ -28,7 +37,9 @@ * columns, spelled in the one lock the grid reads (objectui#10163). * * And one form-level step built on the render pass: {@link gateFormFields}, - * which adds the ADR-0092 D4 managed-object lock to it. It is the ONE step + * which adds the form-wide lock to it — the form's affordance closed by the + * ADR-0092 D4 managed-object policy, the effective API operation set or the + * caller's object grant (objectui#12082). It is the ONE step * every `ObjectForm` layout draws its resolved fields through (objectui#10612). * * ## Why both halves live in one module @@ -55,15 +66,21 @@ * would brick those surfaces without adding any security the server does not * already provide. * - * ⚠️ The managed-object lock {@link gateFormFields} adds is NOT a - * per-principal answer, so it does not fail open with the field-level half: - * its first input is the object's own `managedBy` bucket and `userActions`, - * read with no principal at all, exactly as the default arm has always read - * it. Only its second input — the server's effective API operation set — is - * absent without a provider, and absent leaves the bucket's answer standing. + * ⚠️ The form-wide lock {@link gateFormFields} adds is only PARTLY a + * per-principal answer, and only that part fails open: its first input is the + * object's own `managedBy` bucket and `userActions`, read with no principal at + * all, exactly as the default arm has always read it. Its second input — the + * server's effective API operation set — is absent without a provider, and + * absent leaves the bucket's answer standing; its third — the caller's object + * grant, `can` — answers `true` without a provider. */ -import { resolveEffectiveCrudAffordances, type SchemaLike } from '@object-ui/core'; +import { + formFieldsAffordance, + resolveAffordance, + resolveFieldAffordance, + type SchemaLike, +} from '@object-ui/core'; /** * The permission surface this module consumes — structurally the subset of @@ -73,7 +90,7 @@ import { resolveEffectiveCrudAffordances, type SchemaLike } from '@object-ui/cor */ export interface FieldWritePrincipal { isLoaded: boolean; - checkField: (object: string, field: string, action: 'read' | 'write') => boolean; + checkField(object: string, field: string, action: 'read' | 'write' | 'create'): boolean; } /** A field-name predicate: `true` when the caller may write that field. */ @@ -87,19 +104,30 @@ export type FieldWriteGate = (fieldName: string) => boolean; * this straight to `sanitizeFormData`, whose option is absent-or-predicate, so * an unresolved principal produces the byte-identical payload it produced * before this gate existed. + * + * `mode` is required, not optional: it picks the question (objectui#12082), + * and a container that forgot it would silently strip a create-only caller's + * whole body again. A mode that writes nothing (`view`) gets the edit + * question, the stricter of the two. */ export function fieldWriteGate( perms: FieldWritePrincipal | null | undefined, objectName: string, + mode: string | undefined, ): FieldWriteGate | undefined { if (!perms?.isLoaded) return undefined; - return (fieldName: string) => perms.checkField(objectName, fieldName, 'write'); + const affordance = formFieldsAffordance(mode) ?? 'editFormFields'; + return (fieldName: string) => resolveFieldAffordance(affordance, perms, objectName, fieldName); } export interface ApplyFieldPermissionsOptions { perms: FieldWritePrincipal | null | undefined; objectName: string; - /** A `view`-mode form renders everything read-only already. */ + /** + * The form's mode, which picks the field question (objectui#12082): `create` + * asks the insert rule, any other mode but `view` the update rule. A + * `view`-mode form renders everything read-only already. + */ mode?: string; /** * Optional hint placed on a field the caller may read but not edit, used @@ -131,11 +159,14 @@ function gateByPermission>( ): T[] | undefined { if (!Array.isArray(fields)) return fields; if (!perms?.isLoaded) return fields; + // The form's row in the affordance-to-grant map: `undefined` for a `view` + // form, which asks no write question at all. + const affordance = formFieldsAffordance(mode); const out: T[] = []; for (const f of fields) { if (!f?.name) { out.push(f); continue; } if (!perms.checkField(objectName, f.name, 'read')) continue; // omit entirely - if (mode !== 'view' && !perms.checkField(objectName, f.name, 'write')) { + if (affordance && !resolveFieldAffordance(affordance, perms, objectName, f.name)) { out.push(markDenied(f)); continue; } @@ -160,12 +191,14 @@ export function applyFieldPermissions>( } /** - * The principal surface {@link gateFormFields} reads: the field-level resolver - * plus the server's effective API operation set for an object (`/me/permissions` - * `apiOperations`, objectstack#3391). `undefined` from it means "no effective set", which - * leaves the object's own affordance standing. + * The principal surface {@link gateFormFields} reads: the field-level resolver, + * the server's effective API operation set for an object (`/me/permissions` + * `apiOperations`, objectstack#3391; `undefined` means "no effective set", + * which leaves the object's own affordance standing), and the caller's object + * grant (`usePermissions().can`, objectui#12082). */ export interface FormFieldPrincipal extends FieldWritePrincipal { + can(object: string, action: 'create' | 'update' | 'delete'): boolean; getObjectApiOperations?: (object: string) => readonly string[] | undefined; } @@ -180,20 +213,27 @@ export interface GateFormFieldsOptions extends ApplyFieldPermissionsOptions { } /** - * The managed-object blanket lock (ADR-0092 D4 / ADR-0103): `true` when the - * object's resolved CRUD affordance for the form's mode is CLOSED — `edit` for - * an edit form, `create` for a create form. + * The form-wide lock: `true` when the form's affordance is CLOSED — the + * `createFormFields` row for a create form, `editFormFields` for an edit form, + * resolved through the affordance-to-grant map (`resolveAffordance` in + * `@object-ui/core`, objectui#12082). + * + * That resolver is the SAME one the record header, the list toolbar, the + * related lists and the grids read, so a form cannot disagree with the button + * that opened it. Three layers, intersected: * - * It routes through the SAME shared `resolveEffectiveCrudAffordances` policy - * the detail (`isObjectInlineEditable`) and grid surfaces use, instead of - * re-deriving the bucket lock: `platform` and admin-editable `config` resolve - * open; the engine-owned buckets (`engine-owned`, `append-only`, - * `better-auth`) resolve closed unless the object OPENED per-record writing via - * `userActions.{edit,create}` (e.g. sys_user opens `edit` for its profile - * fields). objectstack#3546 intersects that with the server's effective API operation set - * for the object, so the lock also engages when the server denies `update` - * (edit) or `create` (create) — the intersection the detail header and the - * list toolbar apply. + * - the managed-object policy (ADR-0092 D4 / ADR-0103): `platform` and + * admin-editable `config` resolve open; the engine-owned buckets + * (`engine-owned`, `append-only`, `better-auth`) resolve closed unless the + * object OPENED per-record writing via `userActions.{edit,create}` (e.g. + * sys_user opens `edit` for its profile fields); + * - the server's effective API operation set for the object (objectstack#3546), + * so the lock engages when the server denies `update` (edit) or `create` + * (create); + * - the caller's object grant (objectui#12082): `allowEdit` for an edit form, + * `allowCreate` for a create form. Before the map, the edit form's half of + * this reached the fields only through the field question's `allowEdit` + * fallback, and the create form's half not at all. * * Any other mode never locks here: a `view` form disables every field on its * own, and a form with no declared mode was never locked by the default arm. @@ -206,19 +246,19 @@ function managedModeLocked( mode: string | undefined, ): boolean { if (mode !== 'edit' && mode !== 'create') return false; - const affordances = resolveEffectiveCrudAffordances( - objectSchema, - perms?.getObjectApiOperations?.(objectName), - ); - return mode === 'edit' ? !affordances.edit : !affordances.create; + const affordance = formFieldsAffordance(mode); + if (!affordance) return false; + return !resolveAffordance(affordance, { objectSchema, objectName, perms }).allowed; } /** The form-level affordance {@link closedFormAffordance} can report closed. */ export type ClosedFormAffordance = 'create' | 'edit'; /** - * Which affordance the managed-object lock found CLOSED for this form, or - * `undefined` when the lock does not engage (objectui#11000). + * Which affordance the form-wide lock found CLOSED for this form, or + * `undefined` when the lock does not engage (objectui#11000) — closed by the + * managed-object policy, the effective API operation set or, since + * objectui#12082, the caller's object grant. * * It is {@link managedModeLocked}, the very predicate {@link gateFormFields} * disables every drawn field on, read for its reason: `create` for a create @@ -253,7 +293,7 @@ export function closedFormAffordance({ * * 1. field-level security — {@link applyFieldPermissions}: drop what the * caller may not READ, lock what they may read but not WRITE; - * 2. the managed-object lock — every drawn field is `disabled` when + * 2. the form-wide lock — every drawn field is `disabled` when * {@link managedModeLocked} says the mode's affordance is closed. Only * `disabled`, not `readOnly`: the default arm's lock always drew a * disabled input, and the submit button is left as it is. diff --git a/packages/plugin-form/src/formChrome.i18n-11071.test.tsx b/packages/plugin-form/src/formChrome.i18n-11071.test.tsx index acda6da3d7..1970e427c0 100644 --- a/packages/plugin-form/src/formChrome.i18n-11071.test.tsx +++ b/packages/plugin-form/src/formChrome.i18n-11071.test.tsx @@ -48,7 +48,11 @@ import { registerAllFields } from '@object-ui/fields'; const { permsStub } = vi.hoisted(() => ({ permsStub: { isLoaded: true, - checkField: (_object: string, field: string, op: string) => !(field === 'salary' && op === 'write'), + // Readable but not writable: an explicit field-level `editable: false` + // refuses the insert question (`create`) and the update one (`write`) + // alike (objectui#12082). + checkField: (_object: string, field: string, op: string) => !(field === 'salary' && op !== 'read'), + can: () => true, getObjectApiOperations: () => undefined, }, })); diff --git a/packages/plugin-form/src/sanitize.ts b/packages/plugin-form/src/sanitize.ts index cbd29b0959..1333697658 100644 --- a/packages/plugin-form/src/sanitize.ts +++ b/packages/plugin-form/src/sanitize.ts @@ -119,7 +119,8 @@ const COMPUTED_FIELD_TYPES = new Set([ * refused for the next, on the identical object — so it cannot be read off * `objectSchema` and must not be re-derived here. `fieldWriteGate` in * `./fieldWriteGate` adapts the ONE resolver that owns that answer - * (`checkField(object, field, 'write')` in `@object-ui/permissions`) into this + * (`checkField` in `@object-ui/permissions`, asked the question the form's + * mode reads from the affordance-to-grant map — objectui#12082) into this * predicate. It arrives here, at the single outbound filter, rather than as a * strip loop after each container's call, because every such loop is a copy * that can be forgotten — and one of the three containers had forgotten it From 50eb7df54367e6763997049d7e84411283565c58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:56:03 +0000 Subject: [PATCH 02/10] fix(app-shell): the record header and the list New / Import read the affordance-to-grant map (objectui#12082) - RecordDetailView: resolveRecordHeaderActionGates takes the caller's permissions and resolves the recordEdit / recordDelete rows, so Edit and Delete read the update / delete grant (before: none at all). - ObjectView / ObjectDataPage: New and Import are the listNew / listImport rows (policy, effective operations and the create grant in one verdict, predicates only when it allows). - importTargetFields: the wizard's write targets ask the create question, so a create-only caller offered Import keeps its insertable fields. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../app-shell/src/views/ObjectDataPage.tsx | 39 +++++---- packages/app-shell/src/views/ObjectView.tsx | 36 ++++---- ...ecordDetailView.headerActionGates.test.tsx | 86 +++++++++++++++---- .../app-shell/src/views/RecordDetailView.tsx | 62 ++++++------- .../src/views/importTargetFields.test.ts | 27 +++++- .../app-shell/src/views/importTargetFields.ts | 12 ++- 6 files changed, 173 insertions(+), 89 deletions(-) diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index 5c70aa147d..2fd7955664 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -43,7 +43,7 @@ import { formatMetadataError } from '@object-ui/data-objectstack'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions, useFieldPermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { resolveFilterPlaceholders } from '@object-ui/core'; +import { resolveAffordance, resolveFilterPlaceholders, type SchemaLike } from '@object-ui/core'; import { normalizeFilterOperator, ViewFilterRuleSchema } from '@objectstack/spec/ui'; import type { ViewFilterRule } from '@objectstack/spec/ui'; import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState.js'; @@ -73,7 +73,7 @@ import { PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; -import { resolveEffectiveCrudAffordances, type RowCrudPredicates } from '../utils/crudAffordances.js'; +import type { RowCrudPredicates } from '../utils/crudAffordances.js'; /** Field types the auto-derived user-filter bar offers as dropdowns. */ const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']); @@ -195,7 +195,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { const { objectLabel, objectPluralLabel, fieldLabel } = useObjectLabel(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const { can, getObjectApiOperations } = usePermissions(); + const perms = usePermissions(); + const { can } = perms; const { canRead } = useFieldPermissions(objectName ?? ''); const { user, activeOrganization } = useAuth(); const { isAdmin } = useWorkspaceAdminStatus(); @@ -423,17 +424,19 @@ export function ObjectDataPage({ dataSource, objects }: any) { // objectstack#3391 effective-API-operation intersection was absent, so the toolbar could // offer a create the server would 405. // - // Resolved exactly as `ObjectView` does: the spec's bucket/`userActions` - // matrix (ADR-0103, delegated to `resolveCrudAffordances`), INTERSECTED with - // the server-resolved effective API operations for this object. `undefined` - // (unrestricted object / old backend) leaves the bucket affordances as-is. - const affordances = React.useMemo( + // Resolved exactly as `ObjectView` does, through the SAME `listNew` row of + // the affordance-to-grant map (`resolveAffordance` in `@object-ui/core`, + // objectui#12082): the spec's bucket/`userActions` matrix (ADR-0103, + // delegated to `resolveCrudAffordances`), INTERSECTED with the + // server-resolved effective API operations for this object, AND the caller's + // create grant. `undefined` operations (unrestricted object / old backend) + // leave the bucket affordances as-is. + const newVerdict = React.useMemo( () => - resolveEffectiveCrudAffordances( - objectDef as any, - objectDef ? getObjectApiOperations(objectDef.name) : undefined, - ), - [objectDef, getObjectApiOperations], + objectDef + ? resolveAffordance('listNew', { objectSchema: objectDef as SchemaLike, objectName: objectDef.name, perms }) + : { allowed: false }, + [objectDef, perms], ); /** @@ -460,10 +463,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { * ONE RENDER POINT here, unlike `ObjectView`: this page has no phone FAB — * the whole PageHeader lives under `hidden sm:block`. */ - const objectCanCreate = !!objectDef && affordances.create && can(objectDef.name, 'create'); - const createPredicates: RowCrudPredicates | undefined = objectCanCreate - ? affordances.createPredicates - : undefined; + const objectCanCreate = newVerdict.allowed; + const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates; /** `visibleWhen` — fails CLOSED, declared-ness by `?? true` rather than by * truthiness, so `visibleWhen: false` (the objectui#3492 shape) hides "New" * instead of reading as "ungated". The `true` default is a boolean, which @@ -543,8 +544,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { <> {/* [#5164] `objectCanCreate && createVisible` — the bucket + object-level `userActions` + objectstack#3391 effective-operations - verdict (all folded into `affordances.create`) AND the - principal's grant, then the toolbar-scope `visibleWhen` layer + verdict AND the principal's grant (the map's `listNew` row, + objectui#12082), then the toolbar-scope `visibleWhen` layer on top of it. Greyed, not gone, is the `disabledWhen` case. */} {objectCanCreate && createVisible && (