Skip to content

Commit 4ccd790

Browse files
fix(plugin-kanban): a card move on a read-only external object says the table is read-only, not that you lack permission (#30532)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVWpRnE3E33m6sXj7FL8vXA)_ Fixes #30410 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:看板拖动卡片被服务端以 EXTERNAL_WRITE_FORBIDDEN(外部数据源只读表)拒绝时,提示改为「这张表是只读的:它的记录来自外部数据源,不接受修改。」,与表单、删除、行内编辑、日历等七处一致。 - 为什么改:以前提示「您无权执行此操作」,管理员会去翻权限集,而任何权限集都解不开这张表。 - 风险与回滚:只按错误码 EXTERNAL_WRITE_FORBIDDEN 换文案;作者标注的 userMessage 仍优先,其他 403 与其他错误一字不变;回滚即撤本 PR。 - 先看:ui/packages/plugin-kanban/src/ObjectKanban.tsx 卡片移动 catch 里的 refusal 三元式;ui/packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx 末尾新增的 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx`,预期 8 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes `ui/packages/plugin-kanban/src/ObjectKanban.tsx`, the card-move `catch` (the `const refusal =` expression): `isExternalWriteRefusal(err)` (`@object-ui/react`, added by #30403) is now asked after `declaredUserMessage(err)` and before `isPermissionError(err)`, and answers `form.readOnlyExternalSource`. This is byte-for-byte the order of the calendar reschedule door in `ObjectCalendar.tsx`, the closest sibling. Order measured across #30403's seven doors (merge commit `a91409c42`): every door that reads `declaredUserMessage` asks it FIRST, before `isExternalWriteRefusal` (form page `refusalText`, record dialog in `form.tsx`, stage change `stageRefusalLines`, calendar reschedule); the three doors that ask `isExternalWriteRefusal` without a `declaredUserMessage` (record header delete, list row delete, inline edit) never read the marking at all. So an author-marked `userMessage` still wins here too. Every other 403 keeps `errors.unauthorized`. Out of scope, untouched (claim fence): the lane-refusal paths and every other kanban refusal. ## Tests New top-level `describe` in the existing harness `ObjectKanban.markedRefusalToast.test.tsx` (real `onDragEnd`, error built through `normaliseClientError`): - an unmarked `403 EXTERNAL_WRITE_FORBIDDEN` (`code` plus `details.code`) toasts the read-only line, and not the gate's diagnostic; - a marked one still toasts the marking. The plain-403 control is the file's existing "keeps the localized substitution for an UNMARKED 403", still green. ## Acceptance notes - None beyond the fence above. ## Verification <details><summary>Targeted tests and type-check at d9ca156</summary> ``` turbo run build --filter='@object-ui/plugin-kanban^...' : Tasks 52 successful, 52 total · VERDICT command-exit 0 pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-kanban/ : Test Files 85 passed (85) · Tests 551 passed (551) · VERDICT command-exit 0 pnpm --dir ui --filter @object-ui/plugin-kanban run type-check : VERDICT command-exit 0 ``` </details> <details><summary>Ablation at d9ca156</summary> `node scripts/ablation-replace.mjs` replaced the anchor `(isExternalWriteRefusal(err)` with `(false` (anchor x1 to x0, blob 618d26a to b7edb7d6b008), ran the harness file: `Tests 1 failed | 7 passed (8)`, the new read-only pin red with `Received: "You are not authorized to perform this action."`. Restored: blob equals HEAD (618d26a), `git diff HEAD` empty. </details> <details><summary>Gates</summary> At d9ca156, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families; each was run with its exit code recorded before any pipe: ``` 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 (first run exit 124 = my 300s timeout wrapper, rerun uncapped) 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, 0 UNRUN. Also: `pnpm --dir ui run check:spec-symbols` exit 0; `pnpm --dir ui run check:new-line-citations` exit 0 (0 new citations). </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWn6zhNa4LL7VB1rtL Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3b2aaf commit 4ccd790

3 files changed

Lines changed: 53 additions & 1 deletion

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@object-ui/plugin-kanban': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
A kanban card move refused because the object's external datasource does not admit writes (`403 EXTERNAL_WRITE_FORBIDDEN`, ADR-0015) now says the table is read-only (`form.readOnlyExternalSource`) instead of "You are not authorized to perform this action.", which sent administrators looking through permission sets that can never unlock the table. The read-only check runs after an author-marked `userMessage` and before the permission check, the order the other write doors use; every other 403 keeps today's text.

‎ui/packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,42 @@ describe('ObjectKanban — a marked refusal reaches the card-move toast (#5902)'
241241
expect(String(said)).not.toContain('insufficient privileges');
242242
});
243243
});
244+
245+
/**
246+
* 403 EXTERNAL_WRITE_FORBIDDEN: the ADR-0015 write gate refusing a write on an
247+
* object bound to an external datasource that does not admit writes. Shaped
248+
* like the gate's body (`code` on the error, the whole body under `details`
249+
* where `@objectstack/client` parks it), optionally marked.
250+
*/
251+
const READ_ONLY_LINE =
252+
'This table is read-only. Its records come from an external data source that does not accept changes.';
253+
const externalWriteForbidden = (userMessage?: string) =>
254+
normaliseClientError(
255+
Object.assign(
256+
new Error("Write 'update' blocked on object 'task': datasource 'ext' is external"),
257+
{
258+
code: 'EXTERNAL_WRITE_FORBIDDEN',
259+
httpStatus: 403,
260+
details: { code: 'EXTERNAL_WRITE_FORBIDDEN', object: 'task' },
261+
...(userMessage ? { userMessage } : {}),
262+
},
263+
),
264+
);
265+
266+
describe('ObjectKanban — a card move on a read-only external object says the table is read-only', () => {
267+
it('renders the read-only line, not the permission line, for EXTERNAL_WRITE_FORBIDDEN', async () => {
268+
// RED before the fix: `isPermissionError` claims every 403 and printed
269+
// "You are not authorized to perform this action.". The plain-403 control
270+
// is "keeps the localized substitution for an UNMARKED 403" above.
271+
await mountBoard(makeDataSource(externalWriteForbidden()));
272+
const said = await dropAndReadToast();
273+
expect(said).toBe(READ_ONLY_LINE);
274+
expect(String(said)).not.toContain('blocked on object');
275+
});
276+
277+
it('still renders an author-marked refusal ahead of the read-only line', async () => {
278+
// The order every other write door uses: the marking wins first.
279+
await mountBoard(makeDataSource(externalWriteForbidden(MARKED)));
280+
expect(await dropAndReadToast()).toBe(MARKED);
281+
});
282+
});

‎ui/packages/plugin-kanban/src/ObjectKanban.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
useSafeTranslate,
1616
extractWriteErrorMessage,
1717
isPermissionError,
18+
isExternalWriteRefusal,
1819
declaredUserMessage,
1920
useSettledSchema,
2021
useDataInvalidation,
@@ -1752,7 +1753,11 @@ export const ObjectKanban: React.FC<ObjectKanbanComponentProps> = ({
17521753
// contract draws — a marked 409 or 400 renders identically.
17531754
const refusal =
17541755
declaredUserMessage(err) ??
1755-
(isPermissionError(err)
1756+
// A write the object's external datasource does not admit is not a
1757+
// missing permission: say the table is read-only (ADR-0015).
1758+
(isExternalWriteRefusal(err)
1759+
? tt('form.readOnlyExternalSource', 'This table is read-only. Its records come from an external data source that does not accept changes.')
1760+
: isPermissionError(err)
17561761
? tt('errors.unauthorized', 'You are not authorized to perform this action.')
17571762
: extractWriteErrorMessage(err) ?? tt('table.saveFailed', 'Save failed'));
17581763
toast.error(refusal);

0 commit comments

Comments
 (0)