Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/29186-form-load-refusal-text.md
Original file line number Diff line number Diff line change
@@ -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-②: 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:

- 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.
175 changes: 175 additions & 0 deletions ui/apps/console/src/components/FormPage.loadRefusal-29186.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// 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` 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: {
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(
<MemoryRouter initialEntries={[`/forms/showcase_task.edit?recordId=${RECORD_ID}`]}>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
</Routes>
</MemoryRouter>,
);
}

/**
* 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 () => {
// 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: 'RECORD_NOT_FOUND',
object: 'showcase_task',
},
});
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: 'RECORD_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: '<html><body><h1>502 Bad Gateway</h1></body></html>' });
await expectLoadBanner('Could not open this record. Please try again.', 502);
});
});
6 changes: 3 additions & 3 deletions ui/apps/console/src/components/FormPage.recordId.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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\.$/);
});

/**
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
Expand Down
35 changes: 27 additions & 8 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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:
Expand All @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
}
})
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ const ar = {
noPermissionToEdit: "ليس لديك إذن لتعديل سجلات {{object}}. الحقول للقراءة فقط.",
noPermissionToEditRecord: "ليس لديك إذن لتعديل هذا السجل. الحقول للقراءة فقط.",
submitFailed: "تعذّر الحفظ. يرجى المحاولة مرة أخرى.",
noPermissionToOpenRecord: "ليس لديك إذن للوصول إلى هذا السجل.",
recordNotFoundOrHidden: "هذا السجل غير موجود، أو ليس لديك إذن للوصول إليه.",
openRecordFailed: "تعذّر فتح هذا السجل. يرجى المحاولة مرة أخرى.",
uploadInFlight: "انتظر حتى ينتهي الرفع قبل الحفظ.",
clearedOnHide: "تم مسح ما لم يعد ينطبق على القيم الحالية: {{fields}}",
visibleWhenFaulted: "تعذّر الإرسال: تعذّر تقييم قاعدة visibleWhen لـ {{fields}}. يجب إصلاح القاعدة قبل أن يصبح إرسال هذا النموذج ممكنًا.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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é.",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ const ja = {
noPermissionToEdit: "{{object}}を編集する権限がありません。フィールドは読み取り専用です。",
noPermissionToEditRecord: "このレコードを編集する権限がありません。フィールドは読み取り専用です。",
submitFailed: "保存できませんでした。もう一度お試しください。",
noPermissionToOpenRecord: "このレコードにアクセスする権限がありません。",
recordNotFoundOrHidden: "このレコードは存在しないか、アクセスする権限がありません。",
openRecordFailed: "このレコードを開けませんでした。もう一度お試しください。",
uploadInFlight: "アップロードが完了してから保存してください。",
clearedOnHide: "現在の値に該当しなくなった項目をクリアしました: {{fields}}",
visibleWhenFaulted: "送信できません: {{fields}} の visibleWhen ルールを評価できませんでした。このフォームを送信するには、先にルールを修正する必要があります。",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ const ko = {
noPermissionToEdit: "{{object}} 편집 권한이 없습니다. 필드는 읽기 전용입니다.",
noPermissionToEditRecord: "이 레코드를 편집할 권한이 없습니다. 필드는 읽기 전용입니다.",
submitFailed: "저장하지 못했습니다. 다시 시도해 주세요.",
noPermissionToOpenRecord: "이 레코드에 접근할 권한이 없습니다.",
recordNotFoundOrHidden: "이 레코드가 존재하지 않거나 접근할 권한이 없습니다.",
openRecordFailed: "이 레코드를 열지 못했습니다. 다시 시도해 주세요.",
uploadInFlight: "업로드가 완료된 후에 저장하세요.",
clearedOnHide: "현재 값에 더 이상 해당하지 않는 항목을 비웠습니다: {{fields}}",
visibleWhenFaulted: "제출할 수 없습니다: {{fields}}의 visibleWhen 규칙을 평가할 수 없습니다. 이 양식을 제출하려면 먼저 규칙을 수정해야 합니다.",
Expand Down
Loading
Loading