diff --git a/.changeset/26594-record-form-field-access-data-objectstack.md b/.changeset/26594-record-form-field-access-data-objectstack.md new file mode 100644 index 00000000000..464281d8231 --- /dev/null +++ b/.changeset/26594-record-form-field-access-data-objectstack.md @@ -0,0 +1,10 @@ +--- +'@object-ui/data-objectstack': patch +--- + +feat(data-objectstack): `ObjectStackAdapter.getRecordFieldAccess` reads `GET /data/:object/:id/field-access` + +Clause-②: yes (widening) +Metadata-protocol: none + +The adapter implements `DataSource.getRecordFieldAccess` through the client's `data.fieldAccess`. A record the read answers 404 for, and a server without the route (501 `NOT_IMPLEMENTED`), resolve to `null`, so the caller keeps its row-blind answer. Any other refusal is thrown, as a read's is. diff --git a/.changeset/26594-record-form-field-access-permissions.md b/.changeset/26594-record-form-field-access-permissions.md new file mode 100644 index 00000000000..244df9a4189 --- /dev/null +++ b/.changeset/26594-record-form-field-access-permissions.md @@ -0,0 +1,10 @@ +--- +'@object-ui/permissions': patch +--- + +feat(permissions): `RecordFieldAccessScope` answers one record's field locks from the server's record-scoped projection + +Clause-②: yes (widening) +Metadata-protocol: none + +Below `RecordFieldAccessScope`, `checkField(object, field, 'write')` for the scoped record answers from the server's projection of that record wherever `/auth/me/permissions` reports a field-level lock. That reader holds no row, so it reports a lock that applies only from a stage onward (a field permission `when`) on every row; the scope replaces that one input with the write gate's verdict for the stored row. The object-grant fallback, the owner-transfer rule, `read` and `create`, and every other object keep the enclosing provider's answer. Until the answer arrives, and when there is none, the row-blind lock stands. When its `revision` prop moves (the row was saved or re-read), it asks again and the lock stands until the new answer lands. The client never evaluates `when`. A nested scope for the same record asks once. diff --git a/.changeset/26594-record-form-field-access-plugin-form.md b/.changeset/26594-record-form-field-access-plugin-form.md new file mode 100644 index 00000000000..8bad991d456 --- /dev/null +++ b/.changeset/26594-record-form-field-access-plugin-form.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-form': patch +--- + +fix(plugin-form): the record form's edit dialog disables only the fields the server locks on that record + +Clause-②: no +Metadata-protocol: none + +An edit form for a stored record (`ModalForm`, the console's edit dialog; `ObjectForm` and every layout beneath it; `DrawerForm`) now asks the data source for that record's field projection and answers its field locks from it, through `RecordFieldAccessScope`. A field locked only from `approved` onward is live on a draft and locked on an approved row, in what the form draws and in what its save sends. The form asks again after a write to the record or a re-read of it, so a save that approves the row locks the field again. Create forms, `view` forms and data sources without `getRecordFieldAccess` behave as before. Row-level write scope is not part of the answer: on a row the caller may read but not write, the fields stay open and the server refuses the save, as before. diff --git a/.changeset/26594-record-form-field-access-types.md b/.changeset/26594-record-form-field-access-types.md new file mode 100644 index 00000000000..4b360dd095f --- /dev/null +++ b/.changeset/26594-record-form-field-access-types.md @@ -0,0 +1,10 @@ +--- +'@object-ui/types': patch +--- + +feat(types): `DataSource.getRecordFieldAccess` asks the server for one record's field projection + +Clause-②: yes (widening) +Metadata-protocol: none + +`DataSource` gains the optional `getRecordFieldAccess(resource, id)`, resolving to the new `RecordFieldAccess` type (`object`, `recordId`, `readable`, `editable`), or `null` when there is no answer. `editable` is the server's write gate judged on the stored row, so a field permission whose `when` names only some rows locks the field only on those rows. Adapters that do not speak to an ObjectStack server can leave it out. diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index c0a122537cf..e6eb2fb21ba 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -64,7 +64,11 @@ fields: { an update of the stored row. So a lock that applies from a stage onward disables the field only on the rows its condition names — this is the answer a record's edit form reads. A row that is absent or that the caller - cannot read answers the record read's `404`. + cannot read answers the record read's `404`. The answer is field-level + only: the object grant and the record's row-level write scope (sharing, + row-level security, ownership) are not part of it, so on a row the caller + may read but not write it still lists fields as editable, and the update + is refused as a whole. - **Union without deny is unchanged.** A matched conditioned lock is still out-voted by another set's unconditioned `editable: true`; an entry without `when` votes on every row, as before (ADR-0066 ⑧, note of diff --git a/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts b/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts index 688ec034e4a..5157b3c1698 100644 --- a/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts @@ -14,6 +14,10 @@ // row-blind lock is shown to stay a lock on every row. // - `rfa_memo` is `private`: a member reads only the rows they own. It is the // row the caller cannot read, for the existence-leak leg. +// - `rfa_notice` is `public_read`: everyone reads, only the owner writes. A +// row someone else owns is one the member may READ but not WRITE — the +// row-level write scope the record-scoped projection declares it leaves +// out, measured against the PATCH. import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; @@ -54,6 +58,17 @@ export const RecordFieldAccessMemo = ObjectSchema.create({ }, }); +export const RecordFieldAccessNotice = ObjectSchema.create({ + name: 'rfa_notice', + sharingModel: 'public_read', + label: 'RFA Notice', + pluralLabel: 'RFA Notices', + fields: { + title: Field.text({ label: 'Title', required: true }), + summary: Field.text({ label: 'Summary' }), + }, +}); + export const recordFieldAccessFixtureStack = defineStack({ manifest: { id: 'com.dogfood.record-field-access-fixture', @@ -63,7 +78,7 @@ export const recordFieldAccessFixtureStack = defineStack({ name: 'Record Field Access Fixture', description: 'A field lock that holds only from a stage onward (field permission `when`).', }, - objects: [RecordFieldAccessContract, RecordFieldAccessMemo], + objects: [RecordFieldAccessContract, RecordFieldAccessMemo, RecordFieldAccessNotice], } as any); /** The `when` the conditioned entry carries, as authored. */ @@ -81,6 +96,7 @@ export const recordFieldAccessMemberSet: PermissionSet = PermissionSetSchema.par objects: { rfa_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, rfa_memo: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + rfa_notice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, }, fields: { 'rfa_contract.summary': { readable: true, editable: false, when: APPROVED_ONLY }, diff --git a/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts b/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts index 0370ce16f93..be95c8a254b 100644 --- a/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts +++ b/packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts @@ -31,6 +31,7 @@ describe('a row-conditioned field lock, on the record the form edits', () => { let draftId: string; let approvedId: string; let hiddenMemoId: string; + let othersNoticeId: string; beforeAll(async () => { stack = await bootStack(recordFieldAccessFixtureStack, { security: recordFieldAccessFixtureSecurity() }); @@ -43,6 +44,8 @@ describe('a row-conditioned field lock, on the record the form edits', () => { approvedId = (await ql.insert('rfa_contract', { title: 'A', status: 'approved' }, { context: SYS })).id; // A private row the member does not own, so cannot read. hiddenMemoId = (await ql.insert('rfa_memo', { title: 'approved' }, { context: SYS })).id; + // A public-read row someone else owns: readable, not writable, for the member. + othersNoticeId = (await ql.insert('rfa_notice', { title: 'N' }, { context: SYS })).id; }, 180_000); afterAll(async () => { @@ -118,6 +121,23 @@ describe('a row-conditioned field lock, on the record the form edits', () => { } }, 60_000); + it('measure — a row the caller may read but not write: the projection is field-level only, the PATCH refuses the row', async () => { + // The row-level write scope (sharing, row-level security, ownership) is + // declared outside `editable`: the projection lists the fields + // field-level security admits, and the write is refused for the row as a + // whole. A reader ANDs this answer with the record's own write scope; the + // console's edit dialog has never gated on that scope, so the server's + // 403 on save is its answer there, before this card and after it. + const read = await stack.apiAs(memberTok, 'GET', `/data/rfa_notice/${othersNoticeId}`); + expect(read.status, await read.clone().text()).toBe(200); + const { status, body } = await fieldAccess('rfa_notice', othersNoticeId); + expect(status).toBe(200); + const access = GetRecordFieldAccessResponseSchema.parse(body.data); + expect(access.editable).toEqual(expect.arrayContaining(['title', 'summary'])); + const patch = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_notice/${othersNoticeId}`, { title: 'N' }); + expect(patch.status, await patch.clone().text()).toBe(403); + }, 60_000); + it('the row-less reader still answers as before, and the route takes no query parameter', async () => { const res = await stack.apiAs(memberTok, 'GET', '/auth/me/permissions'); const body: any = await res.json(); diff --git a/ui/packages/data-objectstack/src/getRecordFieldAccess-26594.test.ts b/ui/packages/data-objectstack/src/getRecordFieldAccess-26594.test.ts new file mode 100644 index 00000000000..1940deafe6a --- /dev/null +++ b/ui/packages/data-objectstack/src/getRecordFieldAccess-26594.test.ts @@ -0,0 +1,67 @@ +/** + * 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. + */ + +/** + * `ObjectStackAdapter.getRecordFieldAccess` (objectstack#26594) — the record + * form's question to the server: `GET /api/v1/data/:object/:id/field-access`. + * + * Pinned: + * - the wire: that route, the object and id encoded, the envelope unwrapped; + * - "no answer" is `null`, never a throw, for the two refusals that mean it — + * the record read's own 404 (absent or hidden from the caller) and a server + * without the route (501 `NOT_IMPLEMENTED`) — so the form keeps its + * row-blind answer; + * - any other refusal reaches the caller, as a read's does. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackAdapter } from './index'; + +type FetchSpy = ReturnType Promise>>; + +function makeDS(fetchImpl: FetchSpy, baseUrl = 'http://test.local') { + const ds: any = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl as any }); + ds.connected = true; + ds.connectionState = 'connected'; + return ds; +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +const ACCESS = { object: 'rfa_contract', recordId: 'c/1', readable: ['title', 'summary'], editable: ['title', 'summary'] }; + +describe('[#26594] ObjectStackAdapter.getRecordFieldAccess', () => { + it("GETs the record's field-access route and hands back the projection", async () => { + const fetchImpl: FetchSpy = vi.fn(async () => json(200, { success: true, data: ACCESS })); + const ds = makeDS(fetchImpl); + await expect(ds.getRecordFieldAccess('rfa_contract', 'c/1')).resolves.toEqual(ACCESS); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('http://test.local/api/v1/data/rfa_contract/c%2F1/field-access'); + expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + }); + + it('a record the read answers 404 for is no answer, not a failure', async () => { + const fetchImpl: FetchSpy = vi.fn(async () => + json(404, { success: false, error: { code: 'RECORD_NOT_FOUND', message: 'Record c1 not found in rfa_memo' } })); + await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_memo', 'c1')).resolves.toBeNull(); + }); + + it('a server without the route (501) is no answer, not a failure', async () => { + const fetchImpl: FetchSpy = vi.fn(async () => + json(501, { success: false, error: { code: 'NOT_IMPLEMENTED', message: 'no record-scoped projection' } })); + await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_contract', 'c1')).resolves.toBeNull(); + }); + + it('any other refusal reaches the caller', async () => { + const fetchImpl: FetchSpy = vi.fn(async () => + json(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'no read' } })); + await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_contract', 'c1')).rejects.toMatchObject({ httpStatus: 403 }); + }); +}); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 64e9a14a15c..ee740118abb 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -47,6 +47,7 @@ import { import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts'; import type { DataSource, + RecordFieldAccess, ConnectionState, // #4564 — the canonical `deleteView` receipt shapes, declared beside the // `DataSource` interface they belong to. Imported for the local uses below @@ -1018,6 +1019,17 @@ export function is404Error(error: unknown): boolean { return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']); } +/** + * A server that does not offer the route asked (`501 NOT_IMPLEMENTED`) — for an + * optional capability, "no answer", never a failure of the read. + */ +function isNotImplementedError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const err = error as Record; + if (err.httpStatus === 501 || err.status === 501 || err.statusCode === 501) return true; + return errorCodeIs({ code: typeof err.code === 'string' ? err.code : '' }, 'NOT_IMPLEMENTED'); +} + // `API_ACCESS_DENIED_CODES` / `isApiAccessDeniedError` live in // `@object-ui/core` (`utils/api-access-denied.ts`) so that `@object-ui/react` // can classify the denial without depending on this adapter. Re-exported here @@ -7231,6 +7243,28 @@ export class ObjectStackAdapter implements DataSource { return response.json(); } + /** + * The field projection of ONE stored record (objectstack#26594): + * `GET /data/{object}/{id}/field-access` through the client's + * `data.fieldAccess`. Its `editable` is the server's write gate judged on + * the stored row, so a field permission whose `when` names only some rows + * locks the field only on those rows. + * + * `null` when there is no answer to give: a record the caller cannot see + * (the route answers the record read's own 404), or a server with no + * record-scoped projection (501). Any other failure is thrown, as a read's. + */ + async getRecordFieldAccess(resource: string, id: string): Promise { + await this.connect(); + try { + const access = await this.client.data.fieldAccess(resource, String(id)); + return access ?? null; + } catch (error: unknown) { + if (is404Error(error) || isNotImplementedError(error)) return null; + throw error; + } + } + /** * Cancel (recall) the active pending approval request for a given record. * diff --git a/ui/packages/permissions/src/RecordFieldAccessScope.tsx b/ui/packages/permissions/src/RecordFieldAccessScope.tsx new file mode 100644 index 00000000000..feb3b4efa43 --- /dev/null +++ b/ui/packages/permissions/src/RecordFieldAccessScope.tsx @@ -0,0 +1,192 @@ +/** + * 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 { useContext, useEffect, useState, type ReactNode } from 'react'; +import { SystemFieldName } from '@objectstack/spec/system'; +import { PermCtx, type PermissionContextValue } from './PermissionContext.js'; + +/** + * The part of the server's record-scoped field projection this scope reads + * (`GET /data/{object}/{id}/field-access`, `GetRecordFieldAccessResponse` in + * `@objectstack/spec`): the fields the caller may write on THIS record, as + * field-level security judges an update of the stored row. + */ +export interface RecordFieldAccessAnswer { + readonly editable: readonly string[]; +} + +export interface RecordFieldAccessScopeProps { + /** The object the record belongs to. Matched case-insensitively, as `MePermissionsProvider` matches object names. */ + object: string; + /** The record whose stored row the answer is judged on. */ + recordId: string; + /** + * Asks the server for the record's projection. `null` / `undefined` — and a + * rejection — mean "no answer": the record is gone from the caller's sight, + * or the server offers no record-scoped projection. Below the scope the + * enclosing provider's row-blind answer then stands, unchanged. + */ + load: (object: string, recordId: string) => Promise; + /** + * Moves whenever the stored row may have changed (a save, a re-read). The + * scope then asks again, and holds the row-blind lock until the new answer + * lands, so a row that moved into the state a `when` names is never drawn + * from the earlier answer. + */ + revision?: number; + children: ReactNode; +} + +/** + * Which (object, record) a context value this module built answers for, so a + * scope nested under one for the same record neither fetches again nor wraps + * the answer twice (`ObjectForm` dispatching into `ModalForm`, both of which + * hold the record). + */ +const SCOPE_OF = new WeakMap(); + +/** One derived value per enclosing value, record and answer (see below). */ +const SCOPED = new WeakMap>>(); + +/** Stands in for "no answer yet / no answer" as a cache key. */ +const NO_ANSWER: object = {}; + +const scopeKey = (object: string, recordId: string) => JSON.stringify([(object ?? '').toLowerCase(), recordId]); + +/** + * The enclosing value with the field `write` verdict of `object` answered from + * the record's projection, cached on the enclosing value itself. + * + * The cache is a module-level `WeakMap` keyed on the enclosing context value + * and on the answer object (the shape `ServerReadScope` and + * `discardProofCache.ts` use), never a `useMemo`: consumers name the + * permission value in dependency arrays, so a discarded memo would re-run + * their effects with nothing changed (AGENTS.md #10). + * + * Only ONE input of the enclosing `write` verdict is replaced: the explicit + * field-level lock. `/auth/me/permissions` holds no row, so it reports an entry + * whose `when` names only some rows as its lock (`editable: false` in + * `getFieldPermissions`), and the record's projection is the server's verdict + * on that entry for this row. Every other input stays the enclosing + * provider's: + * + * - a field with no lock entry (or one the entry leaves editable) keeps the + * enclosing answer — the object grant fallback, which the projection does + * not carry; + * - the record's owner field keeps the enclosing answer — writing it is a + * transfer, which needs a grant the projection does not carry either, and + * reading the lock alone would offer a transfer the server refuses; + * - `read` and `create` keep the enclosing answer — reads are judged + * row-blind, and a create form holds no stored row. + * + * The projection never widens past the enclosing provider on anything but + * that lock, and on the lock it is the write gate's own verdict for the row. + */ +function scoped( + parent: PermissionContextValue, + object: string, + recordId: string, + answer: RecordFieldAccessAnswer | null, +): PermissionContextValue { + const key = scopeKey(object, recordId); + let byRecord = SCOPED.get(parent); + if (!byRecord) { + byRecord = new Map(); + SCOPED.set(parent, byRecord); + } + let byAnswer = byRecord.get(key); + if (!byAnswer) { + byAnswer = new WeakMap(); + byRecord.set(key, byAnswer); + } + const answerKey = answer ?? NO_ANSWER; + const hit = byAnswer.get(answerKey); + if (hit) return hit; + + const objKey = (object ?? '').toLowerCase(); + const editable = new Set(answer?.editable ?? []); + const lockedByEntry = (field: string): boolean => { + const entries = [ + ...parent.getFieldPermissions(objKey), + ...(object !== objKey ? parent.getFieldPermissions(object) : []), + ]; + return entries.some((entry) => entry.field === field && entry.write === false); + }; + const value: PermissionContextValue = answer + ? { + ...parent, + checkField: (o, field, action) => { + if ( + action !== 'write' + || (o ?? '').toLowerCase() !== objKey + || field === SystemFieldName.OWNER_ID + || !lockedByEntry(field) + ) { + return parent.checkField(o, field, action); + } + return editable.has(field); + }, + } + : { ...parent }; + SCOPE_OF.set(value, key); + byAnswer.set(answerKey, value); + return value; +} + +/** + * Answers the field `write` verdict of ONE stored record from the server's + * record-scoped projection (objectstack#26594, ruling B). + * + * `/auth/me/permissions` holds no row, so a field permission whose `when` + * names only some rows (a summary locked from `approved` onward) is reported + * there as its lock, and a form reading it disables the field on every row — + * including the draft the server lets the caller write. Below this scope, the + * form that edits `recordId` reads the server's own verdict for that row + * instead: the field is disabled exactly where the write gate refuses it. The + * client never evaluates `when` itself. + * + * Until the answer arrives, and when there is none, the enclosing answer + * stands, so a conditioned field shows locked rather than briefly open. When + * `revision` moves (the row was saved or re-read), the scope asks again and + * the lock stands until the new answer lands. + * + * ⛔ The projection is field-level only. The record's row-level write scope + * (sharing, row-level security, ownership) and the object grant are not part + * of it: a row the caller may read but not write still answers its fields + * editable here, and the server refuses the save as a whole, as it did before + * this scope existed. The form-wide lock (`gateFormFields` in + * `@object-ui/plugin-form`) keeps reading the object grant. + * + * With no enclosing provider there is no field answer to refine, so the scope + * renders its children as they are and asks nothing. + */ +export function RecordFieldAccessScope({ object, recordId, load, revision = 0, children }: RecordFieldAccessScopeProps) { + const parent = useContext(PermCtx); + const key = scopeKey(object, recordId); + const nested = !!parent && SCOPE_OF.get(parent) === key; + const [answer, setAnswer] = useState<{ key: string; revision: number; value: RecordFieldAccessAnswer | null } | null>(null); + + useEffect(() => { + if (!parent || nested) return; + let alive = true; + Promise.resolve() + .then(() => load(object, recordId)) + .then( + (value) => { if (alive) setAnswer({ key, revision, value: value && Array.isArray(value.editable) ? value : null }); }, + () => { if (alive) setAnswer({ key, revision, value: null }); }, + ); + return () => { alive = false; }; + // `load` is deliberately not a trigger: callers pass an inline closure, and + // the answer belongs to the record, not to the function that fetched it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [!!parent, nested, key, revision]); + + if (!parent || nested) return <>{children}; + const current = answer?.key === key && answer.revision === revision ? answer.value : null; + return {children}; +} diff --git a/ui/packages/permissions/src/__tests__/RecordFieldAccessScope-26594.test.tsx b/ui/packages/permissions/src/__tests__/RecordFieldAccessScope-26594.test.tsx new file mode 100644 index 00000000000..d4a225772ee --- /dev/null +++ b/ui/packages/permissions/src/__tests__/RecordFieldAccessScope-26594.test.tsx @@ -0,0 +1,168 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectstack#26594 — `RecordFieldAccessScope`: below it, the field `write` + * verdict of ONE stored record answers from the server's record-scoped + * projection wherever `/auth/me/permissions` reports a field-level lock, and + * everything else is the enclosing provider's. + * + * The truth table, under a signed-in answer whose `rfa_contract.summary` entry + * is a lock that the server conditions on the row (`when: status in + * ['approved']` — `/auth/me/permissions` holds no row, so it serves the entry + * as its lock), `ref_no` locked on every row, and an owner entry locked: + * - `summary` on a draft the server unlocks → writable; on an approved row → locked; + * - `ref_no` → locked on both, as the server answers; + * - `title` (no entry) → the enclosing object-grant answer; + * - `owner_id` → the enclosing answer (a transfer needs a grant the projection does not carry); + * - `read` / `create` and every other object → the enclosing answer; + * - before the answer arrives, and on no answer → the enclosing answer (locked); + * - a new `revision` (the row was saved or re-read) → asked again, locked until it lands; + * - a nested scope for the same record asks once; no provider → asks nothing. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import { MePermissionsProvider, type MePermissionsResponse } from '../MePermissionsProvider'; +import { RecordFieldAccessScope } from '../RecordFieldAccessScope'; +import { usePermissions } from '../usePermissions'; + +type Perms = ReturnType; + +function Probe({ seen }: { seen: Perms[] }) { + seen.push(usePermissions()); + return null; +} + +const ANSWER: MePermissionsResponse = { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + roles: ['member'], + permissionSets: ['rfa_fixture_member'], + objects: { + rfa_contract: { allowRead: true, allowCreate: true, allowEdit: true }, + invoice: { allowRead: true, allowEdit: true }, + }, + fields: { + 'rfa_contract.summary': { readable: true, editable: false }, + 'rfa_contract.ref_no': { readable: true, editable: false }, + 'rfa_contract.owner_id': { readable: true, editable: false }, + 'invoice.summary': { readable: true, editable: false }, + }, +}; + +const DRAFT = { editable: ['title', 'status', 'summary', 'owner_id'] }; +const APPROVED = { editable: ['title', 'status', 'owner_id'] }; + +function mount(load: (o: string, id: string) => Promise, nested = false) { + const seen: Perms[] = []; + const probe = ; + render( + + + {nested + ? {probe} + : probe} + + , + ); + return { last: () => seen[seen.length - 1] }; +} + +describe('[#26594] RecordFieldAccessScope', () => { + it("a lock the server lifts on this row is writable; a lock it keeps stays locked", async () => { + const { last } = mount(async () => DRAFT); + await waitFor(() => expect(last().checkField('rfa_contract', 'summary', 'write')).toBe(true)); + expect(last().checkField('rfa_contract', 'ref_no', 'write')).toBe(false); + // Matched case-insensitively, as the provider matches object names. + expect(last().checkField('RFA_Contract', 'summary', 'write')).toBe(true); + }); + + it('on a row the condition names, the lock stands', async () => { + const load = vi.fn(async () => APPROVED); + const { last } = mount(load); + await waitFor(() => expect(load).toHaveBeenCalledWith('rfa_contract', 'c1')); + await waitFor(() => expect(last().checkField('rfa_contract', 'title', 'write')).toBe(true)); + expect(last().checkField('rfa_contract', 'summary', 'write')).toBe(false); + expect(last().checkField('rfa_contract', 'ref_no', 'write')).toBe(false); + }); + + it('only the field-level lock is replaced: owner, read, create and other objects keep the enclosing answer', async () => { + const { last } = mount(async () => DRAFT); + await waitFor(() => expect(last().checkField('rfa_contract', 'summary', 'write')).toBe(true)); + // In `editable`, but writing the owner is a transfer the projection does not judge. + expect(last().checkField('rfa_contract', 'owner_id', 'write')).toBe(false); + expect(last().checkField('rfa_contract', 'summary', 'create')).toBe(false); + expect(last().checkField('rfa_contract', 'summary', 'read')).toBe(true); + expect(last().checkField('invoice', 'summary', 'write')).toBe(false); + expect(last().getFieldPermissions('rfa_contract')).toContainEqual({ field: 'summary', read: true, write: false }); + expect(last().isLoaded).toBe(true); + expect(last().userId).toBe('u1'); + }); + + it('a field the answer omits but no entry locks keeps the object-grant answer', async () => { + const { last } = mount(async () => ({ editable: [] })); + await waitFor(() => expect(last().checkField('rfa_contract', 'summary', 'write')).toBe(false)); + expect(last().checkField('rfa_contract', 'title', 'write')).toBe(true); + }); + + it('before the answer arrives, and with no answer, the row-blind lock stands', async () => { + let resolve: ((v: any) => void) | undefined; + const pending = mount(() => new Promise((r) => { resolve = r; })); + await waitFor(() => expect(resolve).toBeTypeOf('function')); + expect(pending.last().checkField('rfa_contract', 'summary', 'write')).toBe(false); + resolve!(null); + await new Promise((r) => setTimeout(r, 0)); + expect(pending.last().checkField('rfa_contract', 'summary', 'write')).toBe(false); + + const refused = mount(async () => { throw Object.assign(new Error('gone'), { httpStatus: 404 }); }); + await new Promise((r) => setTimeout(r, 0)); + expect(refused.last().checkField('rfa_contract', 'summary', 'write')).toBe(false); + }); + + it('a new revision asks again and holds the lock until the new answer lands', async () => { + const seen: Perms[] = []; + const answers: Array<(v: any) => void> = []; + const load = vi.fn(() => new Promise((r) => { answers.push(r); })); + const tree = (revision: number) => ( + + + + + + ); + const { rerender } = render(tree(0)); + await waitFor(() => expect(answers).toHaveLength(1)); + await act(async () => { answers[0](DRAFT); }); + await waitFor(() => expect(seen[seen.length - 1].checkField('rfa_contract', 'summary', 'write')).toBe(true)); + + rerender(tree(1)); + await waitFor(() => expect(answers).toHaveLength(2)); + expect(seen[seen.length - 1].checkField('rfa_contract', 'summary', 'write')).toBe(false); + await act(async () => { answers[1](APPROVED); }); + expect(seen[seen.length - 1].checkField('rfa_contract', 'summary', 'write')).toBe(false); + expect(seen[seen.length - 1].checkField('rfa_contract', 'title', 'write')).toBe(true); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('a scope nested under one for the same record asks once and answers the same', async () => { + const load = vi.fn(async () => DRAFT); + const { last } = mount(load, true); + await waitFor(() => expect(last().checkField('rfa_contract', 'summary', 'write')).toBe(true)); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('with no enclosing provider there is nothing to refine, and nothing is asked', () => { + const load = vi.fn(async () => DRAFT); + const seen: Perms[] = []; + render( + + + , + ); + expect(load).not.toHaveBeenCalled(); + expect(seen[seen.length - 1].checkField('rfa_contract', 'summary', 'write')).toBe(true); + }); +}); diff --git a/ui/packages/permissions/src/index.ts b/ui/packages/permissions/src/index.ts index e8b5b575837..47ee7d6f644 100644 --- a/ui/packages/permissions/src/index.ts +++ b/ui/packages/permissions/src/index.ts @@ -22,6 +22,11 @@ export { PermissionProvider, type PermissionProviderProps } from './PermissionProvider.js'; export { MePermissionsProvider, type MePermissionsProviderProps, type MePermissionsResponse } from './MePermissionsProvider.js'; export { ServerReadScope, type ServerReadScopeProps } from './ServerReadScope.js'; +export { + RecordFieldAccessScope, + type RecordFieldAccessScopeProps, + type RecordFieldAccessAnswer, +} from './RecordFieldAccessScope.js'; export { usePermissions } from './usePermissions.js'; export { useFieldPermissions } from './useFieldPermissions.js'; export { PermissionGuard, type PermissionGuardProps } from './PermissionGuard.js'; diff --git a/ui/packages/plugin-form/src/DrawerForm.tsx b/ui/packages/plugin-form/src/DrawerForm.tsx index 4b8e3f6b5ce..ed6bd2ea98e 100644 --- a/ui/packages/plugin-form/src/DrawerForm.tsx +++ b/ui/packages/plugin-form/src/DrawerForm.tsx @@ -61,6 +61,7 @@ import { type LoadedRecordSnapshot, } from './sanitize'; import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; +import { RecordFieldAccessForForm } from './recordFieldAccess'; import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; import { resolveInitialRecord } from './initialRecord'; @@ -233,7 +234,18 @@ export interface DrawerFormProps { className?: string; } -export const DrawerForm: React.FC = ({ +/** + * An `edit` form for a stored record answers its field locks from that record + * (objectstack#26594) — see `recordFieldAccess.tsx`. + */ +export const DrawerForm: React.FC = ({ schema, ...rest }) => ( + + {/* `schema=` by name, so the handler-key census follows the document into the body. */} + + +); + +const DrawerFormBody: React.FC = ({ schema, dataSource, className, diff --git a/ui/packages/plugin-form/src/ModalForm.tsx b/ui/packages/plugin-form/src/ModalForm.tsx index 7f87066f814..53729c1f367 100644 --- a/ui/packages/plugin-form/src/ModalForm.tsx +++ b/ui/packages/plugin-form/src/ModalForm.tsx @@ -65,6 +65,7 @@ import { type LoadedRecordSnapshot, } from './sanitize'; import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; +import { RecordFieldAccessForForm } from './recordFieldAccess'; import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; import { resolveInitialRecord } from './initialRecord'; @@ -310,7 +311,18 @@ const DIALOG_DEFAULT_COLUMNS = UNDECLARED_SECTION_COLUMNS; const clampColumns = (n: unknown): number | undefined => typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; -export const ModalForm: React.FC = ({ +/** + * An `edit` form for a stored record answers its field locks from that record + * (objectstack#26594) — see `recordFieldAccess.tsx`. + */ +export const ModalForm: React.FC = ({ schema, ...rest }) => ( + + {/* `schema=` by name, so the handler-key census follows the document into the body. */} + + +); + +const ModalFormBody: React.FC = ({ schema, dataSource, className, diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index b41f9006841..7fbcf60da76 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -58,6 +58,7 @@ import { } from './sanitize'; import { formWritePayload } from './writePayload'; import { applyFieldPermissions, closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; +import { RecordFieldAccessForForm } from './recordFieldAccess'; import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { resolveInitialRecord } from './initialRecord'; import { hasInlineFieldSource, isInlineFieldDef, noSubmitTargetError } from './submitTarget'; @@ -214,7 +215,18 @@ function localizeFormLabels( * /> * ``` */ -export const ObjectForm: React.FC = ({ +/** + * An `edit` form for a stored record answers its field locks from that record + * (objectstack#26594) — see `recordFieldAccess.tsx`. + */ +export const ObjectForm: React.FC = ({ schema, ...rest }) => ( + + {/* `schema=` by name, so the handler-key census follows the document into the body. */} + + +); + +const ObjectFormBody: React.FC = ({ schema: rawSchema, dataSource, }) => { diff --git a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 4931e06aa8a..1ba6b1f8e28 100644 --- a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -806,8 +806,8 @@ const WRITE_SITES: Record = { note: "the FALLBACK remove (an `add` config, no host delete) reads the same row the host's delete reads, so it never puts back a delete the host withheld", } as WriteSite, // ── plugin-form ── - 'packages/plugin-form/src/DrawerForm.tsx :: DrawerForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), - 'packages/plugin-form/src/DrawerForm.tsx :: DrawerForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/DrawerForm.tsx :: DrawerFormBody > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/DrawerForm.tsx :: DrawerFormBody > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/EmbeddableForm.tsx :: EmbeddableForm > handleSubmit :: create': outOfFamily('a public embeddable form submits with no signed-in principal, so there is no grant to read; the server\'s public-form policy decides'), 'packages/plugin-form/src/LineItemsPanel.tsx :: LineItemsPanel > save :: batch': @@ -816,8 +816,8 @@ const WRITE_SITES: Record = { ...mapped(['createFormFields', 'editFormFields', 'relatedNew', 'relatedRowDelete'], MASTER_DETAIL, FIELD_WRITE_GATE), note: "the master's fields are its header ObjectForm's; its line grids' add reads relatedNew of the child, and remove relatedRowDelete on an EDIT form only — a create form's batch holds only creates, so a removed line there is never a server delete", } as WriteSite, - 'packages/plugin-form/src/ModalForm.tsx :: ModalForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), - 'packages/plugin-form/src/ModalForm.tsx :: ModalForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/ModalForm.tsx :: ModalFormBody > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/ModalForm.tsx :: ModalFormBody > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/ObjectForm.tsx :: SimpleObjectForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/ObjectForm.tsx :: SimpleObjectForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/SplitForm.tsx :: SplitForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), diff --git a/ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx b/ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx new file mode 100644 index 00000000000..95b7e51d508 --- /dev/null +++ b/ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx @@ -0,0 +1,212 @@ +/** + * 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. + */ + +/** + * objectstack#26594 (ruling B) — the record form's edit dialog asks the server + * for THIS record's field projection and disables only what that answer locks. + * + * The persona is the contract app's finance role: `summary` is locked from + * `approved` onward (`FieldPermissionSchema.when`). `/auth/me/permissions` + * holds no row, so it serves that entry as its lock, and before this card the + * dialog disabled `summary` on the draft the server lets the caller save. + * `ref_no` is locked on every row: the control that a lock the server keeps + * stays locked. + * + * Rows, each for the console's edit dialog (`ModalForm` rendered directly, the + * way `AppContent` mounts it) and for `ObjectForm`'s edit page: + * - a draft the server unlocks → `summary` drawn live, `ref_no` locked, and + * the save sends `summary` (the outbound gate reads the same answer); + * - an approved row → both locked, the save withholds both; + * - a save after which the server judges the row approved → asked again, + * and `summary` locks (the answer follows the stored row, not the mount); + * and the controls: + * - a create form holds no stored row and asks nothing; + * - a data source without the method keeps the row-blind lock. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup, fireEvent, act } from '@testing-library/react'; +import React from 'react'; + +import { MePermissionsProvider } from '@object-ui/permissions'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from './ObjectForm'; +import { ModalForm } from './ModalForm'; + +registerAllFields(); +afterEach(cleanup); + +const FIELDS = { + title: { type: 'text', label: 'Title' }, + summary: { type: 'text', label: 'Summary' }, + ref_no: { type: 'text', label: 'Reference number' }, +}; + +const PRINCIPAL: any = { + authenticated: true, + userId: 'u-1', + tenantId: null, + roles: ['finance'], + permissionSets: ['rfa_fixture_member'], + objects: { rfa_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false } }, + // As `/auth/me/permissions` serves them: the conditioned entry as its lock. + fields: { + 'rfa_contract.summary': { readable: true, editable: false }, + 'rfa_contract.ref_no': { readable: true, editable: false }, + }, +}; + +const ROWS = { + draft: { + record: { id: 'c1', title: 'MSA', summary: 'Draft terms', ref_no: 'R-1' }, + access: { object: 'rfa_contract', recordId: 'c1', readable: ['id', 'title', 'summary', 'ref_no'], editable: ['title', 'summary'] }, + }, + approved: { + record: { id: 'c1', title: 'MSA', summary: 'Final terms', ref_no: 'R-1' }, + access: { object: 'rfa_contract', recordId: 'c1', readable: ['id', 'title', 'summary', 'ref_no'], editable: ['title'] }, + }, +}; + +function makeDS(row: (typeof ROWS)[keyof typeof ROWS], withMethod = true) { + const listeners = new Set<(e: any) => void>(); + return { + getObjectSchema: vi.fn().mockResolvedValue({ name: 'rfa_contract', fields: FIELDS }), + findOne: vi.fn().mockResolvedValue({ ...row.record }), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn().mockResolvedValue({ ...row.record }), + update: vi.fn().mockImplementation(async (resource: string, id: string, data: any) => { + const record = { ...row.record, ...data }; + // As the adapter does: every successful write is reported to `onMutation` subscribers. + for (const listener of listeners) listener({ type: 'update', resource, id, record }); + return record; + }), + onMutation: (listener: (e: any) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ...(withMethod ? { getRecordFieldAccess: vi.fn().mockResolvedValue(row.access) } : {}), + }; +} + +/** Every drawn field → `true` when it kept no enabled control. Read off `document.body`: the modal portals out. */ +function locked(): Record { + const out: Record = {}; + for (const item of document.body.querySelectorAll('[data-field]')) { + const name = item.getAttribute('data-field') as string; + out[name] = item.querySelector('input:not([disabled]), textarea:not([disabled])') === null; + } + return out; +} + +const CONTAINERS: Array<{ container: string; mount: (ds: any, mode: 'edit' | 'create') => React.ReactElement }> = [ + { + container: "the console's edit dialog (ModalForm)", + mount: (ds, mode) => ( + + ), + }, + { + container: 'the edit page (ObjectForm)', + mount: (ds, mode) => ( + + ), + }, +]; + +async function drawn(mode: 'edit' | 'create') { + await waitFor(() => { + const el = document.querySelector('input[name="title"]') as HTMLInputElement | null; + if (!el) throw new Error('form not drawn yet'); + if (mode === 'edit' && el.value !== 'MSA') throw new Error('record not on screen yet'); + }); +} + +describe.each(CONTAINERS)('[#26594] $container', ({ mount }) => { + it("on a draft the server unlocks, the conditioned field is live and the unconditioned lock holds", async () => { + const ds = makeDS(ROWS.draft); + render({mount(ds, 'edit')}); + await drawn('edit'); + await waitFor(() => expect(locked()).toEqual({ title: false, summary: false, ref_no: true })); + expect(ds.getRecordFieldAccess).toHaveBeenCalledWith('rfa_contract', 'c1'); + expect(ds.getRecordFieldAccess).toHaveBeenCalledTimes(1); + }); + + it('the save sends what the record answer leaves writable, and withholds what it locks', async () => { + const ds = makeDS(ROWS.draft); + render({mount(ds, 'edit')}); + await drawn('edit'); + await waitFor(() => expect(locked().summary).toBe(false)); + const summary = document.querySelector('input[name="summary"]') as HTMLInputElement; + fireEvent.change(summary, { target: { value: 'Revised terms' } }); + const form = document.body.querySelector('form') as HTMLFormElement; + await act(async () => { fireEvent.submit(form); }); + await waitFor(() => expect(ds.update).toHaveBeenCalled()); + const sent = ds.update.mock.calls[0][2]; + expect(sent.summary).toBe('Revised terms'); + expect(sent).not.toHaveProperty('ref_no'); + }); + + it('a save that moves the row into the state the condition names locks the field again', async () => { + const ds = makeDS(ROWS.draft); + render({mount(ds, 'edit')}); + await drawn('edit'); + await waitFor(() => expect(locked().summary).toBe(false)); + // The server now judges the stored row approved: the next answer locks `summary`. + ds.getRecordFieldAccess!.mockResolvedValue(ROWS.approved.access); + fireEvent.change(document.querySelector('input[name="title"]') as HTMLInputElement, { target: { value: 'MSA v2' } }); + const form = document.body.querySelector('form') as HTMLFormElement; + await act(async () => { fireEvent.submit(form); }); + await waitFor(() => expect(ds.update).toHaveBeenCalled()); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(locked().summary).toBe(true)); + expect(locked().ref_no).toBe(true); + }); + + it('on a row the condition names, the field stays locked', async () => { + const ds = makeDS(ROWS.approved); + render({mount(ds, 'edit')}); + await drawn('edit'); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(locked()).toEqual({ title: false, summary: true, ref_no: true }); + }); + + it('a create form holds no stored row and asks nothing', async () => { + const ds = makeDS(ROWS.draft); + render({mount(ds, 'create')}); + await drawn('create'); + expect(ds.getRecordFieldAccess).not.toHaveBeenCalled(); + expect(locked()).toEqual({ title: false, summary: true, ref_no: true }); + }); + + it('a data source without the method keeps the row-blind lock', async () => { + const ds = makeDS(ROWS.draft, false); + render({mount(ds, 'edit')}); + await drawn('edit'); + expect(locked()).toEqual({ title: false, summary: true, ref_no: true }); + }); +}); diff --git a/ui/packages/plugin-form/src/recordFieldAccess.tsx b/ui/packages/plugin-form/src/recordFieldAccess.tsx new file mode 100644 index 00000000000..fd3151bb098 --- /dev/null +++ b/ui/packages/plugin-form/src/recordFieldAccess.tsx @@ -0,0 +1,85 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The record a form edits answers its own field locks (objectstack#26594). + * + * A form that edits a STORED record holds a row, so it can ask the server for + * that row's field projection (`DataSource.getRecordFieldAccess`, the + * `GET /data/{object}/{id}/field-access` route) instead of reading only the + * row-blind `/auth/me/permissions` answer. The answer reaches every field gate + * below — `gateFormFields`, `applyFieldPermissions` and the outbound + * `fieldWriteGate`, in every layout — through `RecordFieldAccessScope` + * (`@object-ui/permissions`), because each of them asks `usePermissions()` and + * the scope answers there. Nothing in this module or below it evaluates a + * field permission's `when`: the verdict is the server's write gate, judged on + * the stored row. + * + * Mounted by the three containers a record form can be rendered through + * directly — `ObjectForm` (whose layouts, master-detail header included, all + * draw beneath it), `ModalForm` (the console's edit dialog) and `DrawerForm`. + * A container rendered inside another for the same record reuses the outer + * answer rather than asking again. + * + * Only an `edit` form with a record id asks. A create form holds no stored row, + * a `view` form writes nothing, and a data source without the method (a + * non-ObjectStack backend, a designer preview) keeps the row-blind answer. + * + * The answer belongs to the stored row, so it is asked again whenever that row + * may have moved: a write the data source reports for this record (the form's + * own save included) and a change on the data-invalidation bus for it (the + * signal the form re-reads its record on). A save that moves the row into the + * state a `when` names therefore locks the field again, rather than leaving + * the earlier answer standing. + */ + +import React, { useEffect, useState } from 'react'; +import { RecordFieldAccessScope } from '@object-ui/permissions'; +import { useDataInvalidation } from '@object-ui/react'; +import type { DataSource } from '@object-ui/types'; + +export interface RecordFieldAccessForFormProps { + schema: { objectName?: string; mode?: string; recordId?: string | number | null }; + dataSource?: DataSource | null; + children: React.ReactNode; +} + +export function RecordFieldAccessForForm({ schema, dataSource, children }: RecordFieldAccessForFormProps) { + const objectName = schema?.objectName; + const recordId = schema?.recordId; + const load = dataSource?.getRecordFieldAccess; + const asks = schema?.mode === 'edit' + && !!objectName + && recordId !== undefined && recordId !== null && recordId !== '' + && typeof load === 'function'; + const id = asks ? String(recordId) : undefined; + const busRevision = useDataInvalidation(asks ? objectName : undefined, id); + const [writeRevision, setWriteRevision] = useState(0); + useEffect(() => { + if (!asks || typeof dataSource?.onMutation !== 'function') return; + const object = (objectName as string).toLowerCase(); + return dataSource.onMutation((event) => { + if ((event?.resource ?? '').toLowerCase() !== object) return; + if (event.id != null && String(event.id) !== id) return; + setWriteRevision((n) => n + 1); + }); + }, [asks, dataSource, objectName, id]); + if (!asks) { + return <>{children}; + } + return ( + (load as NonNullable).call(dataSource, object, recordKey)} + revision={busRevision + writeRevision} + > + {children} + + ); +} diff --git a/ui/packages/types/src/data.ts b/ui/packages/types/src/data.ts index f853c646680..8c963c1ddb3 100644 --- a/ui/packages/types/src/data.ts +++ b/ui/packages/types/src/data.ts @@ -343,6 +343,27 @@ export interface DeleteViewResult { published?: ViewHomeDeleteOutcome; } +/** + * The record-scoped field projection (`GET /data/{object}/{id}/field-access`, + * `GetRecordFieldAccessResponse` in `@objectstack/spec`), as + * {@link DataSource.getRecordFieldAccess} returns it. + */ +export interface RecordFieldAccess { + /** Object name. */ + object: string; + /** The record the projection was judged on. */ + recordId: string; + /** Fields the caller may read on the object; reads are judged row-blind. */ + readable: string[]; + /** + * Fields the caller may write on this record, as field-level security + * judges an update of the stored row. The object grant, the record's + * row-level write scope and a field's own rules (readonly, computed types) + * are not part of it. + */ + editable: string[]; +} + /** * The backend-agnostic data contract every adapter implements. * @@ -588,6 +609,22 @@ export interface DataSource { recordId: string, ): Promise<{ requestId: string; status: string }>; + /** + * The field projection of ONE stored record (objectstack#26594): which + * fields the caller may read, and may write on THIS record as field-level + * security judges an update of its stored row — so a field permission whose + * `when` names only some rows locks the field only on those rows. The + * record form's edit dialog reads it through `RecordFieldAccessScope` + * (`@object-ui/permissions`). + * + * `null` means no answer: the record is absent or hidden from the caller, + * or the server offers no record-scoped projection. The caller then keeps + * the row-blind answer `/auth/me/permissions` gives. + * + * Optional — adapters that don't speak to an ObjectStack server can omit it. + */ + getRecordFieldAccess?(resource: string, id: string): Promise; + /** * Get object schema/metadata. * Used by ObjectQL-aware components to auto-generate UI from object metadata. diff --git a/ui/packages/types/src/index.ts b/ui/packages/types/src/index.ts index ba49cb923cd..d9695170063 100644 --- a/ui/packages/types/src/index.ts +++ b/ui/packages/types/src/index.ts @@ -443,6 +443,7 @@ export type { ConnectionState, DeleteViewResult, ViewHomeDeleteOutcome, + RecordFieldAccess, GlobalSearchHit, GlobalSearchResult, BatchRef,