Skip to content

Commit 817cfa4

Browse files
fix(console): a refused form submit says a localized sentence, never the server's response body (#29184)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbFRxphhoFxVrLjWvErpd4Ep)_ 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): <details><summary>Tests</summary> - `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. </details> <details><summary>Derived gates (`dispatch-gates --commands`, 33 families), reconciled with `--ran`: 33 run, 0 NOT-MEASURED</summary> ```text 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 ``` </details> 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 - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI's Lint UI is the authority) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 block is the dispatch contract's Chinese summary) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRRUHCK9kYPaCjRKL2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b95b1ef commit 817cfa4

5 files changed

Lines changed: 399 additions & 38 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): a refused form submit says a localized sentence, never the server's response body
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Console form page (`/forms/NAME`, the in-app form and the public `/f/SLUG`) drew a refused write as the verb, the status and the raw response body, so a member without create rights read `Create failed (403): {"error":…,"code":"PERMISSION_DENIED","object":…}` in English on the banner and the toast. The page now reads the refusal the way the record dialog does, and the banner and the toast say the same sentence:
11+
12+
- a sentence the producer marked for the end user (`userMessage`), or a delegated administrator's scope refusal, as written;
13+
- any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as the localized "You don't have permission to save this record.";
14+
- the per-rule lines no input carries, one per line, as before;
15+
- the server's own sentence for any other `4xx`, read out of the envelope (`error`, or the wrapped `error.message`), never the body itself;
16+
- a `5xx`, or a refusal with no sentence (a proxy's markup page), as the localized "Could not save. Please try again."
17+
18+
The fields a refusal names are still marked under their inputs. No i18n key is added: both lines are the existing `form.noPermissionToSave` and `form.submitFailed`, present in all ten packs.

‎ui/apps/console/src/components/FormPage.nativeInvalid-27496.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ describe('#27496 — a submit the browser blocks replaces the stale banner', ()
103103
stubPublic(false, 400, { error: 'Validation failed', code: 'VALIDATION_FAILED' });
104104
await refuseThenSubmitBadEmail();
105105
await fixEmailAndSubmit();
106-
expect(await screen.findByText(/Submit failed \(400\)/)).toBeInTheDocument();
106+
// The server's sentence, out of the envelope, never the body (#29019).
107+
expect(await screen.findByText('Validation failed')).toBeInTheDocument();
107108
});
108109
});

‎ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
* error to, the same refusal in the runtime dispatcher's wrapped envelope, and
1818
* the flat `409 UNIQUE_VIOLATION` naming its one `field`.
1919
*
20-
* The banner and the toast are unchanged: a refusal that names no field marks
21-
* no row and still says what it said before.
20+
* A refusal that names no field marks no row and still says the server's
21+
* sentence; what the banner says for every other refusal is pinned in
22+
* `FormPage.refusalText-29019.test.tsx`.
2223
*/
2324

2425
import type React from 'react';
@@ -180,9 +181,10 @@ describe('#27417 — a server refusal naming fields marks each of them', () => {
180181
expect(describedText(email)).toContain('Email is not a valid email address');
181182
// The row the refusal does not name stays unmarked.
182183
expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid', 'true');
183-
// The banner and the toast are unchanged.
184+
// Every named row carries its reason, so the banner and the toast say the
185+
// server's sentence — never the response body (#29019).
184186
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
185-
expect(String(vi.mocked(toast.error).mock.calls[0][0])).toContain('Submit failed (400)');
187+
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe('Validation failed');
186188
});
187189

