Skip to content

Commit a91409c

Browse files
fix(core,react,i18n,components,console): a refused write on a read-only external object says the table is read-only, not that you lack permission (#30403)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg94yb5pVXBLeYzq5skXAbtg)_ domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG ## 维护者速读 - 改了什么:对外部只读表(数据源 allowWrites/对象 writable 不全为 true)写入被服务端拒绝时,新建/编辑表单、记录对话框、记录删除、列表行删除、行内编辑、日历拖动改期、阶段切换七处都改为提示「这张表是只读的:它的记录来自外部数据源,不接受修改。」(十种语言)。 - 为什么改:以前提示「您没有权限保存这条记录」或直接甩出英文诊断原文,管理员会去翻权限集,而任何权限集都解不开这张表。 - 风险与回滚:只按服务端错误码 EXTERNAL_WRITE_FORBIDDEN 换文案,其他 403 和其他错误一字不变;回滚即撤本 PR。 - 先看:ui/packages/core/src/utils/external-write-refusal.ts(判定只读 code)、ui/apps/console/src/components/FormPage.tsx 的 refusalText、ui/packages/core/src/actions/recordDelete.ts 的失败 toast。 - 两分钟验证:examples/app-showcase 跑 `objectstack dev --seed-admin --fresh`,打开 External Customers 点「新建」填名称后创建,横幅应为上述只读提示,不再是「您没有权限保存这条记录。」 - 需要你做的:无,席位落地。 Refs #30334 (item 2) Clause-②: yes (widening) Metadata-protocol: none ## What changes Item 2 of the card: "If a write still reaches the server, the message says the table is read-only, not that the user lacks permission." The ADR-0015 write gate (objectql `assertWriteAllowed`) refuses a write on an object bound to an external datasource unless `datasource.external.allowWrites` AND `object.external.writable` are both true. Measured on a live boot (app-showcase, object `showcase_ext_customer` on datasource `showcase_external`): `POST`, `PATCH` and `DELETE` on `/api/v1/data/showcase_ext_customer` each answer `403` with `{"error":"Write 'insert' blocked on object …","code":"EXTERNAL_WRITE_FORBIDDEN","object":"showcase_ext_customer"}`. - **`@object-ui/core`: `isExternalWriteRefusal(err)`**, re-exported from `@object-ui/react` beside the other write-refusal readers. True only for the code the spec declares as `EXTERNAL_ERROR_CODES.writeForbidden`, read from `code` then `details.code` (the pair `isPermissionError` reads). It reads the server's verdict only; it re-derives nothing from the two flags. - **`@object-ui/i18n`: `form.readOnlyExternalSource`** in all ten packs. - **Every surface that met this refusal now says it**, asked before `isPermissionError` (which claims every 403): - console record form page (`FormPage` `refusalText`), and the record dialog (`@object-ui/components` form) used by the list's 新建 and by 编辑; - record page header delete (`RecordDetailView`) and list row delete (`@object-ui/core` `recordDelete`), which showed the gate's raw English diagnostic; - record page inline edit (`InlineEditSaveBar`), which pinned the raw diagnostic on the edited field; now the read-only line, no field marked; - calendar drag-to-reschedule (`ObjectCalendar`) and record path stage change (`stageRefusalLines`), same class, not walked live. Not in this PR (seat ruling on the claim): hiding New / Import / Edit / Delete on such an object is #30370 (the served `/auth/me/permissions` entry for the object reads `allowCreate/allowEdit/allowDelete: true` and `apiOperations` includes `create`, `update`, `delete`, `import`: `buildEffectiveObjectPermissions` never reads the ADR-0015 gate), and the 在队列中 filter is #30372 (the spec plans the injected `queue` column on external objects too). ## Tests - `FormPage.externalWriteRefusal-30334.test.tsx`: the form page's banner and toast for the gate's envelope, en and zh, plus a PERMISSION_DENIED control. - `form-write-error-external-30334.test.tsx`: the record dialog, `code` and `details.code`, plus a FORBIDDEN control. - `error-message.externalWriteRefusal-30334.test.ts`: the predicate, with controls (other 403 codes, `EXTERNAL_WRITE_REFUSED` 422, the gate's text without its code). - `stageRefusalLines.externalWrite-30334.test.ts`: the stage change lines, with a control. ## Acceptance notes - Kanban card moves (`plugin-kanban`) read the refusal the same way and are left alone: that package is held by the in-flight #30220 claim. - Flow runs whose write node meets the gate already say the server's own sentence, not the permission line (`flowFailureMessage` reads the inner refusal's text when `isPermissionError` is false for a status-less refusal); not changed. - Bulk delete reports a count summary only; not changed. ## Verification Live walk: `examples/app-showcase`, `objectstack dev --seed-admin --fresh -p 4630`, console Vite against it, zh locale, signed in as the seeded admin. "Before" is the same boot with this PR's eight source files checked out at `fdad1786` (restored afterwards, `git diff HEAD` empty). Screenshots are in the project files under `ui-seat3/30334/`. | Surface | Before | After | |---|---|---| | 新建 → 创建 (`*-3-create-refused.png`) | 您没有权限保存这条记录。 | 这张表是只读的:它的记录来自外部数据源,不接受修改。 | | 编辑 → 保存 (`*-4-edit-refused.png`) | 您没有权限保存这条记录。 | same read-only line | | record header 删除 (`*-6-delete-refused.png`) | Write 'delete' blocked on object 'showcase_ext_customer': datasource 'showcase_external' is external … | same read-only line | | list row 删除 (`*-7-row-delete-refused.png`) | 删除 External Customer 失败 + the raw diagnostic | 删除 External Customer 失败 + the read-only line | | inline edit 保存 (`*-9-inline-edit-refused.png`) | the raw diagnostic, on the field | the read-only line, no field marked | The source table was not changed by any of these writes (the gate refused each). <details><summary>Targeted vitest at 7647399 (199 files: the new tests, every test importing a changed module, the i18n package's tests; the later merge of main and the changeset fix touch no ui file)</summary> ``` Test Files 199 passed (199) Tests 2191 passed | 90 skipped (2281) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Type-check at 7647399</summary> `pnpm exec turbo run type-check --filter=@object-ui/core --filter=@object-ui/react --filter=@object-ui/i18n --filter=@object-ui/components --filter=@object-ui/plugin-calendar --filter=@object-ui/plugin-detail --filter=@object-ui/app-shell --filter=@object-ui/console --concurrency=2`: `Tasks: 81 successful, 81 total`, VERDICT command-exit 0. </details> <details><summary>Gates</summary> At ae3c67c, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families; each was run and its exit code recorded before any pipe, then reconciled: ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 ``` `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED. `node scripts/check-sdui-manifest.mjs` is among them. `pnpm --dir ui check:new-line-citations`: 0 new citations. The `Lint UI` red on `check:liveness` naming `query/searchFields` is main's (#30367), not this change's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CajiubyEqtA9TdrtCf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efcc1a7 commit a91409c

27 files changed

Lines changed: 460 additions & 15 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/react': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/i18n': patch
6+
'@object-ui/components': patch
7+
'@object-ui/plugin-calendar': patch
8+
'@object-ui/plugin-detail': patch
9+
'@object-ui/console': patch
10+
---
11+
12+
fix(core,react,i18n,components,app-shell,console): a write refused because the object's external datasource does not accept writes says the table is read-only, not "you don't have permission"
13+
14+
Clause-②: yes (widening)
15+
Metadata-protocol: none
16+
17+
- **`@object-ui/core`: `isExternalWriteRefusal(err)`**, re-exported from `@object-ui/react` beside the other write-refusal readers. True when the server refused a write with `EXTERNAL_WRITE_FORBIDDEN`, the ADR-0015 write gate's code for an object bound to an external datasource that does not admit writes (`datasource.external.allowWrites` and `object.external.writable` must both be true). Read from `code`, then `details.code`, like `isPermissionError`. The refusal is a 403, so `isPermissionError` answers true for it too: a caller asks this first.
18+
- **`@object-ui/i18n`: `form.readOnlyExternalSource`**, in all ten packs: "This table is read-only. Its records come from an external data source that does not accept changes."
19+
- **Every surface that met this refusal now says the table is read-only:** the console record form page and the record dialog (`@object-ui/components` form), which said "you don't have permission to save this record"; the record page delete, the list row delete and the record page inline edit, which showed the gate's raw diagnostic; and the calendar's drag-to-reschedule and the record path's stage change. A permission line sent an administrator through permission sets for a grant that cannot exist.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#30334 — a write the object's external datasource
5+
* does not admit says the table is read-only, not that the user lacks
6+
* permission.
7+
*
8+
* The ADR-0015 write gate refuses an insert on an object bound to an external
9+
* datasource unless `datasource.external.allowWrites` AND
10+
* `object.external.writable` are both true, answering `403
11+
* EXTERNAL_WRITE_FORBIDDEN`. Read as any other 403, the banner said "you don't
12+
* have permission", which sends an administrator through permission sets for a
13+
* grant that cannot exist. The fixture is the envelope the data route answers
14+
* for that refusal.
15+
*/
16+
17+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
18+
import { render, screen, waitFor } from '@testing-library/react';
19+
import userEvent from '@testing-library/user-event';
20+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
21+
import { toast } from 'sonner';
22+
import { createI18n } from '@object-ui/i18n';
23+
import { FormPage } from './FormPage';
24+
25+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
26+
27+
const FIELDS = {
28+
name: { type: 'text', label: 'Name' },
29+
};
30+
31+
/** The gate's refusal of a `POST /data/:object` on a read-only external object. */
32+
const EXTERNAL_WRITE_FORBIDDEN = {
33+
status: 403,
34+
body: {
35+
error:
36+
"Write 'insert' blocked on object 'spare_parts': datasource 'parts_db' is external (schemaMode=external). " +
37+
'Requires datasource.external.allowWrites=true (got false) AND object.external.writable=true (got false).',
38+
code: 'EXTERNAL_WRITE_FORBIDDEN',
39+
},
40+
};
41+
42+
let posts = 0;
43+
44+
function answer(ok: boolean, status: number, body: unknown): Response {
45+
const text = typeof body === 'string' ? body : JSON.stringify(body);
46+
return {
47+
ok,
48+
status,
49+
statusText: ok ? 'OK' : 'Refused',
50+
json: async () => JSON.parse(text),
51+
text: async () => text,
52+
} as unknown as Response;
53+
}
54+
55+
function stubInternal(refusal: { status: number; body: unknown }) {
56+
const view = {
57+
name: 'spare_parts.form',
58+
object: 'spare_parts',
59+
viewKind: 'form',
60+
label: 'Spare part',
61+
config: { type: 'simple', sections: [{ fields: ['name'] }] },
62+
};
63+
const object = { name: 'spare_parts', label: 'Spare part', fields: FIELDS };
64+
vi.stubGlobal(
65+
'fetch',
66+
vi.fn(async (url: string, init?: RequestInit) => {
67+
if (init?.method === 'POST') {
68+
posts += 1;
69+
return answer(false, refusal.status, refusal.body);
70+
}
71+
if (String(url).includes('/meta/view/')) return answer(true, 200, view);
72+
if (String(url).includes('/meta/object/')) return answer(true, 200, object);
73+
throw new Error(`unstubbed fetch: ${url}`);
74+
}),
75+
);
76+
return render(
77+
<MemoryRouter initialEntries={['/forms/spare_parts.form']}>
78+
<Routes>
79+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
80+
</Routes>
81+
</MemoryRouter>,
82+
);
83+
}
84+
85+
async function fillAndSubmit(submitName: RegExp | string = /Submit/) {
86+
await userEvent.type(await screen.findByLabelText('Name'), 'Bolt');
87+
await userEvent.click(screen.getByRole('button', { name: submitName }));
88+
}
89+
90+
async function bannerText(): Promise<string> {
91+
await waitFor(() => expect(document.querySelector('[id^="form-error:"]')).not.toBeNull());
92+
return document.querySelector('[id^="form-error:"]')?.textContent ?? '';
93+
}
94+
95+
function toastText(): string {
96+
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
97+
return String(vi.mocked(toast.error).mock.calls[0][0]);
98+
}
99+
100+
beforeEach(() => {
101+
posts = 0;
102+
vi.mocked(toast.error).mockClear();
103+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
104+
});
105+
106+
afterEach(() => {
107+
vi.unstubAllGlobals();
108+
});
109+
110+
describe('#30334 — a refused write on a read-only external object', () => {
111+
it('says the table is read-only, in the banner and the toast alike', async () => {
112+
stubInternal(EXTERNAL_WRITE_FORBIDDEN);
113+
await fillAndSubmit();
114+
115+
const banner = await bannerText();
116+
expect(posts).toBe(1);
117+
expect(banner).toBe(
118+
'This table is read-only. Its records come from an external data source that does not accept changes.',
119+
);
120+
expect(banner).not.toBe("You don't have permission to save this record.");
121+
expect(toastText()).toBe(banner);
122+
// The gate's diagnostic (flags, machine names) is not the reader's sentence.
123+
expect(banner).not.toContain('allowWrites');
124+
expect(banner).not.toContain('spare_parts');
125+
});
126+
127+
it('zh: reads in the reader\'s language, not as the permission line', async () => {
128+
createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
129+
stubInternal(EXTERNAL_WRITE_FORBIDDEN);
130+
await fillAndSubmit('提交');
131+
132+
const banner = await bannerText();
133+
expect(banner).toBe('这张表是只读的:它的记录来自外部数据源,不接受修改。');
134+
expect(banner).not.toBe('您没有权限保存这条记录。');
135+
expect(toastText()).toBe(banner);
136+
});
137+
138+
it('any other 403 still reads the permission line', async () => {
139+
stubInternal({ status: 403, body: { error: 'Denied', code: 'PERMISSION_DENIED' } });
140+
await fillAndSubmit();
141+
142+
expect(await bannerText()).toBe("You don't have permission to save this record.");
143+
});
144+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ import {
165165
SchemaRendererContext,
166166
declaredUserMessage,
167167
delegationScopeMessage,
168+
isExternalWriteRefusal,
168169
extractFieldErrors,
169170
isPermissionError,
170171
unmarkedRefusalLines,
@@ -2077,13 +2078,16 @@ class WriteRefusal extends Error {
20772078
*
20782079
* 1. the sentence the producer MARKED for the end user (`userMessage`), or a
20792080
* delegated administrator's scope refusal, which names only their own scope;
2080-
* 2. any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as the fixed
2081+
* 2. a write the object's external datasource does not admit
2082+
* (`EXTERNAL_WRITE_FORBIDDEN`) as the localized read-only line: no
2083+
* permission can lift it, so the permission line would mislead;
2084+
* 3. any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as the fixed
20812085
* localized line — the server's text names objects and policies, not
20822086
* something the submitter can act on (objectstack#3821, #24914 B′);
2083-
* 3. the per-rule lines no row carries (`unmarkedRefusalLines`);
2084-
* 4. the server's sentence of a `4xx` — it answers the request the submitter
2087+
* 4. the per-rule lines no row carries (`unmarkedRefusalLines`);
2088+
* 5. the server's sentence of a `4xx` — it answers the request the submitter
20852089
* made, and they can change it;
2086-
* 5. otherwise — a `5xx`, or a refusal that gave no sentence — the localized
2090+
* 6. otherwise — a `5xx`, or a refusal that gave no sentence — the localized
20872091
* generic line: a server fault's text is a diagnostic, not a reason.
20882092
*/
20892093
function refusalText(
@@ -2098,6 +2102,7 @@ function refusalText(
20982102
}
20992103
const marked = declaredUserMessage(err) ?? delegationScopeMessage(err);
21002104
if (marked) return marked;
2105+
if (isExternalWriteRefusal(err)) return t('form.readOnlyExternalSource');
21012106
if (isPermissionError(err)) return t('form.noPermissionToSave');
21022107
if (unmarked && unmarked.length > 1) return unmarked;
21032108
if (unmarked?.[0]) return unmarked[0];

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHea
1515
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1616
import { usePermissions } from '@object-ui/permissions';
1717
import { useDisplayLocale, useLocalization, pickLocalized } from '@object-ui/i18n';
18-
import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, isRecordPageOf, useMetadata, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError, answeredLoadFailure, extractWriteErrorMessage } from '@object-ui/react';
18+
import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, isRecordPageOf, useMetadata, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError, answeredLoadFailure, extractWriteErrorMessage, isExternalWriteRefusal } from '@object-ui/react';
1919
import type { AnsweredLoadFailure } from '@object-ui/react';
2020
import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveAffordance, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields, type AffordanceGrantPrincipal, type SchemaLike } from '@object-ui/core';
2121
import { toast } from 'sonner';
@@ -4641,7 +4641,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
46414641
const baseAppUrl = appName ? `/apps/${appName}` : '';
46424642
navigate(`${baseAppUrl}/${objectName}`, { replace: true });
46434643
} catch (err: any) {
4644-
toast.error(err?.message || 'Delete failed');
4644+
// A delete the object's external datasource does not admit
4645+
// (ADR-0015, `EXTERNAL_WRITE_FORBIDDEN`) says the table is
4646+
// read-only, never the gate's diagnostic.
4647+
toast.error(
4648+
isExternalWriteRefusal(err)
4649+
? t('form.readOnlyExternalSource')
4650+
: err?.message || 'Delete failed',
4651+
);
46454652
}
46464653
},
46474654
} as any);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#30334 — the record dialog's refusal of a write the object's
11+
* external datasource does not admit (`403 EXTERNAL_WRITE_FORBIDDEN`, the
12+
* ADR-0015 write gate) says the table is read-only, not "you don't have
13+
* permission": no permission set can lift it.
14+
*/
15+
import { describe, it, expect, vi, beforeEach } from 'vitest';
16+
import React from 'react';
17+
import { render, screen, fireEvent } from '@testing-library/react';
18+
import '@testing-library/jest-dom';
19+
import { ComponentRegistry } from '@object-ui/core';
20+
// Registers the renderers at module scope (object-ui/no-dynamic-import-in-test-hook).
21+
import '../renderers';
22+
23+
const SCHEMA = {
24+
type: 'form',
25+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
26+
submitText: 'Save',
27+
} as any;
28+
29+
const GATE_TEXT =
30+
"Write 'insert' blocked on object 'spare_parts': datasource 'parts_db' is external (schemaMode=external). " +
31+
'Requires datasource.external.allowWrites=true (got false) AND object.external.writable=true (got false).';
32+
33+
function renderForm(onAction: () => Promise<unknown>) {
34+
const Form = ComponentRegistry.get('form')!;
35+
return render(<Form schema={SCHEMA} onAction={onAction} />);
36+
}
37+
38+
describe('#30334 — a write the external datasource does not admit', () => {
39+
beforeEach(() => {
40+
vi.spyOn(console, 'warn').mockImplementation(() => {});
41+
});
42+
43+
it('reads the read-only line, not the permission line or the gate diagnostic', async () => {
44+
const { container } = renderForm(async () => {
45+
throw Object.assign(new Error(GATE_TEXT), { code: 'EXTERNAL_WRITE_FORBIDDEN', httpStatus: 403 });
46+
});
47+
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
48+
49+
expect(await screen.findByText(/this table is read-only/i)).toBeInTheDocument();
50+
expect(screen.queryByText(/permission to save/i)).not.toBeInTheDocument();
51+
expect(container.textContent).not.toMatch(/allowWrites/);
52+
expect(container.textContent).not.toMatch(/spare_parts/);
53+
});
54+
55+
it('reads the code from `details` as well', async () => {
56+
renderForm(async () => {
57+
throw Object.assign(new Error(GATE_TEXT), {
58+
httpStatus: 403,
59+
details: { error: GATE_TEXT, code: 'EXTERNAL_WRITE_FORBIDDEN' },
60+
});
61+
});
62+
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
63+
64+
expect(await screen.findByText(/this table is read-only/i)).toBeInTheDocument();
65+
});
66+
67+
it('CONTROL: any other 403 keeps the permission line', async () => {
68+
renderForm(async () => {
69+
throw Object.assign(new Error('FORBIDDEN: insufficient privileges'), { code: 'FORBIDDEN', status: 403 });
70+
});
71+
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
72+
73+
expect(await screen.findByText(/permission to save/i)).toBeInTheDocument();
74+
expect(screen.queryByText(/this table is read-only/i)).not.toBeInTheDocument();
75+
});
76+
});

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { FullscreenEditor } from '../../custom/fullscreen-editor';
4242
import { CharacterCount } from '../../custom/character-count';
4343
import { cn } from '../../lib/utils';
4444
import React from 'react';
45-
import { SchemaRendererContext, usePredicateScope, useCapabilityGate, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage, unmarkedRefusalLines } from '@object-ui/react';
45+
import { SchemaRendererContext, usePredicateScope, useCapabilityGate, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage, isExternalWriteRefusal, unmarkedRefusalLines } from '@object-ui/react';
4646
import { createSafeTranslation } from '@object-ui/i18n';
4747
import type { RendererHostProps } from '../../lib/renderer-host-props';
4848
import { currencyCodeFieldNames, useUsedCurrencyCodes, withUsedCurrencyCodes } from './usedCurrencyCodes';
@@ -500,6 +500,8 @@ const useSafeFormTranslation = createSafeTranslation(
500500
'validation.formInvalidJoiner': ', ',
501501
'errors.forbidden': 'Access denied.',
502502
'form.noPermissionToSave': "You don't have permission to save this record.",
503+
// objectstack#30334 — a write the external datasource does not admit. Byte-identical to the `en` pack.
504+
'form.readOnlyExternalSource': 'This table is read-only. Its records come from an external data source that does not accept changes.',
503505
'form.submitFailed': 'Could not save. Please try again.',
504506
'form.clearedOnHide': 'Cleared — no longer applicable given the current values: {{fields}}',
505507
// objectui#8069 — the submit refusal for a field `visibleWhen` that could
@@ -3218,14 +3220,19 @@ ComponentRegistry.register('form',
32183220
// boundary to fix (objectstack#26059, Q3). It is read before
32193221
// `isPermissionError`, which answers true for any 403; every other
32203222
// denial keeps the fixed line (objectstack#24914 B′).
3223+
// A write the object's external datasource does not admit (`403
3224+
// EXTERNAL_WRITE_FORBIDDEN`, ADR-0015) says the table is read-only:
3225+
// no permission lifts it, so the permission line would mislead.
32213226
// A refusal listing one entry per failed rule is said one line per
32223227
// entry left unmarked above, not as the envelope's `; `-joined
32233228
// summary, which would also repeat the reasons already under inputs.
32243229
const unmarked = unmarkedRefusalLines(error, (field) => shownInline.has(field));
32253230
const errorMessage: string | string[] =
32263231
declaredUserMessage(error) ??
32273232
delegationScopeMessage(error) ??
3228-
(isPermissionError(error)
3233+
(isExternalWriteRefusal(error)
3234+
? t('form.readOnlyExternalSource')
3235+
: isPermissionError(error)
32293236
? t('form.noPermissionToSave')
32303237
: unmarked && unmarked.length > 1
32313238
? unmarked

‎ui/packages/core/src/actions/recordDelete.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@
7777

7878
import type { ActionResult } from './ActionRunner.js';
7979
import { getRecordDisplayName } from '../utils/record-title.js';
80+
import { isExternalWriteRefusal } from '../utils/external-write-refusal.js';
81+
82+
/** The `en` pack's `form.readOnlyExternalSource`, for a host with no translation. */
83+
const READ_ONLY_EXTERNAL_SOURCE_EN =
84+
'This table is read-only. Its records come from an external data source that does not accept changes.';
8085

8186
type Row = Record<string, unknown>;
8287

@@ -272,8 +277,13 @@ export const recordDelete = Object.freeze({
272277
toast.success(recordDelete.successText(deps, record));
273278
return { success: true, reload: true, silent: true };
274279
} catch (err) {
280+
// A delete the object's external datasource does not admit (ADR-0015,
281+
// `EXTERNAL_WRITE_FORBIDDEN`) says the table is read-only, not the
282+
// gate's diagnostic, which names datasource flags and machine names.
275283
toast.error(t('objectActions.deleteFailed', { label }), {
276-
description: (err as Error).message,
284+
description: isExternalWriteRefusal(err)
285+
? t('form.readOnlyExternalSource', { defaultValue: READ_ONLY_EXTERNAL_SOURCE_EN })
286+
: (err as Error).message,
277287
});
278288
// The toast above (label + error message) is the feedback; returning
279289
// WITHOUT `error` keeps the runner from toasting the raw message again.

‎ui/packages/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export * from './utils/expand-fields.js';
4343
// The `enable`-block API denial predicate, read by `@object-ui/react` and
4444
// re-exported by `@object-ui/data-objectstack` (objectstack#25917).
4545
export * from './utils/api-access-denied.js';
46+
export * from './utils/external-write-refusal.js';
4647
// The RETIREMENT gate (objectui#4914, maintainer ruling B). Homed here rather
4748
// than in `@object-ui/fields` because `@object-ui/components` is one of its six
4849
// consumers and `fields` depends on `components` — see the module's docblock

0 commit comments

Comments
 (0)