Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/10120-form-omits-fls-denied-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/plugin-form': patch
'@object-ui/fields': patch
---

A form no longer submits — nor offers — a field the CALLER may read but not edit (objectui#10120).

**Clause-②: no** — no exported symbol is added, removed, renamed or retyped, no key on a published payload moves, and no accept set is relaxed. `sanitizeFormData` gains an optional third argument and `fieldWriteGate` / `applyFieldPermissions` are new, but `@object-ui/plugin-form` publishes `.` only, from `index.tsx`, which re-exports neither module — measured on the built `dist/index.d.ts`, where neither name appears. `LookupField`'s props are unchanged. What moves is what the client PUTS on the wire and which controls it draws.

**What it was.** The platform refuses a write to a field the caller's permission set marks `editable: false`, and it cannot tell a round-trip of the value it just served from an attempted write. A form echoing an UNCHANGED `score` back is therefore a 403 for the whole save, even when the user touched only a field they *are* allowed to edit — so the standard edit form was unusable for any role with a field-level restriction on it. The list view's inline grid succeeded on the same record for the same user, because it sends only the changed cell.

The verdict that answers 「may this caller edit this field」 already existed and one path already used it: `checkField(object, field, 'write')` in `@object-ui/permissions`, reading the server's `/me/permissions` grant. What had gone wrong is that each form container spelled its own COPY of the strip and of the render pass, and the family had drifted — the same defect class as objectui#10108, one layer up. Measured on one record with one permission set: the simple form and the modal withheld `score` while the drawer sent it, and the drawer rendered it as a live input while the other two rendered it disabled. One family, three answers; the third container carried neither half.

**What changed, in observable terms.**

- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm` produce the same payloads they did before — their loops were correct, they were just copies.
- The render pass is likewise one function for all three containers. ⚠️ `DrawerForm` previously drew a field the caller may read but not edit as a live input; it now draws it read-only and disabled, exactly as the other two already did. A field the caller may not READ is dropped, also as before.
- Both halves stay fail-open with no `PermissionProvider` / `MePermissionsProvider` mounted, unchanged: a standalone form, a designer preview and a guest surface have no resolvable principal, and the server still enforces.
- A lookup's selected chip no longer offers its remove ✕ when the field is disabled. ⚠️ This is how BOTH refusals reach the widget — a field the object declares `readonly` is folded into `disabled` by the form's section builder, and a field the permission set refuses is marked disabled by the pass above — so a reporter could previously clear a master-detail parent the server would then refuse to unset. The trigger and the browse button were already disabled; the chip's ✕ was the one control the gate had missed. The chips themselves stay: the value is readable, only the affordance goes.

**Deliberately not here.** Submitting only DIRTY fields is objectui#10156, filed with its own risk argument (a false CLEAN silently drops a user's edit and returns 200). Naming the refused field in the 403 instead of the generic console message is owned by the producing side.
114 changes: 114 additions & 0 deletions packages/fields/src/widgets/LookupField.chipRemoveGate-10120.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A non-interactive lookup offers no way to clear its value (objectui#10120).
*
* ## What was measured
*
* `readonly` has always returned a display-only rendering, so it was never the
* gap. `disabled` was: it disabled the picker trigger and the "Browse all
* records" button and left the selected chip's ✕ fully live — one widget,
* two opposite answers to "may this user change this value?", with the live
* one being the only control that could actually change it. Measured on the
* card's form: trigger `disabled=true`, browse `disabled=true`, chip remove
* `disabled=false`.
*
* ## Why `disabled` is the case that matters here
*
* It is how BOTH of this card's refusals arrive at the widget. A field the
* object declares `readonly` is folded into `disabled` by the form's section
* builder, and a field the caller's permission set marks `editable: false` is
* marked `readOnly` AND `disabled` by the form's field-permission pass. So the
* reporter in the card could clear the master-detail parent 「所属填报单」 on a
* sheet line — a write the server then refuses. ⭐ A refusal the UI invites is
* worse than a refusal it prevents.
*
* The chips themselves stay: the value is readable, and hiding it would lose
* information the user is entitled to. Only the affordance goes.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { LookupField } from './LookupField';

afterEach(cleanup);

const SHEETS = [
{ id: 'SHEET1', name: 'Sheet One' },
{ id: 'SHEET2', name: 'Sheet Two' },
];

const makeDataSource = () => ({
find: vi.fn(async (_obj: string, params: any) => {
const wanted: any[] = params?.$filter?.id?.$in ?? [];
return { data: wanted.length ? SHEETS.filter((s) => wanted.includes(s.id)) : SHEETS };
}),
});

const removeButtons = (root: HTMLElement) =>
Array.from(root.querySelectorAll('button[aria-label]'))
.filter((b) => /^Remove /.test(b.getAttribute('aria-label') || ''));

describe('LookupField — the chip remove control follows the field\'s interactivity (objectui#10120)', () => {
it('offers no remove control when the field is disabled, while still showing the value', async () => {
const onChange = vi.fn();
const { container } = render(
<LookupField
value={'SHEET1'}
onChange={onChange}
disabled
dataSource={makeDataSource() as never}
field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet' } as never}
/>,
);
// The value is still READ — a disabled field is not a hidden one.
await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy());
expect(removeButtons(container)).toHaveLength(0);
expect(onChange).not.toHaveBeenCalled();
});

it('offers no remove control on a disabled MULTI-value lookup either — one per chip is one per refusal', async () => {
const { container } = render(
<LookupField
value={['SHEET1', 'SHEET2']}
onChange={() => {}}
disabled
dataSource={makeDataSource() as never}
field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet', multiple: true } as never}
/>,
);
await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy());
expect(screen.getByText('Sheet Two')).toBeTruthy();
expect(removeButtons(container)).toHaveLength(0);
});

it('CONTROL — an editable lookup keeps its remove control, one per selected chip', async () => {
const { container } = render(
<LookupField
value={['SHEET1', 'SHEET2']}
onChange={() => {}}
dataSource={makeDataSource() as never}
field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet', multiple: true } as never}
/>,
);
await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy());
// Without this row the two assertions above would pass on a widget that had
// stopped rendering chips at all.
expect(removeButtons(container)).toHaveLength(2);
});

it('CONTROL — `readonly` was already display-only and is unchanged by this card', async () => {
const { container } = render(
<LookupField
value={'SHEET1'}
onChange={() => {}}
readonly
dataSource={makeDataSource() as never}
field={{ reference_to: 'kpi_entry_sheet', label: 'Sheet' } as never}
/>,
);
await waitFor(() => expect(screen.getByText('Sheet One')).toBeTruthy());
expect(container.querySelectorAll('button')).toHaveLength(0);
});
});
50 changes: 35 additions & 15 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,22 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
</Button>
);

/**
* Whether the selected-value chips offer their remove control.
*
* `readonly` already returned a display-only rendering far above, so the case
* this answers is the DISABLED one — and it is not a rare one: a field the
* object declares `readonly` arrives here as `disabled` (the form's section
* builder folds `field.readonly` into `disabled`), and so does a field the
* caller's field-level security marks `editable: false`. Both disabled the
* picker trigger and the browse button while leaving the chip's ✕ live, so
* the one control that could still CHANGE the value was the one control the
* gate had missed — a reporter could clear a master-detail parent the server
* would then refuse to unset (objectui#10120). ⭐ A refusal the UI invites is
* worse than a refusal it prevents: the chips stay, the affordance goes.
*/
const chipsRemovable = !props.disabled;

return (
<div className={compact ? '' : 'space-y-2'}>
{/* Selected values display (full mode only — compact shows it in-trigger) */}
Expand All @@ -1205,28 +1221,32 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
</AvatarFallback>
</Avatar>
<span className="max-w-[10rem] truncate">{chipLabel}</span>
{chipsRemovable && (
<button
onClick={() => handleRemove(opt?.value)}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
type="button"
aria-label={t('lookup.remove', { label: chipLabel })}
>
<X className="size-3" />
</button>
)}
</span>
);
}
return (
<Badge key={idx} variant="outline" className="gap-1">
{chipLabel}
{chipsRemovable && (
<button
onClick={() => handleRemove(opt?.value)}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="ml-1 hover:text-destructive"
type="button"
aria-label={t('lookup.remove', { label: chipLabel })}
>
<X className="size-3" />
</button>
</span>
);
}
return (
<Badge key={idx} variant="outline" className="gap-1">
{chipLabel}
<button
onClick={() => handleRemove(opt?.value)}
className="ml-1 hover:text-destructive"
type="button"
aria-label={t('lookup.remove', { label: chipLabel })}
>
<X className="size-3" />
</button>
)}
</Badge>
);
})}
Expand Down
37 changes: 31 additions & 6 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import { applyFieldPermissions, fieldWriteGate } from './fieldWriteGate';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { resolveInitialRecord } from './initialRecord';
import { usePermissions } from '@object-ui/permissions';
Expand Down Expand Up @@ -194,7 +195,24 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
className,
}) => {
const { fieldLabel, sectionLabel } = useSafeFieldLabel();
const { userId: currentUserId } = usePermissions();
const perms = usePermissions();
const { userId: currentUserId } = perms;
/**
* FLS gate: drop non-readable fields, render non-editable ones read-only.
* The drawer is the third container of this family and used to carry NEITHER
* half of it — the same edit that the modal and the simple form refused to
* send, this one sent, and the field the other two rendered disabled this one
* rendered as a live input (objectui#10120). One pass, shared.
*/
const applyFieldPerms = useCallback(
(fields: FormField[]): FormField[] =>
applyFieldPermissions(fields, {
perms,
objectName: schema.objectName,
mode: schema.mode,
}) as FormField[],
[perms, schema.objectName, schema.mode],
);
const { t } = useDiscardTranslation();
const previewMode = usePreviewMode();
const [objectSchema, setObjectSchema] = useState<any>(null);
Expand Down Expand Up @@ -407,7 +425,12 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
}

let result;
const payload = sanitizeFormData(data, objectSchema);
// FLS defence-in-depth, inside the ONE outbound filter: react-hook-form
// retains state for unmounted/disabled fields, so the render gate above
// is not enough on its own (objectui#10120).
const payload = sanitizeFormData(data, objectSchema, {
canEdit: fieldWriteGate(perms, schema.objectName),
});
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time. Create only: on an edit form a cleared column is
Expand Down Expand Up @@ -458,7 +481,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
} finally {
setIsSubmitting(false);
}
}, [schema, dataSource, objectSchema, saveWithOcc, formData]);
}, [schema, dataSource, objectSchema, saveWithOcc, formData, perms]);

// Actually close the drawer, firing onCancel only when the close originated
// from the explicit Cancel button.
Expand Down Expand Up @@ -579,7 +602,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
const isCollapsed = collapsedSections[sectionKey] ?? (section.collapsed ?? false);
// Resolved before the divider push so the membership claim below can
// name exactly the fields this group contributes (#6236).
const sectionFields = buildSectionFields(section);
const sectionFields = applyFieldPerms(buildSectionFields(section));

// The ONE path from a section configuration to its divider row
// (objectui#9849) — `projectSectionDivider` owns every key this row
Expand Down Expand Up @@ -644,7 +667,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
const columns = (Number(derivedSections[0]?.columns) || 1) as 1 | 2 | 3 | 4;
const allFields: FormField[] = [];
derivedSections.forEach((section, index) => {
const body = buildSectionFields(section);
const body = applyFieldPerms(buildSectionFields(section));
if (!body.length) return;
const sectionKey = section.name || String(index);
// Only a section that declares itself collapsible can hide its fields —
Expand Down Expand Up @@ -701,7 +724,9 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
}

// Apply auto-layout for flat fields (infer columns + colSpan)
const autoLayoutResult = applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
const autoLayoutResult = applyAutoLayout(
applyFieldPerms(formFields), objectSchema, schema.columns, schema.mode,
);

// Flat fields layout — use container-query grid classes so the form
// responds to the drawer width, not the viewport width.
Expand Down
44 changes: 16 additions & 28 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import { applyFieldPermissions, fieldWriteGate } from './fieldWriteGate';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { resolveInitialRecord } from './initialRecord';
import { usePermissions } from '@object-ui/permissions';
Expand Down Expand Up @@ -219,25 +220,16 @@ export const ModalForm: React.FC<ModalFormProps> = ({
const { t } = useDiscardTranslation();
const previewMode = usePreviewMode();
const perms = usePermissions();
// FLS gate: drop non-readable fields, disable non-editable ones.
// FLS gate: drop non-readable fields, disable non-editable ones. ONE pass,
// shared with `ObjectForm` and `DrawerForm` (objectui#10120).
// Fail-open when no PermissionProvider mounted (perms.isLoaded false).
const applyFieldPerms = useCallback(
(fields: FormField[]): FormField[] => {
if (!perms?.isLoaded) return fields;
const out: FormField[] = [];
for (const f of fields) {
if (!f?.name) { out.push(f); continue; }
const canRead = perms.checkField(schema.objectName, f.name, 'read');
if (!canRead) continue;
const canWrite = perms.checkField(schema.objectName, f.name, 'write');
if (!canWrite && schema.mode !== 'view') {
out.push({ ...f, readOnly: true, disabled: true });
} else {
out.push(f);
}
}
return out;
},
(fields: FormField[]): FormField[] =>
applyFieldPermissions(fields, {
perms,
objectName: schema.objectName,
mode: schema.mode,
}) as FormField[],
[perms, schema.objectName, schema.mode],
);
const [objectSchema, setObjectSchema] = useState<any>(null);
Expand Down Expand Up @@ -460,17 +452,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
}

let result;
let payload = sanitizeFormData(data, objectSchema);
// FLS defence-in-depth: strip non-editable fields from payload.
// react-hook-form retains state for unmounted/disabled fields; we
// must never trust the client to omit them.
if (perms?.isLoaded) {
const stripped: Record<string, any> = {};
for (const k of Object.keys(payload)) {
if (perms.checkField(schema.objectName, k, 'write')) stripped[k] = payload[k];
}
payload = stripped;
}
// FLS defence-in-depth, inside the ONE outbound filter: react-hook-form
// retains state for unmounted/disabled fields, so the gate above is not
// enough on its own — but the verdict is the same resolver's, adapted by
// `fieldWriteGate` rather than copied here (objectui#10120).
const payload = sanitizeFormData(data, objectSchema, {
canEdit: fieldWriteGate(perms, schema.objectName),
});
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time. Create only: on an edit form a cleared column is
Expand Down
Loading
Loading