Skip to content

Commit 2735de6

Browse files
authored
feat: render the server's effective API operation set (#3391 PR-4) (#2823)
The frontend consumes the per-object effective API operation set the server resolves (/me/permissions apiOperations, framework #3391) — never the raw apiMethods — so CRUD/Import/Export buttons match what the server admits, and a 405 import refusal shows a dedicated message instead of a silent fallback. - core: resolveCrudAffordances(obj, effectiveApiOperations?) optional 2nd-arg intersection - permissions: /me/permissions apiOperations + getObjectApiOperations + check(import→allowCreate, export→allowRead) - app-shell ObjectView: Import affordance ∩ effective (identity-import bypass unaffected) - plugin-list/plugin-grid: Export button+handler gate on effective export; plugin-grid gains @object-ui/permissions dep - plugin-grid ImportWizard: isImportNotAllowed (405) at all catch sites → dedicated grid.import.notAllowed message, no fallback Backward-compatible: missing effective set preserves current default-allow behavior.
1 parent 4b60d2d commit 2735de6

25 files changed

Lines changed: 377 additions & 16 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/permissions": minor
4+
"@object-ui/plugin-grid": minor
5+
"@object-ui/plugin-list": minor
6+
"@object-ui/app-shell": minor
7+
"@object-ui/i18n": minor
8+
---
9+
10+
feat: render the server's effective API operation set (#3391 PR-4)
11+
12+
The frontend now consumes the per-object **effective API operation set** the
13+
server resolves (from `/me/permissions` `apiOperations`, framework #3391) —
14+
never the raw `apiMethods` — so Import/Export/New/Edit/Delete buttons match what
15+
the server will actually admit, and a 405 import refusal shows a dedicated
16+
message instead of silently falling back.
17+
18+
- **core** `resolveCrudAffordances(obj, effectiveApiOperations?)` — new optional
19+
second argument intersects each affordance bit with its API operation
20+
(create/import→create/import, edit→update, delete→delete, exportCsv→export).
21+
Omitting it (old backend / no effective set) leaves affordances unchanged.
22+
- **permissions**`/me/permissions` response carries per-object
23+
`apiOperations`; `PermissionContextValue.getObjectApiOperations(object)`
24+
exposes it (undefined when absent → callers keep current behavior); `check()`
25+
maps `import→allowCreate`, `export→allowRead`.
26+
- **app-shell** `ObjectView` intersects its toolbar affordances with the object's
27+
effective operations (Import); the platform-admin identity-import bypass is
28+
unaffected.
29+
- **plugin-list** `ListView` / **plugin-grid** `ObjectGrid` gate the Export
30+
button (and export handler) on effective `export`; `plugin-grid` gains the
31+
`@object-ui/permissions` workspace dependency.
32+
- **plugin-grid** `ImportWizard` — a 405 / `OBJECT_API_METHOD_NOT_ALLOWED`
33+
import refusal is detected by a new `isImportNotAllowed` predicate at every
34+
catch site (async, sync, dry-run) and STOPS with a dedicated
35+
`grid.import.notAllowed` message (10 locales + fallback dict) — it never falls
36+
back to the sync/legacy path (which 405s too), distinct from the 404
37+
route-absent fallback.
38+
39+
Backward-compatible: a missing effective set (unrestricted object, older
40+
backend, or no permission provider) preserves the current default-allow
41+
behavior everywhere.

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
350350
// Admin users automatically get design tools (no toggle needed)
351351
const { user, activeOrganization } = useAuth();
352352
const isAdmin = useIsWorkspaceAdmin();
353-
const { can } = usePermissions();
353+
const { can, getObjectApiOperations } = usePermissions();
354354

355355
// Get Object Definition. The outer ObjectView wrapper already guards the
356356
// missing-object case, so this always resolves while this component is
@@ -405,9 +405,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
405405
// hide the lot — those flows go through purpose-built actions on the
406406
// source record (e.g. "Submit for Approval" on an Opportunity creates
407407
// an `sys_approval_request`). Permissions still gate the buttons.
408+
// [#3391] Intersect the bucket affordances with the server's effective API
409+
// operation set for this object (from /me/permissions apiOperations), so the
410+
// toolbar never offers Import/Export/New/Edit/Delete the server would 405.
411+
// `undefined` (unrestricted object / old backend) leaves affordances as-is.
412+
// The identity-import bypass below is independent of `affordances.import`.
408413
const affordances = useMemo(
409-
() => resolveCrudAffordances(objectDef as any),
410-
[objectDef],
414+
() => resolveCrudAffordances(objectDef as any, getObjectApiOperations(objectDef.name)),
415+
[objectDef, getObjectApiOperations],
411416
);
412417

413418
// Propagate externally-triggered refreshes (e.g. global ModalForm submit)

packages/core/src/utils/managedBy.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,46 @@ describe('MANAGED_BY_BUCKETS', () => {
143143
expect(MANAGED_BY_BUCKETS).toEqual(['platform', 'config', 'system', 'engine-owned', 'append-only', 'better-auth']);
144144
});
145145
});
146+
147+
describe('resolveCrudAffordances — effective API operations (#3391)', () => {
148+
it('undefined effective set → affordances unchanged (backward-compatible)', () => {
149+
const platform = { managedBy: 'platform' };
150+
expect(resolveCrudAffordances(platform)).toEqual(resolveCrudAffordances(platform, undefined));
151+
expect(resolveCrudAffordances(platform, undefined)).toEqual({
152+
create: true, import: true, edit: true, delete: true, exportCsv: true,
153+
});
154+
});
155+
156+
it('ANDs each affordance bit with its API operation (create/import→create/import, edit→update, delete→delete, exportCsv→export)', () => {
157+
// A full-CRUD platform object whose server effective set is read-only + list-derived.
158+
const aff = resolveCrudAffordances({ managedBy: 'platform' }, ['get', 'list', 'export']);
159+
expect(aff).toMatchObject({
160+
create: false, import: false, edit: false, delete: false, exportCsv: true,
161+
});
162+
});
163+
164+
it('keeps a bit only when BOTH the affordance and the effective op allow it', () => {
165+
// create+update present → create/import/edit survive; no delete/list → delete/export drop.
166+
const aff = resolveCrudAffordances({ managedBy: 'platform' }, ['create', 'update', 'import']);
167+
expect(aff.create).toBe(true);
168+
expect(aff.edit).toBe(true);
169+
expect(aff.import).toBe(true);
170+
expect(aff.delete).toBe(false);
171+
expect(aff.exportCsv).toBe(false);
172+
});
173+
174+
it('empty effective set → all bits off (deny-all)', () => {
175+
expect(resolveCrudAffordances({ managedBy: 'platform' }, [])).toMatchObject({
176+
create: false, import: false, edit: false, delete: false, exportCsv: false,
177+
});
178+
});
179+
180+
it('never re-enables a bit the bucket/userActions already denied', () => {
181+
// config bucket has no import; even if the server would allow import, the
182+
// UI-intent axis keeps it off (intersection, never union).
183+
const aff = resolveCrudAffordances({ managedBy: 'config' }, ['create', 'update', 'delete', 'import', 'export']);
184+
expect(aff.import).toBe(false); // config never imports
185+
expect(aff.create).toBe(true);
186+
expect(aff.exportCsv).toBe(true);
187+
});
188+
});

