Skip to content

Commit 4e0ff3e

Browse files
fix(console): a refused edit-form record read says a localized line, never the response body (#29322)
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y <!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbSd2nbuE4y8yizmVhooNYQU)_ Fixes #29186 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:控制台编辑表单(`/forms/NAME?recordId=ID`)读取记录被拒时,横幅不再显示「Could not open record "ID" of OBJECT for editing (403): {…}」,改为一句本地化的话:无读权限(403 / `PERMISSION_DENIED`)显示「您没有权限查看这条记录。」,404 显示「这条记录不存在,或者您没有权限查看它。」(行级安全把看不见的行也答成 404,两者不可区分),其他失败显示「无法打开这条记录,请重试。」。 - **为什么改**:没有读权限的成员打开编辑链接,看到的是英文 JSON,还暴露了记录 id 和对象机器名;#29019 已修好提交路径,加载路径还没有。 - **风险与回滚**:只动 `FormPage.tsx` 的加载失败分支,复用 #29184 的 `WriteRefusal` / `refusalText` / `refusalContent`;十个语言包各加三个 `form` 键(`@object-ui/i18n` 的发布面因此变宽,声明行按席位修正为 `yes (widening)`,changeset 仍为 patch)。回滚即还原此 PR。 - **先看**:`ui/apps/console/src/components/FormPage.tsx` 中 `refusalText` 的 `verb === 'Load'` 分支和加载器的 `throw new WriteRefusal('Load', …)`;`FormPage.loadRefusal-29186.test.tsx`。 - **两分钟验证**:`cd ui && pnpm exec vitest run apps/console/src/components/FormPage.loadRefusal-29186.test.tsx`,预期 6 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes `ui/apps/console/src/components/FormPage.tsx`, the ten i18n packs, tests and a changeset: - **The load throws the refusal the submit throws.** The `recordId` branch of `loadInternalForm` no longer builds `Could not open record "ID" of OBJECT for editing (STATUS): BODY`. It throws `new WriteRefusal('Load', status, body)`: the parser PR #29184 added for writes, its verb widened to `'Load'` and kept on the error as `verb`. No second parser. The raw body stays on `.body` for a debugger. - **`refusalText` reads a load on its own three lines,** before any write rule, and never the server's text: 1. a `404` as `form.recordNotFoundOrHidden`, asked first, so a 404 whose text happens to name an access rule still reads as not-found-or-hidden (the text-sniffing `isPermissionError` would otherwise tell the two apart); 2. any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as `form.noPermissionToOpenRecord`; 3. anything else as `form.openRecordFailed`. `form.noPermissionToSave` is not used for a load. - **The loader's catch** sends a `WriteRefusal` through `refusalText`; every other load error keeps its message (the object-mismatch and blank-id refusals are unchanged). - **Three new keys** in all ten packs (ar, de, en, es, fr, ja, ko, pt, ru, zh): "You don't have access to this record.", "This record doesn't exist, or you don't have access to it.", "Could not open this record. Please try again." ## Measurement (showcase, before and after) `examples/app-showcase` on `objectstack dev --seed-admin --fresh`, Console source on Vite, a member created by the admin (default member permission set). The before leg served `origin/main`'s `FormPage.tsx` from the same Vite; restored after, blob equal to HEAD. | Case | Server answer | Banner before | Banner after | |---|---|---|---| | `/forms/showcase_contact.form?recordId=ID`, member has no read right on contacts | `403` `{"error":"You do not have permission…","code":"PERMISSION_DENIED","object":"showcase_contact"}` | `Could not open record "ID" of showcase_contact for editing (403): {"error":…,"code":"PERMISSION_DENIED","object":"showcase_contact"}` (at 375 px the JSON runs off the banner) | You don't have access to this record. | | `/forms/showcase_inquiry.contact?recordId=ID`, the admin's inquiry, hidden from the member by the private OWD (the admin reads it 200) | `404` `{"error":"Record ID not found in showcase_inquiry","code":"RECORD_NOT_FOUND","object":"showcase_inquiry"}` | `Could not open record "ID" of showcase_inquiry for editing (404): {"error":…,"code":"RECORD_NOT_FOUND",…}` | This record doesn't exist, or you don't have access to it. | A missing id answers the same `404 RECORD_NOT_FOUND` shape as the hidden row (measured on `showcase_task`), so the one line covers both. Screenshots, desktop and 375 px, before and after, are in the project files under `issue-29186/`. ## Tests - New `FormPage.loadRefusal-29186.test.tsx` (6 cases; fixtures are the measured bodies): a 403 `PERMISSION_DENIED` read shows the denial line, and the page text has no `{`, no status, no record id and no object name, with no form or Submit left behind and no write issued; the same in `zh`; a 404 `RECORD_NOT_FOUND` shows the not-found-or-hidden line; a 404 whose text says "Access denied by row-level security policy" still shows that line; a 500 shows the generic line and not the driver text; a 502 markup page shows the generic line. - `FormPage.recordId.test.tsx`: its three load-refusal pins asserted the old raw text (`Could not open record "task-404".*404`) and now assert the localized lines; what they guard (an error state, no create form, no write) is unchanged. - `FormPage.refusalText-29019.test.tsx` (the write refusals) passes unchanged. - **Ablation** (`scripts/ablation-replace.mjs`, anchor `else if (e instanceof WriteRefusal) setError(refusalText(e, null, t));` x1 -> x0, replaced by a banner of the status and the raw body): the new suite went **6/6 red**; restored, blob equals HEAD and `git diff HEAD` is empty. ## Acceptance notes - The load banner keeps no "Go back" control and is the same panel as before; only its text changed. - Other load errors in `loadInternalForm` (form metadata `Could not load form metadata (STATUS): view/NAME`, the object-mismatch refusal) still draw their developer-facing message. They are outside this card's ruling (the record read); noted for the seat, not filed. ## Verification Local subset. Head `8b71e4ae` (branch merged with `origin/main` twice, both clean; the second merge brought an unrelated key into the same packs, so the i18n suite and the FormPage suites were 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 packages/i18n/` on `f4273fb0`: Test Files 139 passed (139), Tests 1839 passed, 13 skipped. - On `8b71e4ae`: `packages/i18n/` plus the three FormPage load/write refusal suites, Test Files 98 passed (98), Tests 1469 passed, 13 skipped. - `pnpm exec turbo run type-check --filter=@object-ui/console --filter=@object-ui/i18n`: 72 tasks successful (console's `type-check` covers `src`, test files included). - Ablation: `scripts/ablation-replace.mjs` on `FormPage.tsx`, anchor x1 -> x0, the new suite 6 failed (6); restored, blob == HEAD, `git diff HEAD` empty. </details> <details><summary>Derived gates (`dispatch-gates --commands`, 34 families) on `7c4f91c8`, reconciled with `--ran`: 34 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 --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: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: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 pnpm check:type-check-debt :: exit 0 ``` Re-run on the final head `8b71e4ae` after the second merge (changeset, closing-keyword, manifest and ratchet families): ```text node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:published-files :: exit 0 pnpm check:type-check-debt :: 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSXNWqmd9k2JHw7drz --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSXNWqmd9k2JHw7drz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 422000d commit 4e0ff3e