188190
it('internal route: the wrapped dispatcher envelope marks both named fields', async () => {
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#29019 — a refused write says a sentence in the
5+
* reader's language, never the response body.
6+
*
7+
* A member with read but no create right opened a form link, pressed Submit,
8+
* and the banner and the toast both read the server's answer verbatim:
9+
* `Create failed (403): {"error":"You do not have permission…","code":
10+
* "PERMISSION_DENIED","object":"…"}` — English JSON, with the object's machine
11+
* name, under a page whose report twin says "You don't have access".
12+
*
13+
* The page now reads the refusal the way the record dialog does: a denial is
14+
* the localized `form.noPermissionToSave` line, a server fault (`5xx`) the
15+
* localized `form.submitFailed` line, a `4xx` the server's own sentence out of
16+
* the envelope, and the rows a refusal names are still marked. The fixtures
17+
* are the envelopes the server answers: the flat body the data routes answer,
18+
* the runtime dispatcher's wrapped one, and a proxy's markup page.
19+
*/
20+
21+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
22+
import { render, screen, waitFor } from '@testing-library/react';
23+
import userEvent from '@testing-library/user-event';
24+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
25+
import { toast } from 'sonner';
26+
import { createI18n } from '@object-ui/i18n';
27+
import { FormPage } from './FormPage';
28+
29+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
30+
31+
const FIELDS = {
32+
name: { type: 'text', label: 'Name' },
33+
email: { type: 'text', label: 'Email' },
34+
};
35+
36+
/** The QA run's refusal, byte for byte: a `POST /data/:object` without create. */
37+
const PERMISSION_DENIED = {
38+
status: 403,
39+
body: {
40+
error: 'You do not have permission to perform this action. Contact your administrator if you need access.',
41+
code: 'PERMISSION_DENIED',
42+
object: 'qar8_fsb',
43+
},
44+
};
45+
46+
let posts = 0;
47+
48+
type Refusal = { status: number; body: unknown };
49+
50+
/** One answer of the stubbed server; a string body goes out as it is. */
51+
function answer(ok: boolean, status: number, body: unknown): Response {
52+
const text = typeof body === 'string' ? body : JSON.stringify(body);
53+
return {
54+
ok,
55+
status,
56+
statusText: ok ? 'OK' : 'Refused',
57+
json: async () => JSON.parse(text),
58+
text: async () => text,
59+
} as unknown as Response;
60+
}
61+
62+
/** The internal route `/forms/:name`, refusing its `POST /data/:object` with `refusal`. */
63+
function stubInternal(refusal: Refusal) {
64+
const view = {
65+
name: 'qar8_fsb.form',
66+
object: 'qar8_fsb',
67+
viewKind: 'form',
68+
label: 'Feedback',
69+
config: { type: 'simple', sections: [{ fields: ['name', 'email'] }] },
70+
};
71+
const object = { name: 'qar8_fsb', label: 'Feedback', fields: FIELDS };
72+
vi.stubGlobal(
73+
'fetch',
74+
vi.fn(async (url: string, init?: RequestInit) => {
75+
if (init?.method === 'POST') {
76+
posts += 1;
77+
return answer(false, refusal.status, refusal.body);
78+
}
79+
if (String(url).includes('/meta/view/')) return answer(true, 200, view);
80+
if (String(url).includes('/meta/object/')) return answer(true, 200, object);
81+
throw new Error(`unstubbed fetch: ${url}`);
82+
}),
83+
);
84+
return render(
85+
<MemoryRouter initialEntries={['/forms/qar8_fsb.form']}>
86+
<Routes>
87+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
88+
</Routes>
89+
</MemoryRouter>,
90+
);
91+
}
92+
93+
/** The public route `/f/:slug`, refusing its `POST /forms/:slug/submit` with `refusal`. */
94+
function stubPublic(refusal: Refusal) {
95+
const payload = {
96+
slug: 'feedback',
97+
object: 'qar8_fsb',
98+
label: 'Feedback',
99+
form: { type: 'simple', sections: [{ fields: ['name', 'email'] }] },
100+
objectSchema: { name: 'qar8_fsb', fields: FIELDS },
101+
};
102+
vi.stubGlobal(
103+
'fetch',
104+
vi.fn(async (url: string, init?: RequestInit) => {
105+
if (init?.method === 'POST') {
106+
posts += 1;
107+
return answer(false, refusal.status, refusal.body);
108+
}
109+
if (!String(url).includes('/forms/')) throw new Error(`unstubbed fetch: ${url}`);
110+
return answer(true, 200, payload);
111+
}),
112+
);
113+
return render(
114+
<MemoryRouter initialEntries={['/f/feedback']}>
115+
<Routes>
116+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
117+
</Routes>
118+
</MemoryRouter>,
119+
);
120+
}
121+
122+
async function fillAndSubmit(submitName: RegExp | string = /Submit/) {
123+
await userEvent.type(await screen.findByLabelText('Name'), 'Ada');
124+
await userEvent.click(screen.getByRole('button', { name: submitName }));
125+
}
126+
127+
/** The banner's text, once the refusal has drawn it. */
128+
async function bannerText(): Promise<string> {
129+
await waitFor(() => expect(document.querySelector('[id^="form-error:"]')).not.toBeNull());
130+
return document.querySelector('[id^="form-error:"]')?.textContent ?? '';
131+
}
132+
133+
/** The one refusal toast's content, as a string. */
134+
function toastText(): string {
135+
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
136+
return String(vi.mocked(toast.error).mock.calls[0][0]);
137+
}
138+
139+
/** Neither the body nor the transport's prose reached the reader. */
140+
function expectNoRawBody(text: string, status: number) {
141+
expect(text).not.toContain('{');
142+
expect(text).not.toContain(String(status));
143+
expect(text).not.toMatch(/failed \(/);
144+
}
145+
146+
beforeEach(() => {
147+
posts = 0;
148+
vi.mocked(toast.error).mockClear();
149+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
150+
});
151+
152+
afterEach(() => {
153+
vi.unstubAllGlobals();
154+
});
155+
156+
describe('#29019 — a denied create reads the localized denial', () => {
157+
it('internal route: the 403 PERMISSION_DENIED banner and toast say the same localized sentence', async () => {
158+
stubInternal(PERMISSION_DENIED);
159+
await fillAndSubmit();
160+
161+
const banner = await bannerText();
162+
expect(posts).toBe(1);
163+
expect(banner).toBe("You don't have permission to save this record.");
164+
expect(toastText()).toBe(banner);
165+
expectNoRawBody(banner, 403);
166+
// No row is to blame for a denial.
167+
expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0);
168+
});
169+
170+
it('zh: the same refusal reads in the reader\'s language', async () => {
171+
createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
172+
stubInternal(PERMISSION_DENIED);
173+
await fillAndSubmit('提交');
174+
175+
const banner = await bannerText();
176+
expect(banner).toBe('您没有权限保存这条记录。');
177+
expect(toastText()).toBe(banner);
178+
expectNoRawBody(banner, 403);
179+
});
180+
181+
it('public route: the dispatcher\'s wrapped 403 envelope reads the same denial', async () => {
182+
stubPublic({
183+
status: 403,
184+
body: {
185+
success: false,
186+
error: { code: 'PERMISSION_DENIED', message: 'Access denied for qar8_fsb', httpStatus: 403 },
187+
},
188+
});
189+
await fillAndSubmit();
190+
191+
const banner = await bannerText();
192+
expect(banner).toBe("You don't have permission to save this record.");
193+
expect(toastText()).toBe(banner);
194+
expectNoRawBody(banner, 403);
195+
});
196+
197+
it('a sentence the producer marked for the end user is said as written', async () => {
198+
stubInternal({
199+
status: 403,
200+
body: { ...PERMISSION_DENIED.body, userMessage: 'Feedback closes on Fridays.' },
201+
});
202+
await fillAndSubmit();
203+
204+
expect(await bannerText()).toBe('Feedback closes on Fridays.');
205+
expect(toastText()).toBe('Feedback closes on Fridays.');
206+
});
207+
});
208+
209+
describe('#29019 — every other refusal', () => {
210+
it('a 400 with field details still marks its fields, and says no body', async () => {
211+
stubInternal({
212+
status: 400,
213+
body: {
214+
error: 'Validation failed',
215+
code: 'VALIDATION_FAILED',
216+
fields: [{ field: 'email', code: 'required', message: 'Email is required' }],
217+
},
218+
});
219+
await fillAndSubmit();
220+
221+
const email = screen.getByLabelText('Email');
222+
await waitFor(() => expect(email).toHaveAttribute('aria-invalid', 'true'));
223+
expect(document.getElementById(email.getAttribute('aria-describedby') ?? '')?.textContent).toContain(
224+
'Email is required',
225+
);
226+
expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true');
227+
const banner = await bannerText();
228+
expect(banner).toBe('Validation failed');
229+
expect(toastText()).toBe(banner);
230+
expectNoRawBody(banner, 400);
231+
});
232+
233+
it('a 500 reads the localized generic sentence, not the server fault', async () => {
234+
stubInternal({
235+
status: 500,
236+
body: { error: 'SQLITE_ERROR: no such table: qar8_fsb', code: 'INTERNAL_ERROR' },
237+
});
238+
await fillAndSubmit();
239+
240+
const banner = await bannerText();
241+
expect(banner).toBe('Could not save. Please try again.');
242+
expect(toastText()).toBe(banner);
243+
expectNoRawBody(banner, 500);
244+
expect(banner).not.toContain('SQLITE');
245+
});
246+
247+
it('a proxy\'s markup page reads the localized generic sentence', async () => {
248+
stubPublic({ status: 502, body: '<html><body><h1>502 Bad Gateway</h1></body></html>' });
249+
await fillAndSubmit();
250+
251+
const banner = await bannerText();
252+
expect(banner).toBe('Could not save. Please try again.');
253+
expect(toastText()).toBe(banner);
254+
expect(banner).not.toContain('<');
255+
});
256+
});

0 commit comments

Comments
 (0)