packages/core/src/utils/managedBy.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,36 @@ export function userActionPredicates(
112112
return normalizeUserAction(v, false).predicates;
113113
}
114114

115-
/** Resolve the effective CRUD affordances for an object schema. */
116-
export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances {
115+
/**
116+
* The affordance-bit → server API-operation mapping used to intersect the
117+
* UI-intent affordances with the server's effective API operation set (#3391).
118+
* This is the objectui end of the framework's `API_METHOD_DERIVATION` contract:
119+
* the button predicate is `affordance(op) ∧ effective.api(op)`.
120+
*/
121+
const AFFORDANCE_TO_API_OPERATION = {
122+
create: 'create',
123+
import: 'import',
124+
edit: 'update',
125+
delete: 'delete',
126+
exportCsv: 'export',
127+
} as const;
128+
129+
/**
130+
* Resolve the effective CRUD affordances for an object schema.
131+
*
132+
* @param obj The object schema (managedBy bucket + userActions overrides).
133+
* @param effectiveApiOperations OPTIONAL server-resolved effective API operation
134+
* set for this object (from `/me/permissions` `apiOperations`, #3391). When
135+
* provided, each affordance bit is ANDed with the corresponding API operation
136+
* (create/import→create/import, edit→update, delete→delete, exportCsv→export),
137+
* so the UI never shows a button the server would 405. Passing `undefined`
138+
* (old backend / no effective set) leaves the affordances untouched —
139+
* backward-compatible. An empty array means "expose nothing" → all bits off.
140+
*/
141+
export function resolveCrudAffordances(
142+
obj: SchemaLike | null | undefined,
143+
effectiveApiOperations?: readonly string[] | null,
144+
): CrudAffordances {
117145
const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform';
118146
const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
119147
const o = obj?.userActions ?? {};
@@ -128,6 +156,17 @@ export function resolveCrudAffordances(obj: SchemaLike | null | undefined): Crud
128156
};
129157
if (edit.predicates) out.editPredicates = edit.predicates;
130158
if (del.predicates) out.deletePredicates = del.predicates;
159+
// [#3391] Intersect with the server's effective API operations when present.
160+
// The frontend consumes the effective set the server hands down; it never
161+
// reads the raw `apiMethods` nor re-derives.
162+
if (Array.isArray(effectiveApiOperations)) {
163+
const eff = new Set(effectiveApiOperations);
164+
for (const [bit, op] of Object.entries(AFFORDANCE_TO_API_OPERATION) as Array<
165+
[keyof typeof AFFORDANCE_TO_API_OPERATION, string]
166+
>) {
167+
if (out[bit] && !eff.has(op)) out[bit] = false;
168+
}
169+
}
131170
return out;
132171
}
133172