14 files changed

Lines changed: 255 additions & 11 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(console): a refused edit-form record read says a localized line, never the response body
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
An edit form (`/forms/NAME?recordId=ID`, and the in-app edit route) reads its record before it draws. When that read was refused, the banner said `Could not open record "ID" of OBJECT for editing (403): {"error":…}`: the record id, the object's machine name, the status and the server's JSON, in English. The load now reads its refusal through the same `WriteRefusal` the submit uses, and the banner says one localized line:
12+
13+
- a denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as "You don't have access to this record.";
14+
- a `404` as "This record doesn't exist, or you don't have access to it." Row-level security answers a hidden row with the same `404`, so the line never tells the two apart;
15+
- anything else as "Could not open this record. Please try again."
16+
17+
The response body stays on the thrown error for a debugger and never reaches the page. The three lines are new `form` keys (`noPermissionToOpenRecord`, `recordNotFoundOrHidden`, `openRecordFailed`) in all ten packs.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#29186 — a refused record read on an edit form says
5+
* a sentence in the reader's language, never the response body.
6+
*
7+
* `/forms/:name?recordId=ID` reads the record before it draws the form. When
8+
* that read was refused, the banner said
9+
* `Could not open record "ID" of OBJECT for editing (403): {"error":…}` — the
10+
* record id, the object's machine name, the status and the server's JSON.
11+
*
12+
* The load now reads its refusal through the same `WriteRefusal` the submit
13+
* uses, and the banner draws one fixed localized line per outcome: a denial is
14+
* "you don't have access", a `404` is "doesn't exist, or you don't have access"
15+
* (row-level security answers a hidden row with the same `404`, so the line
16+
* never tells the two apart), and anything else is the generic line.
17+
*/
18+
19+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
20+
import { render, screen } from '@testing-library/react';
21+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
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 VIEW = {
28+
name: 'showcase_task.edit',
29+
object: 'showcase_task',
30+
viewKind: 'form',
31+
label: 'Log Time',
32+
config: { type: 'simple', sections: [{ fields: ['title', 'hours'] }] },
33+
};
34+
35+
const OBJECT = {
36+
name: 'showcase_task',
37+
label: 'Task',
38+
fields: {
39+
title: { type: 'text', label: 'Title' },
40+
hours: { type: 'text', label: 'Hours' },
41+
},
42+
};
43+
44+
const RECORD_ID = 'rec_7Hq2Kx9';
45+
46+
/**
47+
* A `GET /data/:object/:id` on an object the caller has no read right on, as
48+
* a showcase member measured it on `showcase_contact`.
49+
*/
50+
const PERMISSION_DENIED = {
51+
status: 403,
52+
body: {
53+
error: 'You do not have permission to perform this action. Contact your administrator if you need access.',
54+
code: 'PERMISSION_DENIED',
55+
object: 'showcase_task',
56+
},
57+
};
58+
59+
let writes = 0;
60+
61+
/** One answer of the stubbed server; a string body goes out as it is. */
62+
function answer(ok: boolean, status: number, body: unknown): Response {
63+
const text = typeof body === 'string' ? body : JSON.stringify(body);
64+
return {
65+
ok,
66+
status,
67+
statusText: ok ? 'OK' : 'Refused',
68+
json: async () => JSON.parse(text),
69+
text: async () => text,
70+
} as unknown as Response;
71+
}
72+
73+
/** The internal edit route, its record read answered with `refusal`. */
74+
function openEdit(refusal: { status: number; body: unknown }) {
75+
vi.stubGlobal(
76+
'fetch',
77+
vi.fn(async (url: string, init?: RequestInit) => {
78+
const method = (init?.method ?? 'GET').toUpperCase();
79+
if (method !== 'GET') {
80+
writes += 1;
81+
return answer(true, 200, {});
82+
}
83+
if (String(url).includes('/meta/view/')) return answer(true, 200, VIEW);
84+
if (String(url).includes('/meta/object/')) return answer(true, 200, OBJECT);
85+
if (String(url).includes(`/data/showcase_task/${RECORD_ID}`)) {
86+
return answer(false, refusal.status, refusal.body);
87+
}
88+
throw new Error(`unstubbed fetch: ${method} ${url}`);
89+
}),
90+
);
91+
return render(
92+
<MemoryRouter initialEntries={[`/forms/showcase_task.edit?recordId=${RECORD_ID}`]}>
93+
<Routes>
94+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
95+
</Routes>
96+
</MemoryRouter>,
97+
);
98+
}
99+
100+
/**
101+
* The banner reads exactly `line`, the page says nothing of the response, and
102+
* no editable form is left behind it.
103+
*/
104+
async function expectLoadBanner(line: string, status: number) {
105+
const banner = await screen.findByText(line);
106+
expect(banner.textContent).toBe(line);
107+
const page = document.body.textContent ?? '';
108+
expect(page).not.toContain('{');
109+
expect(page).not.toContain(String(status));
110+
expect(page).not.toContain(RECORD_ID);
111+
expect(page).not.toContain('showcase_task');
112+
expect(screen.queryByLabelText(/Title/)).not.toBeInTheDocument();
113+
expect(screen.queryByRole('button', { name: /Submit/ })).not.toBeInTheDocument();
114+
expect(writes).toBe(0);
115+
}
116+
117+
beforeEach(() => {
118+
writes = 0;
119+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
120+
});
121+
122+
afterEach(() => {
123+
vi.unstubAllGlobals();
124+
});
125+
126+
describe('a refused edit-form record read draws a localized line (#29186)', () => {
127+
it('a 403 PERMISSION_DENIED read says "you don\'t have access", not the body', async () => {
128+
openEdit(PERMISSION_DENIED);
129+
await expectLoadBanner("You don't have access to this record.", 403);
130+
});
131+
132+
it('says the denial in the reader\'s language', async () => {
133+
createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
134+
openEdit(PERMISSION_DENIED);
135+
await expectLoadBanner('您没有权限查看这条记录。', 403);
136+
});
137+
138+
it('a 404 says "doesn\'t exist, or you don\'t have access"', async () => {
139+
// The body a showcase member measured for an inquiry the private OWD hides
140+
// from them — the same shape a missing id answers.
141+
openEdit({
142+
status: 404,
143+
body: {
144+
error: `Record ${RECORD_ID} not found in showcase_task`,
145+
code: 'RECORD_NOT_FOUND',
146+
object: 'showcase_task',
147+
},
148+
});
149+
await expectLoadBanner("This record doesn't exist, or you don't have access to it.", 404);
150+
});
151+
152+
it('a 404 whose text names an access rule still reads as not-found-or-hidden', async () => {
153+
// Row-level security answers a hidden row with 404; whatever its text
154+
// says, the line must not tell "hidden" from "missing".
155+
openEdit({
156+
status: 404,
157+
body: { error: 'Access denied by row-level security policy', code: 'RECORD_NOT_FOUND' },
158+
});
159+
await expectLoadBanner("This record doesn't exist, or you don't have access to it.", 404);
160+
});
161+
162+
it('a 500 says the generic line, not the driver text', async () => {
163+
openEdit({
164+
status: 500,
165+
body: { error: 'SQLITE_BUSY: database is locked', code: 'INTERNAL_ERROR' },
166+
});
167+
await expectLoadBanner('Could not open this record. Please try again.', 500);
168+
expect(document.body.textContent).not.toContain('SQLITE_BUSY');
169+
});
170+
171+
it('a proxy\'s markup page says the generic line', async () => {
172+
openEdit({ status: 502, body: '<html><body><h1>502 Bad Gateway</h1></body></html>' });
173+
await expectLoadBanner('Could not open this record. Please try again.', 502);
174+
});
175+
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ describe('a recordId this route cannot honour FAILS CLOSED (ruling point 2)', ()
286286
]),
287287
);
288288
renderInternal('?recordId=task-404');
289-
await expectRefusal(/Could not open record "task-404".*404/s);
289+
await expectRefusal(/^This record doesn't exist, or you don't have access to it\.$/);
290290
});
291291

