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
14 changes: 14 additions & 0 deletions .changeset/console-403-search-scope-nav-gating.md
Original file line number Diff line number Diff line change
@@ -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 <status>` 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(<name>, '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.
17 changes: 10 additions & 7 deletions packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <object>" 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` /
Expand Down
25 changes: 18 additions & 7 deletions packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<name>, '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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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);
});
});
29 changes: 18 additions & 11 deletions packages/app-shell/src/providers/ExpressionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ const ar = {
noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.",
loadErrorTitle: "تعذّر تحميل السجلات",
loadErrorMessage: "حدث خطأ أثناء تحميل هذه البيانات. تحقق من اتصالك وحاول مرة أخرى.",
loadErrorForbiddenTitle: "ليس لديك صلاحية الوصول",
loadErrorForbiddenMessage: "ليس لديك إذن لعرض هذه السجلات. تواصل مع المسؤول إذا كنت تعتقد أنه يجب أن يكون لديك وصول.",
loadErrorUnauthorizedTitle: "يلزم تسجيل الدخول",
loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.",
retry: "إعادة المحاولة",
managedBy: {
system: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ const ja = {
noMatchesMessage: "現在のフィルターや検索条件に一致するレコードはありません。条件を調整するか解除してください。",
loadErrorTitle: "レコードを読み込めませんでした",
loadErrorMessage: "データの読み込み中に問題が発生しました。接続を確認して再試行してください。",
loadErrorForbiddenTitle: "アクセス権がありません",
loadErrorForbiddenMessage: "これらのレコードを表示する権限がありません。必要な場合は管理者にお問い合わせください。",
loadErrorUnauthorizedTitle: "サインインが必要です",
loadErrorUnauthorizedMessage: "セッションの有効期限が切れたか、サインアウトしています。再度サインインしてください。",
retry: "再試行",
managedBy: {
system: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ const ko = {
noMatchesMessage: "현재 필터나 검색어와 일치하는 레코드가 없습니다. 조건을 조정하거나 지워 보세요.",
loadErrorTitle: "레코드를 불러오지 못했습니다",
loadErrorMessage: "이 데이터를 불러오는 중 문제가 발생했습니다. 연결을 확인하고 다시 시도하세요.",
loadErrorForbiddenTitle: "접근 권한이 없습니다",
loadErrorForbiddenMessage: "이 레코드를 볼 권한이 없습니다. 접근이 필요하면 관리자에게 문의하세요.",
loadErrorUnauthorizedTitle: "로그인이 필요합니다",
loadErrorUnauthorizedMessage: "세션이 만료되었거나 로그아웃되었습니다. 다시 로그인한 후 확인하세요.",
retry: "다시 시도",
managedBy: {
system: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ const ru = {
noMatchesMessage: "Ни одна запись не соответствует текущим фильтрам или поиску. Измените или сбросьте их.",
loadErrorTitle: "Не удалось загрузить записи",
loadErrorMessage: "При загрузке данных произошла ошибка. Проверьте соединение и попробуйте снова.",
loadErrorForbiddenTitle: "Нет доступа",
loadErrorForbiddenMessage: "У вас нет прав на просмотр этих записей. Обратитесь к администратору, если считаете, что доступ вам нужен.",
loadErrorUnauthorizedTitle: "Требуется вход",
loadErrorUnauthorizedMessage: "Сессия истекла или вы вышли из системы. Войдите снова, чтобы просмотреть эти записи.",
retry: "Повторить",
managedBy: {
system: {
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,10 @@ const zh = {
noMatchesMessage: '没有符合当前筛选或搜索条件的记录,试试调整或清除它们。',
loadErrorTitle: '无法加载记录',
loadErrorMessage: '加载数据时出错。请检查网络连接后重试。',
loadErrorForbiddenTitle: '无权访问',
loadErrorForbiddenMessage: '你没有查看这些记录的权限。如需访问,请联系管理员。',
loadErrorUnauthorizedTitle: '需要登录',
loadErrorUnauthorizedMessage: '登录状态已过期或已退出。请重新登录后查看这些记录。',
retry: '重试',
managedBy: {
system: {
Expand Down
Loading
Loading