diff --git a/.changeset/console-403-search-scope-nav-gating.md b/.changeset/console-403-search-scope-nav-gating.md new file mode 100644 index 0000000000..0d3f37aa51 --- /dev/null +++ b/.changeset/console-403-search-scope-nav-gating.md @@ -0,0 +1,14 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/plugin-list": patch +"@object-ui/react": patch +"@object-ui/i18n": patch +--- + +fix(console): three real-user console failures — 403 blamed on the network, ⌘K search capped at 8 objects, nav gating fields inert + +1. **List error panel classifies the failure** (`plugin-list`, `i18n`): a 403/401 from the data source used to render the same "check your connection" copy as a genuine outage, sending users to debug their network while the server was correctly denying access. The panel now classifies by `httpStatus`/`status`/`statusCode`, the `PERMISSION_DENIED`/`UNAUTHORIZED` error codes, or an `HTTP ` message prefix, and renders dedicated permission-denied / sign-in-required copy (all nine locales). + +2. **⌘K / full-page search scope is no longer truncated** (`react`): `maxObjectsQueried` caps the per-object fanout fallback, not the search scope — it used to slice the candidate pool itself, so the `objects` whitelist sent to the platform's `/api/v1/search` only ever named the first 8 nav objects. Which sidebar group came first decided which records were findable; everything later in the nav was unsearchable no matter what the user typed. + +3. **Nav gating fields finally gate** (`app-shell`): `evaluateVisibility` only evaluated `${…}` template strings, so the `{ dialect: 'cel', source }` envelopes the spec normalizes every authored `visible` predicate into fell through to a blanket "visible" — a constant-false predicate still rendered for everyone. It now delegates to `ExpressionEvaluator.evaluateCondition`, which routes CEL envelopes to the canonical `@objectstack/formula` engine. And the sidebars' `requiredPermissions` check treats a bare name as an ADR-0066 system capability (union of the user's permission-set `systemPermissions` from `/me/permissions`) — the same subset rule the server applies to `AppSchema.requiredPermissions` — instead of misreading it as `can(, 'read')`, which had degraded `requiredPermissions` into a hide-from-everyone switch (admins included). The `object:action` form and the legacy object-read fallback keep working. diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index d1730c5417..4effbc2e7e 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -237,17 +237,20 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri [evaluator], ); - // Permission check from Console permissions context - const { can } = usePermissions(); + // Permission check from Console permissions context. Mirrors + // UnifiedSidebar: `object:action` → object CRUD gate; a bare name is an + // ADR-0066 system capability (union of permission-set `systemPermissions`), + // with the legacy "can read " reading kept as fallback. + const { can, hasCapabilities } = usePermissions(); const checkPerm = React.useCallback( (permissions: string[]) => permissions.every((perm: string) => { const parts = perm.split(':'); - const [object, action] = parts.length >= 2 - ? [parts[0], parts[1]] - : [perm, 'read']; - return can(object, action as any); + if (parts.length >= 2) { + return can(parts[0], parts[1] as any); + } + return hasCapabilities([perm]) || can(perm, 'read'); }), - [can], + [can, hasCapabilities], ); // Runtime capability checker — gates nav entries with `requiresObject` / diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 0e230cf40b..f19fa40663 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -311,17 +311,28 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { [evaluator], ); - // Permission check - const { can } = usePermissions(); + // Permission check for nav `requiredPermissions` entries. + // + // Two authored forms: + // - `object:action` → object CRUD gate. + // - bare name → an ADR-0066 system capability, checked against the union of + // the user's permission-set `systemPermissions` (from /me/permissions) — + // the SAME subset rule the server applies to `AppSchema.requiredPermissions`. + // This used to be misread as `can(, 'read')` only, so a nav item + // requiring a capability was hidden even from users whose permission set + // granted it (admins included) — `requiredPermissions` degenerated into a + // "hide from everyone" switch. The object-read fallback stays for nav + // items that gate on a plain object name. + const { can, hasCapabilities } = usePermissions(); const checkPerm = React.useCallback( (permissions: string[]) => permissions.every((perm: string) => { const parts = perm.split(':'); - const [object, action] = parts.length >= 2 - ? [parts[0], parts[1]] - : [perm, 'read']; - return can(object, action as any); + if (parts.length >= 2) { + return can(parts[0], parts[1] as any); + } + return hasCapabilities([perm]) || can(perm, 'read'); }), - [can], + [can, hasCapabilities], ); // Runtime capability gate: hide nav items targeting objects/services diff --git a/packages/app-shell/src/providers/ExpressionProvider.evaluateVisibility.test.ts b/packages/app-shell/src/providers/ExpressionProvider.evaluateVisibility.test.ts new file mode 100644 index 0000000000..994f667e9d --- /dev/null +++ b/packages/app-shell/src/providers/ExpressionProvider.evaluateVisibility.test.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { ExpressionEvaluator } from '@object-ui/core'; +import { evaluateVisibility } from './ExpressionProvider'; + +/** + * Regression: nav/area `visible` predicates arrive from the server as + * `{ dialect: 'cel', source }` envelopes (the spec's ExpressionInputSchema + * normalizes every authored string into that shape). evaluateVisibility used + * to fall through to a blanket `return true` for anything that wasn't a + * `${…}` template string — so a constant-false CEL predicate still rendered + * the menu item for everyone, and "hide this nav item by role" was + * unimplementable from app metadata. + */ + +function makeEvaluator(user: Record) { + const context = { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} }; + return new ExpressionEvaluator(context as any); +} + +describe('evaluateVisibility', () => { + const worker = makeEvaluator({ id: 'u1', positions: ['worker'] }); + const orgAdmin = makeEvaluator({ id: 'u2', positions: ['org_admin'] }); + + it('keeps literal handling: booleans and "true"/"false" strings', () => { + expect(evaluateVisibility(undefined, worker)).toBe(true); + expect(evaluateVisibility(true, worker)).toBe(true); + expect(evaluateVisibility('true', worker)).toBe(true); + expect(evaluateVisibility(false, worker)).toBe(false); + expect(evaluateVisibility('false', worker)).toBe(false); + }); + + it('evaluates a CEL envelope against current_user (spec P`…` form)', () => { + const visible = { dialect: 'cel', source: "'org_admin' in current_user.positions" }; + expect(evaluateVisibility(visible, orgAdmin)).toBe(true); + expect(evaluateVisibility(visible, worker)).toBe(false); + }); + + it('still evaluates ${…} template expressions', () => { + const evaluator = makeEvaluator({ role: 'admin' }); + expect(evaluateVisibility("${user.role === 'admin'}", evaluator)).toBe(true); + expect(evaluateVisibility("${user.role === 'guest'}", evaluator)).toBe(false); + }); + + it('fails open (visible) on an unevaluable predicate', () => { + expect(evaluateVisibility({ dialect: 'cel', source: 'not ] valid (' }, worker)).toBe(true); + }); +}); diff --git a/packages/app-shell/src/providers/ExpressionProvider.tsx b/packages/app-shell/src/providers/ExpressionProvider.tsx index 614d3b0c89..7ce2f99618 100644 --- a/packages/app-shell/src/providers/ExpressionProvider.tsx +++ b/packages/app-shell/src/providers/ExpressionProvider.tsx @@ -99,25 +99,32 @@ export function useExpressionContext(): ExpressionContextValue { * - boolean: true/false * - string "true"/"false" * - template expression: "${user.role === 'admin'}" + * - `{ dialect: 'cel', source }` envelopes — the shape the spec's + * `ExpressionInputSchema` normalizes every authored `visible` string into, + * which is what nav/area items carry after the server serves the app schema + * - bare expression strings (evaluated as one boolean expression) * - * Returns true if the item should be visible. + * Everything non-literal is delegated to `evaluator.evaluateCondition`, which + * routes CEL envelopes to the canonical `@objectstack/formula` engine. The + * envelope and bare-string shapes used to fall through to a blanket + * `return true`, so a constant-false nav `visible` predicate (e.g. + * ``P`'org_admin' in current_user.positions` ``) still rendered for everyone — + * the app author had no working way to hide a menu item by role. + * + * Returns true if the item should be visible (fail-open on evaluation errors, + * matching the evaluator's own default). */ export function evaluateVisibility( - expression: string | boolean | undefined, + expression: string | boolean | { dialect?: string; source?: string } | undefined, evaluator: ExpressionEvaluator, ): boolean { if (expression === undefined || expression === null) return true; if (expression === true || expression === 'true') return true; if (expression === false || expression === 'false') return false; - if (typeof expression === 'string' && expression.includes('${')) { - try { - const result = evaluator.evaluateCondition(expression); - return result; - } catch { - return true; // Default to visible on error - } + try { + return evaluator.evaluateCondition(expression); + } catch { + return true; // Default to visible on error } - - return true; } diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index fc653e753e..9f97f135a8 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -448,6 +448,10 @@ const ar = { noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.", loadErrorTitle: "تعذّر تحميل السجلات", loadErrorMessage: "حدث خطأ أثناء تحميل هذه البيانات. تحقق من اتصالك وحاول مرة أخرى.", + loadErrorForbiddenTitle: "ليس لديك صلاحية الوصول", + loadErrorForbiddenMessage: "ليس لديك إذن لعرض هذه السجلات. تواصل مع المسؤول إذا كنت تعتقد أنه يجب أن يكون لديك وصول.", + loadErrorUnauthorizedTitle: "يلزم تسجيل الدخول", + loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.", retry: "إعادة المحاولة", managedBy: { system: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b8bdaf915c..cba4b23aef 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -448,6 +448,10 @@ const de = { noMatchesMessage: "Keine Datensätze entsprechen Ihren aktuellen Filtern oder Ihrer Suche. Passen Sie sie an oder setzen Sie sie zurück.", loadErrorTitle: "Datensätze konnten nicht geladen werden", loadErrorMessage: "Beim Laden dieser Daten ist etwas schiefgelaufen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + loadErrorForbiddenTitle: "Kein Zugriff", + loadErrorForbiddenMessage: "Sie haben keine Berechtigung, diese Datensätze anzuzeigen. Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.", + loadErrorUnauthorizedTitle: "Anmeldung erforderlich", + loadErrorUnauthorizedMessage: "Ihre Sitzung ist abgelaufen oder Sie sind abgemeldet. Melden Sie sich erneut an, um diese Datensätze anzuzeigen.", retry: "Erneut versuchen", managedBy: { system: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a7c1ef0159..ee53de8542 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -477,6 +477,10 @@ const en = { noMatchesMessage: 'No records match your current filters or search. Try adjusting or clearing them.', loadErrorTitle: 'Couldn\u2019t load records', loadErrorMessage: 'Something went wrong while loading this data. Check your connection and try again.', + loadErrorForbiddenTitle: 'You don\u2019t have access', + loadErrorForbiddenMessage: 'You don\u2019t have permission to view these records. Contact your administrator if you think you should have access.', + loadErrorUnauthorizedTitle: 'Sign in required', + loadErrorUnauthorizedMessage: 'Your session has expired or you are signed out. Sign in again to view these records.', retry: 'Retry', managedBy: { system: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 80b58eb93f..ddcd127c68 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -448,6 +448,10 @@ const es = { noMatchesMessage: "Ningún registro coincide con sus filtros o su búsqueda actuales. Pruebe a ajustarlos o borrarlos.", loadErrorTitle: "No se pudieron cargar los registros", loadErrorMessage: "Algo salió mal al cargar estos datos. Compruebe su conexión y vuelva a intentarlo.", + loadErrorForbiddenTitle: "No tiene acceso", + loadErrorForbiddenMessage: "No tiene permiso para ver estos registros. Contacte a su administrador si cree que debería tener acceso.", + loadErrorUnauthorizedTitle: "Se requiere iniciar sesión", + loadErrorUnauthorizedMessage: "Su sesión ha expirado o ha cerrado sesión. Inicie sesión de nuevo para ver estos registros.", retry: "Reintentar", managedBy: { system: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 50718a10d4..565e1132ad 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -448,6 +448,10 @@ const fr = { noMatchesMessage: "Aucun enregistrement ne correspond à vos filtres ou à votre recherche. Essayez de les ajuster ou de les effacer.", loadErrorTitle: "Impossible de charger les enregistrements", loadErrorMessage: "Une erreur s'est produite lors du chargement de ces données. Vérifiez votre connexion et réessayez.", + loadErrorForbiddenTitle: "Accès refusé", + loadErrorForbiddenMessage: "Vous n'avez pas la permission de consulter ces enregistrements. Contactez votre administrateur si vous pensez devoir y accéder.", + loadErrorUnauthorizedTitle: "Connexion requise", + loadErrorUnauthorizedMessage: "Votre session a expiré ou vous êtes déconnecté. Reconnectez-vous pour consulter ces enregistrements.", retry: "Réessayer", managedBy: { system: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 51f634ce16..235cf265f7 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -448,6 +448,10 @@ const ja = { noMatchesMessage: "現在のフィルターや検索条件に一致するレコードはありません。条件を調整するか解除してください。", loadErrorTitle: "レコードを読み込めませんでした", loadErrorMessage: "データの読み込み中に問題が発生しました。接続を確認して再試行してください。", + loadErrorForbiddenTitle: "アクセス権がありません", + loadErrorForbiddenMessage: "これらのレコードを表示する権限がありません。必要な場合は管理者にお問い合わせください。", + loadErrorUnauthorizedTitle: "サインインが必要です", + loadErrorUnauthorizedMessage: "セッションの有効期限が切れたか、サインアウトしています。再度サインインしてください。", retry: "再試行", managedBy: { system: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 962b3d4ac7..63b8c6f4c7 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -448,6 +448,10 @@ const ko = { noMatchesMessage: "현재 필터나 검색어와 일치하는 레코드가 없습니다. 조건을 조정하거나 지워 보세요.", loadErrorTitle: "레코드를 불러오지 못했습니다", loadErrorMessage: "이 데이터를 불러오는 중 문제가 발생했습니다. 연결을 확인하고 다시 시도하세요.", + loadErrorForbiddenTitle: "접근 권한이 없습니다", + loadErrorForbiddenMessage: "이 레코드를 볼 권한이 없습니다. 접근이 필요하면 관리자에게 문의하세요.", + loadErrorUnauthorizedTitle: "로그인이 필요합니다", + loadErrorUnauthorizedMessage: "세션이 만료되었거나 로그아웃되었습니다. 다시 로그인한 후 확인하세요.", retry: "다시 시도", managedBy: { system: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 233cc82ea6..7013095c80 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -448,6 +448,10 @@ const pt = { noMatchesMessage: "Nenhum registro corresponde aos seus filtros ou à sua busca atuais. Tente ajustá-los ou limpá-los.", loadErrorTitle: "Não foi possível carregar os registros", loadErrorMessage: "Algo deu errado ao carregar estes dados. Verifique sua conexão e tente novamente.", + loadErrorForbiddenTitle: "Você não tem acesso", + loadErrorForbiddenMessage: "Você não tem permissão para ver estes registros. Contate seu administrador se acha que deveria ter acesso.", + loadErrorUnauthorizedTitle: "É necessário entrar", + loadErrorUnauthorizedMessage: "Sua sessão expirou ou você saiu. Entre novamente para ver estes registros.", retry: "Tentar novamente", managedBy: { system: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 48b35a2998..535a435dab 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -448,6 +448,10 @@ const ru = { noMatchesMessage: "Ни одна запись не соответствует текущим фильтрам или поиску. Измените или сбросьте их.", loadErrorTitle: "Не удалось загрузить записи", loadErrorMessage: "При загрузке данных произошла ошибка. Проверьте соединение и попробуйте снова.", + loadErrorForbiddenTitle: "Нет доступа", + loadErrorForbiddenMessage: "У вас нет прав на просмотр этих записей. Обратитесь к администратору, если считаете, что доступ вам нужен.", + loadErrorUnauthorizedTitle: "Требуется вход", + loadErrorUnauthorizedMessage: "Сессия истекла или вы вышли из системы. Войдите снова, чтобы просмотреть эти записи.", retry: "Повторить", managedBy: { system: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a4a2136cf6..8a07f15f2d 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -485,6 +485,10 @@ const zh = { noMatchesMessage: '没有符合当前筛选或搜索条件的记录,试试调整或清除它们。', loadErrorTitle: '无法加载记录', loadErrorMessage: '加载数据时出错。请检查网络连接后重试。', + loadErrorForbiddenTitle: '无权访问', + loadErrorForbiddenMessage: '你没有查看这些记录的权限。如需访问,请联系管理员。', + loadErrorUnauthorizedTitle: '需要登录', + loadErrorUnauthorizedMessage: '登录状态已过期或已退出。请重新登录后查看这些记录。', retry: '重试', managedBy: { system: { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index b34feb896e..aec152c47f 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, RefreshIndicator, DataEmptyState } from '@object-ui/components'; import type { SortItem } from '@object-ui/components'; -import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, RotateCw, Loader2, icons, type LucideIcon } from 'lucide-react'; +import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, icons, type LucideIcon } from 'lucide-react'; import type { FilterGroup } from '@object-ui/components'; import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher'; import { ViewSettingsPopover } from './components/ViewSettingsPopover'; @@ -270,6 +270,29 @@ export function evaluateConditionalFormatting( return resolveConditionalFormatting(record, rules as any, scope) as React.CSSProperties; } +/** + * Classify a failed data fetch by HTTP status / error code so the error panel + * can say what actually happened. A 403 rendered as "check your connection" + * 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' | 'network' { + const e = err as any; + // The ObjectStack client decorates errors with `httpStatus`; raw fetch + // wrappers surface `status` / `statusCode`; some adapters only embed the + // status in the message ("HTTP 403 Forbidden — …"). + let status = [e?.httpStatus, e?.status, e?.statusCode] + .find((s: unknown): s is number => typeof s === 'number'); + if (status === undefined) { + const m = /HTTP (\d{3})\b/.exec(String(e?.message ?? '')); + if (m) status = Number(m[1]); + } + const code = typeof e?.code === 'string' ? e.code.toUpperCase() : ''; + if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN') return 'forbidden'; + if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED') return 'unauthorized'; + return 'network'; +} + // Default English translations for fallback when I18nProvider is not available const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.recordCount': '{{count}} records', @@ -286,6 +309,12 @@ const LIST_DEFAULT_TRANSLATIONS: Record = { // Load FAILED (network / server error) — distinct from empty. Offer retry. 'list.loadErrorTitle': 'Couldn\u2019t load records', 'list.loadErrorMessage': 'Something went wrong while loading this data. Check your connection and try again.', + // Load DENIED — the server answered, with a 403/401. Blaming the network + // here sends users chasing connectivity ghosts. + 'list.loadErrorForbiddenTitle': 'You don’t have access', + 'list.loadErrorForbiddenMessage': 'You don’t have permission to view these records. Contact your administrator if you think you should have access.', + 'list.loadErrorUnauthorizedTitle': 'Sign in required', + 'list.loadErrorUnauthorizedMessage': 'Your session has expired or you are signed out. Sign in again to view these records.', 'list.retry': 'Retry', 'list.search': 'Search', 'list.filter': 'Filter', @@ -501,6 +530,8 @@ export const ListView = React.forwardRef(({ // not tell a user to "create your first record" when the fetch actually // 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' | '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. @@ -1200,6 +1231,7 @@ export const ListView = React.forwardRef(({ console.error("ListView data fetch error:", err); setData([]); setLoadError((err as any)?.message ? String((err as any).message) : String(err ?? 'Unknown error')); + setLoadErrorKind(classifyLoadError(err)); } } finally { if (isMounted && requestId === fetchRequestIdRef.current) { @@ -2455,11 +2487,22 @@ export const ListView = React.forwardRef(({ {loadError && data.length === 0 ? ( } + icon={loadErrorKind === 'network' + ? + : } iconWrapperClassName="mb-3" - title={t('list.loadErrorTitle')} - description={t('list.loadErrorMessage')} + title={t( + loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenTitle' + : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedTitle' + : 'list.loadErrorTitle', + )} + description={t( + loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage' + : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage' + : 'list.loadErrorMessage', + )} action={(