292292
/**
@@ -305,7 +305,7 @@ describe('a recordId this route cannot honour FAILS CLOSED (ruling point 2)', ()
305305
]),
306306
);
307307
renderInternal('?recordId=task-42');
308-
await expectRefusal(/Could not open record "task-42".*403/s);
308+
await expectRefusal(/^You don't have access to this record\.$/);
309309
});
310310

311311
it("an object mismatch in the payload ⇒ refuses to open the form", async () => {
@@ -342,7 +342,7 @@ describe('a recordId this route cannot honour FAILS CLOSED (ruling point 2)', ()
342342
]),
343343
);
344344
renderInternal('?recordId=task-404');
345-
await screen.findByText(/Could not open record/);
345+
await screen.findByText(/This record doesn't exist/);
346346
expect(calls.filter((c) => c.method === 'POST' || c.method === 'PATCH')).toHaveLength(0);
347347
});
348348
});

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

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1902,11 +1902,10 @@ async function loadInternalForm(
19021902
channel,
19031903
);
19041904
if (!recRes.ok) {
1905-
const detail = await recRes.text().catch(() => '');
1906-
throw new Error(
1907-
`Could not open record "${recordId}" of ${objectName} for editing (${recRes.status})` +
1908-
`${detail ? `: ${detail}` : recRes.statusText ? `: ${recRes.statusText}` : ''}`,
1909-
);
1905+
// The body rides on the refusal for a debugger; the banner draws only
1906+
// the localized line {@link refusalText} picks for a load.
1907+
const body = await recRes.text().catch(() => '');
1908+
throw new WriteRefusal('Load', recRes.status, body);
19101909
}
19111910
record = readLoadedRecord(await recRes.json(), objectName, recordId);
19121911
}
@@ -1982,7 +1981,8 @@ function firstSentence(...vals: unknown[]): string | null {
19821981
}
19831982

19841983
/**
1985-
* The error a refused submit throws (objectstack-ai/objectstack#27417): the
1984+
* The error a refused submit throws (objectstack-ai/objectstack#27417), and
1985+
* the one a refused edit-form record read throws (`verb` `'Load'`): the
19861986
* response attached the way `@objectstack/client` attaches it to its own
19871987
* errors — the HTTP `status`, the semantic `code`, `details` from the wrapped
19881988
* envelope's `error.details` (else the whole flat body), and the producer's
@@ -2008,11 +2008,13 @@ class WriteRefusal extends Error {
20082008
readonly body: string;
20092009
/** The server's sentence for this refusal, or `null` when it gave none. */
20102010
readonly reason: string | null;
2011+
/** What was refused: a write (`Submit`/`Create`/`Update`) or the record read. */
2012+
readonly verb: 'Submit' | 'Create' | 'Update' | 'Load';
20112013
code?: string;
20122014
details?: unknown;
20132015
userMessage?: string;
20142016

