From 3733eaa947d1babd33ee203dafc78e794d20a3d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:45:22 +0000 Subject: [PATCH 1/4] fix(console): a refused edit-form record read says a localized line, never the response body Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSXNWqmd9k2JHw7drz --- .../FormPage.loadRefusal-29186.test.tsx | 166 ++++++++++++++++++ .../src/components/FormPage.recordId.test.tsx | 6 +- ui/apps/console/src/components/FormPage.tsx | 35 +++- ui/packages/i18n/src/locales/ar.ts | 3 + ui/packages/i18n/src/locales/de.ts | 3 + ui/packages/i18n/src/locales/en.ts | 6 + ui/packages/i18n/src/locales/es.ts | 3 + ui/packages/i18n/src/locales/fr.ts | 3 + ui/packages/i18n/src/locales/ja.ts | 3 + ui/packages/i18n/src/locales/ko.ts | 3 + ui/packages/i18n/src/locales/pt.ts | 3 + ui/packages/i18n/src/locales/ru.ts | 3 + ui/packages/i18n/src/locales/zh.ts | 3 + 13 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx diff --git a/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx b/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx new file mode 100644 index 00000000000..fed3e700c9f --- /dev/null +++ b/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#29186 — a refused record read on an edit form says + * a sentence in the reader's language, never the response body. + * + * `/forms/:name?recordId=ID` reads the record before it draws the form. 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. + * + * The load now reads its refusal through the same `WriteRefusal` the submit + * uses, and the banner draws one fixed localized line per outcome: a denial is + * "you don't have access", a `404` is "doesn't exist, or you don't have access" + * (row-level security answers a hidden row with the same `404`, so the line + * never tells the two apart), and anything else is the generic line. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createI18n } from '@object-ui/i18n'; +import { FormPage } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const VIEW = { + name: 'showcase_task.edit', + object: 'showcase_task', + viewKind: 'form', + label: 'Log Time', + config: { type: 'simple', sections: [{ fields: ['title', 'hours'] }] }, +}; + +const OBJECT = { + name: 'showcase_task', + label: 'Task', + fields: { + title: { type: 'text', label: 'Title' }, + hours: { type: 'text', label: 'Hours' }, + }, +}; + +const RECORD_ID = 'rec_7Hq2Kx9'; + +/** A `GET /data/:object/:id` the caller may not read. */ +const PERMISSION_DENIED = { + status: 403, + body: { + error: 'You do not have permission to perform this action. Contact your administrator if you need access.', + code: 'PERMISSION_DENIED', + object: 'showcase_task', + }, +}; + +let writes = 0; + +/** One answer of the stubbed server; a string body goes out as it is. */ +function answer(ok: boolean, status: number, body: unknown): Response { + const text = typeof body === 'string' ? body : JSON.stringify(body); + return { + ok, + status, + statusText: ok ? 'OK' : 'Refused', + json: async () => JSON.parse(text), + text: async () => text, + } as unknown as Response; +} + +/** The internal edit route, its record read answered with `refusal`. */ +function openEdit(refusal: { status: number; body: unknown }) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const method = (init?.method ?? 'GET').toUpperCase(); + if (method !== 'GET') { + writes += 1; + return answer(true, 200, {}); + } + if (String(url).includes('/meta/view/')) return answer(true, 200, VIEW); + if (String(url).includes('/meta/object/')) return answer(true, 200, OBJECT); + if (String(url).includes(`/data/showcase_task/${RECORD_ID}`)) { + return answer(false, refusal.status, refusal.body); + } + throw new Error(`unstubbed fetch: ${method} ${url}`); + }), + ); + return render( + + + } /> + + , + ); +} + +/** + * The banner reads exactly `line`, the page says nothing of the response, and + * no editable form is left behind it. + */ +async function expectLoadBanner(line: string, status: number) { + const banner = await screen.findByText(line); + expect(banner.textContent).toBe(line); + const page = document.body.textContent ?? ''; + expect(page).not.toContain('{'); + expect(page).not.toContain(String(status)); + expect(page).not.toContain(RECORD_ID); + expect(page).not.toContain('showcase_task'); + expect(screen.queryByLabelText(/Title/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Submit/ })).not.toBeInTheDocument(); + expect(writes).toBe(0); +} + +beforeEach(() => { + writes = 0; + createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('a refused edit-form record read draws a localized line (#29186)', () => { + it('a 403 PERMISSION_DENIED read says "you don\'t have access", not the body', async () => { + openEdit(PERMISSION_DENIED); + await expectLoadBanner("You don't have access to this record.", 403); + }); + + it('says the denial in the reader\'s language', async () => { + createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false }); + openEdit(PERMISSION_DENIED); + await expectLoadBanner('您没有权限查看这条记录。', 403); + }); + + it('a 404 says "doesn\'t exist, or you don\'t have access"', async () => { + openEdit({ + status: 404, + body: { error: `Record ${RECORD_ID} not found in showcase_task`, code: 'NOT_FOUND' }, + }); + await expectLoadBanner("This record doesn't exist, or you don't have access to it.", 404); + }); + + it('a 404 whose text names an access rule still reads as not-found-or-hidden', async () => { + // Row-level security answers a hidden row with 404; whatever its text + // says, the line must not tell "hidden" from "missing". + openEdit({ + status: 404, + body: { error: 'Access denied by row-level security policy', code: 'NOT_FOUND' }, + }); + await expectLoadBanner("This record doesn't exist, or you don't have access to it.", 404); + }); + + it('a 500 says the generic line, not the driver text', async () => { + openEdit({ + status: 500, + body: { error: 'SQLITE_BUSY: database is locked', code: 'INTERNAL_ERROR' }, + }); + await expectLoadBanner('Could not open this record. Please try again.', 500); + expect(document.body.textContent).not.toContain('SQLITE_BUSY'); + }); + + it('a proxy\'s markup page says the generic line', async () => { + openEdit({ status: 502, body: '

