Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/studio-object-sharing-model-owd.md
Original file line number Diff line number Diff line change
@@ -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).
36 changes: 36 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,24 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -2063,6 +2081,24 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '暂无应用软件包',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, onPatch = vi.fn()) {
render(
<ObjectSettingsPanel name="leave_request" draft={draft} onPatch={onPatch} locale="en-US" />,
);
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');
});
});
77 changes: 75 additions & 2 deletions packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,22 @@
* (`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
* back to guessing which heuristic picked their title/stepper/columns.
*/

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';
Expand Down Expand Up @@ -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 <select> reflects the real runtime behaviour.
const rawSharing = typeof draft.sharingModel === 'string' ? draft.sharingModel : '';
const sharingModel =
rawSharing === 'read'
? 'public_read'
: rawSharing === 'read_write' || rawSharing === 'full'
? 'public_read_write'
: rawSharing;
const sharingDescKey =
sharingModel === 'private'
? 'engine.studio.settings.sharingDescPrivate'
: sharingModel === 'public_read'
? 'engine.studio.settings.sharingDescPublicRead'
: sharingModel === 'public_read_write'
? 'engine.studio.settings.sharingDescPublicReadWrite'
: sharingModel === 'controlled_by_parent'
? 'engine.studio.settings.sharingDescControlledByParent'
: 'engine.studio.settings.sharingDescUnset';
// Unset falls through to the "fully public" runtime default — flag it so the
// designer doesn't silently ship org-wide read/write.
const sharingIsUnset = sharingModel === '';

return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto">
<section className="rounded-lg border">
Expand All @@ -78,6 +109,48 @@ export function ObjectSettingsPanel({
</div>
</section>

<section className="rounded-lg border">
<header className="flex items-center gap-2 border-b px-3 py-2">
<ShieldCheck className="h-3.5 w-3.5" />
<span className="text-[13px] font-medium">{t('engine.studio.settings.sharing', locale)}</span>
<span className="text-[11px] text-muted-foreground">
{t('engine.studio.settings.sharingHint', locale)}
</span>
</header>
<div className="grid max-w-xl gap-2 p-3">
<label className="block">
<span className="mb-1 block text-[11px] text-muted-foreground">
{t('engine.studio.settings.sharingModel', locale)}
</span>
<select
value={sharingModel}
disabled={disabled}
onChange={(e) =>
onPatch(e.target.value ? { sharingModel: e.target.value } : { sharingModel: undefined })
}
className="w-full rounded border bg-background px-2 py-1 text-[12px]"
>
<option value="">{t('engine.studio.settings.sharingUnset', locale)}</option>
<option value="private">{t('engine.studio.settings.sharingPrivate', locale)}</option>
<option value="public_read">{t('engine.studio.settings.sharingPublicRead', locale)}</option>
<option value="public_read_write">{t('engine.studio.settings.sharingPublicReadWrite', locale)}</option>
<option value="controlled_by_parent">
{t('engine.studio.settings.sharingControlledByParent', locale)}
</option>
</select>
</label>
<p
className={
sharingIsUnset
? 'text-[11px] text-amber-600 dark:text-amber-500'
: 'text-[11px] text-muted-foreground'
}
>
{t(sharingDescKey, locale)}
</p>
</div>
</section>

<section className="rounded-lg border">
<header className="flex items-center gap-2 border-b px-3 py-2">
<Sparkles className="h-3.5 w-3.5" />
Expand Down