2015-
constructor(verb: 'Submit' | 'Create' | 'Update', status: number, body: string) {
2017+
constructor(verb: 'Submit' | 'Create' | 'Update' | 'Load', status: number, body: string) {
20162018
let parsed: unknown;
20172019
try {
20182020
parsed = JSON.parse(body);
@@ -2044,6 +2046,7 @@ class WriteRefusal extends Error {
20442046
this.status = status;
20452047
this.body = body;
20462048
this.reason = reason;
2049+
this.verb = verb;
20472050
if (!envelope) return;
20482051
const code = firstSentence(envelope.code, wrapped?.code);
20492052
if (code) this.code = code;
@@ -2056,6 +2059,16 @@ class WriteRefusal extends Error {
20562059
}
20572060

20582061
/**
2062+
* What the banner says for a refused record read (`verb` `'Load'`): one fixed
2063+
* localized line, never the server's text, the status or the record id —
2064+
*
2065+
* - a `404` as "not found, or you can't see it": row-level security answers a
2066+
* hidden row with the same `404` as a missing one, so the line must not
2067+
* tell the two apart. Asked first, before any text-sniffing denial reader;
2068+
* - any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as "you don't
2069+
* have access to this record";
2070+
* - anything else as the localized generic line.
2071+
*
20592072
* What the banner and the toast say for a refused write — the record dialog's
20602073
* reading (`@object-ui/components`' form renderer), so a refusal reads the same
20612074
* on both surfaces:
@@ -2076,6 +2089,11 @@ function refusalText(
20762089
unmarked: string[] | null,
20772090
t: (key: string) => string,
20782091
): string | string[] {
2092+
if (err.verb === 'Load') {
2093+
if (err.status === 404) return t('form.recordNotFoundOrHidden');
2094+
if (isPermissionError(err)) return t('form.noPermissionToOpenRecord');
2095+
return t('form.openRecordFailed');
2096+
}
20792097
const marked = declaredUserMessage(err) ?? delegationScopeMessage(err);
20802098
if (marked) return marked;
20812099
if (isPermissionError(err)) return t('form.noPermissionToSave');
@@ -2085,7 +2103,7 @@ function refusalText(
20852103
}
20862104

20872105
/**
2088-
* A submit refusal as the banner and the toast draw it: one string as itself,
2106+
* A refusal as the banner and the toast draw it: one string as itself,
20892107
* several reasons as one list item each.
20902108
*/
20912109
function refusalContent(refusal: string | string[]): ReactNode {
@@ -2665,6 +2683,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
26652683
setLoaded(null);
26662684
if (e instanceof FormNotFoundError) setNotFound(true);
26672685
else if (e instanceof PublicFormUnavailableError) setPublicUnavailable(true);
2686+
else if (e instanceof WriteRefusal) setError(refusalText(e, null, t));
26682687
else setError(e?.message ?? String(e));
26692688
}
26702689
})

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ const ar = {
190190
noPermissionToEdit: "ليس لديك إذن لتعديل سجلات {{object}}. الحقول للقراءة فقط.",
191191
noPermissionToEditRecord: "ليس لديك إذن لتعديل هذا السجل. الحقول للقراءة فقط.",
192192
submitFailed: "تعذّر الحفظ. يرجى المحاولة مرة أخرى.",
193+
noPermissionToOpenRecord: "ليس لديك إذن للوصول إلى هذا السجل.",
194+
recordNotFoundOrHidden: "هذا السجل غير موجود، أو ليس لديك إذن للوصول إليه.",
195+
openRecordFailed: "تعذّر فتح هذا السجل. يرجى المحاولة مرة أخرى.",
193196
uploadInFlight: "انتظر حتى ينتهي الرفع قبل الحفظ.",
194197
clearedOnHide: "تم مسح ما لم يعد ينطبق على القيم الحالية: {{fields}}",
195198
visibleWhenFaulted: "تعذّر الإرسال: تعذّر تقييم قاعدة visibleWhen لـ {{fields}}. يجب إصلاح القاعدة قبل أن يصبح إرسال هذا النموذج ممكنًا.",

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ const de = {
176176
noPermissionToEdit: "Sie haben keine Berechtigung, „{{object}}“-Datensätze zu bearbeiten. Die Felder sind schreibgeschützt.",
177177
noPermissionToEditRecord: "Sie haben keine Berechtigung, diesen Datensatz zu bearbeiten. Die Felder sind schreibgeschützt.",
178178
submitFailed: "Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.",
179+
noPermissionToOpenRecord: "Sie haben keinen Zugriff auf diesen Datensatz.",
180+
recordNotFoundOrHidden: "Dieser Datensatz existiert nicht, oder Sie haben keinen Zugriff darauf.",
181+
openRecordFailed: "Der Datensatz konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.",
179182
uploadInFlight: "Warten Sie, bis der Upload abgeschlossen ist, bevor Sie speichern.",
180183
clearedOnHide: "Geleert, was für die aktuellen Werte nicht mehr gilt: {{fields}}",
181184
visibleWhenFaulted: "Absenden nicht möglich: Die visibleWhen-Regel von {{fields}} konnte nicht ausgewertet werden. Die Regel muss korrigiert werden, bevor dieses Formular abgesendet werden kann.",

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,12 @@ const en = {
233233
// allows it: sharing, ownership or row-level security (objectstack#27138).
234234
noPermissionToEditRecord: "You don't have permission to edit this record. The fields are read-only.",
235235
submitFailed: 'Could not save. Please try again.',
236+
// The banner an edit form shows when its record read is refused: a
237+
// denial, a not-found (which row-level security answers for a hidden row,
238+
// so the line never tells the two apart), and any other failure.
239+
noPermissionToOpenRecord: "You don't have access to this record.",
240+
recordNotFoundOrHidden: "This record doesn't exist, or you don't have access to it.",
241+
openRecordFailed: 'Could not open this record. Please try again.',
236242
uploadInFlight: 'Wait for the upload to finish before saving.',
237243
// The notice the record form raises when a field's own `visibleWhen` turns
238244
// it invisible and the form clears the value it held (objectui#8070 names

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ const es = {
183183
noPermissionToEdit: "No tienes permiso para editar registros de {{object}}. Los campos son de solo lectura.",
184184
noPermissionToEditRecord: "No tienes permiso para editar este registro. Los campos son de solo lectura.",
185185
submitFailed: "No se pudo guardar. Inténtalo de nuevo.",
186+
noPermissionToOpenRecord: "No tienes acceso a este registro.",
187+
recordNotFoundOrHidden: "Este registro no existe o no tienes acceso a él.",
188+
openRecordFailed: "No se pudo abrir este registro. Inténtalo de nuevo.",
186189
uploadInFlight: "Espere a que finalice la carga antes de guardar.",
187190
clearedOnHide: "Se borró lo que ya no corresponde a los valores actuales: {{fields}}",
188191
visibleWhenFaulted: "No se puede enviar: no se pudo evaluar la regla visibleWhen de {{fields}}. Hay que corregir la regla antes de poder enviar este formulario.",

‎ui/packages/i18n/src/locales/fr.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ const fr = {
178178
noPermissionToEdit: "Vous n'avez pas l'autorisation de modifier les enregistrements {{object}}. Les champs sont en lecture seule.",
179179
noPermissionToEditRecord: "Vous n'avez pas l'autorisation de modifier cet enregistrement. Les champs sont en lecture seule.",
180180
submitFailed: "Échec de l'enregistrement. Veuillez réessayer.",
181+
noPermissionToOpenRecord: "Vous n'avez pas accès à cet enregistrement.",
182+
recordNotFoundOrHidden: "Cet enregistrement n'existe pas, ou vous n'y avez pas accès.",
183+
openRecordFailed: "Impossible d'ouvrir cet enregistrement. Veuillez réessayer.",
181184
uploadInFlight: "Attendez la fin du téléversement avant de sauvegarder.",
182185
clearedOnHide: "Ce qui ne s'applique plus aux valeurs actuelles a été effacé : {{fields}}",
183186
visibleWhenFaulted: "Envoi impossible : la règle visibleWhen de {{fields}} n'a pas pu être évaluée. La règle doit être corrigée avant que ce formulaire puisse être envoyé.",

‎ui/packages/i18n/src/locales/ja.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ const ja = {
176176
noPermissionToEdit: "{{object}}を編集する権限がありません。フィールドは読み取り専用です。",
177177
noPermissionToEditRecord: "このレコードを編集する権限がありません。フィールドは読み取り専用です。",
178178
submitFailed: "保存できませんでした。もう一度お試しください。",
179+
noPermissionToOpenRecord: "このレコードにアクセスする権限がありません。",
180+
recordNotFoundOrHidden: "このレコードは存在しないか、アクセスする権限がありません。",
181+
openRecordFailed: "このレコードを開けませんでした。もう一度お試しください。",
179182
uploadInFlight: "アップロードが完了してから保存してください。",
180183
clearedOnHide: "現在の値に該当しなくなった項目をクリアしました: {{fields}}",
181184
visibleWhenFaulted: "送信できません: {{fields}} の visibleWhen ルールを評価できませんでした。このフォームを送信するには、先にルールを修正する必要があります。",

0 commit comments

Comments
 (0)