Skip to content

fix(console): a refused form submit says a localized sentence, never the server's response body - #29184

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-aldhur
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-aldhur

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

Fixes #29019

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:控制台表单页(/forms/NAME、应用内表单、公开 /f/SLUG)提交被拒时,横幅和 toast 不再显示服务端原始 JSON,改为与记录对话框同一套读法:无权限显示本地化的「您没有权限保存这条记录。」,5xx 显示本地化的「保存失败,请重试。」,其他 4xx 显示服务端信封里的那句话;字段标记照旧。
  • 为什么改:没有创建权限的成员打开表单链接提交后,看到的是英文的 Create failed (403): {"error":…,"code":"PERMISSION_DENIED","object":…},连对象机器名都露出来了。
  • 风险与回滚:只动 FormPage.tsx 的拒绝文案读取;没有新增 i18n 键(复用十个语言包里已有的 form.noPermissionToSave / form.submitFailed)。回滚即还原此 PR。
  • 先看:ui/apps/console/src/components/FormPage.tsx 的 WriteRefusal 与 refusalText;FormPage.refusalText-29019.test.tsx。
  • 两分钟验证:cd ui && pnpm exec vitest run apps/console/src/components/FormPage.refusalText-29019.test.tsx,预期 7 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

ui/apps/console/src/components/FormPage.tsx only (plus its tests and a changeset):

  • The thrown refusal no longer carries the body as its text. refusedWriteError(message, body) is replaced by a page-local WriteRefusal error that attaches the response the way @objectstack/client does: status, code, details (the wrapped envelope's error.details, else the whole flat body) and the producer-marked userMessage from either dialect. Its message is the server's own sentence (the envelope's error, the wrapped error.message, or a plain-text body), never the body itself; the raw body stays on .body for a debugger.
  • The banner and the toast say one sentence, read the way the record dialog reads it (@object-ui/components' form renderer, same @object-ui/react readers, same order):
    1. a producer-marked userMessage, or a delegated administrator's scope refusal, as written;
    2. any other denial (403, PERMISSION_DENIED, FORBIDDEN) as form.noPermissionToSave ("You don't have permission to save this record.");
    3. the per-rule lines no input carries (unmarkedRefusalLines), as before;
    4. the server's sentence for any other 4xx;
    5. a 5xx, or a refusal with no sentence (a proxy's markup page), as form.submitFailed ("Could not save. Please try again.").
  • Field markers are unchanged: extractFieldErrors reads the same code/details it read before.
  • No new i18n key. Both lines already exist in all ten packs (ar, de, en, es, fr, ja, ko, pt, ru, zh), so Clause-②: no holds by construction and no pack changes.

Why

A member with read but no create right on an object opened its form link, pressed Submit, and read Create failed (403): {"error":"You do not have permission to perform this action. …","code":"PERMISSION_DENIED","object":"qar8_fsb"} verbatim, in English, on the banner and the toast. The record dialog already says the localized denial for the same refusal.

Tests

  • New FormPage.refusalText-29019.test.tsx (7 cases): the QA run's 403 PERMISSION_DENIED create on the internal route reads the localized denial on the banner and the toast, identical, with no {, no status code and no failed (; the same in zh (您没有权限保存这条记录。); the dispatcher's wrapped 403 on the public route; a producer-marked userMessage said as written; a 400 VALIDATION_FAILED with fields[] still marks its field and says the server's sentence; a 500 reads the generic line and not the driver text; a 502 markup page reads the generic line.
  • Two older pins asserted the raw text (Submit failed (400)) and now assert the server's sentence: FormPage.refusalMarks-27417.test.tsx (the banner/toast pin of the first case) and FormPage.nativeInvalid-27496.test.tsx.
  • Ablation (scripts/ablation-replace.mjs, anchor ? refusalText(err, unmarked, t) x1 -> x0, replaced by the old … failed (STATUS): BODY text): the new suite went 7/7 red; restored, blob equals HEAD and git diff HEAD is empty.

Acceptance notes

  • Up-front refusal: not done, measured. The in-app route /apps/:appName/forms/:name renders under ConsolePermissionsScope (App.tsx pathless layout around /home and /apps/:appName/*), so it already holds the caller's /me/permissions answer. The app-less /forms/:name route of the reproduction is outside that scope (App.tsx, the /forms/:name route under ProtectedRoute alone), so knowing the create right there needs a new request; the public /f/:slug is anonymous. Doing it on one route only would make the same form behave differently by URL, so it is reported to the seat as a finding instead.
  • The record-load failure on an edit form (Could not open record … (STATUS): DETAIL, the load path, not the submit) still appends the response body; it is outside this card's ruling (the submit/create/update messages) and is reported to the seat.

Verification

Local subset on head bc782a0d (merged with origin/main as c83558d5; the FormPage suites re-run there):

Tests
  • cd ui && pnpm exec vitest run --maxWorkers=2 apps/console/src/components/FormPage apps/console/src/components/InternalFormRoute apps/console/src/components/InAppFormRoute on c83558d5: Test Files 43 passed (43), Tests 390 passed (390).
  • pnpm --filter @object-ui/console type-check: exit 0 (its tsconfig.json includes src, so the test files are compiled).
  • Ablation: scripts/ablation-replace.mjs on FormPage.tsx, anchor x1 -> x0, the new suite 7 failed (7); restored, blob == HEAD, git diff HEAD empty.
Derived gates (`dispatch-gates --commands`, 33 families), reconciled with `--ran`: 33 run, 0 NOT-MEASURED
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 --dir ui exec vitest run scripts/ :: 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:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: 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

Every other family is CI's: Lint UI, Type Check UI, Test UI and the rest of the required set on this PR.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (CI's Lint UI is the authority)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (not touched)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (the 维护者速读 block is the dispatch contract's Chinese summary)

🤖 Generated with Claude Code


Generated by Claude Code

…the response body

FormPage threw `Create failed (403): {"error":…}` and drew that string on the
banner and the toast. The refusal now carries the response the way the client
attaches it (status, code, details, userMessage) and the page reads it the way
the record dialog does: a denial is form.noPermissionToSave, a 5xx or a refusal
without a sentence is form.submitFailed, a 4xx keeps the server's own sentence
out of the envelope, and the named rows are still marked.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jRRUHCK9kYPaCjRKL2
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

席位核对 · ACCEPT · 2026-11-02T09:13Z

Head-sha: c83558d5a4267adee2ae34cfadaa2290eb560de4

  • Shape: draft → main, Fixes #29019 in the body only. 5 files, +361 / −38 (399 lines ≤ 3000). No governed path: check-governed-merges --pr 29184 hit 0 of 5 paths. Author objectstack-fleet[bot]. Clause-②: no and Metadata-protocol: none are line-initial in the body and the changeset, as claimed. No contract review is owed.
  • File surface: inside the claim: FormPage.tsx, a new FormPage.refusalText-29019 test, two older tests repointed from the Submit failed (400) text, and the changeset. No i18n pack changed, because both sentences already exist in all ten packs. sectionFields.ts is untouched.
  • Wanted, read in the diff:
    • refusedWriteError became a page-local WriteRefusal. It parses the plain or wrapped envelope for the code, the details, userMessage and the server's sentence, and keeps the raw body for a debugger only, never drawn.
    • The banner and the toast draw one sentence, in the record dialog's order: userMessage / delegation scope, then form.noPermissionToSave for a denial, then unmarked rule lines, then the server's 4xx sentence, else form.submitFailed (5xx and proxy markup).
    • The field markers are unchanged.
  • Up-front refusal: measured and not done. /forms/NAME sits outside ConsolePermissionsScope and would need a new request, as the brief allowed. Filed as console form link: a user without create right is offered a fillable create form on /forms/NAME, and learns of the refusal only on Submit #29187 (p3).
  • Tests:
    • the new suite passes 7 of 7: 403 in en and zh, a wrapped 403, userMessage, a 400 that still marks its fields, 500, 502;
    • the ablation that puts the raw body back went red on 7 of 7;
    • FormPage* and the form routes pass 43 files and 390 tests on the merged head;
    • console type-check 0;
    • 33 of 33 derived gates exited 0.
  • Finding: an edit form whose record read is refused still draws the raw body on its load banner. Filed as console FormPage: an edit form whose record read is refused draws the raw server body on its load banner ("Could not open record … for editing (403): {…}") #29186 (p2).
  • Gates (check-runs on this head): 44 success, 11 skipped, 0 failed or pending. All ten required contexts succeeded or were skipped by their path filter.
  • Landing: ordinary queue landing, pr_ready then automerge_enable. Fixes #29019 closes the card on merge.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 09:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 09:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 817cfa4 Nov 2, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-aldhur branch November 2, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console FormPage: a refused create shows the raw server JSON envelope to the end user ("Create failed (403): {"error":…})

1 participant