diff --git a/.changeset/list-row-crud-effective-ops.md b/.changeset/list-row-crud-effective-ops.md new file mode 100644 index 000000000..3daafa94e --- /dev/null +++ b/.changeset/list-row-crud-effective-ops.md @@ -0,0 +1,48 @@ +--- +"@object-ui/plugin-grid": minor +"@object-ui/plugin-list": minor +--- + +feat: gate list row Edit/Delete and bulk delete on the server's effective operation set (#3720) + +The **fourth** surface #3391 left open. The three earlier rounds — the toolbar +(objectui#2823), detail/form (#3546, objectui#2832 + #2876) and related lists +(#3546) — all route through `resolveCrudAffordances`. The main list's **row +CRUD** does not: it has its own resolver (`plugin-grid`'s +`resolveRowCrudAffordances`), so none of those rounds ever reached it. + +Its gate was `operations ?? { update: !!onEdit, delete: !!onDelete }` — and +`ObjectView` wires `onEdit`/`onDelete` unconditionally while view JSON rarely +declares `operations`, so it was effectively always-on. A caller whose effective +set carried neither `update` nor `delete` still got the row kebab's Edit/Delete +**and** the bulk delete, the most destructive affordance on the list. + +- **plugin-grid** `resolveRowCrudAffordances` now takes `managedBy` and + `effectiveApiOperations` and resolves the object verdict through the shared + `resolveCrudAffordances` policy — so the row gate is the SAME decision the + toolbar, record header, form and related lists make. It also returns + `objectCanDelete`, the object-level delete verdict that bulk delete gates on + (bulk rides `onBulkDelete`, a different callback from the row `onDelete`). +- **plugin-grid** `ObjectGrid` threads its existing `effectiveApiOps` — until + now fed only to Export — into the row gate, and applies the delete verdict to + bulk delete: the implicit `['delete']`, an author-declared + `bulkActions: ['delete']`, and any `bulkActionDefs` entry with + `operation: 'delete'`. A declared bulk action is a *wiring* declaration, not a + permission grant. Custom action ids and non-delete operations pass through + untouched. +- **plugin-list** `ListView`'s own bulk bar (the non-grid views — kanban / + calendar / gallery; the grid path delegates to `ObjectGrid`) drops its + built-in `delete` under the same verdict. + +Also closes the ADR-0103 gap on this chain: `rowCrudAffordances` documented the +bucket lock as "applied upstream via the view's `operations.*`", but the +all-open default meant it never was — an engine-owned `system` / `append-only` / +`better-auth` object leaked a generic row Edit/Delete that the engine rejects +(`assertEngineOwnedWriteAllowed`). Running the shared policy applies it, and a +`userActions` opt-in still re-opens it (e.g. `sys_user`'s `edit`). + +Same semantics as the earlier rounds: **intersection, never union** — a server +grant cannot re-open what the bucket or `userActions` closed, and a +`userActions` opt-in cannot survive a server denial. A missing effective set +(unrestricted object, older backend, or no `PermissionProvider`) preserves the +current behavior. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 5bdbcfaf4..ce7b9ccaf 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1444,19 +1444,25 @@ export const ObjectGrid: React.FC = ({ const wantEditAction = rowActionsList.includes('edit'); const wantDeleteAction = rowActionsList.includes('delete'); const customRowActions = rowActionsList.filter(a => a !== 'edit' && a !== 'delete'); - // Honor the object's CRUD affordance flags: when `userActions.edit`/`delete` - // is explicitly false the object opted out of the generic row Edit/Delete - // (e.g. sys_environment ships a dedicated Rename + cascade-Delete instead). - // This stops a generic "Delete" from duplicating the object's own Delete - // action, and a generic "Edit" the object turned off from leaking back in. - const { canEdit, canDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({ + // Honor the object's resolved CRUD affordance: the ADR-0103 lifecycle bucket + // (`managedBy`), the `userActions.edit`/`delete` override — explicit `false` + // opts out of the generic row Edit/Delete (e.g. sys_environment ships a + // dedicated Rename + cascade-Delete instead, and the generic entries would + // duplicate them) — and [#3720] the server's effective API operation set, so + // the row kebab never offers an update/delete the server would reject. + // `operations` above only says whether the CONSUMER wired the affordance; it + // is not a permission grant, which is why the object verdict is ANDed here + // rather than assumed to have been applied upstream. + const { canEdit, canDelete, objectCanDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({ operationsUpdate: operations?.update, operationsDelete: operations?.delete, wantEditAction, wantDeleteAction, hasOnEdit: !!onEdit, hasOnDelete: !!onDelete, + managedBy: (objectSchema as any)?.managedBy, userActions: (objectSchema as any)?.userActions, + effectiveApiOperations: effectiveApiOps, }); const hasActions = !!(operations && (operations.update || operations.delete)); const hasRowActions = customRowActions.length > 0 || rowActionDefsList.length > 0 || wantEditAction || wantDeleteAction; @@ -1578,10 +1584,22 @@ export const ObjectGrid: React.FC = ({ // a bulk-delete affordance is implicitly available (canDelete + onBulkDelete // wired by the consumer). This gives every list a multi-select + delete UX // out of the box without forcing each view JSON to declare bulkActions. - const explicitBulkActions = schema.batchActions ?? schema.bulkActions; - const bulkActionDefs: BulkActionDef[] = Array.isArray(schema.bulkActionDefs) + // [#3720] Bulk delete is the most destructive affordance on the list, so it + // rides the same object-level `delete` verdict as the row kebab (bucket lock + // ∧ userActions ∧ the server's effective operation set). An author-declared + // `bulkActions: ['delete']` / `bulkActionDefs[].operation === 'delete'` is a + // WIRING declaration, not a permission grant — so the built-in delete is + // filtered out of both when the verdict is off. Custom action ids and + // non-delete operations are untouched: they route through the action runner + // and carry their own gates. + const declaredBulkActions = schema.batchActions ?? schema.bulkActions; + const explicitBulkActions = objectCanDelete + ? declaredBulkActions + : declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete'); + const bulkActionDefs: BulkActionDef[] = (Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs - : []; + : [] + ).filter((def: BulkActionDef) => objectCanDelete || def?.operation !== 'delete'); const effectiveBulkActions: string[] = explicitBulkActions && explicitBulkActions.length > 0 ? explicitBulkActions diff --git a/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts b/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts index a4f3dd584..ee7fa0777 100644 --- a/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts +++ b/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts @@ -2,39 +2,45 @@ import { describe, it, expect } from 'vitest'; import { resolveRowCrudAffordances } from '../rowCrudAffordances'; +/** The row-level verdict only — drops the object-level bulk-delete bit. */ +const rowGate = (opts: Parameters[0]) => { + const { canEdit, canDelete } = resolveRowCrudAffordances(opts); + return { canEdit, canDelete }; +}; + describe('resolveRowCrudAffordances', () => { const wired = { operationsUpdate: true, operationsDelete: true, hasOnEdit: true, hasOnDelete: true }; it('surfaces generic edit/delete by default (userActions undefined)', () => { - expect(resolveRowCrudAffordances({ ...wired })).toEqual({ canEdit: true, canDelete: true }); + expect(rowGate({ ...wired })).toEqual({ canEdit: true, canDelete: true }); }); it('keeps both when userActions explicitly true', () => { - expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: true, delete: true } })) + expect(rowGate({ ...wired, userActions: { edit: true, delete: true } })) .toEqual({ canEdit: true, canDelete: true }); }); it('suppresses generic edit/delete when the object opts out (userActions false)', () => { // sys_environment case: edit:false + delete:false → no generic kebab entries, // leaving only the object's dedicated Rename / Delete actions (no duplicate). - expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: false, delete: false } })) + expect(rowGate({ ...wired, userActions: { edit: false, delete: false } })) .toEqual({ canEdit: false, canDelete: false }); }); it('gates edit and delete independently', () => { - expect(resolveRowCrudAffordances({ ...wired, userActions: { edit: false } })) + expect(rowGate({ ...wired, userActions: { edit: false } })) .toEqual({ canEdit: false, canDelete: true }); - expect(resolveRowCrudAffordances({ ...wired, userActions: { delete: false } })) + expect(rowGate({ ...wired, userActions: { delete: false } })) .toEqual({ canEdit: true, canDelete: false }); }); it('still requires the callback + operation to be wired (opt-out is not opt-in)', () => { - expect(resolveRowCrudAffordances({ hasOnEdit: false, hasOnDelete: false, operationsUpdate: true, operationsDelete: true })) + expect(rowGate({ hasOnEdit: false, hasOnDelete: false, operationsUpdate: true, operationsDelete: true })) .toEqual({ canEdit: false, canDelete: false }); }); it('honors explicit rowActions (edit/delete strings) when callbacks exist', () => { - expect(resolveRowCrudAffordances({ wantEditAction: true, wantDeleteAction: true, hasOnEdit: true, hasOnDelete: true })) + expect(rowGate({ wantEditAction: true, wantDeleteAction: true, hasOnEdit: true, hasOnDelete: true })) .toEqual({ canEdit: true, canDelete: true }); }); @@ -65,7 +71,8 @@ describe('resolveRowCrudAffordances', () => { it('object form without predicates adds nothing (boolean-equivalent)', () => { const res = resolveRowCrudAffordances({ ...wired, userActions: { edit: { enabled: true }, delete: {} } }); - expect(res).toEqual({ canEdit: true, canDelete: true }); + expect(rowGate({ ...wired, userActions: { edit: { enabled: true }, delete: {} } })) + .toEqual({ canEdit: true, canDelete: true }); expect(res.editPredicates).toBeUndefined(); expect(res.deletePredicates).toBeUndefined(); }); @@ -79,5 +86,116 @@ describe('resolveRowCrudAffordances', () => { expect(res.canEdit).toBe(false); expect(res.editPredicates).toBeUndefined(); }); + + it('a server denial drops the predicates along with the affordance', () => { + const res = resolveRowCrudAffordances({ + ...wired, + userActions: { edit: { disabledWhen: 'record.frozen == true' } }, + effectiveApiOperations: ['get', 'list'], + }); + expect(res.canEdit).toBe(false); + expect(res.editPredicates).toBeUndefined(); + }); + }); + + // [#3720] The fourth face of #3391: the row kebab intersects with the + // server-resolved effective operation set (`/me/permissions` apiOperations), + // matching the toolbar (objectui#2823), detail/form (#3546) and related-list + // gates. Intersection, never union. + describe('#3720 effective API operation set', () => { + const FULL = ['get', 'list', 'create', 'update', 'delete']; + + it('keeps both entries when the effective set carries update + delete', () => { + expect(rowGate({ ...wired, effectiveApiOperations: FULL })) + .toEqual({ canEdit: true, canDelete: true }); + }); + + it('hides both when the effective set is read-only', () => { + expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list'] })) + .toEqual({ canEdit: false, canDelete: false }); + }); + + it('gates the two operations independently', () => { + expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list', 'update'] })) + .toEqual({ canEdit: true, canDelete: false }); + expect(rowGate({ ...wired, effectiveApiOperations: ['get', 'list', 'delete'] })) + .toEqual({ canEdit: false, canDelete: true }); + }); + + it('an empty effective set exposes nothing', () => { + expect(rowGate({ ...wired, effectiveApiOperations: [] })) + .toEqual({ canEdit: false, canDelete: false }); + }); + + it('a missing effective set preserves the pre-#3720 behavior', () => { + expect(rowGate({ ...wired, effectiveApiOperations: undefined })) + .toEqual({ canEdit: true, canDelete: true }); + expect(rowGate({ ...wired, effectiveApiOperations: null })) + .toEqual({ canEdit: true, canDelete: true }); + }); + + it('intersects, never unions — a server grant cannot re-open a userActions opt-out', () => { + expect(rowGate({ ...wired, userActions: { edit: false, delete: false }, effectiveApiOperations: FULL })) + .toEqual({ canEdit: false, canDelete: false }); + }); + + it('intersects, never unions — a userActions opt-in cannot survive a server denial', () => { + expect(rowGate({ ...wired, userActions: { edit: true, delete: true }, effectiveApiOperations: ['get', 'list'] })) + .toEqual({ canEdit: false, canDelete: false }); + }); + }); + + // [#3720] The same chain never applied the ADR-0103 bucket lock either: the + // module used to assume it arrived upstream via the view's `operations.*`, + // but `operations` defaults to "whatever the consumer wired" and every main + // list wires onEdit/onDelete unconditionally. Now it runs the shared policy. + describe('#3720 ADR-0103 lifecycle bucket', () => { + it('an absent managedBy resolves to the platform bucket (both entries on)', () => { + expect(rowGate({ ...wired, managedBy: undefined })) + .toEqual({ canEdit: true, canDelete: true }); + }); + + it('config objects keep generic edit/delete', () => { + expect(rowGate({ ...wired, managedBy: 'config' })) + .toEqual({ canEdit: true, canDelete: true }); + }); + + it.each(['system', 'engine-owned', 'append-only', 'better-auth'])( + 'engine-owned bucket %s hides generic edit/delete', + (managedBy) => { + expect(rowGate({ ...wired, managedBy })).toEqual({ canEdit: false, canDelete: false }); + }, + ); + + it('a userActions opt-in re-opens a bucket-locked object (sys_user edit)', () => { + expect(rowGate({ ...wired, managedBy: 'better-auth', userActions: { edit: true } })) + .toEqual({ canEdit: true, canDelete: false }); + }); + + it('the server effective set still clamps a bucket opt-in', () => { + expect(rowGate({ + ...wired, + managedBy: 'better-auth', + userActions: { edit: true }, + effectiveApiOperations: ['get', 'list'], + })).toEqual({ canEdit: false, canDelete: false }); + }); + }); + + // [#3720] Bulk delete gates on the OBJECT verdict, not the row one: it rides + // `onBulkDelete`, a different callback from the row `onDelete`. + describe('#3720 objectCanDelete (the bulk-delete gate)', () => { + it('stays true when the row callback is absent but the object allows delete', () => { + const res = resolveRowCrudAffordances({ operationsUpdate: true, operationsDelete: true, hasOnDelete: false }); + expect(res.canDelete).toBe(false); + expect(res.objectCanDelete).toBe(true); + }); + + it('follows the object verdict through every layer', () => { + expect(resolveRowCrudAffordances({ ...wired, userActions: { delete: false } }).objectCanDelete).toBe(false); + expect(resolveRowCrudAffordances({ ...wired, managedBy: 'append-only' }).objectCanDelete).toBe(false); + expect(resolveRowCrudAffordances({ ...wired, effectiveApiOperations: ['get', 'list'] }).objectCanDelete).toBe(false); + expect(resolveRowCrudAffordances({ ...wired, effectiveApiOperations: ['get', 'list', 'delete'] }).objectCanDelete).toBe(true); + }); }); }); diff --git a/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx b/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx new file mode 100644 index 000000000..c0da6dcaa --- /dev/null +++ b/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx @@ -0,0 +1,258 @@ +/** + * 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. + */ + +/** + * ObjectGrid row-level CRUD + bulk delete vs the server's effective API + * operation set (#3720 — the fourth face of #3391). + * + * The main list's row kebab is a chain of its own: it never went through + * `resolveCrudAffordances`, so the toolbar (objectui#2823), detail/form + * (#3546) and related-list rounds all missed it. `ObjectView` wires + * `onEdit`/`onDelete` unconditionally and view JSON rarely declares + * `operations`, so the gate was effectively "always on" — the row Edit/Delete + * AND the (destructive) bulk delete rendered even for a caller whose effective + * set carried neither `update` nor `delete`. + * + * These probe the REAL ObjectGrid through `usePermissions`, asserting the + * user-visible outcome (menu items / selection checkboxes / bulk button). + * The matrix mirrors objectui#2876 and keeps the `userActions` opt-out control + * group: it proves the assertions can actually observe "hidden", so a hidden + * result under a restricted effective set is not a false positive. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// Stable stub identity: ObjectGrid carries `perms` in `useMemo` dependency +// arrays (the real `usePermissions` memoizes for exactly that reason), so a +// fresh object per call would churn those memos every render. `vi.hoisted` +// builds it before the hoisted `vi.mock` factory runs; the accessor reads +// mutable state so a test can swap the effective set without changing identity. +const { permsStub, state } = vi.hoisted(() => { + const state: { effectiveOps: string[] | undefined } = { effectiveOps: undefined }; + return { + state, + // `isLoaded: false` keeps the FIELD-level filter out of the way so the + // object-level gate is the only variable under test. + permsStub: { + isLoaded: false, + checkField: () => true, + getObjectApiOperations: () => state.effectiveOps, + }, + }; +}); + +vi.mock('@object-ui/permissions', () => ({ usePermissions: () => permsStub })); + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; + +registerAllFields(); + +const OBJECT = 'test_object'; +const ROWS = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, +]; + +const FULL = ['get', 'list', 'create', 'update', 'delete']; +const READ_ONLY = ['get', 'list']; + +interface Case { + /** Server-resolved effective operation set; `undefined` = no annotation. */ + effectiveOps?: string[]; + /** ADR-0103 lifecycle bucket on the fetched object schema. */ + managedBy?: string; + userActions?: Record; + /** Extra view-schema keys (e.g. an author-declared `bulkActions`). */ + schema?: Record; +} + +function renderGrid(c: Case) { + state.effectiveOps = c.effectiveOps; + const dataSource: any = { + getObjectSchema: async (name: string) => ({ + name, + ...(c.managedBy ? { managedBy: c.managedBy } : {}), + ...(c.userActions ? { userActions: c.userActions } : {}), + fields: { id: { type: 'text' }, name: { type: 'text', label: 'Name' } }, + }), + }; + const schema: any = { + type: 'object-grid', + objectName: OBJECT, + columns: [{ field: 'name', label: 'Name' }], + data: { provider: 'value', items: ROWS }, + ...c.schema, + }; + return render( + + + {}} + onDelete={() => {}} + onBulkDelete={() => {}} + /> + + , + ); +} + +/** + * Render the grid and report what the row kebab surfaces. Waits for the async + * `getObjectSchema` fetch so the affordance verdict is the settled one — an + * assertion taken before it lands would read the pre-fetch (all-open) state. + */ +async function rowKebab(c: Case): Promise<{ edit: boolean; delete: boolean }> { + renderGrid(c); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + // Let the schema fetch settle before reading the (possibly empty) menu. + await waitFor(() => expect(screen.getByText('Bob')).toBeInTheDocument()); + const triggers = screen.queryAllByTestId('row-action-trigger'); + // No trigger at all = the menu had nothing to show (RowActionMenu renders + // the "⋮" only when at least one entry survives its gates). + if (triggers.length === 0) return { edit: false, delete: false }; + await userEvent.click(triggers[0]); + return { + edit: screen.queryAllByTestId('row-action-builtin-edit').length > 0, + delete: screen.queryAllByTestId('row-action-builtin-delete').length > 0, + }; +} + +/** Whether the grid offers multi-select (the entry point to bulk delete). */ +async function hasSelection(c: Case): Promise { + renderGrid(c); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Bob')).toBeInTheDocument()); + return screen.queryAllByRole('checkbox').length > 0; +} + +beforeEach(() => { state.effectiveOps = undefined; }); +afterEach(() => { cleanup(); }); + +describe('ObjectGrid row CRUD vs the effective API operation set (#3720)', () => { + // The exact matrix from the issue: A baseline, B the bug, C control group. + it('A — full effective set keeps row Edit/Delete and multi-select', async () => { + expect(await rowKebab({ effectiveOps: FULL })).toEqual({ edit: true, delete: true }); + cleanup(); + expect(await hasSelection({ effectiveOps: FULL })).toBe(true); + }); + + it('B — a read-only effective set hides row Edit/Delete and multi-select', async () => { + expect(await rowKebab({ effectiveOps: READ_ONLY })).toEqual({ edit: false, delete: false }); + cleanup(); + expect(await hasSelection({ effectiveOps: READ_ONLY })).toBe(false); + }); + + it('C — control group: the userActions opt-out is observably honored', async () => { + // Proves the assertions above can actually observe "hidden" — without this + // control a passing B could just be a broken probe. + expect(await rowKebab({ effectiveOps: FULL, userActions: { edit: false, delete: false } })) + .toEqual({ edit: false, delete: false }); + }); + + it('gates update and delete independently', async () => { + expect(await rowKebab({ effectiveOps: ['get', 'list', 'update'] })) + .toEqual({ edit: true, delete: false }); + cleanup(); + expect(await rowKebab({ effectiveOps: ['get', 'list', 'delete'] })) + .toEqual({ edit: false, delete: true }); + }); + + it('a missing effective set preserves the pre-#3720 behavior', async () => { + // Unrestricted object / old backend / no PermissionProvider. + expect(await rowKebab({ effectiveOps: undefined })).toEqual({ edit: true, delete: true }); + cleanup(); + expect(await hasSelection({ effectiveOps: undefined })).toBe(true); + }); + + it('intersects, never unions — a server grant cannot re-open a userActions opt-out', async () => { + expect(await rowKebab({ effectiveOps: FULL, userActions: { edit: false, delete: false } })) + .toEqual({ edit: false, delete: false }); + }); + + it('intersects, never unions — a userActions opt-in cannot survive a server denial', async () => { + expect(await rowKebab({ effectiveOps: READ_ONLY, userActions: { edit: true, delete: true } })) + .toEqual({ edit: false, delete: false }); + }); +}); + +describe('ObjectGrid row CRUD vs the ADR-0103 bucket lock (#3720)', () => { + it('engine-owned buckets no longer leak generic row Edit/Delete', async () => { + expect(await rowKebab({ managedBy: 'system' })).toEqual({ edit: false, delete: false }); + cleanup(); + expect(await rowKebab({ managedBy: 'append-only' })).toEqual({ edit: false, delete: false }); + }); + + it('platform / config objects are untouched', async () => { + expect(await rowKebab({ managedBy: 'platform' })).toEqual({ edit: true, delete: true }); + cleanup(); + expect(await rowKebab({ managedBy: 'config' })).toEqual({ edit: true, delete: true }); + }); + + it('a userActions opt-in re-opens a bucket-locked object (sys_user-style edit)', async () => { + expect(await rowKebab({ managedBy: 'better-auth', userActions: { edit: true } })) + .toEqual({ edit: true, delete: false }); + }); +}); + +describe('ObjectGrid bulk delete vs the object delete verdict (#3720)', () => { + it('drops an author-declared bulkActions delete when the server denies it', async () => { + // `bulkActions: ['delete']` is a WIRING declaration, not a permission + // grant — the destructive button must not outlive the effective set. + renderGrid({ effectiveOps: READ_ONLY, schema: { bulkActions: ['delete'] } }); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Bob')).toBeInTheDocument()); + expect(screen.queryAllByRole('checkbox').length).toBe(0); + }); + + it('keeps a declared bulkActions delete when the effective set allows it', async () => { + renderGrid({ effectiveOps: FULL, schema: { bulkActions: ['delete'] } }); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + const boxes = screen.queryAllByRole('checkbox'); + expect(boxes.length).toBeGreaterThan(0); + await userEvent.click(boxes[boxes.length - 1]); + await waitFor(() => expect(screen.getByTestId('bulk-action-delete')).toBeInTheDocument()); + }); + + it('keeps NON-delete bulk actions when the server denies delete', async () => { + // Custom ids route through the action runner with their own gates — the + // delete verdict must not take them down with it. + renderGrid({ effectiveOps: READ_ONLY, schema: { bulkActions: ['delete', 'notify'] } }); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + const boxes = screen.queryAllByRole('checkbox'); + expect(boxes.length).toBeGreaterThan(0); + await userEvent.click(boxes[boxes.length - 1]); + await waitFor(() => expect(screen.getByTestId('bulk-action-notify')).toBeInTheDocument()); + expect(screen.queryByTestId('bulk-action-delete')).not.toBeInTheDocument(); + }); + + it('drops a bulkActionDefs entry whose operation is delete', async () => { + renderGrid({ + effectiveOps: READ_ONLY, + schema: { + bulkActionDefs: [ + { name: 'purge', label: 'Purge', operation: 'delete' }, + { name: 'reassign', label: 'Reassign', operation: 'update' }, + ], + }, + }); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + const boxes = screen.queryAllByRole('checkbox'); + expect(boxes.length).toBeGreaterThan(0); + await userEvent.click(boxes[boxes.length - 1]); + await waitFor(() => expect(screen.getByTestId('bulk-action-reassign')).toBeInTheDocument()); + expect(screen.queryByTestId('bulk-action-purge')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/rowCrudAffordances.ts b/packages/plugin-grid/src/rowCrudAffordances.ts index 5d416c4e9..a246bdee3 100644 --- a/packages/plugin-grid/src/rowCrudAffordances.ts +++ b/packages/plugin-grid/src/rowCrudAffordances.ts @@ -16,17 +16,31 @@ * `operations.update` / `operations.delete` (or an explicit `'edit'` / * `'delete'` in `rowActions`) AND an `onEdit` / `onDelete` callback exists. * - * 2. The OBJECT's CRUD affordance flags (`userActions.edit` / `userActions.delete`). - * When an object sets these to **`false`** it has deliberately opted out of - * the generic row CRUD — typically because it ships dedicated actions - * instead (e.g. `sys_environment` replaces generic edit with a `Rename` - * action and generic delete with a cascade-teardown `Delete` action). Before - * this gate, the generic entries rendered anyway, producing a confusing - * duplicate (a generic "Delete" next to the object's own "Delete" action) - * and leaking a generic "Edit" the object had turned off. + * 2. The OBJECT's resolved CRUD affordance — the SAME shared policy the + * toolbar, the record header, the form and the related lists run + * (`resolveCrudAffordances` in `@object-ui/core`). It folds three layers: * - * `userActions.edit` / `delete` left `undefined` (or `true`) preserves the - * out-of-the-box behaviour — every main list keeps its Edit/Delete kebab. + * a. the ADR-0103 lifecycle bucket (`managedBy`) — engine-owned + * `system` / `append-only` / `better-auth` objects default their + * generic edit/delete OFF; + * b. the object's `userActions.edit` / `userActions.delete` overrides, + * which both opt a bucket-locked object back IN and opt a `platform` + * object OUT — the latter typically because it ships dedicated + * actions instead (e.g. `sys_environment` replaces generic edit with + * a `Rename` action and generic delete with a cascade-teardown + * `Delete` action, and the generic entries would otherwise render a + * confusing duplicate); + * c. [#3720] the SERVER's effective API operation set for the object + * (`/me/permissions` `apiOperations`, #3391) — `edit` is ANDed with + * `update` and `delete` with `delete`, so a row never offers a + * mutation the server would reject. + * + * Layer (c) is an INTERSECTION, never a union: a server grant cannot re-open + * what the bucket or `userActions` closed, and a `userActions` opt-in cannot + * survive a server denial. An absent effective set (unrestricted object / old + * backend / no `PermissionProvider`) leaves the bucket verdict untouched, and + * an absent `managedBy` resolves to the `platform` bucket — so every main list + * on an ordinary object keeps its Edit/Delete kebab out of the box. * * Since objectui#2614, `userActions.edit` / `delete` also accept an object * form `{ enabled?, visibleWhen?, disabledWhen? }`: `enabled` carries the @@ -36,7 +50,7 @@ * (they never affect the object-level `canEdit` / `canDelete` verdict). */ -import { normalizeUserAction, type RowCrudPredicates, type UserActionOverride } from '@object-ui/core'; +import { resolveCrudAffordances, type RowCrudPredicates, type UserActionOverride } from '@object-ui/core'; // The `userActions.{edit,delete}` override shape (bare boolean or #2614 object // form) and its per-record predicates are parsed in exactly one place — @@ -53,27 +67,47 @@ export function resolveRowCrudAffordances(opts: { wantDeleteAction?: boolean; hasOnEdit?: boolean; hasOnDelete?: boolean; + /** The object's ADR-0103 lifecycle bucket; absent → the `platform` default. */ + managedBy?: string | null; /** The object's `userActions` block ({ create, edit, delete, import }). */ userActions?: { edit?: RowCrudUserAction; delete?: RowCrudUserAction } | null; + /** + * [#3720] The server-resolved effective API operation set for this object + * (`/me/permissions` `apiOperations`, #3391). `undefined` / `null` (old + * backend, unrestricted object, no provider) leaves the object verdict + * untouched; an empty array means "expose nothing" → both entries hidden. + */ + effectiveApiOperations?: readonly string[] | null; }): { canEdit: boolean; canDelete: boolean; + /** + * The OBJECT-level delete verdict, independent of the row `onDelete` + * wiring. BULK delete rides a different callback (`onBulkDelete`), so it + * gates on this rather than on `canDelete` — otherwise a consumer that + * wires only the bulk handler would be judged by whether the *row* handler + * happens to be present. + */ + objectCanDelete: boolean; editPredicates?: RowCrudPredicates; deletePredicates?: RowCrudPredicates; } { - // Opt-out model (base = true): the generic Edit/Delete surface UNLESS the - // object explicitly disabled the flag (`false` / `{ enabled: false }`). The - // bucket-level lock is applied upstream via the view's `operations.*`. - const edit = normalizeUserAction(opts.userActions?.edit, true); - const del = normalizeUserAction(opts.userActions?.delete, true); + // The object-level verdict comes from the shared policy — bucket default, + // `userActions` override, then the server's effective operation set. The row + // gate is that verdict AND the consumer having actually wired the affordance. + const aff = resolveCrudAffordances( + { managedBy: opts.managedBy, userActions: opts.userActions }, + opts.effectiveApiOperations, + ); const canEdit = - !!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && edit.enabled; + !!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && aff.edit; const canDelete = - !!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && del.enabled; + !!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && aff.delete; return { canEdit, canDelete, - editPredicates: canEdit ? edit.predicates : undefined, - deletePredicates: canDelete ? del.predicates : undefined, + objectCanDelete: aff.delete, + editPredicates: canEdit ? aff.editPredicates : undefined, + deletePredicates: canDelete ? aff.deletePredicates : undefined, }; } diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 1ff0116ce..31998ca7a 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -19,7 +19,7 @@ import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, buildExpandFields, buildExportFileName } from '@object-ui/core'; +import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveCrudAffordances } from '@object-ui/core'; import { useObjectTranslation, useObjectLabel, useSafeFieldLabel, createSafeTranslation } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; @@ -656,6 +656,21 @@ export const ListView = React.forwardRef(({ schema.operations?.export !== false && (effectiveApiOps ? effectiveApiOps.includes('export') : true); + // [#3720] Bulk-action gate for the NON-grid views (kanban / calendar / + // gallery / …), whose bulk bar this component renders itself — the grid path + // delegates to ObjectGrid, which gates its own. A declared `bulkActions` + // entry is a WIRING declaration, not a permission grant, so the built-in + // `delete` is dropped unless the object's resolved delete affordance allows + // it: the ADR-0103 bucket lock ∧ `userActions.delete` ∧ the server's + // effective API operation set (#3391). Custom action ids pass through + // untouched — they route through the action runner with their own gates. + const permittedBulkActions = React.useMemo(() => { + const declared = schema.bulkActions; + if (!declared || declared.length === 0) return declared; + if (resolveCrudAffordances(objectDef as any, effectiveApiOps).delete) return declared; + return declared.filter((a: unknown) => String(a).toLowerCase() !== 'delete'); + }, [schema.bulkActions, objectDef, effectiveApiOps]); + // Normalize exportOptions: support both ObjectUI object format and spec string[] format const resolvedExportOptions = React.useMemo(() => { if (!schema.exportOptions) return undefined; @@ -2458,7 +2473,7 @@ export const ListView = React.forwardRef(({ )} {/* Bulk Actions Bar — skip for grid view since ObjectGrid renders its own BulkActionBar */} - {schema.bulkActions && schema.bulkActions.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && ( + {permittedBulkActions && permittedBulkActions.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && (
(({ {selectedRows.length} {selectedRows.length === 1 ? 'item' : 'items'} selected
- {schema.bulkActions.map((action: any) => { + {permittedBulkActions.map((action: any) => { const actionStr = String(action).toLowerCase(); const isDestructive = actionStr.includes('delete') || actionStr.includes('remove') || actionStr.includes('destroy'); const Icon = isDestructive ? Trash2 : null;