diff --git a/.changeset/spotty-donkeys-shake.md b/.changeset/spotty-donkeys-shake.md new file mode 100644 index 000000000..d1312829b --- /dev/null +++ b/.changeset/spotty-donkeys-shake.md @@ -0,0 +1,69 @@ +--- +'@object-ui/data-objectstack': minor +'@object-ui/plugin-list': patch +'@object-ui/i18n': patch +--- + +fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state + +A list pointed at an object whose `enable` block withholds the API rendered its ordinary +empty state, so *"this page cannot work, and never could"* reached the user as *"you have no +records"* (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose +`sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so +to nobody. That is also why the upstream defect objectstack#7544 survived review for its +whole life: a merely unpopulated page invites nobody to click through. + +The masking had two halves, in two packages, and neither package could see the other: + +- **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded + **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived + at the surface as a successful empty result, indistinguishable from a genuinely empty + object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED` + (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one + would have pinned the object to "empty" for the rest of the session. +- **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405 + half was never swallowed, so it already reached this panel, but classified as `network`: + *"check your connection and try again"* for a condition no retry can change. It now says + the object is not exposed through the API, that this is a setting on the object rather than + a permission, and it offers **no Retry** button, because every retry re-fetches the + identical refusal. + +Both denials are pure functions of the object's metadata — no user, no permission, no +context — so neither is transient or per-user, which is exactly the case where a silent empty +state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a +missing collection, a missing record and a disabled object are all 404. + +**A genuinely empty object still renders the ordinary empty state**, and a backend without an +optional collection still degrades to empty — pinned in both directions, at the adapter, at +the view, and once end-to-end over a real adapter and a real `ListView`. + +Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search` +branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`. +It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the +branch a list takes whenever it expands a lookup or runs a search is no longer anonymous. + +New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the +`en` pack and mirrored in the list defaults map. + +## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch + +Two independent reasons, either of which is sufficient under this repo's precedent +(objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift): + +1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown): + boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple + `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's + public surface. Additive surface growth is minor. +2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now + **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with + `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that + relied on those two codes arriving as a successful empty result now receives a rejected + promise carrying `code` + `httpStatus`, and must handle it. + +Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404 +with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move +is scoped to the two denial codes named above and to nothing else. + +Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks +`@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change +described in the body, which is what this note is. diff --git a/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx b/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx new file mode 100644 index 000000000..d14c45b79 --- /dev/null +++ b/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx @@ -0,0 +1,156 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4408 — the masking, pinned end to end. + * + * The two halves of this defect live in two packages and neither can see the + * other: `@object-ui/data-objectstack` turned the server's 404 into a resolved + * empty result, and `@object-ui/plugin-list` rendered a resolved empty result + * as its ordinary empty state. Each package's own suite can only pin its own + * half — plugin-list is deliberately backend-agnostic (AGENTS.md #1) and does + * not depend on any adapter. `app-shell` is where both already meet, the same + * reason the defaults-map mirror gate lives here, so the composition is + * asserted here: a real `ObjectStackAdapter` over a stubbed transport, feeding + * the real `ListView`. + * + * The scenario is the reported one. `sys_jwks` declares + * `enable.apiEnabled: false`, so `GET /data/sys_jwks` answers **404** with + * `code: 'OBJECT_API_DISABLED'`, and the Setup page carried the `managedBy` + * empty-state override ("No identity records"). Every persona saw an ordinary, + * unpopulated list. Nobody clicked through, which is also how the upstream + * defect objectstack#7544 survived review for its entire life. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ListView } from '@object-ui/plugin-list'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; + +/** The Setup page's schema: the `managedBy` empty-state override, as shipped. */ +const schema: ListViewSchema = { + type: 'list-view', + objectName: 'sys_jwks', + fields: ['name'], + emptyState: { + icon: 'ShieldAlert', + title: 'No identity records', + message: + 'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.', + }, +} as ListViewSchema; + +/** + * An adapter whose transport answers the data route with `status` + `body`. + * Discovery is answered 200 so `connect()` resolves normally. + */ +function makeAdapter(status: number, body: unknown) { + const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { + const href = String(url); + if (href.includes('/data/')) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response( + JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl }); + return ds; +} + +function renderList(ds: any) { + return render( + + + , + ); +} + +describe('object list · an OBJECT_API_DISABLED 404 is not an empty list (#4408)', () => { + it('renders the honest cannot-work state, NOT the generic empty state', async () => { + const ds = makeAdapter(404, { + code: 'OBJECT_API_DISABLED', + message: 'Object API is disabled for sys_jwks', + }); + const { container } = renderList(ds); + + const panel = await waitFor(() => { + const el = container.querySelector('[data-testid="list-error-state"]'); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + + expect(panel.getAttribute('data-error-kind')).toBe('api-disabled'); + + // The masking, gone: the empty state must not be what this page shows, and + // the override copy that used to stand in for the failure must be absent. + expect(container.querySelector('[data-testid="empty-state"]')).toBeNull(); + expect(container.textContent).not.toContain('No identity records'); + + // And the panel says the true thing. + expect(panel.textContent).toMatch(/API/); + expect(panel.textContent).not.toMatch(/connection/i); + }); + + it('renders the same honest state for the 405 sibling', async () => { + const ds = makeAdapter(405, { + error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: 'Method not allowed' }, + }); + const { container } = renderList(ds); + + const panel = await waitFor(() => { + const el = container.querySelector('[data-testid="list-error-state"]'); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + expect(panel.getAttribute('data-error-kind')).toBe('api-disabled'); + expect(container.querySelector('[data-testid="empty-state"]')).toBeNull(); + }); + + /** + * The binding control, in the card's own words: a genuinely empty object must + * STILL render the ordinary empty state. Empty is the overwhelmingly common + * case and the one the empty state exists for — a fix that turned "no records + * yet" into an error surface would be worse than the bug it repairs. + */ + it('CONTROL: a 2xx with zero rows still renders the ordinary empty state', async () => { + const ds = makeAdapter(200, { success: true, data: { records: [], total: 0 } }); + const { container } = renderList(ds); + + await waitFor(() => { + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); + }); + + // The override copy is intact, and no error surface appeared. + expect(container.textContent).toContain('No identity records'); + expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull(); + }); + + /** + * The other control: a 404 that is NOT an enable-block denial. A backend + * without this optional collection still degrades to empty — the probes + * (AppHeader's `sys_presence` / `sys_activity`, …) read empty data as + * "feature unavailable", and turning those into error panels would be a + * second, louder regression. + */ + it('CONTROL: a bare 404 (collection absent) still renders the empty state', async () => { + const ds = makeAdapter(404, { message: 'Not found' }); + const { container } = renderList(ds); + + await waitFor(() => { + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); + }); + expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull(); + }); +}); diff --git a/packages/data-objectstack/src/apiAccessDenied.test.ts b/packages/data-objectstack/src/apiAccessDenied.test.ts new file mode 100644 index 000000000..7fe69a8fc --- /dev/null +++ b/packages/data-objectstack/src/apiAccessDenied.test.ts @@ -0,0 +1,233 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4408 — an `enable`-block denial must not arrive at the surface as + * "no data". + * + * `find()` degraded EVERY 404 into `{ data: [], total: 0 }` and memoised the + * resource in `missingResources`, so a list pointed at an object the server + * refuses to expose (`enable.apiEnabled: false` → HTTP 404 + + * `code: 'OBJECT_API_DISABLED'`) resolved successfully with zero rows. A + * resolved promise with zero rows is indistinguishable from a genuinely empty + * object, so the list rendered its ordinary empty state: "you have no records" + * over a page that can never hold any. The reported instance (Setup › Advanced + * › Signing Keys) could not load for any persona and said so to nobody — which + * is also how the upstream defect objectstack#7544 survived review for its + * whole life. + * + * The discrimination is on the ADR-0112 `code`, never the status: a 404 from a + * missing collection and a 404 from a disabled object are the same status, and + * the optional-collection probes below depend on the missing-collection half + * still degrading to empty. Both halves are asserted here, in both directions. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackAdapter, isApiAccessDeniedError, API_ACCESS_DENIED_CODES } from './index'; + +/** An error shaped the way `@objectstack/client`'s fetch wrapper throws one. */ +function clientError(message: string, httpStatus: number, code?: string) { + const err = new Error(message) as Error & { httpStatus: number; code?: string }; + err.httpStatus = httpStatus; + if (code) err.code = code; + return err; +} + +function makeDS(find: any) { + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { data: { find } }; + return ds; +} + +describe('isApiAccessDeniedError', () => { + it('matches both enable-block denial codes', () => { + expect(isApiAccessDeniedError({ code: 'OBJECT_API_DISABLED' })).toBe(true); + expect(isApiAccessDeniedError({ code: 'OBJECT_API_METHOD_NOT_ALLOWED' })).toBe(true); + expect([...API_ACCESS_DENIED_CODES]).toEqual([ + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', + ]); + }); + + it('matches case-insensitively (the pre-ADR-0112 spelling still resolves)', () => { + expect(isApiAccessDeniedError({ code: 'object_api_disabled' })).toBe(true); + }); + + it('does NOT match the codes a missing collection or record answers with', () => { + expect(isApiAccessDeniedError({ code: 'OBJECT_NOT_FOUND' })).toBe(false); + expect(isApiAccessDeniedError({ code: 'RECORD_NOT_FOUND' })).toBe(false); + expect(isApiAccessDeniedError({ code: 'PERMISSION_DENIED' })).toBe(false); + // A bare 404 carries no code at all — status alone must never qualify. + expect(isApiAccessDeniedError({ httpStatus: 404 })).toBe(false); + expect(isApiAccessDeniedError(undefined)).toBe(false); + }); +}); + +describe('ObjectStackDataSource.find — enable-block denials are surfaced, not emptied', () => { + // ── The defect pin ──────────────────────────────────────────────────────── + it('REJECTS a 404 OBJECT_API_DISABLED instead of resolving to zero rows', async () => { + const find = vi.fn().mockRejectedValue( + clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'), + ); + const ds = makeDS(find); + + await expect(ds.find('sys_jwks')).rejects.toMatchObject({ + code: 'OBJECT_API_DISABLED', + httpStatus: 404, + }); + }); + + it('REJECTS the 405 sibling and keeps its code intact for the surface', async () => { + const find = vi.fn().mockRejectedValue( + clientError('Method not allowed', 405, 'OBJECT_API_METHOD_NOT_ALLOWED'), + ); + const ds = makeDS(find); + + await expect(ds.find('sys_jwks')).rejects.toMatchObject({ + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + httpStatus: 405, + }); + }); + + it('does not memoise a denial — the second call still asks and still rejects', async () => { + // `missingResources` short-circuits later calls to `{ data: [], total: 0 }` + // WITHOUT touching the network. Absorbing a denial into it would pin the + // object to "empty" for the rest of the session, so the honest state would + // appear once and then silently revert to the masking empty state. + const find = vi.fn().mockRejectedValue( + clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'), + ); + const ds = makeDS(find); + + await expect(ds.find('sys_jwks')).rejects.toThrow(); + await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED' }); + expect(find).toHaveBeenCalledTimes(2); + }); + + // ── The controls: must stay green on BOTH sides of the fix ──────────────── + it('CONTROL: a bare 404 (collection absent on this backend) still degrades to empty', async () => { + // The optional-collection probes (AppHeader's sys_presence/sys_activity …) + // read empty data as "feature unavailable". Turning those into thrown + // errors would be a worse bug than the one being fixed. + const find = vi.fn().mockRejectedValue(clientError('Not found', 404)); + const ds = makeDS(find); + + await expect(ds.find('sys_presence')).resolves.toMatchObject({ data: [], total: 0 }); + }); + + it('CONTROL: a 404 OBJECT_NOT_FOUND still degrades to empty, and is still memoised', async () => { + const find = vi.fn().mockRejectedValue(clientError('No such object', 404, 'OBJECT_NOT_FOUND')); + const ds = makeDS(find); + + await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 }); + // Memoised: the second call short-circuits without asking again. + await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 }); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: a 404 RECORD_NOT_FOUND is not an enable-block denial', async () => { + const find = vi.fn().mockRejectedValue(clientError('No such record', 404, 'RECORD_NOT_FOUND')); + const ds = makeDS(find); + + await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 }); + }); + + it('CONTROL: a 2xx with zero rows resolves as an ordinary empty result', async () => { + // The overwhelmingly common case, and the one the empty state exists for. + // A fix that turned "no records yet" into an error surface would be worse + // than the bug. + const find = vi.fn().mockResolvedValue({ records: [], total: 0 }); + const ds = makeDS(find); + + await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 }); + }); + + it('CONTROL: a 2xx with rows is untouched', async () => { + const find = vi.fn().mockResolvedValue({ records: [{ id: '1' }], total: 1 }); + const ds = makeDS(find); + + await expect(ds.find('account')).resolves.toMatchObject({ data: [{ id: '1' }], total: 1 }); + }); + + it('CONTROL: a 403 still propagates (it was never swallowed)', async () => { + const find = vi.fn().mockRejectedValue(clientError('Forbidden', 403, 'PERMISSION_DENIED')); + const ds = makeDS(find); + + await expect(ds.find('account')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }); +}); + +/** + * The `$expand` / `$search` branch of `find()` bypasses `@objectstack/client` + * and hand-rolls its own fetch, so it also has to stamp the ADR-0112 envelope + * itself. It used to set only `status`, which left the surface with a bare 404 + * or 405 and nothing to discriminate on — the same denial, arriving anonymous, + * on the path a list takes whenever it expands a lookup or runs a search. + */ +describe('ObjectStackDataSource.find — the raw $expand/$search branch carries the code', () => { + function makeRawDS(status: number, body: unknown) { + const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { + if (String(url).includes('/data/')) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl }); + ds.connected = true; + ds.connectionState = 'connected'; + return ds; + } + + it('propagates a top-level `code` from the error body', async () => { + const ds = makeRawDS(404, { code: 'OBJECT_API_DISABLED', message: 'Object API is disabled' }); + + await expect(ds.find('sys_jwks', { $search: 'anything' })).rejects.toMatchObject({ + code: 'OBJECT_API_DISABLED', + status: 404, + httpStatus: 404, + }); + }); + + it('propagates a nested `error.code` envelope, like the client wrapper does', async () => { + const ds = makeRawDS(405, { + error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: 'Method not allowed' }, + }); + + await expect(ds.find('sys_jwks', { $expand: ['owner'] })).rejects.toMatchObject({ + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + httpStatus: 405, + }); + }); + + it('CONTROL: a body with no code leaves `code` undefined rather than inventing one', async () => { + const ds = makeRawDS(404, { message: 'Not found' }); + + await expect(ds.find('sys_jwks', { $expand: ['owner'] })).rejects.toMatchObject({ + status: 404, + }); + await ds.find('sys_jwks', { $expand: ['owner'] }).catch((e: any) => { + expect(e.code).toBeUndefined(); + expect(isApiAccessDeniedError(e)).toBe(false); + }); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index edfb87c18..862008e58 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -449,6 +449,49 @@ export function is404Error(error: unknown): boolean { return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']); } +/** + * The two denials the server derives from an object's `enable` block — + * `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`). + * + * - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is + * not exposed over the data API at all. + * - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the + * `enable.apiMethods` whitelist. + * + * Both are **pure functions of the object's metadata**: no user, no permission, + * no context, no request body. So neither is transient and neither is + * per-user — when one happens it is a permanent property of that object, and + * every retry of every persona gets the identical answer. + * + * That is exactly why they must not be degraded into "no data". A 404 from a + * missing collection means *this backend doesn't have that table*, which the + * optional-collection probes below legitimately read as "feature unavailable"; + * a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering + * the second with an empty result set renders "you have no records" over a + * surface that is not allowed to have any (objectui#4408 — it also hid the + * upstream defect objectstack#7544 for its entire life, because a merely + * unpopulated page invites nobody to click through). + */ +export const API_ACCESS_DENIED_CODES = [ + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', +] as const; + +/** + * True when `error` is an `enable`-block API denial (see + * {@link API_ACCESS_DENIED_CODES}). + * + * Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot + * separate a disabled object from a missing collection, and 405 alone cannot + * separate a withheld method from any other method rejection. The code survives + * the transport — `@objectstack/client`'s fetch wrapper stamps `error.code` + * from the response envelope, and both spellings are declared members of the + * spec's `StandardErrorCode` — so no heuristic on status is needed or wanted. + */ +export function isApiAccessDeniedError(error: unknown): boolean { + return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES); +} + /** * Thrown when the deployment has no analytics capability installed * (framework#3891 / #4019). @@ -1310,7 +1353,14 @@ export class ObjectStackAdapter implements DataSource { const result: unknown = await this.client.data.find(resource, queryOptions); return this.normalizeQueryResult(result, params); } catch (err) { - if (is404Error(err)) { + // An `enable`-block denial is NOT a missing collection. The object + // exists and the server is deliberately refusing to expose it, forever + // and for everyone — degrading that to an empty result set tells the + // user "you have no records" about a page that can never hold any + // (objectui#4408). Rethrow so the surface can say what happened; the + // `missingResources` memo must not absorb it either, or the very first + // denial would silently pin every later call to empty. + if (!isApiAccessDeniedError(err) && is404Error(err)) { // Mark the resource so subsequent calls don't repeat the 404. this.missingResources.add(resource); return { data: [], total: 0 } as QueryResult; @@ -2523,6 +2573,16 @@ export class ObjectStackAdapter implements DataSource { const errorBody = await res.json().catch(() => ({ message: res.statusText })); const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any; err.status = res.status; + // Carry the ADR-0112 envelope, not just the status. This branch bypasses + // `@objectstack/client` — whose fetch wrapper stamps `code`/`httpStatus` + // from the error body — so dropping the code here made THIS path (the one + // taken whenever the view expands a lookup or runs a search) the only list + // fetch on which a semantic denial arrives indistinguishable from any + // other 404/405, leaving the surface nothing to discriminate on + // (objectui#4408). Same precedence as the client's wrapper: the top-level + // `code` first, then the nested envelope's. + err.code = errorBody?.code ?? errorBody?.error?.code; + err.httpStatus = res.status; throw err; } diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 7d295ffd5..dcbc6de40 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -511,6 +511,8 @@ const ar = { loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.", loadErrorRejectedTitle: "تم رفض استعلام طريقة العرض هذه", loadErrorRejectedMessage: "تعذّر على الخادم معالجة عامل التصفية أو خيارات الاستعلام لطريقة العرض هذه. مسح عوامل التصفية يحل المشكلة عادةً؛ وإذا كانت طريقة العرض محفوظة بهذا الشكل، فيجب على المسؤول تصحيحها.", + loadErrorApiDisabledTitle: "هذا الكائن غير متاح عبر واجهة API", + loadErrorApiDisabledMessage: "لا يمكن لهذه الصفحة تحميل سجلاتها لأن الكائن غير معروض عبر واجهة API. هذا إعداد خاص بالكائن نفسه وليس صلاحية — يجب على المسؤول تفعيل الوصول عبر API حتى تعمل هذه الصفحة.", retry: "إعادة المحاولة", managedBy: { system: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 075b36b2e..c93f50c4f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -507,6 +507,8 @@ const de = { loadErrorUnauthorizedMessage: "Ihre Sitzung ist abgelaufen oder Sie sind abgemeldet. Melden Sie sich erneut an, um diese Datensätze anzuzeigen.", loadErrorRejectedTitle: "Die Abfrage dieser Ansicht wurde abgelehnt", loadErrorRejectedMessage: "Der Server konnte den Filter oder die Abfrageoptionen dieser Ansicht nicht verarbeiten. Das Zurücksetzen der Filter behebt das meist; ist die Ansicht so gespeichert, muss ein Administrator sie korrigieren.", + loadErrorApiDisabledTitle: "Dieses Objekt ist über die API nicht verfügbar", + loadErrorApiDisabledMessage: "Diese Seite kann ihre Datensätze nicht laden, weil das Objekt nicht über die API bereitgestellt wird. Das ist eine Einstellung am Objekt selbst und keine Berechtigung – ein Administrator muss den API-Zugriff dafür aktivieren, damit diese Seite funktioniert.", retry: "Erneut versuchen", managedBy: { system: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 324e6c9e4..ad0676a08 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -578,6 +578,8 @@ const en = { loadErrorUnauthorizedMessage: 'Your session has expired or you are signed out. Sign in again to view these records.', loadErrorRejectedTitle: 'This view’s query was rejected', loadErrorRejectedMessage: 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.', + loadErrorApiDisabledTitle: 'This object isn’t available through the API', + loadErrorApiDisabledMessage: 'This page can’t load its records because the object is not exposed through the API. That is a setting on the object itself, not a permission — an administrator has to enable API access for it before this page can work.', retry: 'Retry', managedBy: { system: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a0a5439b2..8df0f261d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -511,6 +511,8 @@ const es = { loadErrorUnauthorizedMessage: "Su sesión ha expirado o ha cerrado sesión. Inicie sesión de nuevo para ver estos registros.", loadErrorRejectedTitle: "La consulta de esta vista fue rechazada", loadErrorRejectedMessage: "El servidor no pudo procesar el filtro ni las opciones de consulta de esta vista. Borrar los filtros suele resolverlo; si la vista está guardada así, un administrador debe corregirla.", + loadErrorApiDisabledTitle: "Este objeto no está disponible a través de la API", + loadErrorApiDisabledMessage: "Esta página no puede cargar sus registros porque el objeto no está expuesto a través de la API. Es un ajuste del propio objeto, no un permiso: un administrador debe habilitar el acceso por API para que esta página funcione.", retry: "Reintentar", managedBy: { system: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 78a3a4939..3901e58e7 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -507,6 +507,8 @@ const fr = { loadErrorUnauthorizedMessage: "Votre session a expiré ou vous êtes déconnecté. Reconnectez-vous pour consulter ces enregistrements.", loadErrorRejectedTitle: "La requête de cette vue a été rejetée", loadErrorRejectedMessage: "Le serveur n’a pas pu traiter le filtre ou les options de requête de cette vue. Effacer les filtres suffit généralement ; si la vue est enregistrée ainsi, un administrateur doit la corriger.", + loadErrorApiDisabledTitle: "Cet objet n’est pas disponible via l’API", + loadErrorApiDisabledMessage: "Cette page ne peut pas charger ses enregistrements car l’objet n’est pas exposé via l’API. Il s’agit d’un paramètre de l’objet lui-même, pas d’une autorisation : un administrateur doit activer l’accès API pour que cette page fonctionne.", retry: "Réessayer", managedBy: { system: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 025e1c895..8ac807019 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -507,6 +507,8 @@ const ja = { loadErrorUnauthorizedMessage: "セッションの有効期限が切れたか、サインアウトしています。再度サインインしてください。", loadErrorRejectedTitle: "このビューのクエリは拒否されました", loadErrorRejectedMessage: "サーバーはこのビューのフィルターまたはクエリ設定を処理できませんでした。フィルターを解除すると解消することがほとんどです。ビューがこの状態で保存されている場合は、管理者による修正が必要です。", + loadErrorApiDisabledTitle: "このオブジェクトは API から利用できません", + loadErrorApiDisabledMessage: "このオブジェクトが API に公開されていないため、このページはレコードを読み込めません。これは権限ではなくオブジェクト自体の設定です。管理者が API アクセスを有効にするまで、このページは動作しません。", retry: "再試行", managedBy: { system: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index a8fd36f0d..3d9291688 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -507,6 +507,8 @@ const ko = { loadErrorUnauthorizedMessage: "세션이 만료되었거나 로그아웃되었습니다. 다시 로그인한 후 확인하세요.", loadErrorRejectedTitle: "이 뷰의 쿼리가 거부되었습니다", loadErrorRejectedMessage: "서버가 이 뷰의 필터 또는 쿼리 옵션을 처리하지 못했습니다. 필터를 지우면 대부분 해결됩니다. 뷰가 이 상태로 저장되어 있다면 관리자가 수정해야 합니다.", + loadErrorApiDisabledTitle: "이 객체는 API로 제공되지 않습니다", + loadErrorApiDisabledMessage: "객체가 API에 공개되어 있지 않아 이 페이지는 레코드를 불러올 수 없습니다. 이는 권한이 아니라 객체 자체의 설정입니다. 관리자가 API 액세스를 활성화해야 이 페이지가 동작합니다.", retry: "다시 시도", managedBy: { system: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 89d149f98..63f239fc7 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -506,6 +506,8 @@ const pt = { loadErrorUnauthorizedMessage: "Sua sessão expirou ou você saiu. Entre novamente para ver estes registros.", loadErrorRejectedTitle: "A consulta desta visualização foi rejeitada", loadErrorRejectedMessage: "O servidor não conseguiu processar o filtro ou as opções de consulta desta visualização. Limpar os filtros costuma resolver; se a visualização estiver salva assim, um administrador precisa corrigi-la.", + loadErrorApiDisabledTitle: "Este objeto não está disponível pela API", + loadErrorApiDisabledMessage: "Esta página não consegue carregar seus registros porque o objeto não está exposto pela API. Isso é uma configuração do próprio objeto, não uma permissão — um administrador precisa habilitar o acesso via API para que esta página funcione.", retry: "Tentar novamente", managedBy: { system: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b0f77d08c..260d994bf 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -513,6 +513,8 @@ const ru = { loadErrorUnauthorizedMessage: "Сессия истекла или вы вышли из системы. Войдите снова, чтобы просмотреть эти записи.", loadErrorRejectedTitle: "Запрос этого представления отклонён", loadErrorRejectedMessage: "Сервер не смог обработать фильтр или параметры запроса этого представления. Обычно помогает сброс фильтров; если представление сохранено в таком виде, исправить его должен администратор.", + loadErrorApiDisabledTitle: "Этот объект недоступен через API", + loadErrorApiDisabledMessage: "Страница не может загрузить записи, потому что объект не открыт через API. Это настройка самого объекта, а не права доступа — администратор должен включить доступ по API, чтобы эта страница заработала.", retry: "Повторить", managedBy: { system: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 3534c1468..8294ecf06 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -550,6 +550,8 @@ const zh = { loadErrorUnauthorizedMessage: '登录状态已过期或已退出。请重新登录后查看这些记录。', loadErrorRejectedTitle: '该视图的查询被拒绝', loadErrorRejectedMessage: '服务器无法处理该视图的筛选条件或查询参数。清除筛选条件通常即可恢复;如果视图本身就是这样保存的,需要管理员修正。', + loadErrorApiDisabledTitle: '该对象未开放 API 访问', + loadErrorApiDisabledMessage: '此页面无法加载记录,因为该对象未通过 API 开放。这是对象自身的设置,而不是权限问题 —— 需要管理员为其启用 API 访问,此页面才能正常工作。', retry: '重试', managedBy: { system: { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index a82790143..f93c54f9b 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -393,7 +393,9 @@ export function evaluateConditionalFormatting( * is indistinguishable from a real outage — users were told to debug their * network when the server had (correctly) denied them access. */ -function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'rejected' | 'network' { +function classifyLoadError( + err: unknown, +): 'api-disabled' | 'forbidden' | 'unauthorized' | 'rejected' | 'network' { const e = err as any; // The ObjectStack client decorates errors with `httpStatus`; raw fetch // wrappers surface `status` / `statusCode`; some adapters only embed the @@ -405,6 +407,14 @@ function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'reject if (m) status = Number(m[1]); } const code = typeof e?.code === 'string' ? e.code.toUpperCase() : ''; + // The object's `enable` block withholds this operation — checked FIRST, and + // on the CODE alone. Status cannot carry this verdict: the denial is a 404, + // the same status a missing collection and a missing record answer with, and + // its 405 sibling is the same status any other method rejection uses. Unlike + // every kind below it this one is not about the request, the session or the + // network — it is a permanent property of the object, identical for every + // persona and every retry (objectui#4408). + if (API_ACCESS_DENIED_CODES.has(code)) return 'api-disabled'; if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN') return 'forbidden'; if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED') return 'unauthorized'; // The server understood the request and refused it as malformed. Retrying @@ -423,6 +433,25 @@ const REJECTED_REQUEST_CODES = new Set([ 'INVALID_QUERY', ]); +/** + * The denials the server derives from the object's `enable` block — + * `apiAccessDenialFromEnable` in objectstack's REST server. + * + * - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is + * not exposed over the data API at all. + * - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the + * `enable.apiMethods` whitelist. + * + * Both are pure functions of the object's metadata: no user, no permission, no + * request body. So the honest copy for them is neither "try again" (nothing + * will change) nor "ask your administrator for access" (this is not a + * permission grant — the object is not published to the API at all). + */ +const API_ACCESS_DENIED_CODES = new Set([ + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', +]); + // Default English translations for fallback when I18nProvider is not available. // // Every row whose key the `en` pack also defines must stay byte-identical to it, @@ -461,6 +490,13 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { // request, so the copy points at the filter instead of the network. 'list.loadErrorRejectedTitle': 'This view’s query was rejected', 'list.loadErrorRejectedMessage': 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.', + // Load DENIED BY THE OBJECT — the server answered 404 `OBJECT_API_DISABLED` + // or 405 `OBJECT_API_METHOD_NOT_ALLOWED`. This is not "no records", not a + // permission grant anyone can give, and not something a retry can change: + // the object's `enable` block withholds the API, for every user, permanently. + // Say that, because the alternative reads as an empty list (objectui#4408). + 'list.loadErrorApiDisabledTitle': 'This object isn’t available through the API', + 'list.loadErrorApiDisabledMessage': 'This page can’t load its records because the object is not exposed through the API. That is a setting on the object itself, not a permission — an administrator has to enable API access for it before this page can work.', 'list.retry': 'Retry', // The bare NOUN, for the search button's tooltip. It is deliberately NOT the // input placeholder: that is `table.search` below (objectui#4375). @@ -763,7 +799,7 @@ export const ListView = React.forwardRef(({ // failed. Captured here so the render can show a retryable error panel. const [loadError, setLoadError] = React.useState(null); // What KIND of failure `loadError` is — drives which error panel copy shows. - const [loadErrorKind, setLoadErrorKind] = React.useState<'forbidden' | 'unauthorized' | 'rejected' | 'network'>('network'); + const [loadErrorKind, setLoadErrorKind] = React.useState<'api-disabled' | 'forbidden' | 'unauthorized' | 'rejected' | 'network'>('network'); // Start in loading state when we will fetch from a dataSource so the empty // state doesn't flash before the first effect runs. Inline data (schema.data // as an array or a `value` provider) starts as not-loading. @@ -2966,18 +3002,24 @@ export const ListView = React.forwardRef(({ : } iconWrapperClassName="mb-3" title={t( - loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenTitle' - : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedTitle' - : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedTitle' - : 'list.loadErrorTitle', + loadErrorKind === 'api-disabled' ? 'list.loadErrorApiDisabledTitle' + : loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenTitle' + : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedTitle' + : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedTitle' + : 'list.loadErrorTitle', )} description={t( - loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage' - : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage' - : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedMessage' - : 'list.loadErrorMessage', + loadErrorKind === 'api-disabled' ? 'list.loadErrorApiDisabledMessage' + : loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage' + : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage' + : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedMessage' + : 'list.loadErrorMessage', )} - action={( + action={loadErrorKind === 'api-disabled' ? undefined : ( + // No Retry for an `enable`-block denial. The verdict is a pure + // function of the object's metadata, so every retry re-fetches + // the identical refusal — offering the button is the same wrong + // advice as "check your connection", just spelled as a control.