From da1d695ccff7ce36d7f585a31187bb8f7e23d2a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 16:31:14 +0000 Subject: [PATCH] feat(app-shell): add object record sharing model (OWD) control to Studio Settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object designer's Data pillar Settings tab had no control for an object's sharingModel (Org-Wide Default), so record-level isolation was invisible and unconfigurable at design time. An admin who ticked Read/Edit in the permission matrix silently got org-wide read/write, because an unset sharingModel falls through to the runtime's fully-public default. ObjectSettingsPanel now renders a "Record sharing (OWD)" section with a sharingModel selector (private / public_read / public_read_write / controlled_by_parent), a per-option description of the runtime effect, and an amber warning when unset spelling out the fully-public default. Legacy aliases (read → public_read, read_write/full → public_read_write) normalise to their canonical value for display. Fully localized (en-US / zh-CN) and unit-tested. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/studio-object-sharing-model-owd.md | 7 ++ .../src/views/metadata-admin/i18n.ts | 36 +++++++++ .../ObjectSettingsPanel.test.tsx | 73 ++++++++++++++++++ .../studio-design/ObjectSettingsPanel.tsx | 77 ++++++++++++++++++- 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 .changeset/studio-object-sharing-model-owd.md create mode 100644 packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx diff --git a/.changeset/studio-object-sharing-model-owd.md b/.changeset/studio-object-sharing-model-owd.md new file mode 100644 index 000000000..5c7c0d954 --- /dev/null +++ b/.changeset/studio-object-sharing-model-owd.md @@ -0,0 +1,7 @@ +--- +'@object-ui/app-shell': minor +--- + +Studio: expose the object record sharing model (OWD) in the Data pillar Settings tab. + +The object designer had no control for an object's `sharingModel` (Org-Wide Default), so record-level isolation was invisible and unconfigurable at design time — an admin who ticked Read/Edit in the permission matrix silently got org-wide read/write, because an unset `sharingModel` falls through to the runtime's fully-public default. `ObjectSettingsPanel` now renders a "Record sharing (OWD)" section with a `sharingModel` selector (`private` / `public_read` / `public_read_write` / `controlled_by_parent`), a per-option description of the runtime effect, and an amber warning when unset that spells out the fully-public default. Legacy aliases (`read` → `public_read`, `read_write`/`full` → `public_read_write`) are normalised to their canonical value for display. Fully localized (en-US / zh-CN). diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 6b03fb9fa..5fc170bc2 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1028,6 +1028,24 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.settings.removeField': 'Remove {field}', 'engine.studio.settings.addFieldOption': '+ Add field…', 'engine.studio.settings.undeclared': '(not declared — surfaces pick heuristically)', + 'engine.studio.settings.sharing': 'Record sharing (OWD)', + 'engine.studio.settings.sharingHint': 'Baseline record visibility applied before roles and sharing rules (ADR-0056)', + 'engine.studio.settings.sharingModel': 'Sharing model (sharingModel) — who can see and edit records another user owns', + 'engine.studio.settings.sharingUnset': '(not set)', + 'engine.studio.settings.sharingPrivate': 'Private — owner only', + 'engine.studio.settings.sharingPublicRead': 'Public read — everyone reads, only the owner writes', + 'engine.studio.settings.sharingPublicReadWrite': 'Public read/write — everyone reads and writes', + 'engine.studio.settings.sharingControlledByParent': 'Controlled by parent — inherited from the master record', + 'engine.studio.settings.sharingDescUnset': + 'Not set — records are fully public: every user in the tenant can read AND edit all records. Choose Private to isolate records by owner.', + 'engine.studio.settings.sharingDescPrivate': + 'Only the owner (plus users granted via roles or sharing rules) can access a record. Read / Edit permissions then apply to owned records only.', + 'engine.studio.settings.sharingDescPublicRead': + 'Every user in the tenant can read all records; only the owner can edit.', + 'engine.studio.settings.sharingDescPublicReadWrite': + 'Every user in the tenant can read and edit all records.', + 'engine.studio.settings.sharingDescControlledByParent': + 'Record visibility is inherited from the parent (master) record through its master-detail relationship.', 'engine.studio.pkg.writable': 'Writable', 'engine.studio.pkg.heading': 'Packages (apps)', 'engine.studio.pkg.none': 'No app packages yet', @@ -2063,6 +2081,24 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.settings.removeField': '移除 {field}', 'engine.studio.settings.addFieldOption': '+ 添加字段…', 'engine.studio.settings.undeclared': '(未声明 —— 各处按启发式自动挑选)', + 'engine.studio.settings.sharing': '记录共享模型(OWD)', + 'engine.studio.settings.sharingHint': '在角色与共享规则之前生效的记录可见性基线(ADR-0056)', + 'engine.studio.settings.sharingModel': '共享模型(sharingModel)—— 谁能查看和编辑他人拥有的记录', + 'engine.studio.settings.sharingUnset': '(未设置)', + 'engine.studio.settings.sharingPrivate': 'Private 私有 —— 仅记录所有者', + 'engine.studio.settings.sharingPublicRead': 'Public read 公共只读 —— 所有人可读,仅所有者可写', + 'engine.studio.settings.sharingPublicReadWrite': 'Public read/write 公共读写 —— 所有人可读可写', + 'engine.studio.settings.sharingControlledByParent': 'Controlled by parent 受父级控制 —— 继承自主记录', + 'engine.studio.settings.sharingDescUnset': + '未设置 —— 记录完全公开:租户内每个用户都能读取并编辑所有记录。选择 Private 可按所有者隔离记录。', + 'engine.studio.settings.sharingDescPrivate': + '只有所有者(以及经角色或共享规则授予的用户)能访问记录。此时读取 / 编辑权限仅作用于自己拥有的记录。', + 'engine.studio.settings.sharingDescPublicRead': + '租户内每个用户都能读取所有记录;仅所有者可编辑。', + 'engine.studio.settings.sharingDescPublicReadWrite': + '租户内每个用户都能读取并编辑所有记录。', + 'engine.studio.settings.sharingDescControlledByParent': + '记录可见性通过主从(master-detail)关系继承自父级(主)记录。', 'engine.studio.pkg.writable': '可写', 'engine.studio.pkg.heading': '软件包(应用)', 'engine.studio.pkg.none': '暂无应用软件包', diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx new file mode 100644 index 000000000..3c85cdf3e --- /dev/null +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx @@ -0,0 +1,73 @@ +/** + * 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 { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ObjectSettingsPanel } from './ObjectSettingsPanel'; + +const baseDraft = { + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'select', label: 'Status' }, + }, +}; + +function renderPanel(draft: Record, onPatch = vi.fn()) { + render( + , + ); + return onPatch; +} + +describe('ObjectSettingsPanel — record sharing (OWD)', () => { + it('exposes the sharing model control with the four canonical OWD options', () => { + renderPanel(baseDraft); + // The section header is present. + expect(screen.getByText('Record sharing (OWD)')).toBeTruthy(); + // Canonical OWD options are all offered. + expect(screen.getByRole('option', { name: 'Private — owner only' })).toBeTruthy(); + expect( + screen.getByRole('option', { name: 'Public read — everyone reads, only the owner writes' }), + ).toBeTruthy(); + expect( + screen.getByRole('option', { name: 'Public read/write — everyone reads and writes' }), + ).toBeTruthy(); + expect( + screen.getByRole('option', { name: 'Controlled by parent — inherited from the master record' }), + ).toBeTruthy(); + }); + + it('warns that an unset sharing model means fully public records', () => { + renderPanel(baseDraft); + expect( + screen.getByText(/records are fully public/i), + ).toBeTruthy(); + }); + + it('patches sharingModel when a model is picked', () => { + const onPatch = renderPanel(baseDraft); + const select = screen.getByDisplayValue('(not set)') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'private' } }); + expect(onPatch).toHaveBeenCalledWith({ sharingModel: 'private' }); + }); + + it('clears sharingModel back to unset', () => { + const onPatch = renderPanel({ ...baseDraft, sharingModel: 'private' }); + const select = screen.getByDisplayValue('Private — owner only') as HTMLSelectElement; + fireEvent.change(select, { target: { value: '' } }); + expect(onPatch).toHaveBeenCalledWith({ sharingModel: undefined }); + }); + + it('normalises legacy aliases (read → public_read) for display', () => { + renderPanel({ ...baseDraft, sharingModel: 'read' }); + const select = screen.getByDisplayValue( + 'Public read — everyone reads, only the owner writes', + ) as HTMLSelectElement; + expect(select.value).toBe('public_read'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx index 29a2a13a5..31e2093d3 100644 --- a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx @@ -14,7 +14,14 @@ * (`getMetadataDefaultInspector('object')` → ObjectDefaultInspector): * label / pluralLabel / icon / description, one implementation for both * surfaces. - * 2. Semantic roles (ADR-0085) — the cross-surface presentation roles: + * 2. Record sharing (ADR-0056) — the object-level Org-Wide Default (OWD) + * `sharingModel` (private | public_read | public_read_write | + * controlled_by_parent). This is the baseline record-level visibility the + * runtime applies BEFORE roles and sharing rules: without it the platform + * treats records as fully public (every tenant user reads AND edits every + * record), so record isolation is invisible/unconfigurable at design time + * unless the builder exposes it. + * 3. Semantic roles (ADR-0085) — the cross-surface presentation roles: * `nameField`, `stageField` (string | false | unset), `highlightFields`. * These are the ONLY presentation knobs the protocol carries, so the * builder must make them directly editable — otherwise designers fall @@ -22,7 +29,7 @@ */ import React from 'react'; -import { Settings2, Sparkles, X } from 'lucide-react'; +import { Settings2, ShieldCheck, Sparkles, X } from 'lucide-react'; import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; import { readFields } from '../metadata-admin/previews/object-fields-io'; import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n'; @@ -55,6 +62,30 @@ export function ObjectSettingsPanel({ (e) => e.def.hidden !== true && !highlightFields.includes(e.name), ); + // Record sharing (OWD). The spec keeps legacy aliases (`read` → public_read, + // `read_write`/`full` → public_read_write) for back-compat; normalise them to + // the canonical value so the + onPatch(e.target.value ? { sharingModel: e.target.value } : { sharingModel: undefined }) + } + className="w-full rounded border bg-background px-2 py-1 text-[12px]" + > + + + + + + + +

+ {t(sharingDescKey, locale)} +

+ + +