502 Bad Gateway

' }); + await expectLoadBanner('Could not open this record. Please try again.', 502); + }); +}); diff --git a/ui/apps/console/src/components/FormPage.recordId.test.tsx b/ui/apps/console/src/components/FormPage.recordId.test.tsx index 104ae331ed2..256df603402 100644 --- a/ui/apps/console/src/components/FormPage.recordId.test.tsx +++ b/ui/apps/console/src/components/FormPage.recordId.test.tsx @@ -286,7 +286,7 @@ describe('a recordId this route cannot honour FAILS CLOSED (ruling point 2)', () ]), ); renderInternal('?recordId=task-404'); - await expectRefusal(/Could not open record "task-404".*404/s); + await expectRefusal(/^This record doesn't exist, or you don't have access to it\.$/); }); /** @@ -305,7 +305,7 @@ describe('a recordId this route cannot honour FAILS CLOSED (ruling point 2)', () ]), ); renderInternal('?recordId=task-42'); - await expectRefusal(/Could not open record "task-42".*403/s); + await expectRefusal(/^You don't have access to this record\.$/); }); 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)', () ]), ); renderInternal('?recordId=task-404'); - await screen.findByText(/Could not open record/); + await screen.findByText(/This record doesn't exist/); expect(calls.filter((c) => c.method === 'POST' || c.method === 'PATCH')).toHaveLength(0); }); }); diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index f5afeaa3fec..0c59879d2a4 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -1902,11 +1902,10 @@ async function loadInternalForm( channel, ); if (!recRes.ok) { - const detail = await recRes.text().catch(() => ''); - throw new Error( - `Could not open record "${recordId}" of ${objectName} for editing (${recRes.status})` + - `${detail ? `: ${detail}` : recRes.statusText ? `: ${recRes.statusText}` : ''}`, - ); + // The body rides on the refusal for a debugger; the banner draws only + // the localized line {@link refusalText} picks for a load. + const body = await recRes.text().catch(() => ''); + throw new WriteRefusal('Load', recRes.status, body); } record = readLoadedRecord(await recRes.json(), objectName, recordId); } @@ -1982,7 +1981,8 @@ function firstSentence(...vals: unknown[]): string | null { } /** - * The error a refused submit throws (objectstack-ai/objectstack#27417): the + * The error a refused submit throws (objectstack-ai/objectstack#27417), and + * the one a refused edit-form record read throws (`verb` `'Load'`): the * response attached the way `@objectstack/client` attaches it to its own * errors — the HTTP `status`, the semantic `code`, `details` from the wrapped * envelope's `error.details` (else the whole flat body), and the producer's @@ -2008,11 +2008,13 @@ class WriteRefusal extends Error { readonly body: string; /** The server's sentence for this refusal, or `null` when it gave none. */ readonly reason: string | null; + /** What was refused: a write (`Submit`/`Create`/`Update`) or the record read. */ + readonly verb: 'Submit' | 'Create' | 'Update' | 'Load'; code?: string; details?: unknown; userMessage?: string; - constructor(verb: 'Submit' | 'Create' | 'Update', status: number, body: string) { + constructor(verb: 'Submit' | 'Create' | 'Update' | 'Load', status: number, body: string) { let parsed: unknown; try { parsed = JSON.parse(body); @@ -2044,6 +2046,7 @@ class WriteRefusal extends Error { this.status = status; this.body = body; this.reason = reason; + this.verb = verb; if (!envelope) return; const code = firstSentence(envelope.code, wrapped?.code); if (code) this.code = code; @@ -2056,6 +2059,16 @@ class WriteRefusal extends Error { } /** + * What the banner says for a refused record read (`verb` `'Load'`): one fixed + * localized line, never the server's text, the status or the record id — + * + * - a `404` as "not found, or you can't see it": row-level security answers a + * hidden row with the same `404` as a missing one, so the line must not + * tell the two apart. Asked first, before any text-sniffing denial reader; + * - any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as "you don't + * have access to this record"; + * - anything else as the localized generic line. + * * What the banner and the toast say for a refused write — the record dialog's * reading (`@object-ui/components`' form renderer), so a refusal reads the same * on both surfaces: @@ -2076,6 +2089,11 @@ function refusalText( unmarked: string[] | null, t: (key: string) => string, ): string | string[] { + if (err.verb === 'Load') { + if (err.status === 404) return t('form.recordNotFoundOrHidden'); + if (isPermissionError(err)) return t('form.noPermissionToOpenRecord'); + return t('form.openRecordFailed'); + } const marked = declaredUserMessage(err) ?? delegationScopeMessage(err); if (marked) return marked; if (isPermissionError(err)) return t('form.noPermissionToSave'); @@ -2085,7 +2103,7 @@ function refusalText( } /** - * A submit refusal as the banner and the toast draw it: one string as itself, + * A refusal as the banner and the toast draw it: one string as itself, * several reasons as one list item each. */ function refusalContent(refusal: string | string[]): ReactNode { @@ -2665,6 +2683,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { setLoaded(null); if (e instanceof FormNotFoundError) setNotFound(true); else if (e instanceof PublicFormUnavailableError) setPublicUnavailable(true); + else if (e instanceof WriteRefusal) setError(refusalText(e, null, t)); else setError(e?.message ?? String(e)); } }) diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ae619135e06..b89b46f5a55 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -189,6 +189,9 @@ const ar = { noPermissionToEdit: "ليس لديك إذن لتعديل سجلات {{object}}. الحقول للقراءة فقط.", noPermissionToEditRecord: "ليس لديك إذن لتعديل هذا السجل. الحقول للقراءة فقط.", submitFailed: "تعذّر الحفظ. يرجى المحاولة مرة أخرى.", + noPermissionToOpenRecord: "ليس لديك إذن للوصول إلى هذا السجل.", + recordNotFoundOrHidden: "هذا السجل غير موجود، أو ليس لديك إذن للوصول إليه.", + openRecordFailed: "تعذّر فتح هذا السجل. يرجى المحاولة مرة أخرى.", uploadInFlight: "انتظر حتى ينتهي الرفع قبل الحفظ.", clearedOnHide: "تم مسح ما لم يعد ينطبق على القيم الحالية: {{fields}}", visibleWhenFaulted: "تعذّر الإرسال: تعذّر تقييم قاعدة visibleWhen لـ {{fields}}. يجب إصلاح القاعدة قبل أن يصبح إرسال هذا النموذج ممكنًا.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 49969cde81f..98966f9a8db 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -175,6 +175,9 @@ const de = { noPermissionToEdit: "Sie haben keine Berechtigung, „{{object}}“-Datensätze zu bearbeiten. Die Felder sind schreibgeschützt.", noPermissionToEditRecord: "Sie haben keine Berechtigung, diesen Datensatz zu bearbeiten. Die Felder sind schreibgeschützt.", submitFailed: "Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.", + noPermissionToOpenRecord: "Sie haben keinen Zugriff auf diesen Datensatz.", + recordNotFoundOrHidden: "Dieser Datensatz existiert nicht, oder Sie haben keinen Zugriff darauf.", + openRecordFailed: "Der Datensatz konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.", uploadInFlight: "Warten Sie, bis der Upload abgeschlossen ist, bevor Sie speichern.", clearedOnHide: "Geleert, was für die aktuellen Werte nicht mehr gilt: {{fields}}", 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.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 3b91fc60f28..570e30b312a 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -232,6 +232,12 @@ const en = { // allows it: sharing, ownership or row-level security (objectstack#27138). noPermissionToEditRecord: "You don't have permission to edit this record. The fields are read-only.", submitFailed: 'Could not save. Please try again.', + // The banner an edit form shows when its record read is refused: a + // denial, a not-found (which row-level security answers for a hidden row, + // so the line never tells the two apart), and any other failure. + noPermissionToOpenRecord: "You don't have access to this record.", + recordNotFoundOrHidden: "This record doesn't exist, or you don't have access to it.", + openRecordFailed: 'Could not open this record. Please try again.', uploadInFlight: 'Wait for the upload to finish before saving.', // The notice the record form raises when a field's own `visibleWhen` turns // it invisible and the form clears the value it held (objectui#8070 names diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 65531e20c4b..ed0800d861a 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -182,6 +182,9 @@ const es = { noPermissionToEdit: "No tienes permiso para editar registros de {{object}}. Los campos son de solo lectura.", noPermissionToEditRecord: "No tienes permiso para editar este registro. Los campos son de solo lectura.", submitFailed: "No se pudo guardar. Inténtalo de nuevo.", + noPermissionToOpenRecord: "No tienes acceso a este registro.", + recordNotFoundOrHidden: "Este registro no existe o no tienes acceso a él.", + openRecordFailed: "No se pudo abrir este registro. Inténtalo de nuevo.", uploadInFlight: "Espere a que finalice la carga antes de guardar.", clearedOnHide: "Se borró lo que ya no corresponde a los valores actuales: {{fields}}", 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.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index acd48bec480..c88e10932c6 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -177,6 +177,9 @@ const fr = { noPermissionToEdit: "Vous n'avez pas l'autorisation de modifier les enregistrements {{object}}. Les champs sont en lecture seule.", noPermissionToEditRecord: "Vous n'avez pas l'autorisation de modifier cet enregistrement. Les champs sont en lecture seule.", submitFailed: "Échec de l'enregistrement. Veuillez réessayer.", + noPermissionToOpenRecord: "Vous n'avez pas accès à cet enregistrement.", + recordNotFoundOrHidden: "Cet enregistrement n'existe pas, ou vous n'y avez pas accès.", + openRecordFailed: "Impossible d'ouvrir cet enregistrement. Veuillez réessayer.", uploadInFlight: "Attendez la fin du téléversement avant de sauvegarder.", clearedOnHide: "Ce qui ne s'applique plus aux valeurs actuelles a été effacé : {{fields}}", 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é.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 2cb948b2bdc..10fe609d398 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -175,6 +175,9 @@ const ja = { noPermissionToEdit: "{{object}}を編集する権限がありません。フィールドは読み取り専用です。", noPermissionToEditRecord: "このレコードを編集する権限がありません。フィールドは読み取り専用です。", submitFailed: "保存できませんでした。もう一度お試しください。", + noPermissionToOpenRecord: "このレコードにアクセスする権限がありません。", + recordNotFoundOrHidden: "このレコードは存在しないか、アクセスする権限がありません。", + openRecordFailed: "このレコードを開けませんでした。もう一度お試しください。", uploadInFlight: "アップロードが完了してから保存してください。", clearedOnHide: "現在の値に該当しなくなった項目をクリアしました: {{fields}}", visibleWhenFaulted: "送信できません: {{fields}} の visibleWhen ルールを評価できませんでした。このフォームを送信するには、先にルールを修正する必要があります。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index c4678e3c862..72cff9579fb 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -175,6 +175,9 @@ const ko = { noPermissionToEdit: "{{object}} 편집 권한이 없습니다. 필드는 읽기 전용입니다.", noPermissionToEditRecord: "이 레코드를 편집할 권한이 없습니다. 필드는 읽기 전용입니다.", submitFailed: "저장하지 못했습니다. 다시 시도해 주세요.", + noPermissionToOpenRecord: "이 레코드에 접근할 권한이 없습니다.", + recordNotFoundOrHidden: "이 레코드가 존재하지 않거나 접근할 권한이 없습니다.", + openRecordFailed: "이 레코드를 열지 못했습니다. 다시 시도해 주세요.", uploadInFlight: "업로드가 완료된 후에 저장하세요.", clearedOnHide: "현재 값에 더 이상 해당하지 않는 항목을 비웠습니다: {{fields}}", visibleWhenFaulted: "제출할 수 없습니다: {{fields}}의 visibleWhen 규칙을 평가할 수 없습니다. 이 양식을 제출하려면 먼저 규칙을 수정해야 합니다.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 5e0613e82c9..a7c0e3d7b92 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -177,6 +177,9 @@ const pt = { noPermissionToEdit: "Você não tem permissão para editar registros de {{object}}. Os campos são somente leitura.", noPermissionToEditRecord: "Você não tem permissão para editar este registro. Os campos são somente leitura.", submitFailed: "Não foi possível salvar. Tente novamente.", + noPermissionToOpenRecord: "Você não tem acesso a este registro.", + recordNotFoundOrHidden: "Este registro não existe ou você não tem acesso a ele.", + openRecordFailed: "Não foi possível abrir este registro. Tente novamente.", uploadInFlight: "Aguarde a conclusão do upload antes de salvar.", clearedOnHide: "O que não se aplica mais aos valores atuais foi limpo: {{fields}}", visibleWhenFaulted: "Não é possível enviar: não foi possível avaliar a regra visibleWhen de {{fields}}. A regra precisa ser corrigida antes que este formulário possa ser enviado.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index ed4bd558dd6..9e6100cc57a 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -182,6 +182,9 @@ const ru = { noPermissionToEdit: "У вас нет прав на редактирование записей «{{object}}». Поля доступны только для чтения.", noPermissionToEditRecord: "У вас нет прав на редактирование этой записи. Поля доступны только для чтения.", submitFailed: "Не удалось сохранить. Попробуйте ещё раз.", + noPermissionToOpenRecord: "У вас нет доступа к этой записи.", + recordNotFoundOrHidden: "Эта запись не существует, или у вас нет к ней доступа.", + openRecordFailed: "Не удалось открыть эту запись. Попробуйте ещё раз.", uploadInFlight: "Дождитесь завершения загрузки, прежде чем сохранять.", clearedOnHide: "Очищено то, что больше не применимо к текущим значениям: {{fields}}", visibleWhenFaulted: "Невозможно отправить: не удалось вычислить правило visibleWhen для {{fields}}. Правило нужно исправить, прежде чем эту форму можно будет отправить.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index f9a2d5261f2..57723b64c72 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -180,6 +180,9 @@ const zh = { noPermissionToEdit: '您没有编辑{{object}}的权限,字段均为只读。', noPermissionToEditRecord: '您没有编辑此记录的权限,字段均为只读。', submitFailed: '保存失败,请重试。', + noPermissionToOpenRecord: '您没有权限查看这条记录。', + recordNotFoundOrHidden: '这条记录不存在,或者您没有权限查看它。', + openRecordFailed: '无法打开这条记录,请重试。', uploadInFlight: '请等待上传完成后再保存。', clearedOnHide: '已清空不再适用于当前取值的字段:{{fields}}', visibleWhenFaulted: '无法提交:{{fields}} 的 visibleWhen 规则无法求值。需先修正该规则,才能提交此表单。', From c66acece00d7cdeaed064b1d777c1fb487d72726 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:45:39 +0000 Subject: [PATCH 2/4] chore(changeset): the edit-form load refusal line Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSXNWqmd9k2JHw7drz --- .changeset/29186-form-load-refusal-text.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/29186-form-load-refusal-text.md diff --git a/.changeset/29186-form-load-refusal-text.md b/.changeset/29186-form-load-refusal-text.md new file mode 100644 index 00000000000..6c74cf8880a --- /dev/null +++ b/.changeset/29186-form-load-refusal-text.md @@ -0,0 +1,17 @@ +--- +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +fix(console): a refused edit-form record read says a localized line, never the response body + +Clause-②: no +Metadata-protocol: none + +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: + +- a denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as "You don't have access to this record."; +- 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; +- anything else as "Could not open this record. Please try again." + +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. From f4273fb05e0b34835d13e58b913df30a99609957 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:05:28 +0000 Subject: [PATCH 3/4] test(console): the load-refusal fixtures are the bodies a showcase member measured Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSXNWqmd9k2JHw7drz --- .../FormPage.loadRefusal-29186.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx b/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx index fed3e700c9f..064ed963256 100644 --- a/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx +++ b/ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx @@ -43,7 +43,10 @@ const OBJECT = { const RECORD_ID = 'rec_7Hq2Kx9'; -/** A `GET /data/:object/:id` the caller may not read. */ +/** + * A `GET /data/:object/:id` on an object the caller has no read right on, as + * a showcase member measured it on `showcase_contact`. + */ const PERMISSION_DENIED = { status: 403, body: { @@ -133,9 +136,15 @@ describe('a refused edit-form record read draws a localized line (#29186)', () = }); it('a 404 says "doesn\'t exist, or you don\'t have access"', async () => { + // The body a showcase member measured for an inquiry the private OWD hides + // from them — the same shape a missing id answers. openEdit({ status: 404, - body: { error: `Record ${RECORD_ID} not found in showcase_task`, code: 'NOT_FOUND' }, + body: { + error: `Record ${RECORD_ID} not found in showcase_task`, + code: 'RECORD_NOT_FOUND', + object: 'showcase_task', + }, }); await expectLoadBanner("This record doesn't exist, or you don't have access to it.", 404); }); @@ -145,7 +154,7 @@ describe('a refused edit-form record read draws a localized line (#29186)', () = // says, the line must not tell "hidden" from "missing". openEdit({ status: 404, - body: { error: 'Access denied by row-level security policy', code: 'NOT_FOUND' }, + body: { error: 'Access denied by row-level security policy', code: 'RECORD_NOT_FOUND' }, }); await expectLoadBanner("This record doesn't exist, or you don't have access to it.", 404); }); From 8fb9e1225bab7fe9351b46cefc4b08d82cfd9c28 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:56:56 +0000 Subject: [PATCH 4/4] chore(changeset): the load-refusal changeset declares the i18n widening Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSXNWqmd9k2JHw7drz --- .changeset/29186-form-load-refusal-text.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29186-form-load-refusal-text.md b/.changeset/29186-form-load-refusal-text.md index 6c74cf8880a..62af35a9126 100644 --- a/.changeset/29186-form-load-refusal-text.md +++ b/.changeset/29186-form-load-refusal-text.md @@ -5,7 +5,7 @@ fix(console): a refused edit-form record read says a localized line, never the response body -Clause-②: no +Clause-②: yes (widening) Metadata-protocol: none 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: