From b21824e5b2610e5b34feefee794836b2a3334133 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:43:57 +0000 Subject: [PATCH 1/5] fix(console): in-place edit and list inline edit ask the caller's field write grant, and the owner field asks the transfer grant (objectui#12103) The record page's in-place edit (details body and highlights strip) and a list's in-cell edit now ask each field the `write` question of their row in the affordance-to-grant map (`recordEdit`, `listInlineEdit` gain `field: 'write'`), the same question an edit form asks through `fieldWriteGate`. A field the caller's permission set marks `editable: false` stays a read display / read cell. `MePermissionsProvider.checkField(o, owner, 'write')` now also needs the transfer grant (`objectPermissionGrants(objPerm, 'allowTransfer')` from `@objectstack/spec/security`), because the server refuses an update that writes the owner field (`SystemFieldName.OWNER_ID`) without it. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- packages/core/README.md | 8 +++++- packages/core/src/utils/affordanceGrants.ts | 22 ++++++++++++--- packages/permissions/README.md | 8 ++++++ packages/permissions/package.json | 3 ++- .../permissions/src/MePermissionsProvider.tsx | 27 ++++++++++++++++++- packages/permissions/src/PermissionContext.ts | 4 +++ packages/plugin-detail/src/DetailSection.tsx | 19 ++++++++++++- .../plugin-detail/src/HeaderHighlight.tsx | 17 ++++++++++-- packages/plugin-grid/src/ObjectGrid.tsx | 18 +++++++++++-- pnpm-lock.yaml | 3 +++ 10 files changed, 117 insertions(+), 12 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 52ee53655c..e9c2a1613a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -187,7 +187,13 @@ row's Delete, and the rest — reads the grant it exercises from ONE table, `AFFORDANCE_GRANTS` (objectui#12082). A row names the CRUD-affordance bit the affordance needs, the object grant it exercises (`create`, `update` or `delete`) and, for an affordance that offers fields, the field question it asks -(`create` for an insert, `write` for an update). +(`create` for an insert, `write` for an update). The fields of a form, of the +record page's in-place edit (`recordEdit`) and of a list's in-cell edit +(`listInlineEdit`) each ask that question, so a field the caller's permission +set marks `editable: false` is offered read-only on every one of them +(objectui#12103). The `write` question is the server's whole update rule for a +field, so for the record's owner field it also needs the transfer grant +(`allowTransfer`, or `modifyAllRecords`). `resolveAffordance` is the one verdict: the object's managed-object policy, the server's effective API operation set and the caller's grant must all allow it, diff --git a/packages/core/src/utils/affordanceGrants.ts b/packages/core/src/utils/affordanceGrants.ts index b0fb841c7a..62a4c17e73 100644 --- a/packages/core/src/utils/affordanceGrants.ts +++ b/packages/core/src/utils/affordanceGrants.ts @@ -47,6 +47,16 @@ * 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 `write` question is the server's whole update rule for the field, so it + * also carries the one field whose update needs an OBJECT grant beyond + * `allowEdit`: the record's owner (`SystemFieldName.OWNER_ID` in + * `@objectstack/spec`). The server refuses an update that writes it without + * the transfer grant (`allowTransfer`, or `modifyAllRecords`), so the resolver + * answers `write` for it with that grant too (objectui#12103). Every row that + * asks `write` therefore offers the owner field only to a caller who may + * transfer the record — the in-place edit, the list's in-cell edit and an edit + * form alike, with no row of its own to forget. + * * ## The verdict * * An affordance shows when the managed-object policy, the effective API @@ -120,8 +130,12 @@ export const AFFORDANCE_GRANTS = { 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 Edit — the header CTA and the record body's in-place + * editing (the details body and the highlights strip), each of whose fields + * asks the update question (objectui#12103). + */ + recordEdit: { crud: 'edit', grant: 'update', field: 'write' }, /** The record page's Delete. */ recordDelete: { crud: 'delete', grant: 'delete' }, /** @@ -133,8 +147,8 @@ export const AFFORDANCE_GRANTS = { 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 inline (in-cell) editing, each of whose cells asks the update question (objectui#12103). */ + listInlineEdit: { crud: 'edit', grant: 'update', field: 'write' }, /** A list's bulk Delete. */ listBulkDelete: { crud: 'delete', grant: 'delete' }, /** A grid row's Edit. */ diff --git a/packages/permissions/README.md b/packages/permissions/README.md index 33cf62967a..56d1b7cf1e 100644 --- a/packages/permissions/README.md +++ b/packages/permissions/README.md @@ -148,6 +148,14 @@ such a field through and object admission decides, so `'write'` falls back to the object's `allowEdit` and `'create'` to its `allowCreate`. An explicit field-level entry answers both the same way. +One field's update needs more than its own grant: the record's owner +(`SystemFieldName.OWNER_ID` in `@objectstack/spec`). The server refuses an +update that writes it unless the caller holds the transfer grant +(`allowTransfer`, or `modifyAllRecords`), so `MePermissionsProvider` answers +`'write'` for the owner field with that grant as well (objectui#12103). +`'create'` is unchanged: on insert the server stamps an empty owner to the +caller and accepts the caller's own id. + ```tsx import { usePermissions } from '@object-ui/permissions'; diff --git a/packages/permissions/package.json b/packages/permissions/package.json index 77887fb437..e7a44d0e85 100644 --- a/packages/permissions/package.json +++ b/packages/permissions/package.json @@ -37,7 +37,8 @@ "react": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@object-ui/types": "workspace:*" + "@object-ui/types": "workspace:*", + "@objectstack/spec": "^17.7.0" }, "devDependencies": { "@types/react": "19.2.18", diff --git a/packages/permissions/src/MePermissionsProvider.tsx b/packages/permissions/src/MePermissionsProvider.tsx index 0fe29ed70d..9ab00a250c 100644 --- a/packages/permissions/src/MePermissionsProvider.tsx +++ b/packages/permissions/src/MePermissionsProvider.tsx @@ -19,6 +19,8 @@ import type { PermissionCheckResult, FieldLevelPermission, } from '@object-ui/types'; +import { objectPermissionGrants } from '@objectstack/spec/security'; +import { SystemFieldName } from '@objectstack/spec/system'; import { PermCtx, type PermissionContextValue } from './PermissionContext.js'; import { createDiscardProofCache } from './discardProofCache.js'; @@ -42,6 +44,13 @@ export interface MePermissionsResponse { allowDelete?: boolean; viewAllRecords?: boolean; modifyAllRecords?: boolean; + /** + * The transfer grant: may the caller change a record's owner. The server + * refuses an update that writes the owner field without it (or without + * `modifyAllRecords`, which implies it), and `checkField` reads it for that + * field's `write` question (objectui#12103). + */ + allowTransfer?: boolean; /** * [objectstack#3391] Server-resolved effective API operation set for this object * (enum-ordered). Present only when the object tightens exposure via @@ -307,6 +316,23 @@ export function MePermissionsProvider({ const objKey = (object ?? '').toLowerCase(); const key = `${objKey}.${field}`; const fieldPerm = data.fields?.[key] ?? data.fields?.[`${object}.${field}`]; + const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*']; + // [objectui#12103] An update that writes the record's OWNER is a + // transfer: the server's security step refuses it unless the caller holds + // the transfer grant, whatever the field's own entry says. So `write` for + // that field needs the grant too, read with the spec's own predicate + // (`allowTransfer`, or `modifyAllRecords`, which implies it). The field is + // the one the spec names, as the server's guard keys it. `create` is not + // narrowed: on insert the server stamps an empty owner to the caller and + // accepts the caller's own id. An object the answer does not mention + // takes the unknown-object default below. + if ( + action === 'write' && + field === SystemFieldName.OWNER_ID && + !(objPerm ? objectPermissionGrants(objPerm, 'allowTransfer') : data.authenticated !== true) + ) { + return false; + } // 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) { @@ -318,7 +344,6 @@ export function MePermissionsProvider({ // 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: // - authenticated session → fail-CLOSED. The server resolved this diff --git a/packages/permissions/src/PermissionContext.ts b/packages/permissions/src/PermissionContext.ts index 9dff8b1f80..6be4789bef 100644 --- a/packages/permissions/src/PermissionContext.ts +++ b/packages/permissions/src/PermissionContext.ts @@ -21,6 +21,10 @@ export interface PermissionContextValue { * 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. + * + * `write` is the server's whole update rule, so for the record's owner field + * (`SystemFieldName.OWNER_ID`) it also needs the transfer grant — the server + * refuses an update that writes the owner without it (objectui#12103). */ checkField: (object: string, field: string, action: 'read' | 'write' | 'create') => boolean; /** Get field permissions for an object */ diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index cb928a73c3..c357d1d47b 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -27,6 +27,8 @@ import { } from '@object-ui/components'; import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react'; import { SchemaRenderer, toRenderableSchema, useInlineEdit } from '@object-ui/react'; +import { resolveFieldAffordance } from '@object-ui/core'; +import { usePermissions } from '@object-ui/permissions'; import { getCellRenderer, resolveCellRendererType, @@ -167,6 +169,12 @@ export const DetailSection: React.FC = ({ * session to read, exactly as it has no draft. */ const serverFieldErrors = useInlineEdit()?.fieldErrors ?? null; + /** + * The caller's permissions, for the field question in-place edit asks of each + * field (objectui#12103). With no provider mounted `isLoaded` is false and + * the question is not asked, so a bare `DetailSection` behaves as before. + */ + const perms = usePermissions(); /** * What the copy affordance WRITES (objectui#8395). @@ -378,7 +386,16 @@ export const DetailSection: React.FC = ({ // clipboard are the READ direction of the same refusal, and `isInlineExcluded` // (objectui#4221) already holds the WRITE direction of it. const isMaskedField = isMaskedDetailFieldType(field.type, objectDefField?.type); - const fieldEditable = !isReadonly && !isComputedField && !isSystemField && !isInlineExcluded; + // The CALLER's write grant on this field (objectui#12103): the field + // question of the `recordEdit` row of the affordance-to-grant map, the same + // `write` question an edit form asks (`fieldWriteGate`). A field the + // permission set marks `editable: false` — or the owner field without the + // transfer grant — stays a read display, so in-place edit never offers a + // write the server refuses with 403. The flags above are the object's; this + // one is the principal's, and it only narrows. + const isWriteGranted = !objectName || resolveFieldAffordance('recordEdit', perms, objectName, field.name); + const fieldEditable = + !isReadonly && !isComputedField && !isSystemField && !isInlineExcluded && isWriteGranted; const canInlineEditField = fieldEditable && !!onEnterInlineEdit; const displayValue = (() => { diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index ef977ce211..1adf885de5 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -17,7 +17,8 @@ import { TooltipTrigger, } from '@object-ui/components'; import type { HighlightField } from '@object-ui/types'; -import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; +import { EXPANDABLE_FIELD_TYPES, resolveFieldAffordance } from '@object-ui/core'; +import { usePermissions } from '@object-ui/permissions'; import { getCellRenderer, resolveCellRendererType, useBooleanValueLabel } from '@object-ui/fields'; import { useSafeFieldLabel, useInlineEdit } from '@object-ui/react'; import { Check, X, Pencil } from 'lucide-react'; @@ -63,6 +64,9 @@ export const HeaderHighlight: React.FC = ({ const inline = useInlineEdit(); const editing = inline?.editing ?? false; const canEdit = inline?.canEdit ?? false; + // The caller's permissions, for the field question the strip asks of each + // highlight — the same one the details body asks (objectui#12103). + const perms = usePermissions(); // The STAGED record: the saved record this strip receives, overlaid with the // edit session's draft by the same spread `DetailView` gives the details body @@ -155,7 +159,16 @@ export const HeaderHighlight: React.FC = ({ field.type, objectDefField?.type, ); - const fieldEditable = !isComputed && !isReadonly && !isSystem && !isInlineExcluded; + // The CALLER's write grant on this field (objectui#12103): the + // `recordEdit` row's field question, asked exactly as the details + // body asks it, since the strip and the body are one edit session + // with one Save. A field refused for this caller — `editable: false` + // in their permission set, or the owner without the transfer grant + // — exposes no editor here either. + const isWriteGranted = + !objectName || resolveFieldAffordance('recordEdit', perms, objectName, field.name); + const fieldEditable = + !isComputed && !isReadonly && !isSystem && !isInlineExcluded && isWriteGranted; const canInlineEditField = canEdit && fieldEditable; const editorActive = editing && canInlineEditField; diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 580aa75584..36bfe509aa 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -58,7 +58,7 @@ import { DataEmptyState, resolveIcon, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, resolveAffordance, type SchemaLike, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, type FilterTokenScope } from '@object-ui/core'; +import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, resolveAffordance, resolveFieldAffordance, type SchemaLike, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, type FilterTokenScope } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -4455,7 +4455,21 @@ export const ObjectGrid: React.FC = ({ // mark the column so the data-table never opens an editor (otherwise it // falls back to a plain text box for e.g. a Formula or File cell). Only // force `false`; leave editable unset otherwise so the grid-level flag wins. - if (next.editable !== false && !isFieldInlineEditable(fieldDef)) { + // + // [objectui#12103] …and neither is a field THIS CALLER may not write: the + // field question of the `listInlineEdit` row of the affordance-to-grant map, + // the same `write` question an edit form asks (`fieldWriteGate`). A field + // the permission set marks `editable: false`, or the owner field without + // the transfer grant, keeps a read cell while the rest of the row edits; + // before, its editor opened and the save answered 403. `inlineEditable` + // above stays the object-level verdict for the whole grid — this only + // narrows one column. With no `objectName`, or no provider mounted, nothing + // is asked and the column is untouched. + if ( + next.editable !== false && + (!isFieldInlineEditable(fieldDef) || + (!!objectName && !resolveFieldAffordance('listInlineEdit', perms, objectName, col.accessorKey))) + ) { next.editable = false; } return next; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c8b158ae6..fb1a4949ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1457,6 +1457,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/spec': + specifier: ^17.7.0 + version: 17.7.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@types/react': specifier: 19.2.18 From 6afe7612571c8be271775a19323e0ec47c61eba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:48:26 +0000 Subject: [PATCH 2/5] test(console): pin the field write grant on in-place and in-cell edit, and the owner field's transfer grant (objectui#12103) - The map's enumeration pin holds `recordEdit` and `listInlineEdit` as field rows, pins that they answer every field as an edit form's `fieldWriteGate` does, and pins the owner field's transfer grant on every update-question row (absent / false / allowTransfer / modifyAllRecords, the explicit field refusal, the untouched insert question, fail-open). - The write census names the details body and the highlights strip as readers of the `recordEdit` row behind the in-place edit's save. - Surface pins through the real MePermissionsProvider and the measured envelope: the details body and the highlights strip, and ObjectGrid's in-cell edit, each with an editable-field control. - The changeset. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .changeset/12103-field-write-grants.md | 21 ++ ...sionsProvider.ownerTransfer-12103.test.tsx | 93 +++++++++ ...tailSection.fieldWriteGrant-12103.test.tsx | 179 +++++++++++++++++ .../src/affordanceGrantMap-12082.test.tsx | 126 +++++++++++- .../inlineEditFieldWriteGrant-12103.test.tsx | 180 ++++++++++++++++++ 5 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 .changeset/12103-field-write-grants.md create mode 100644 packages/permissions/src/__tests__/MePermissionsProvider.ownerTransfer-12103.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/inlineEditFieldWriteGrant-12103.test.tsx diff --git a/.changeset/12103-field-write-grants.md b/.changeset/12103-field-write-grants.md new file mode 100644 index 0000000000..637db89db5 --- /dev/null +++ b/.changeset/12103-field-write-grants.md @@ -0,0 +1,21 @@ +--- +'@object-ui/core': minor +'@object-ui/permissions': minor +'@object-ui/plugin-detail': patch +'@object-ui/plugin-grid': patch +--- + +In-place edit on the record page and a list's in-cell edit honour the caller's field write grant, and the owner field honours the transfer grant (objectui#12103). + +**What a caller saw.** A caller whose permission set marks a field `{ readable: true, editable: false }` was offered an editor for it in the record page's details body, in its highlights strip and in a list's grid cells, and Save answered 403 ("Field write denied"). The owner field was offered the same way to a caller without the transfer grant, and Save answered 403 ("requires the transfer grant"). Edit forms already refused both fields through `fieldWriteGate`. + +**What changed.** + +- **The map (`@object-ui/core`).** The `recordEdit` and `listInlineEdit` rows of `AFFORDANCE_GRANTS` gain `field: 'write'`, so the record page's in-place edit and a list's in-cell edit each ask every field the same update question an edit form asks, through `resolveFieldAffordance`. `FieldAffordance` therefore includes both rows. +- **The details body and the highlights strip (`@object-ui/plugin-detail`).** `DetailSection` and `HeaderHighlight` ask that question of each field. A refused field shows no pencil, does not enter the edit session on double-click, and stays a read display in edit mode. The strip and the body share one session and one Save, so they answer alike. +- **The grid (`@object-ui/plugin-grid`).** `ObjectGrid` marks a refused field's column not editable, so its cell keeps the read display while the rest of the row edits. Whether the grid is editable at all is still the object-level `listInlineEdit` verdict. +- **The owner field (`@object-ui/permissions`).** The server refuses an update that writes the record's owner (`SystemFieldName.OWNER_ID` in `@objectstack/spec`) unless the caller holds `allowTransfer` or `modifyAllRecords`. `MePermissionsProvider`'s `checkField(object, owner, 'write')` now answers with that grant, read with the spec's `objectPermissionGrants`. The `write` question is the server's update rule for a field, so every surface that asks it (edit forms, in-place edit, in-cell edit) offers the owner field only to a caller who may transfer the record. `checkField(object, owner, 'create')` is unchanged, because on insert the server stamps an empty owner to the caller and accepts the caller's own id. `MePermissionsResponse` declares the `allowTransfer` bit the endpoint already serves, and `@object-ui/permissions` now depends on `@objectstack/spec` directly. + +**Unchanged.** With no permission provider mounted no field question is asked, so a standalone embed and the designer preview behave as before. The server still enforces every grant; this only stops the console from offering a write it would refuse. + +**Clause-②: yes (widening).** `AFFORDANCE_GRANTS.recordEdit` and `AFFORDANCE_GRANTS.listInlineEdit` gain a `field: 'write'` member, which widens the `FieldAffordance` type by those two rows. `MePermissionsResponse`'s object entry declares an optional `allowTransfer?: boolean`. `checkField`'s action union is unchanged; its `write` answer narrows for the owner field only. No export, prop or language-pack key is added or removed. diff --git a/packages/permissions/src/__tests__/MePermissionsProvider.ownerTransfer-12103.test.tsx b/packages/permissions/src/__tests__/MePermissionsProvider.ownerTransfer-12103.test.tsx new file mode 100644 index 0000000000..da7e6bde9e --- /dev/null +++ b/packages/permissions/src/__tests__/MePermissionsProvider.ownerTransfer-12103.test.tsx @@ -0,0 +1,93 @@ +/** + * 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. + */ + +/** + * objectui#12103 — `checkField(object, owner, 'write')` answers the server's + * whole update rule for the owner field, which includes the transfer grant. + * + * The server's security step refuses an update that writes the record's owner + * (`SystemFieldName.OWNER_ID`) unless the caller holds `allowTransfer` or + * `modifyAllRecords`; measured on a live backend, `/me/permissions` reports the + * grant per object and the refusal is a 403. So the `write` question for that + * one field needs the grant, read with the spec's own predicate, while `create` + * (the insert rule, which stamps and accepts the caller's own id), `read`, and + * every other field are untouched. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; +import { SystemFieldName } from '@objectstack/spec/system'; +import { MePermissionsProvider, type MePermissionsResponse } from '../MePermissionsProvider'; +import { usePermissions } from '../usePermissions'; +import type { PermissionContextValue } from '../PermissionContext'; + +afterEach(cleanup); + +const OBJECT = 'crm_account'; +const OWNER = SystemFieldName.OWNER_ID; +const EDITOR = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }; + +function envelope( + objects: MePermissionsResponse['objects'], + fields: MePermissionsResponse['fields'] = {}, + authenticated = true, +): MePermissionsResponse { + return { authenticated, userId: authenticated ? 'u1' : null, tenantId: null, roles: [], permissionSets: [], objects, fields }; +} + +function checkFieldUnder(perms: MePermissionsResponse): PermissionContextValue['checkField'] { + let captured: PermissionContextValue['checkField'] | null = null; + function Probe() { + captured = usePermissions().checkField; + return null; + } + render( + + + , + ); + if (!captured) throw new Error('the probe did not render'); + return captured; +} + +describe("checkField's write question for the owner field (objectui#12103)", () => { + it('is refused without the transfer grant — false and absent alike', () => { + expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: false } }))(OBJECT, OWNER, 'write')).toBe(false); + expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR } }))(OBJECT, OWNER, 'write')).toBe(false); + }); + + it('is granted by allowTransfer, and by modifyAllRecords, which implies it', () => { + expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: true } }))(OBJECT, OWNER, 'write')).toBe(true); + expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, modifyAllRecords: true } }))(OBJECT, OWNER, 'write')).toBe(true); + }); + + it('reads the wildcard entry for an object the answer does not name, as the object fallback does', () => { + expect(checkFieldUnder(envelope({ '*': { ...EDITOR, modifyAllRecords: true } }))(OBJECT, OWNER, 'write')).toBe(true); + expect(checkFieldUnder(envelope({ '*': { ...EDITOR, allowTransfer: false } }))(OBJECT, OWNER, 'write')).toBe(false); + }); + + it('keeps the unknown-object default: closed for a signed-in caller, open for an anonymous one', () => { + expect(checkFieldUnder(envelope({}))(OBJECT, OWNER, 'write')).toBe(false); + expect(checkFieldUnder(envelope({}, {}, false))(OBJECT, OWNER, 'write')).toBe(true); + }); + + it("an explicit editable: false entry still refuses it with the grant; the grant does not open the field's own refusal", () => { + const check = checkFieldUnder( + envelope({ [OBJECT]: { ...EDITOR, allowTransfer: true } }, { [`${OBJECT}.${OWNER}`]: { readable: true, editable: false } }), + ); + expect(check(OBJECT, OWNER, 'write')).toBe(false); + }); + + it('CONTROL: create, read and every other field are untouched by the transfer grant', () => { + const check = checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: false } })); + expect(check(OBJECT, OWNER, 'create')).toBe(true); + expect(check(OBJECT, OWNER, 'read')).toBe(true); + expect(check(OBJECT, 'name', 'write')).toBe(true); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx new file mode 100644 index 0000000000..d13531299f --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx @@ -0,0 +1,179 @@ +/** + * 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. + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import React from 'react'; +import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { SystemFieldName } from '@objectstack/spec/system'; +import type { DetailViewSection } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; +import { HeaderHighlight } from '../HeaderHighlight'; + +/** + * objectui#12103 — the record page's in-place edit asks the CALLER's field + * write grant. + * + * Measured on a live backend before this change: for a caller whose permission + * set reads `{ readable: true, editable: false }` for a field, and whose object + * grant carries `allowEdit: true` and `allowTransfer: false`, + * `/api/v1/auth/me/permissions` answers exactly that, and the server refuses a + * PATCH of the field ("Field write denied") and of the owner ("requires the + * transfer grant") with 403, while a PATCH of an ordinary field succeeds. The + * details body and the highlights strip still offered an editor for both. + * + * Both surfaces now ask the field question of the `recordEdit` row of the + * affordance-to-grant map — the same `write` question an edit form asks — so + * the envelope below is the measured one, not an invented shape. Assertions + * are behavioural (does double-click enter edit; does edit mode draw an input) + * and do not depend on the localized hint text. + */ + +const OBJECT = 'crm_account'; +const OWNER = SystemFieldName.OWNER_ID; + +/** The measured envelope: may edit the object, may not edit annual revenue, may not transfer. */ +function envelope(objectBits: Record = {}): MePermissionsResponse { + return { + authenticated: true, + userId: 'u-rep', + tenantId: null, + roles: [], + permissionSets: ['rep'], + objects: { + [OBJECT]: { + allowRead: true, + allowCreate: false, + allowEdit: true, + allowDelete: false, + allowTransfer: false, + ...objectBits, + }, + }, + fields: { [`${OBJECT}.annual_revenue`]: { readable: true, editable: false } }, + }; +} + +const objectSchema = { + fields: { + name: { type: 'text' }, + annual_revenue: { type: 'text' }, + // The owner as the platform injects it: an editable lookup to sys_user. + [OWNER]: { type: 'lookup', reference: 'sys_user' }, + }, +}; + +const data = { name: 'Acme', annual_revenue: '5000', [OWNER]: 'Alice' }; + +const section = { + fields: [ + { name: 'name', label: 'Name' }, + { name: 'annual_revenue', label: 'Annual revenue' }, + { name: OWNER, label: 'Owner' }, + ], +} as DetailViewSection; + +/** Render under the measured grant, or under no provider at all. */ +function withPerms(perms: MePermissionsResponse | null, node: React.ReactNode) { + return render(perms ? {node} : <>{node}); +} + +beforeAll(() => { + // useIsMobile() keys off window.innerWidth; pin a desktop width so the + // read-mode desktop row (which carries the affordance) renders. + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +afterEach(cleanup); + +describe('the details body asks the field write grant (objectui#12103)', () => { + function enteredOnDoubleClick(perms: MePermissionsResponse | null, text: string): string[] { + const onEnter = vi.fn(); + withPerms( + perms, + , + ); + fireEvent.doubleClick(screen.getByText(text)); + return onEnter.mock.calls.map((c) => c[0]); + } + + it('a field marked editable: false offers no in-place edit', () => { + expect(enteredOnDoubleClick(envelope(), '5000')).toEqual([]); + }); + + it('the owner field without the transfer grant offers no in-place edit', () => { + expect(enteredOnDoubleClick(envelope(), 'Alice')).toEqual([]); + }); + + it('the owner field WITH the transfer grant does', () => { + expect(enteredOnDoubleClick(envelope({ allowTransfer: true }), 'Alice')).toEqual([OWNER]); + }); + + it('CONTROL: an editable field is unchanged', () => { + expect(enteredOnDoubleClick(envelope(), 'Acme')).toEqual(['name']); + }); + + it('CONTROL: with no permission provider every field edits, as before', () => { + expect(enteredOnDoubleClick(null, '5000')).toEqual(['annual_revenue']); + cleanup(); + expect(enteredOnDoubleClick(null, 'Alice')).toEqual([OWNER]); + }); + + it('in edit mode the refused field stays a read display while the editable one is an input', () => { + withPerms( + envelope(), + , + ); + expect(screen.getByDisplayValue('Acme')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('5000')).toBeNull(); + expect(screen.getByText('5000')).toBeInTheDocument(); + }); +}); + +describe('the highlights strip asks the same question (objectui#12103)', () => { + /** Surfaces the shared session, so a test can read whether double-click entered it. */ + function SessionProbe() { + const inline = useInlineEdit(); + return
{inline?.editing ? `editing:${inline.autoFocusField ?? ''}` : 'idle'}
; + } + + function strip(perms: MePermissionsResponse | null) { + return withPerms( + perms, + + + + , + ); + } + + it('double-clicking a refused highlight does not enter the edit session', () => { + strip(envelope()); + fireEvent.doubleClick(screen.getByText('5000')); + expect(screen.getByTestId('session').textContent).toBe('idle'); + }); + + it('nor does the owner without the transfer grant; with it, it does', () => { + strip(envelope()); + fireEvent.doubleClick(screen.getByText('Alice')); + expect(screen.getByTestId('session').textContent).toBe('idle'); + cleanup(); + strip(envelope({ allowTransfer: true })); + fireEvent.doubleClick(screen.getByText('Alice')); + expect(screen.getByTestId('session').textContent).toBe(`editing:${OWNER}`); + }); + + it('CONTROL: an editable highlight enters the session, and only it becomes an input', () => { + strip(envelope()); + fireEvent.doubleClick(screen.getByText('Acme')); + expect(screen.getByTestId('session').textContent).toBe('editing:name'); + expect(screen.getByDisplayValue('Acme')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('5000')).toBeNull(); + }); +}); diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 494cfc8476..abef1e6403 100644 --- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -68,7 +68,8 @@ import { type FieldAffordance, } from '@object-ui/core'; import { MePermissionsProvider, usePermissions, type MePermissionsResponse } from '@object-ui/permissions'; -import { closedFormAffordance, gateFormFields } from './fieldWriteGate'; +import { SystemFieldName } from '@objectstack/spec/system'; +import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; import ts from 'typescript'; // The ONE answer to "is this span a comment, or code?" — a census that strips // comments with a private regex reports clean over code a phantom comment ate. @@ -118,6 +119,10 @@ const EXPECTED_FIELD_GRANT: Record = { createFormFields: 'create', editFormFields: 'update', listImport: 'create', + // objectui#12103: the record page's in-place edit and a list's in-cell edit + // offer fields to write, so each asks the update question of every field. + recordEdit: 'update', + listInlineEdit: 'update', }; const OBJECT = 'pin_object'; @@ -274,6 +279,117 @@ describe('fail-open: no permission provider mounted (objectui#12082)', () => { }); }); +// --------------------------------------------------------------------------- +// objectui#12103: the record page's in-place edit (`recordEdit`) and a list's +// in-cell edit (`listInlineEdit`) ask each field the SAME question an edit form +// asks, and the owner field's update needs the transfer grant on all of them. +// Measured first on a live backend: `/me/permissions` reported +// `editable: false` for the field and `allowTransfer: false` for the object, +// and the server refused both writes with 403, so the client follows an answer +// the server already gives. +// --------------------------------------------------------------------------- + +/** The owner field, as the spec names it and the server's transfer guard keys it. */ +const OWNER = SystemFieldName.OWNER_ID; + +/** The field rows that ask the update question, and those that ask the insert one. */ +const WRITE_FIELD_ROWS = FIELD_ROWS.filter((r) => AFFORDANCE_GRANTS[r].field === 'write'); +const CREATE_FIELD_ROWS = FIELD_ROWS.filter((r) => AFFORDANCE_GRANTS[r].field === 'create'); + +/** A caller who may edit the object but holds no transfer grant unless one is added. */ +const EDITOR = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }; + +describe("in-place edit and in-cell edit ask an edit form's field question (objectui#12103)", () => { + it('the rows that ask the update question are the edit form, the in-place edit and the in-cell edit', () => { + expect([...WRITE_FIELD_ROWS].sort()).toEqual(['editFormFields', 'listInlineEdit', 'recordEdit']); + }); + + it.each(SHAPES)("under a $shape grant each answers every field exactly as an edit form's gate does", ({ bits }) => { + const perms = principalUnder( + envelope({ ...bits, allowTransfer: false }, { + [`${OBJECT}.annual_revenue`]: { readable: true, editable: false }, + [`${OBJECT}.name`]: { readable: true, editable: true }, + }), + ); + // `fieldWriteGate` is what every ObjectForm layout hands its outbound + // strip: the predicate the card names as the one to follow. + const gate = fieldWriteGate(perms, OBJECT, 'edit'); + expect(gate).toBeDefined(); + for (const field of ['annual_revenue', 'name', 'any_field', OWNER]) { + for (const row of ['recordEdit', 'listInlineEdit'] as const) { + expect(resolveFieldAffordance(row, perms, OBJECT, field), `${row} ${field}`).toBe(gate!(field)); + } + } + }); + + it('a field marked editable: false is refused by both; CONTROL: an editable field is open on both', () => { + const perms = principalUnder( + envelope(EDITOR, { + [`${OBJECT}.annual_revenue`]: { readable: true, editable: false }, + [`${OBJECT}.name`]: { readable: true, editable: true }, + }), + ); + for (const row of ['recordEdit', 'listInlineEdit'] as const) { + expect(resolveFieldAffordance(row, perms, OBJECT, 'annual_revenue'), row).toBe(false); + expect(resolveFieldAffordance(row, perms, OBJECT, 'name'), row).toBe(true); + expect(resolveFieldAffordance(row, perms, OBJECT, 'any_field'), row).toBe(true); + } + }); +}); + +describe('the owner field: an update that writes it needs the transfer grant (objectui#12103)', () => { + it('without the transfer grant every update-question row offers the owner read-only — absent and false alike', () => { + for (const transfer of [{ allowTransfer: false }, {}]) { + const perms = principalUnder(envelope({ ...EDITOR, ...transfer })); + for (const row of WRITE_FIELD_ROWS) { + expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), `${row} ${JSON.stringify(transfer)}`).toBe(false); + } + } + }); + + it('allowTransfer, or modifyAllRecords which implies it, opens the owner on every update-question row', () => { + for (const grant of [{ allowTransfer: true }, { modifyAllRecords: true }]) { + const perms = principalUnder(envelope({ ...EDITOR, ...grant })); + for (const row of WRITE_FIELD_ROWS) { + expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), `${row} ${JSON.stringify(grant)}`).toBe(true); + } + } + }); + + it('CONTROL: the transfer grant moves no other field', () => { + for (const transfer of [false, true]) { + const perms = principalUnder(envelope({ ...EDITOR, allowTransfer: transfer })); + for (const row of WRITE_FIELD_ROWS) { + expect(resolveFieldAffordance(row, perms, OBJECT, 'name'), `${row} transfer=${transfer}`).toBe(true); + } + } + }); + + it('an explicit editable: false entry on the owner refuses it even with the transfer grant', () => { + const perms = principalUnder( + envelope({ ...EDITOR, allowTransfer: true }, { [`${OBJECT}.${OWNER}`]: { readable: true, editable: false } }), + ); + for (const row of WRITE_FIELD_ROWS) { + expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), row).toBe(false); + } + }); + + it('the insert question is not narrowed: on insert the server stamps the caller as owner and accepts their own id', () => { + const perms = principalUnder(envelope({ ...EDITOR, allowTransfer: false })); + expect(CREATE_FIELD_ROWS.length).toBeGreaterThan(0); + for (const row of CREATE_FIELD_ROWS) { + expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), row).toBe(true); + } + }); + + it('with no permission provider the owner reads open, as every field question does', () => { + const perms = principalUnder(null); + for (const row of WRITE_FIELD_ROWS) { + expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), row).toBe(true); + } + }); +}); + // --------------------------------------------------------------------------- // The census: no console source reads a CRUD grant outside the map. // @@ -480,6 +596,8 @@ const DETAIL_VIEW = 'packages/plugin-detail/src/DetailView.tsx'; const OBJECT_GRID = 'packages/plugin-grid/src/ObjectGrid.tsx'; const RECORD_PAGE = 'packages/app-shell/src/views/RecordDetailView.tsx'; const RECORD_DETAILS = 'packages/plugin-detail/src/renderers/record-details.tsx'; +const DETAIL_SECTION = 'packages/plugin-detail/src/DetailSection.tsx'; +const HEADER_HIGHLIGHT = 'packages/plugin-detail/src/HeaderHighlight.tsx'; const CONSOLE_LIST = 'packages/app-shell/src/views/ObjectView.tsx'; const LIST_VIEW = 'packages/plugin-list/src/ListView.tsx'; @@ -583,8 +701,10 @@ const WRITE_SITES: Record = { 'packages/plugin-designer/src/pages/DashboardDesignPage.tsx :: DashboardDesignPage > saveSchema :: update': unmapped("the dashboard designer's Save updates a sys_dashboard row and reads no grant on sys_dashboard"), // ── plugin-detail ── - 'packages/plugin-detail/src/InlineEditSaveBar.tsx :: updateVia :: update': - mapped(['recordEdit'], RECORD_PAGE, RECORD_DETAILS), + 'packages/plugin-detail/src/InlineEditSaveBar.tsx :: updateVia :: update': { + ...mapped(['recordEdit'], RECORD_PAGE, RECORD_DETAILS, DETAIL_SECTION, HEADER_HIGHLIGHT), + note: 'the page and the record body read the object verdict; the details body and the highlights strip ask each field the row\'s field question (objectui#12103)', + } as WriteSite, 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: create': unmapped("a related list's Add existing (picker) creates a junction row and reads no grant on it"), 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: update': diff --git a/packages/plugin-grid/src/__tests__/inlineEditFieldWriteGrant-12103.test.tsx b/packages/plugin-grid/src/__tests__/inlineEditFieldWriteGrant-12103.test.tsx new file mode 100644 index 0000000000..31304e389f --- /dev/null +++ b/packages/plugin-grid/src/__tests__/inlineEditFieldWriteGrant-12103.test.tsx @@ -0,0 +1,180 @@ +/** + * 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. + */ + +/** + * objectui#12103 — a list's in-cell edit asks the CALLER's field write grant. + * + * `inlineEditPermissionGate.test.tsx` owns the OBJECT-level half (the grid is + * editable at all only when the caller holds `update`), and stubs the field + * question out of the way. This file owns the FIELD-level half, through the + * REAL `MePermissionsProvider` and the envelope measured on a live backend: + * `allowEdit: true`, `allowTransfer: false`, and + * `{ readable: true, editable: false }` for one field. The server refuses a + * PATCH of that field and of the owner with 403; before this change both cells + * opened an editor anyway. + * + * The observable is the user's: does clicking the cell open an editor, and + * does the cell carry the editable cursor `data-table` draws only for a column + * it will open. + */ + +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { SystemFieldName } from '@objectstack/spec/system'; +import { ObjectGrid } from '../ObjectGrid'; +import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts'; +import { installExplainDouble } from './explainDouble'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; + +registerAllFields(); + +const OBJECT = 'crm_account'; +const OWNER = SystemFieldName.OWNER_ID; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + __clearRecordCrudVerdictCache(); + installExplainDouble(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** The measured envelope: may edit the object, may not edit annual revenue, may not transfer. */ +function envelope(objectBits: Record = {}): MePermissionsResponse { + return { + authenticated: true, + userId: 'u-rep', + tenantId: null, + roles: [], + permissionSets: ['rep'], + objects: { + [OBJECT]: { + allowRead: true, + allowCreate: false, + allowEdit: true, + allowDelete: false, + allowTransfer: false, + ...objectBits, + }, + }, + fields: { [`${OBJECT}.annual_revenue`]: { readable: true, editable: false } }, + }; +} + +function makeDataSource() { + const store: Record = { + r1: { id: 'r1', name: 'Acme', annual_revenue: '5000', [OWNER]: 'Alice' }, + }; + return { + find: vi.fn(async () => { + const data = Object.values(store).map((r) => ({ ...r })); + return { data, total: data.length, hasMore: false, pageSize: 50 }; + }), + findOne: vi.fn(async (_o: string, id: string) => ({ ...store[id] })), + update: vi.fn(async (_o: string, id: string, changes: Record) => ({ ...store[id], ...changes })), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + annual_revenue: { type: 'text', label: 'Annual revenue' }, + // Typed `text` here so the cell's editor is an the test can + // see; the platform's owner is a lookup, and the gate keys on the + // field, not on its type. + [OWNER]: { type: 'text', label: 'Owner' }, + }, + }), + } as any; +} + +function renderGrid(perms: MePermissionsResponse | null) { + const ds = makeDataSource(); + const schema: any = { + type: 'object-grid', + objectName: OBJECT, + editable: true, + singleClickEdit: true, + columns: [ + { field: 'name', label: 'Name', type: 'text' }, + { field: 'annual_revenue', label: 'Annual revenue', type: 'text' }, + { field: OWNER, label: 'Owner', type: 'text' }, + ], + pagination: { pageSize: 50 }, + }; + const grid = ( + + + {}} onDelete={() => {}} /> + + + ); + return render(perms ? {grid} : grid); +} + +/** The cell showing `text` in the first body row. */ +async function cell(text: string): Promise { + await waitFor(() => expect(screen.getByText(text)).toBeInTheDocument()); + const row = document.querySelector('tbody tr') as HTMLElement; + const td = Array.from(row.querySelectorAll('td')).find((c) => c.textContent?.trim() === text); + if (!td) throw new Error(`no cell reads ${text}`); + return td as HTMLElement; +} + +/** Click the cell and report whether an in-cell editor opened. */ +async function opensEditor(text: string): Promise { + const td = await cell(text); + // `data-table` draws the editable cursor only on a column it will open. + const offered = td.className.includes('cursor-text'); + fireEvent.click(td); + await new Promise((r) => setTimeout(r, 50)); + const opened = td.querySelector('input') != null; + // The two observables agree, or the probe cannot tell the shapes apart. + expect(opened, `${text}: cursor and editor disagree`).toBe(offered); + return opened; +} + +describe("a list's in-cell edit asks the field write grant (objectui#12103)", () => { + it('a field marked editable: false keeps a read cell', async () => { + renderGrid(envelope()); + expect(await opensEditor('5000')).toBe(false); + }); + + it('the owner field without the transfer grant keeps a read cell', async () => { + renderGrid(envelope()); + expect(await opensEditor('Alice')).toBe(false); + }); + + it('the owner field WITH the transfer grant opens its editor', async () => { + renderGrid(envelope({ allowTransfer: true })); + expect(await opensEditor('Alice')).toBe(true); + }); + + it('CONTROL: an editable field in the same row is unchanged', async () => { + renderGrid(envelope()); + expect(await opensEditor('Acme')).toBe(true); + }); + + it('CONTROL: with no permission provider every column edits, as before', async () => { + renderGrid(null); + expect(await opensEditor('5000')).toBe(true); + cleanup(); + renderGrid(null); + expect(await opensEditor('Alice')).toBe(true); + }); +}); From bccbfc5665e91986001c4d3e8ea7809b426b6aa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:52:25 +0000 Subject: [PATCH 3/5] test(plugin-detail): split the highlights strip's control from its edit-mode pin (objectui#12103) Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .../DetailSection.fieldWriteGrant-12103.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx index d13531299f..1d03940c97 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.fieldWriteGrant-12103.test.tsx @@ -169,11 +169,17 @@ describe('the highlights strip asks the same question (objectui#12103)', () => { expect(screen.getByTestId('session').textContent).toBe(`editing:${OWNER}`); }); - it('CONTROL: an editable highlight enters the session, and only it becomes an input', () => { + it('CONTROL: an editable highlight enters the session and becomes an input', () => { strip(envelope()); fireEvent.doubleClick(screen.getByText('Acme')); expect(screen.getByTestId('session').textContent).toBe('editing:name'); expect(screen.getByDisplayValue('Acme')).toBeInTheDocument(); + }); + + it('once the session is open, the refused highlight stays a read display', () => { + strip(envelope()); + fireEvent.doubleClick(screen.getByText('Acme')); expect(screen.queryByDisplayValue('5000')).toBeNull(); + expect(screen.getByText('5000')).toBeInTheDocument(); }); }); From 2ff9d4387bddbed9c67cfd0b9f12c1cbff372133 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:23:29 +0000 Subject: [PATCH 4/5] docs(core,permissions): name the owner field as the spec's SystemFieldName constant spells it (objectui#12103) check:spec-symbols reads a dotted citation of SystemFieldName beside a spec mention as a key the spec does not declare, because the spec exports SystemFieldName as both a const and a value-union type and the gate counts only the type's members. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .changeset/12103-field-write-grants.md | 2 +- packages/core/src/utils/affordanceGrants.ts | 4 ++-- packages/permissions/README.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/12103-field-write-grants.md b/.changeset/12103-field-write-grants.md index 637db89db5..ba504f6a66 100644 --- a/.changeset/12103-field-write-grants.md +++ b/.changeset/12103-field-write-grants.md @@ -14,7 +14,7 @@ In-place edit on the record page and a list's in-cell edit honour the caller's f - **The map (`@object-ui/core`).** The `recordEdit` and `listInlineEdit` rows of `AFFORDANCE_GRANTS` gain `field: 'write'`, so the record page's in-place edit and a list's in-cell edit each ask every field the same update question an edit form asks, through `resolveFieldAffordance`. `FieldAffordance` therefore includes both rows. - **The details body and the highlights strip (`@object-ui/plugin-detail`).** `DetailSection` and `HeaderHighlight` ask that question of each field. A refused field shows no pencil, does not enter the edit session on double-click, and stays a read display in edit mode. The strip and the body share one session and one Save, so they answer alike. - **The grid (`@object-ui/plugin-grid`).** `ObjectGrid` marks a refused field's column not editable, so its cell keeps the read display while the rest of the row edits. Whether the grid is editable at all is still the object-level `listInlineEdit` verdict. -- **The owner field (`@object-ui/permissions`).** The server refuses an update that writes the record's owner (`SystemFieldName.OWNER_ID` in `@objectstack/spec`) unless the caller holds `allowTransfer` or `modifyAllRecords`. `MePermissionsProvider`'s `checkField(object, owner, 'write')` now answers with that grant, read with the spec's `objectPermissionGrants`. The `write` question is the server's update rule for a field, so every surface that asks it (edit forms, in-place edit, in-cell edit) offers the owner field only to a caller who may transfer the record. `checkField(object, owner, 'create')` is unchanged, because on insert the server stamps an empty owner to the caller and accepts the caller's own id. `MePermissionsResponse` declares the `allowTransfer` bit the endpoint already serves, and `@object-ui/permissions` now depends on `@objectstack/spec` directly. +- **The owner field (`@object-ui/permissions`).** The server refuses an update that writes the record's owner (the field the `@objectstack/spec` constant `SystemFieldName` names `OWNER_ID`) unless the caller holds `allowTransfer` or `modifyAllRecords`. `MePermissionsProvider`'s `checkField(object, owner, 'write')` now answers with that grant, read with the spec's `objectPermissionGrants`. The `write` question is the server's update rule for a field, so every surface that asks it (edit forms, in-place edit, in-cell edit) offers the owner field only to a caller who may transfer the record. `checkField(object, owner, 'create')` is unchanged, because on insert the server stamps an empty owner to the caller and accepts the caller's own id. `MePermissionsResponse` declares the `allowTransfer` bit the endpoint already serves, and `@object-ui/permissions` now depends on `@objectstack/spec` directly. **Unchanged.** With no permission provider mounted no field question is asked, so a standalone embed and the designer preview behave as before. The server still enforces every grant; this only stops the console from offering a write it would refuse. diff --git a/packages/core/src/utils/affordanceGrants.ts b/packages/core/src/utils/affordanceGrants.ts index 62a4c17e73..2b93624b18 100644 --- a/packages/core/src/utils/affordanceGrants.ts +++ b/packages/core/src/utils/affordanceGrants.ts @@ -49,8 +49,8 @@ * * The `write` question is the server's whole update rule for the field, so it * also carries the one field whose update needs an OBJECT grant beyond - * `allowEdit`: the record's owner (`SystemFieldName.OWNER_ID` in - * `@objectstack/spec`). The server refuses an update that writes it without + * `allowEdit`: the record's owner, the field the spec's `SystemFieldName` + * constant names `OWNER_ID`. The server refuses an update that writes it without * the transfer grant (`allowTransfer`, or `modifyAllRecords`), so the resolver * answers `write` for it with that grant too (objectui#12103). Every row that * asks `write` therefore offers the owner field only to a caller who may diff --git a/packages/permissions/README.md b/packages/permissions/README.md index 56d1b7cf1e..fa5956420f 100644 --- a/packages/permissions/README.md +++ b/packages/permissions/README.md @@ -148,8 +148,8 @@ such a field through and object admission decides, so `'write'` falls back to the object's `allowEdit` and `'create'` to its `allowCreate`. An explicit field-level entry answers both the same way. -One field's update needs more than its own grant: the record's owner -(`SystemFieldName.OWNER_ID` in `@objectstack/spec`). The server refuses an +One field's update needs more than its own grant: the record's owner, the +field the spec's `SystemFieldName` constant names `OWNER_ID`. The server refuses an update that writes it unless the caller holds the transfer grant (`allowTransfer`, or `modifyAllRecords`), so `MePermissionsProvider` answers `'write'` for the owner field with that grant as well (objectui#12103). From 47a8eee5ec230e036a83adfabf43d4b9b4ed9d14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:43:13 +0000 Subject: [PATCH 5/5] test(plugin-form): type the owner pin's grant shapes for the test tsconfig (objectui#12103) Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- packages/plugin-form/src/affordanceGrantMap-12082.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index abef1e6403..8b993d7698 100644 --- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -339,7 +339,8 @@ describe("in-place edit and in-cell edit ask an edit form's field question (obje describe('the owner field: an update that writes it needs the transfer grant (objectui#12103)', () => { it('without the transfer grant every update-question row offers the owner read-only — absent and false alike', () => { - for (const transfer of [{ allowTransfer: false }, {}]) { + const transfers: Array> = [{ allowTransfer: false }, {}]; + for (const transfer of transfers) { const perms = principalUnder(envelope({ ...EDITOR, ...transfer })); for (const row of WRITE_FIELD_ROWS) { expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), `${row} ${JSON.stringify(transfer)}`).toBe(false); @@ -348,7 +349,8 @@ describe('the owner field: an update that writes it needs the transfer grant (ob }); it('allowTransfer, or modifyAllRecords which implies it, opens the owner on every update-question row', () => { - for (const grant of [{ allowTransfer: true }, { modifyAllRecords: true }]) { + const grants: Array> = [{ allowTransfer: true }, { modifyAllRecords: true }]; + for (const grant of grants) { const perms = principalUnder(envelope({ ...EDITOR, ...grant })); for (const row of WRITE_FIELD_ROWS) { expect(resolveFieldAffordance(row, perms, OBJECT, OWNER), `${row} ${JSON.stringify(grant)}`).toBe(true);