diff --git a/.changeset/sharing-rule-widgets-i18n-and-orderby.md b/.changeset/sharing-rule-widgets-i18n-and-orderby.md new file mode 100644 index 000000000..bc461eedf --- /dev/null +++ b/.changeset/sharing-rule-widgets-i18n-and-orderby.md @@ -0,0 +1,100 @@ +--- +"@object-ui/core": patch +"@object-ui/types": patch +"@object-ui/fields": patch +"@object-ui/i18n": patch +"@object-ui/components": patch +"@object-ui/plugin-detail": patch +"@object-ui/permissions": patch +--- + +fix(core,fields): a string `$orderby` is a clause, not a character array — and localize the sharing-rule widgets (objectstack#3821) + +**The recipient picker listed nothing, ever.** `QueryParams['$orderby']` was +typed as `Record | string[] | SortObject[]`, so `queryParamsToRecord` sent any +non-array value through `Object.entries`. Handed the clause string `'name asc'` +— which callers do build by hand — it walked the string index by index and +emitted `$orderby=0 n,1 a,2 m,3 e,4 ,5 a,6 s,7 c`. The server sorted by columns +that don't exist and every row was filtered out, so +`sys_sharing_rule.recipient_id` rendered "No matches" for every recipient type +and no sharing rule could be created from the Console. `ObjectGrid` builds the +same shape from a schema-level `sort` in three places, so grids with a string +sort silently showed an empty table. + +A string `$orderby` is now passed through verbatim (the server's OData +normalizer has always parsed `'name asc'`), and the type admits `string`. +`RecipientPickerField` additionally switched to the structured +`{ name: 'asc' }` form so it can't regress this way against any data source. + +**The three sharing-rule authoring widgets never had translations.** +`ObjectRefField`, `RecipientPickerField` and `FilterConditionField` hardcoded +their English copy — a Chinese Console showed "Select an object", "Select a +user", "Search…", "No matches", "Edit as JSON". They now go through +`useFieldTranslation` like every other widget, with keys added under `fields.*` +in all ten locales. + +The recipient placeholder was the interesting one: it read +`` `Select a ${recipientType.replace(/_/g,' ')}` ``, interpolating the enum +value into an English sentence — a shape no locale can translate. It is now a +per-type key (`fields.recipient.selectUser`, `…selectBusinessUnit`, …), so +"选择业务单元" and "Select a business unit" no longer have to share a structure. + +**Editing a rule silently dropped its recipient.** The picker resets the stored +id when `recipient_type` changes, because an id valid for a user is meaningless +for a team. It treated the edit form's `'' → 'user'` hydration as such a change: +opening any saved rule blanked the recipient, and saving persisted the blank. +Only a non-empty predecessor now counts as a type switch. + +**Building a filter submitted the surrounding form.** None of `FilterBuilder`'s +controls declared `type="button"`, and a bare ` + , + ); +} + +const CONDITION = { + id: 'c1', + logic: 'and', + conditions: [{ id: 'c1a', field: 'title', operator: 'equals', value: 'x' }], +}; + +describe('FilterBuilder never submits its surrounding form', () => { + it('adding a condition does not submit', () => { + const onSubmit = vi.fn((e: any) => e.preventDefault()); + renderInForm(onSubmit); + fireEvent.click(screen.getByRole('button', { name: /add filter|添加筛选/i })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('removing a condition does not submit', () => { + const onSubmit = vi.fn((e: any) => e.preventDefault()); + renderInForm(onSubmit, CONDITION); + fireEvent.click(screen.getByRole('button', { name: /remove condition|移除条件|删除条件/i })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('clear-all does not submit', () => { + const onSubmit = vi.fn((e: any) => e.preventDefault()); + renderInForm(onSubmit, CONDITION); + fireEvent.click(screen.getByRole('button', { name: /clear all|清除全部/i })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('every FilterBuilder control declares an explicit button type', () => { + const { container } = renderInForm(vi.fn(), CONDITION); + const implicitSubmit = Array.from(container.querySelectorAll('button')).filter( + (b) => !b.getAttribute('type') && b.textContent !== 'save', + ); + expect(implicitSubmit.map((b) => b.textContent)).toEqual([]); + }); +}); diff --git a/packages/components/src/__tests__/form-write-error-message.test.tsx b/packages/components/src/__tests__/form-write-error-message.test.tsx new file mode 100644 index 000000000..53449eb6f --- /dev/null +++ b/packages/components/src/__tests__/form-write-error-message.test.tsx @@ -0,0 +1,86 @@ +/** + * 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#3821 — a rejected write is user-facing copy, not server + * diagnostics. + * + * The form used to render `error.message` verbatim, so a sharing/RLS denial + * surfaced in the dialog and the toast as + * `FORBIDDEN: insufficient privileges to update showcase_private_note + * pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and + * the record id to whoever hit it. + */ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; + +beforeAll(async () => { + await import('../renderers'); +}, 30000); + +const SCHEMA = { + type: 'form', + fields: [{ name: 'title', label: 'Title', type: 'text' }], + submitText: 'Save', +} as any; + +function forbiddenError() { + const err: any = new Error( + 'FORBIDDEN: insufficient privileges to update showcase_private_note pi-TgoJ4_DM55Fqz', + ); + err.code = 'FORBIDDEN'; + err.status = 403; + return err; +} + +function renderForm(onAction: () => Promise) { + const Form = ComponentRegistry.get('form')!; + // `onAction` is a PROP on the renderer, not part of the schema. + return render(
); +} + +describe('form write errors are user-facing copy', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('shows a permission message instead of the raw FORBIDDEN text', async () => { + renderForm(async () => { throw forbiddenError(); }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + const alert = await screen.findByText(/permission to save/i); + expect(alert).toBeInTheDocument(); + }); + + it('never leaks the object machine name or the record id', async () => { + const { container } = renderForm(async () => { throw forbiddenError(); }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + await screen.findByText(/permission to save/i); + expect(container.textContent).not.toMatch(/showcase_private_note/); + expect(container.textContent).not.toMatch(/pi-TgoJ4_DM55Fqz/); + expect(container.textContent).not.toMatch(/FORBIDDEN/); + }); + + it('keeps the server text for non-permission failures — it is the useful part', async () => { + renderForm(async () => { throw new Error('Title must be unique'); }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + expect(await screen.findByText(/title must be unique/i)).toBeInTheDocument(); + }); + + it('falls back to a generic localized message when the error carries no text', async () => { + renderForm(async () => { throw {}; }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + expect(await screen.findByText(/could not save/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/custom/combobox.tsx b/packages/components/src/custom/combobox.tsx index 63565f91e..a1e73a2ee 100644 --- a/packages/components/src/custom/combobox.tsx +++ b/packages/components/src/custom/combobox.tsx @@ -62,16 +62,24 @@ export function Combobox({ variant="outline" role="combobox" aria-expanded={open} - className={cn("w-[200px] justify-between", className)} + className={cn("w-[200px] min-w-0 justify-between", className)} disabled={disabled} > - {value - ? options.find((option) => option.value === value)?.label - : placeholder} + {/* The label is a flex child, so it needs `truncate` + `min-w-0` of + its own — without them a long option ("成员 + (showcase_project_membership)") rendered straight past the + button's border instead of ellipsizing (objectstack#3821). */} + + {value + ? options.find((option) => option.value === value)?.label + : placeholder} + - + {/* Match the trigger instead of a hardcoded 200px: a combobox widened by + its consumer had its own options clipped in the dropdown. */} + diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 88e494664..9aa1dc4f8 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -390,6 +390,7 @@ function FilterBuilder({ {t('filterBuilder.where')} {filterGroup.conditions.length > 1 && ( ); diff --git a/packages/fields/src/widgets/ObjectRefField.tsx b/packages/fields/src/widgets/ObjectRefField.tsx index 2f4d25eac..22d4ec2b9 100644 --- a/packages/fields/src/widgets/ObjectRefField.tsx +++ b/packages/fields/src/widgets/ObjectRefField.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Combobox, EmptyValue, cn } from '@object-ui/components'; import { SchemaRendererContext } from '@object-ui/react'; import type { FieldWidgetProps } from './types'; +import { useFieldTranslation } from './useFieldTranslation'; /** * ObjectRefField — object-name picker for form fields that store the machine @@ -33,6 +34,7 @@ export function ObjectRefField({ ...props }: FieldWidgetProps) { const ctx = React.useContext(SchemaRendererContext); + const { t } = useFieldTranslation(); const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null; const disabled = (props as any).disabled as boolean | undefined; @@ -94,11 +96,14 @@ export function ObjectRefField({ options={options} value={value ?? ''} onValueChange={(v) => onChange(v as any)} - placeholder={objects === null ? 'Loading objects…' : 'Select an object'} - searchPlaceholder="Search objects…" - emptyText={objects === null ? 'Loading…' : 'No objects found'} + placeholder={objects === null ? t('fields.objectRef.loading') : t('fields.objectRef.placeholder')} + searchPlaceholder={t('fields.objectRef.search')} + emptyText={objects === null ? t('common.loading') : t('fields.objectRef.empty')} disabled={disabled} - className={cn(className)} + // Fill the form column like every other input. Combobox's own + // `w-[200px]` is a component default that left this control stranded at + // a third of the row while `名称` beside it ran full width. + className={cn('w-full', className)} /> ); } diff --git a/packages/fields/src/widgets/RecipientPickerField.test.tsx b/packages/fields/src/widgets/RecipientPickerField.test.tsx new file mode 100644 index 000000000..17d532f8b --- /dev/null +++ b/packages/fields/src/widgets/RecipientPickerField.test.tsx @@ -0,0 +1,157 @@ +/** + * 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. + */ + +/** + * RecipientPickerField — the sharing-rule recipient picker (objectstack#3821). + * + * Two regressions are pinned here: + * 1. the candidate query must use the STRUCTURED `$orderby` form. It used to + * pass the clause string `'name asc'`, which ApiDataSource walked + * character by character into `0 n,1 a,2 m,…`; the server then sorted by + * columns that don't exist and every recipient list came back empty, so + * the control read "No matches" forever and no rule could be saved. + * 2. the placeholder must come from a per-type i18n key, not from + * interpolating the enum value into an English sentence. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { RecipientPickerField } from './RecipientPickerField'; + +const USERS = [ + { id: 'usr_1', name: 'Ada Auditor' }, + { id: 'usr_2', name: 'Bob Builder' }, +]; + +function mockDataSource(rows: any[] = USERS) { + return { find: vi.fn().mockResolvedValue({ data: rows }) } as any; +} + +function renderPicker(recipientType: string, dataSource: any, value = '') { + return render( + , + ); +} + +describe('RecipientPickerField — candidate query', () => { + it('orders candidates with the structured $orderby form, never a clause string', async () => { + const ds = mockDataSource(); + renderPicker('user', ds); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + const [object, params] = ds.find.mock.calls[0]; + expect(object).toBe('sys_user'); + expect(params.$orderby).toEqual({ name: 'asc' }); + // A bare string is what regressed; assert the shape explicitly. + expect(typeof params.$orderby).not.toBe('string'); + }); + + it('queries the object mapped to each recipient type', async () => { + for (const [type, object] of [ + ['user', 'sys_user'], + ['team', 'sys_team'], + ['business_unit', 'sys_business_unit'], + ['unit_and_subordinates', 'sys_business_unit'], + ['position', 'sys_position'], + ] as const) { + const ds = mockDataSource([]); + const { unmount } = renderPicker(type, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(ds.find.mock.calls[0][0]).toBe(object); + unmount(); + } + }); + + it('shows the loaded records as options once the list is opened', async () => { + const ds = mockDataSource(); + renderPicker('user', ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole('combobox')); + expect(await screen.findByText('Ada Auditor')).toBeInTheDocument(); + expect(screen.getByText('Bob Builder')).toBeInTheDocument(); + }); +}); + +describe('RecipientPickerField — keeping the stored recipient', () => { + function renderControlled(initialType: string, value: string, onChange: any) { + const ds = mockDataSource(); + const view = render( + , + ); + const rerenderWith = (type: string) => + view.rerender( + , + ); + return { ...view, rerenderWith }; + } + + it('keeps the saved recipient when the type arrives late (edit form hydrating)', async () => { + // The edit dialog mounts the widget before the record lands, so + // recipient_type is '' on the first render and 'user' on the next. That is + // hydration, not the admin changing the type — clearing there wiped the + // saved recipient and the blank got persisted on save. + const onChange = vi.fn(); + const { rerenderWith } = renderControlled('', 'usr_1', onChange); + rerenderWith('user'); + await waitFor(() => expect(onChange).not.toHaveBeenCalled()); + }); + + it('clears the recipient when the admin actually switches type', async () => { + const onChange = vi.fn(); + const { rerenderWith } = renderControlled('user', 'usr_1', onChange); + rerenderWith('team'); + await waitFor(() => expect(onChange).toHaveBeenCalledWith('')); + }); + + it('does not clear when the type is unset back to empty', async () => { + const onChange = vi.fn(); + const { rerenderWith } = renderControlled('user', 'usr_1', onChange); + rerenderWith(''); + await waitFor(() => expect(onChange).not.toHaveBeenCalled()); + }); +}); + +describe('RecipientPickerField — localizable copy', () => { + it('takes the placeholder from a per-type key rather than the raw enum value', async () => { + // With no I18nProvider the widget falls back to the English defaults, so + // the assertion is on the *wording*: 'Select a business unit' can only come + // from the keyed string — interpolating the enum produced + // 'Select a business unit' only by accident and 'Select a unit and + // subordinates' for the next type over. + const ds = mockDataSource([]); + renderPicker('unit_and_subordinates', ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(await screen.findByText('Select a business unit')).toBeInTheDocument(); + expect(screen.queryByText(/unit and subordinates/i)).not.toBeInTheDocument(); + }); + + it('prompts for a recipient type before one is chosen', () => { + renderPicker('', mockDataSource([])); + expect(screen.getByText('Select a recipient type first.')).toBeInTheDocument(); + }); +}); diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index dca7e2807..8801123eb 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Combobox, EmptyValue, cn } from '@object-ui/components'; import { SchemaRendererContext } from '@object-ui/react'; import type { FieldWidgetProps } from './types'; +import { useFieldTranslation } from './useFieldTranslation'; /** * RecipientPickerField — dependent record picker for a polymorphic recipient @@ -31,14 +32,20 @@ interface RecipientMapping { storeField: 'id' | 'name'; /** Candidate display-label fields, in preference order. */ labelFields: string[]; + /** + * i18n key for the "choose one" placeholder. Keyed per type rather than + * interpolating the enum value into an English sentence — "Select a + * business unit" and "选择业务单元" share no structure (objectstack#3821). + */ + placeholderKey: string; } const TYPE_TO_OBJECT: Record = { - user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] }, - team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] }, - business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] }, - unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] }, - position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] }, + user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' }, + team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' }, + business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' }, + unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' }, + position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' }, }; export function RecipientPickerField({ @@ -49,6 +56,7 @@ export function RecipientPickerField({ ...props }: FieldWidgetProps) { const ctx = React.useContext(SchemaRendererContext); + const { t } = useFieldTranslation(); const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null; const disabled = (props as any).disabled as boolean | undefined; const dependentValues: Record = (props as any).dependentValues ?? {}; @@ -57,12 +65,18 @@ export function RecipientPickerField({ const [records, setRecords] = React.useState(null); - // Reset the stored recipient when the type changes AFTER mount (an id for a - // user is not a valid team/business-unit id). The ref starts null so the - // initial render of an existing rule never clears its value. - const prevType = React.useRef(null); + // Reset the stored recipient when the admin PICKS a different type (an id for + // a user is not a valid team/business-unit id). + // + // "Different type" means one non-empty type replacing another. The empty + // string is not a type — it is the edit form before the record has hydrated, + // and treating `'' → 'user'` as a change wiped the saved recipient the moment + // an existing rule was opened for editing (objectstack#3821): the picker went + // blank, and saving persisted the blank. Only a non-empty predecessor can + // invalidate the stored id. + const prevType = React.useRef(''); React.useEffect(() => { - if (prevType.current !== null && prevType.current !== recipientType && value) { + if (prevType.current && recipientType && prevType.current !== recipientType && value) { onChange('' as any); } prevType.current = recipientType; @@ -75,7 +89,11 @@ export function RecipientPickerField({ let cancelled = false; (async () => { try { - const res = await dataSource.find(mapping.object, { $top: 500, $orderby: 'name asc' }); + // Object form, not the 'name asc' clause string: the clause string is + // supported again (objectstack#3821 fixed ApiDataSource walking it + // character by character), but the structured form can't regress that + // way for any data source. + const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } }); const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []); if (!cancelled) setRecords(Array.isArray(list) ? list : []); } catch { @@ -105,7 +123,7 @@ export function RecipientPickerField({ if (!recipientType) { return (

- Select a recipient type first. + {t('fields.recipient.selectTypeFirst')}

); } @@ -137,12 +155,14 @@ export function RecipientPickerField({ value={value ?? ''} onValueChange={(v) => onChange(v as any)} placeholder={ - records === null ? 'Loading…' : `Select a ${recipientType.replace(/_/g, ' ')}` + records === null + ? t('fields.recipient.loading') + : t(mapping.placeholderKey ?? 'fields.recipient.select') } - searchPlaceholder="Search…" - emptyText={records === null ? 'Loading…' : 'No matches'} + searchPlaceholder={t('fields.recipient.search')} + emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')} disabled={disabled} - className={className} + className={cn('w-full', className)} /> ); } diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 2f753ab02..2227c74d3 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -39,6 +39,30 @@ const FIELD_DEFAULTS: Record = { 'lookup.nextPage': 'Next page', 'lookup.jumpToPage': 'Jump to page', 'lookup.retry': 'Retry', + // objectstack#3821 — sharing-rule authoring widgets (object-ref / + // recipient-picker / filter-condition). The recipient placeholder is keyed + // PER TYPE rather than interpolating the enum value into an English + // sentence, which no locale could translate. + 'fields.objectRef.loading': 'Loading objects…', + 'fields.objectRef.placeholder': 'Select an object', + 'fields.objectRef.search': 'Search objects…', + 'fields.objectRef.empty': 'No objects found', + 'fields.recipient.selectTypeFirst': 'Select a recipient type first.', + 'fields.recipient.loading': 'Loading…', + 'fields.recipient.search': 'Search…', + 'fields.recipient.empty': 'No matches', + 'fields.recipient.select': 'Select a recipient', + 'fields.recipient.selectUser': 'Select a user', + 'fields.recipient.selectTeam': 'Select a team', + 'fields.recipient.selectBusinessUnit': 'Select a business unit', + 'fields.recipient.selectPosition': 'Select a position', + 'fields.recipient.selectUnitAndSubordinates': 'Select a business unit', + 'fields.filterCondition.selectObjectFirst': 'Select an object first.', + 'fields.filterCondition.allRecords': 'All records', + 'fields.filterCondition.invalidJson': 'Invalid JSON — the rule will match no records until fixed.', + 'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON', + 'fields.filterCondition.editAsJson': 'Edit as JSON', + 'fields.filterCondition.useVisualBuilder': 'Use visual builder', // objectui#2600 B5 — capability picker scope group headers. 'capability.group.platform': 'Platform', 'capability.group.org': 'Organization', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index b3766cb94..99f05f4f7 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -107,6 +107,8 @@ const ar = { type: "{{field}} يجب أن يكون {{type}} صالحاً", }, form: { + noPermissionToSave: "ليس لديك إذن لحفظ هذا السجل.", + submitFailed: "تعذّر الحفظ. يرجى المحاولة مرة أخرى.", discardTitle: "تجاهل التغييرات؟", discardMessage: "لديك تغييرات غير محفوظة. إذا أغلقت هذا النموذج الآن، ستفقد تعديلاتك.", keepEditing: "متابعة التحرير", @@ -168,6 +170,32 @@ const ar = { basicEditorHint: "محرر النص الغني (أساسي)", placeholder: "اكتب شيئاً...", }, + objectRef: { + loading: "جارٍ تحميل الكائنات…", + placeholder: "اختر كائناً", + search: "بحث في الكائنات…", + empty: "لم يتم العثور على كائنات", + }, + recipient: { + selectTypeFirst: "اختر نوع المستلم أولاً.", + loading: "جارٍ التحميل…", + search: "بحث…", + empty: "لا توجد نتائج مطابقة", + select: "اختر مستلماً", + selectUser: "اختر مستخدماً", + selectTeam: "اختر فريقاً", + selectBusinessUnit: "اختر وحدة عمل", + selectPosition: "اختر منصباً", + selectUnitAndSubordinates: "اختر وحدة عمل", + }, + filterCondition: { + selectObjectFirst: "اختر كائناً أولاً.", + allRecords: "كل السجلات", + invalidJson: "JSON غير صالح — لن تطابق القاعدة أي سجل حتى يتم إصلاحه.", + jsonOnly: "لا يمكن تحرير هذا المعيار إلا بصيغة JSON", + editAsJson: "التحرير بصيغة JSON", + useVisualBuilder: "استخدام المُنشئ المرئي", + }, }, table: { rowsPerPage: "صفوف في الصفحة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index c1f832717..5e56ba047 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -107,6 +107,8 @@ const de = { type: "{{field}} muss ein gültiger {{type}} sein", }, form: { + noPermissionToSave: "Sie haben keine Berechtigung, diesen Datensatz zu speichern.", + submitFailed: "Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.", discardTitle: "Änderungen verwerfen?", discardMessage: "Sie haben ungespeicherte Änderungen. Wenn Sie dieses Formular jetzt schließen, gehen Ihre Bearbeitungen verloren.", keepEditing: "Weiter bearbeiten", @@ -168,6 +170,32 @@ const de = { basicEditorHint: "Rich-Text-Editor (einfach)", placeholder: "Text eingeben...", }, + objectRef: { + loading: "Objekte werden geladen…", + placeholder: "Objekt auswählen", + search: "Objekte suchen…", + empty: "Keine Objekte gefunden", + }, + recipient: { + selectTypeFirst: "Wählen Sie zuerst einen Empfängertyp.", + loading: "Wird geladen…", + search: "Suchen…", + empty: "Keine Treffer", + select: "Empfänger auswählen", + selectUser: "Benutzer auswählen", + selectTeam: "Team auswählen", + selectBusinessUnit: "Geschäftseinheit auswählen", + selectPosition: "Position auswählen", + selectUnitAndSubordinates: "Geschäftseinheit auswählen", + }, + filterCondition: { + selectObjectFirst: "Wählen Sie zuerst ein Objekt.", + allRecords: "Alle Datensätze", + invalidJson: "Ungültiges JSON — die Regel trifft auf keinen Datensatz zu, bis dies behoben ist.", + jsonOnly: "Dieses Kriterium kann nur als JSON bearbeitet werden", + editAsJson: "Als JSON bearbeiten", + useVisualBuilder: "Visuellen Builder verwenden", + }, }, table: { rowsPerPage: "Zeilen pro Seite", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f874021ee..ea936fc38 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -113,6 +113,8 @@ const en = { type: '{{field}} must be a valid {{type}}', }, form: { + noPermissionToSave: "You don't have permission to save this record.", + submitFailed: 'Could not save. Please try again.', addItem: 'Add item', removeItem: 'Remove item', fieldRequired: 'This field is required', @@ -174,6 +176,32 @@ const en = { basicEditorHint: 'Rich text editor (basic)', placeholder: 'Enter text...', }, + objectRef: { + loading: 'Loading objects…', + placeholder: 'Select an object', + search: 'Search objects…', + empty: 'No objects found', + }, + recipient: { + selectTypeFirst: 'Select a recipient type first.', + loading: 'Loading…', + search: 'Search…', + empty: 'No matches', + select: 'Select a recipient', + selectUser: 'Select a user', + selectTeam: 'Select a team', + selectBusinessUnit: 'Select a business unit', + selectPosition: 'Select a position', + selectUnitAndSubordinates: 'Select a business unit', + }, + filterCondition: { + selectObjectFirst: 'Select an object first.', + allRecords: 'All records', + invalidJson: 'Invalid JSON — the rule will match no records until fixed.', + jsonOnly: 'This criteria can only be edited as JSON', + editAsJson: 'Edit as JSON', + useVisualBuilder: 'Use visual builder', + }, }, table: { rowsPerPage: 'Rows per page', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8177c282d..5aee764cb 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -107,6 +107,8 @@ const es = { type: "{{field}} debe ser un {{type}} válido", }, form: { + noPermissionToSave: "No tienes permiso para guardar este registro.", + submitFailed: "No se pudo guardar. Inténtalo de nuevo.", discardTitle: "¿Descartar los cambios?", discardMessage: "Tiene cambios sin guardar. Si cierra este formulario ahora, sus ediciones se perderán.", keepEditing: "Seguir editando", @@ -168,6 +170,32 @@ const es = { basicEditorHint: "Editor de texto enriquecido (básico)", placeholder: "Escribe algo...", }, + objectRef: { + loading: "Cargando objetos…", + placeholder: "Seleccionar un objeto", + search: "Buscar objetos…", + empty: "No se encontraron objetos", + }, + recipient: { + selectTypeFirst: "Selecciona primero un tipo de destinatario.", + loading: "Cargando…", + search: "Buscar…", + empty: "Sin coincidencias", + select: "Seleccionar un destinatario", + selectUser: "Seleccionar un usuario", + selectTeam: "Seleccionar un equipo", + selectBusinessUnit: "Seleccionar una unidad de negocio", + selectPosition: "Seleccionar un puesto", + selectUnitAndSubordinates: "Seleccionar una unidad de negocio", + }, + filterCondition: { + selectObjectFirst: "Selecciona primero un objeto.", + allRecords: "Todos los registros", + invalidJson: "JSON no válido: la regla no coincidirá con ningún registro hasta que se corrija.", + jsonOnly: "Este criterio solo se puede editar como JSON", + editAsJson: "Editar como JSON", + useVisualBuilder: "Usar el constructor visual", + }, }, table: { rowsPerPage: "Filas por página", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 878bb140e..c1190b3da 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -107,6 +107,8 @@ const fr = { type: "{{field}} doit être un {{type}} valide", }, form: { + noPermissionToSave: "Vous n'avez pas l'autorisation d'enregistrer cet enregistrement.", + submitFailed: "Échec de l'enregistrement. Veuillez réessayer.", discardTitle: "Abandonner les modifications ?", discardMessage: "Vous avez des modifications non enregistrées. Si vous fermez ce formulaire maintenant, vos modifications seront perdues.", keepEditing: "Continuer l'édition", @@ -168,6 +170,32 @@ const fr = { basicEditorHint: "Éditeur de texte enrichi (basique)", placeholder: "Écrivez quelque chose...", }, + objectRef: { + loading: "Chargement des objets…", + placeholder: "Sélectionner un objet", + search: "Rechercher des objets…", + empty: "Aucun objet trouvé", + }, + recipient: { + selectTypeFirst: "Sélectionnez d'abord un type de destinataire.", + loading: "Chargement…", + search: "Rechercher…", + empty: "Aucune correspondance", + select: "Sélectionner un destinataire", + selectUser: "Sélectionner un utilisateur", + selectTeam: "Sélectionner une équipe", + selectBusinessUnit: "Sélectionner une unité opérationnelle", + selectPosition: "Sélectionner un poste", + selectUnitAndSubordinates: "Sélectionner une unité opérationnelle", + }, + filterCondition: { + selectObjectFirst: "Sélectionnez d'abord un objet.", + allRecords: "Tous les enregistrements", + invalidJson: "JSON invalide — la règle ne correspondra à aucun enregistrement tant qu'elle n'est pas corrigée.", + jsonOnly: "Ce critère ne peut être modifié qu'en JSON", + editAsJson: "Modifier en JSON", + useVisualBuilder: "Utiliser le constructeur visuel", + }, }, table: { rowsPerPage: "Lignes par page", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index cc3ac986c..54136a0f4 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -107,6 +107,8 @@ const ja = { type: "{{field}}は有効な{{type}}である必要があります", }, form: { + noPermissionToSave: "このレコードを保存する権限がありません。", + submitFailed: "保存できませんでした。もう一度お試しください。", discardTitle: "変更を破棄しますか?", discardMessage: "保存されていない変更があります。このままフォームを閉じると編集内容は失われます。", keepEditing: "編集を続ける", @@ -168,6 +170,32 @@ const ja = { basicEditorHint: "リッチテキストエディター(基本)", placeholder: "テキストを入力...", }, + objectRef: { + loading: "オブジェクトを読み込み中…", + placeholder: "オブジェクトを選択", + search: "オブジェクトを検索…", + empty: "オブジェクトが見つかりません", + }, + recipient: { + selectTypeFirst: "先に受信者タイプを選択してください。", + loading: "読み込み中…", + search: "検索…", + empty: "一致する項目がありません", + select: "受信者を選択", + selectUser: "ユーザーを選択", + selectTeam: "チームを選択", + selectBusinessUnit: "事業単位を選択", + selectPosition: "役職を選択", + selectUnitAndSubordinates: "事業単位を選択", + }, + filterCondition: { + selectObjectFirst: "先にオブジェクトを選択してください。", + allRecords: "すべてのレコード", + invalidJson: "JSON が不正です — 修正するまでこのルールはどのレコードにも一致しません。", + jsonOnly: "この条件は JSON でのみ編集できます", + editAsJson: "JSON で編集", + useVisualBuilder: "ビジュアルビルダーを使用", + }, }, table: { rowsPerPage: "1ページの行数", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2e198a15b..39181ad2b 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -107,6 +107,8 @@ const ko = { type: "{{field}}은(는) 유효한 {{type}}이어야 합니다", }, form: { + noPermissionToSave: "이 레코드를 저장할 권한이 없습니다.", + submitFailed: "저장하지 못했습니다. 다시 시도해 주세요.", discardTitle: "변경 내용을 버릴까요?", discardMessage: "저장하지 않은 변경 내용이 있습니다. 지금 이 양식을 닫으면 편집 내용이 사라집니다.", keepEditing: "계속 편집", @@ -168,6 +170,32 @@ const ko = { basicEditorHint: "서식 있는 텍스트 편집기 (기본)", placeholder: "내용을 입력하세요...", }, + objectRef: { + loading: "객체를 불러오는 중…", + placeholder: "객체 선택", + search: "객체 검색…", + empty: "객체를 찾을 수 없습니다", + }, + recipient: { + selectTypeFirst: "먼저 수신자 유형을 선택하세요.", + loading: "불러오는 중…", + search: "검색…", + empty: "일치하는 항목 없음", + select: "수신자 선택", + selectUser: "사용자 선택", + selectTeam: "팀 선택", + selectBusinessUnit: "사업 단위 선택", + selectPosition: "직위 선택", + selectUnitAndSubordinates: "사업 단위 선택", + }, + filterCondition: { + selectObjectFirst: "먼저 객체를 선택하세요.", + allRecords: "모든 레코드", + invalidJson: "잘못된 JSON — 수정하기 전까지 이 규칙은 어떤 레코드와도 일치하지 않습니다.", + jsonOnly: "이 조건은 JSON으로만 편집할 수 있습니다", + editAsJson: "JSON으로 편집", + useVisualBuilder: "비주얼 빌더 사용", + }, }, table: { rowsPerPage: "페이지당 행 수", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9cbfebaec..0497a122d 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -107,6 +107,8 @@ const pt = { type: "{{field}} deve ser um {{type}} válido", }, form: { + noPermissionToSave: "Você não tem permissão para salvar este registro.", + submitFailed: "Não foi possível salvar. Tente novamente.", discardTitle: "Descartar as alterações?", discardMessage: "Você tem alterações não salvas. Se fechar este formulário agora, suas edições serão perdidas.", keepEditing: "Continuar editando", @@ -168,6 +170,32 @@ const pt = { basicEditorHint: "Editor de texto rico (básico)", placeholder: "Digite algo...", }, + objectRef: { + loading: "Carregando objetos…", + placeholder: "Selecionar um objeto", + search: "Pesquisar objetos…", + empty: "Nenhum objeto encontrado", + }, + recipient: { + selectTypeFirst: "Selecione primeiro um tipo de destinatário.", + loading: "Carregando…", + search: "Pesquisar…", + empty: "Nenhuma correspondência", + select: "Selecionar um destinatário", + selectUser: "Selecionar um usuário", + selectTeam: "Selecionar uma equipe", + selectBusinessUnit: "Selecionar uma unidade de negócio", + selectPosition: "Selecionar um cargo", + selectUnitAndSubordinates: "Selecionar uma unidade de negócio", + }, + filterCondition: { + selectObjectFirst: "Selecione primeiro um objeto.", + allRecords: "Todos os registros", + invalidJson: "JSON inválido — a regra não corresponderá a nenhum registro até ser corrigida.", + jsonOnly: "Este critério só pode ser editado como JSON", + editAsJson: "Editar como JSON", + useVisualBuilder: "Usar o construtor visual", + }, }, table: { rowsPerPage: "Linhas por página", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 911c907d5..ffecf8aac 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -107,6 +107,8 @@ const ru = { type: "{{field}} должно быть допустимым {{type}}", }, form: { + noPermissionToSave: "У вас нет прав на сохранение этой записи.", + submitFailed: "Не удалось сохранить. Попробуйте ещё раз.", discardTitle: "Отменить изменения?", discardMessage: "Есть несохранённые изменения. Если закрыть форму сейчас, правки будут потеряны.", keepEditing: "Продолжить редактирование", @@ -168,6 +170,32 @@ const ru = { basicEditorHint: "Редактор форматированного текста (базовый)", placeholder: "Введите текст...", }, + objectRef: { + loading: "Загрузка объектов…", + placeholder: "Выберите объект", + search: "Поиск объектов…", + empty: "Объекты не найдены", + }, + recipient: { + selectTypeFirst: "Сначала выберите тип получателя.", + loading: "Загрузка…", + search: "Поиск…", + empty: "Совпадений нет", + select: "Выберите получателя", + selectUser: "Выберите пользователя", + selectTeam: "Выберите команду", + selectBusinessUnit: "Выберите бизнес-подразделение", + selectPosition: "Выберите должность", + selectUnitAndSubordinates: "Выберите бизнес-подразделение", + }, + filterCondition: { + selectObjectFirst: "Сначала выберите объект.", + allRecords: "Все записи", + invalidJson: "Некорректный JSON — правило не будет соответствовать ни одной записи, пока это не исправлено.", + jsonOnly: "Этот критерий можно редактировать только как JSON", + editAsJson: "Редактировать как JSON", + useVisualBuilder: "Использовать визуальный конструктор", + }, }, table: { rowsPerPage: "Строк на странице", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index f522a1940..a06501fdc 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -112,6 +112,8 @@ const zh = { type: '{{field}}必须是有效的{{type}}', }, form: { + noPermissionToSave: '您没有权限保存这条记录。', + submitFailed: '保存失败,请重试。', addItem: '添加项目', removeItem: '移除项目', fieldRequired: '此字段为必填项', @@ -173,6 +175,32 @@ const zh = { basicEditorHint: '富文本编辑器(基础)', placeholder: '请输入文字...', }, + objectRef: { + loading: '正在加载对象…', + placeholder: '请选择对象', + search: '搜索对象…', + empty: '未找到对象', + }, + recipient: { + selectTypeFirst: '请先选择接收方类型。', + loading: '加载中…', + search: '搜索…', + empty: '无匹配项', + select: '请选择接收方', + selectUser: '请选择用户', + selectTeam: '请选择团队', + selectBusinessUnit: '请选择业务单元', + selectPosition: '请选择岗位', + selectUnitAndSubordinates: '请选择业务单元', + }, + filterCondition: { + selectObjectFirst: '请先选择对象。', + allRecords: '全部记录', + invalidJson: 'JSON 无效 —— 修正之前该规则不会匹配任何记录。', + jsonOnly: '该条件只能以 JSON 方式编辑', + editAsJson: '以 JSON 编辑', + useVisualBuilder: '使用可视化构建器', + }, }, table: { rowsPerPage: '每页行数', diff --git a/packages/permissions/src/evaluator.ts b/packages/permissions/src/evaluator.ts index f642a2b46..ea0276fc5 100644 --- a/packages/permissions/src/evaluator.ts +++ b/packages/permissions/src/evaluator.ts @@ -57,11 +57,17 @@ export function evaluatePermission({ const roleConfig = objectConfig.roles[roleName]; if (!roleConfig) continue; - if (roleConfig.actions.includes(action)) { + // `actions` is optional in practice: a role config may carry only + // `fieldPermissions` (the field-level gate is the whole point of the + // entry). Reading `.includes` off it unguarded threw a TypeError that + // propagated out of `check()` and took the whole view down with it — + // a permission *check* must never be able to crash a render + // (objectstack#3821). + if (roleConfig.actions?.includes(action)) { // Check row-level permissions if record is provided if (record && roleConfig.rowPermissions?.length) { const rowAllowed = roleConfig.rowPermissions.some( - (rp) => rp.actions.includes(action), + (rp) => rp.actions?.includes(action), ); if (!rowAllowed) continue; } diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index e81583221..0c7aaa048 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -48,6 +48,7 @@ import { usePermissions } from '@object-ui/permissions'; import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; import type { DetailViewSchema, DataSource, ActionSchema, SchemaNode } from '@object-ui/types'; import { useDetailTranslation } from './useDetailTranslation'; +import { useRecordEditable } from './useRecordEditable'; /** Default page size for related lists in the detail view */ const DEFAULT_RELATED_PAGE_SIZE = 5; @@ -261,7 +262,50 @@ export const DetailView: React.FC = ({ summaryFields: filterFields(rawSchema.summaryFields as any[]) as any, }; }, [rawSchema, perms]); - const schema = gatedSchema; + + /** + * Record-level write gate (objectstack#3821). Object-level permissions say + * whether the user may edit `showcase_private_note` at all; they cannot say + * whether they may edit THIS one. A read-only sharing grant sits exactly in + * that gap, so the header used to offer "Edit", open the form, and let the + * user retype a field before the server rejected it with a 403. Ask the + * explain engine for the row-level verdict and reflect it on the CTA. + * + * Only consulted when the object-level check already passes — when it + * doesn't, the buttons are hidden anyway and the request would be waste. + * Fails open (see `useRecordEditable`); the server remains the authority. + */ + const objectAllowsUpdate = React.useMemo( + () => !perms?.isLoaded || !rawSchema.objectName || perms.can(rawSchema.objectName, 'update'), + [perms, rawSchema.objectName], + ); + const objectAllowsDelete = React.useMemo( + () => !perms?.isLoaded || !rawSchema.objectName || perms.can(rawSchema.objectName, 'delete'), + [perms, rawSchema.objectName], + ); + const recordId = + rawSchema.resourceId != null ? String(rawSchema.resourceId) : undefined; + const canEditRecord = useRecordEditable( + rawSchema.objectName, + recordId, + 'update', + objectAllowsUpdate, + ); + const canDeleteRecord = useRecordEditable( + rawSchema.objectName, + recordId, + 'delete', + objectAllowsDelete, + ); + + const schema = React.useMemo( + () => ({ + ...gatedSchema, + showEdit: gatedSchema.showEdit && objectAllowsUpdate && canEditRecord, + showDelete: gatedSchema.showDelete && objectAllowsDelete && canDeleteRecord, + }), + [gatedSchema, objectAllowsUpdate, canEditRecord, objectAllowsDelete, canDeleteRecord], + ); // Fire onDataLoaded whenever the record changes so hosts can publish it diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 2949908e9..ed0d08811 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -47,6 +47,8 @@ export type { ConcurrentUpdateConflict, ConcurrentUpdateDialogProps, } from './ConcurrentUpdateDialog'; +export { useRecordEditable, __clearRecordEditableCache } from './useRecordEditable'; +export type { RecordOperation } from './useRecordEditable'; export { SectionGroup } from './SectionGroup'; export { HeaderHighlight } from './HeaderHighlight'; export { InlineFieldInput, extractLookupId, TEXTUAL_REF_FALLBACK_TYPES } from './InlineFieldInput'; diff --git a/packages/plugin-detail/src/useRecordEditable.test.tsx b/packages/plugin-detail/src/useRecordEditable.test.tsx new file mode 100644 index 000000000..51832330b --- /dev/null +++ b/packages/plugin-detail/src/useRecordEditable.test.tsx @@ -0,0 +1,103 @@ +/** + * 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#3821 — the record-level write gate behind the detail header's + * Edit / Delete CTAs. + * + * A record shared READ-ONLY lives inside an object the user may otherwise edit, + * so object-level permissions said "yes" and the header offered Edit; the user + * only found out at save time, via a 403. The gate asks the explain engine for + * the row-level verdict instead. + * + * Every uncertainty must fail OPEN — a courtesy hint may never be the reason a + * permitted user cannot act. The server is the authority (ADR-0057 D10). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useRecordEditable, __clearRecordEditableCache } from './useRecordEditable'; + +function mockExplain(body: unknown, ok = true) { + return vi.fn(async () => ({ ok, json: async () => body })) as any; +} + +describe('useRecordEditable', () => { + beforeEach(() => { + __clearRecordEditableCache(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('disables the action when the record-level verdict says no', async () => { + vi.stubGlobal('fetch', mockExplain({ allowed: true, record: { recordId: 'r1', visible: false } })); + const { result } = renderHook(() => useRecordEditable('note', 'r1')); + await waitFor(() => expect(result.current).toBe(false)); + }); + + it('keeps the action when the record-level verdict says yes', async () => { + vi.stubGlobal('fetch', mockExplain({ allowed: true, record: { recordId: 'r1', visible: true } })); + const { result } = renderHook(() => useRecordEditable('note', 'r1')); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('asks about the requested operation and record', async () => { + const fetchMock = mockExplain({ record: { recordId: 'r1', visible: true } }); + vi.stubGlobal('fetch', fetchMock); + renderHook(() => useRecordEditable('note', 'r1', 'delete')); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toEqual({ object: 'note', operation: 'delete', recordId: 'r1' }); + }); + + it('fails open on a non-OK response (401 / 403 / 501 deployments)', async () => { + vi.stubGlobal('fetch', mockExplain({}, false)); + const { result } = renderHook(() => useRecordEditable('note', 'r1')); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('fails open when the network throws', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }) as any); + const { result } = renderHook(() => useRecordEditable('note', 'r1')); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('fails open when the report carries no record verdict', async () => { + vi.stubGlobal('fetch', mockExplain({ allowed: false })); + const { result } = renderHook(() => useRecordEditable('note', 'r1')); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('does not call explain when disabled — the object-level gate already decided', async () => { + const fetchMock = mockExplain({ record: { recordId: 'r1', visible: false } }); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => useRecordEditable('note', 'r1', 'update', false)); + expect(result.current).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not call explain without an object or a record id', async () => { + const fetchMock = mockExplain({}); + vi.stubGlobal('fetch', fetchMock); + renderHook(() => useRecordEditable(undefined, 'r1')); + renderHook(() => useRecordEditable('note', undefined)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('memoises the verdict so revisiting a record costs nothing', async () => { + const fetchMock = mockExplain({ record: { recordId: 'r1', visible: false } }); + vi.stubGlobal('fetch', fetchMock); + + const first = renderHook(() => useRecordEditable('note', 'r1')); + await waitFor(() => expect(first.result.current).toBe(false)); + + const second = renderHook(() => useRecordEditable('note', 'r1')); + expect(second.result.current).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin-detail/src/useRecordEditable.ts b/packages/plugin-detail/src/useRecordEditable.ts new file mode 100644 index 000000000..7a027fde0 --- /dev/null +++ b/packages/plugin-detail/src/useRecordEditable.ts @@ -0,0 +1,88 @@ +/** + * 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. + */ + +/** + * Is THIS record editable by the current user? (objectstack#3821) + * + * Object-level permissions can't answer that. A record shared read-only via a + * sharing rule sits inside an object the user may otherwise create and edit + * freely — so the header offered a primary "Edit" CTA that opened the form, let + * the user retype a field, and only then bounced with a 403. The server is the + * authority (ADR-0057 D10) and stays so; this is the courtesy check that stops + * the UI from inviting a write it knows will fail. + * + * The answer comes from the explain engine's record-grained verdict + * (`POST /api/v1/security/explain` with a `recordId`, ADR-0090 D6 / ADR-0095 + * C2) — the same pipeline the enforcement middleware runs, so the button and + * the server can't disagree. Explaining ONESELF needs no special permission; + * only explaining another principal does. + * + * **Fail-open on every uncertainty** — no answer yet, a non-OK response, a + * deployment without `@objectstack/plugin-security` (501), a split SPA origin + * where the cookie doesn't reach the API: the button stays enabled and the + * server does its job. A permission hint must never be the reason a permitted + * user cannot act. + */ +import * as React from 'react'; + +/** Cache keyed by `object:recordId:operation` so revisiting a record is free. */ +const verdictCache = new Map(); + +export type RecordOperation = 'update' | 'delete'; + +export function useRecordEditable( + objectName: string | undefined, + recordId: string | undefined, + operation: RecordOperation = 'update', + enabled = true, +): boolean { + const key = objectName && recordId ? `${objectName}:${recordId}:${operation}` : ''; + const [allowed, setAllowed] = React.useState(() => + key && verdictCache.has(key) ? verdictCache.get(key)! : true, + ); + + React.useEffect(() => { + if (!enabled || !key) { + setAllowed(true); + return; + } + if (verdictCache.has(key)) { + setAllowed(verdictCache.get(key)!); + return; + } + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/v1/security/explain', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ object: objectName, operation, recordId }), + }); + if (!res.ok) return; // 401 / 403 / 501 → fail open + const decision = await res.json(); + const verdict = decision?.record?.visible; + if (typeof verdict !== 'boolean') return; // no record verdict → fail open + verdictCache.set(key, verdict); + if (!cancelled) setAllowed(verdict); + } catch { + /* network/parse failure → fail open */ + } + })(); + return () => { + cancelled = true; + }; + }, [key, objectName, recordId, operation, enabled]); + + return allowed; +} + +/** Test seam — drops the memoised verdicts. */ +export function __clearRecordEditableCache(): void { + verdictCache.clear(); +} diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index bd067353a..2d0d31654 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -35,11 +35,13 @@ export interface QueryParams { /** * Sort order - * Can be a Map { field: 'asc' }, an Array of strings ['field', '-field'], or Array of sort objects + * Can be an OData clause string 'field asc, other desc', a Map { field: 'asc' }, + * an Array of strings ['field', '-field'], or an Array of sort objects. + * @example 'name asc' * @example { createdAt: 'desc', name: 'asc' } * @example ['name', '-createdAt'] */ - $orderby?: Record | string[] | Array<{ field: string; order?: 'asc' | 'desc' }>; + $orderby?: string | Record | string[] | Array<{ field: string; order?: 'asc' | 'desc' }>; /** * Number of records to skip (for pagination)