packages/i18n/src/locales/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const ar = {
198198
},
199199
import: {
200200
title: "استيراد {{object}}",
201+
notAllowed: "هذا الكائن غير متاح للاستيراد.",
201202
stepUpload: "رفع",
202203
stepMapping: "تعيين",
203204
stepPreview: "معاينة",

packages/i18n/src/locales/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const de = {
198198
},
199199
import: {
200200
title: "{{object}} importieren",
201+
notAllowed: "Dieses Objekt ist nicht für den Import freigegeben.",
201202
stepUpload: "Hochladen",
202203
stepMapping: "Zuordnung",
203204
stepPreview: "Vorschau",

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ const en = {
223223
},
224224
import: {
225225
title: 'Import {{object}}',
226+
notAllowed: 'This object is not open for import.',
226227
stepUpload: 'Upload',
227228
stepMapping: 'Mapping',
228229
stepPreview: 'Preview',

packages/i18n/src/locales/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const es = {
198198
},
199199
import: {
200200
title: "Importar {{object}}",
201+
notAllowed: "Este objeto no está habilitado para la importación.",
201202
stepUpload: "Cargar",
202203
stepMapping: "Asignación",
203204
stepPreview: "Vista previa",

packages/i18n/src/locales/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const fr = {
198198
},
199199
import: {
200200
title: "Importer {{object}}",
201+
notAllowed: "Cet objet n'est pas ouvert à l'import.",
201202
stepUpload: "Télécharger",
202203
stepMapping: "Correspondance",
203204
stepPreview: "Aperçu",

packages/i18n/src/locales/ja.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const ja = {
198198
},
199199
import: {
200200
title: "{{object}}をインポート",
201+
notAllowed: "このオブジェクトはインポートに対応していません。",
201202
stepUpload: "アップロード",
202203
stepMapping: "マッピング",
203204
stepPreview: "プレビュー",

0 commit comments

Comments
 (0)