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
12 changes: 12 additions & 0 deletions .changeset/12112-meta-refusal-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
'@object-ui/data-objectstack': minor
---

**A dashboard or a list view the server refuses you, opened by its URL, now says it was refused** (objectui#12112, the console half of objectstack#22611's ruling A).

Since objectstack#22731 the server applies a dashboard's and a list view's `requiredPermissions`: its dashboard and view lists carry only what you may open, and a by-name read of one you may not answers `403 PERMISSION_DENIED`. The console read only the lists, so a refused dashboard's URL showed "Dashboard Not Found", and a refused list view's URL silently showed the object's default view instead.

- **The dashboard page and the object's list page** ask a missed name by name. A `PERMISSION_DENIED` answer shows "You don't have access" with the server's own sentence, which names the item: `You do not have permission to open the 'NAME' dashboard.` (or `view`). Any other answer keeps today's face: "Dashboard Not Found", or the fall-back to the default view. Nothing is asked in draft preview, or by a host whose data source has no probe. The title reuses existing translations; no translation key is added.
- **`@object-ui/data-objectstack`: `ObjectStackAdapter.probeMetaItemAccess(type, name)`**, and its result type `MetaItemAccess`. It is `probeAppAccess` for any type the by-name meta route gates per caller, and it also returns the refusal's `message` with a `denied` verdict. `probeAppAccess` now delegates to it and answers as before.
- An object's own list view, served whole inside the object's metadata to a user who may edit the object, is still listed and opens. The server serves it to that user, and the console adds no rule of its own.
1 change: 1 addition & 0 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso
| **Read-only Packages in Studio** | The Studio home lists code and installed packages under "Installed (read-only · browsable)". Clicking a card opens the package for browsing (`/studio/:packageId/data`). The card's **Customize with an overlay** link opens the package's metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take an org overlay is decided per metadata type (`allowOrgOverride`), not per package, and the directory marks each type. Unless the runtime reports `features.marketplace: false`, the section also links to the marketplace (`/apps/setup/system/marketplace`), the same route as Home's "Start with a template". |
| **Design in Studio** | Workspace admins get a top-bar entry inside a running app that opens its owning package on the Studio design surface. On an interface route — a dashboard, page, or report — it deep-links straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=<type>:<name>`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) it opens the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. |
| **Dashboard Refresh** | A dashboard page shows a **Refresh All** button above its widgets, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted. `0` or no value means no automatic refresh. Known gap: a dataset-bound single-value (KPI) tile does not refresh yet. |
| **Refused Dashboards and List Views** | The server lists only the dashboards and list views a user may open, so one gated by `requiredPermissions` is not in their lists. When such a dashboard or list view is opened by its URL, the console asks the server about it by name. If the server refuses it with `403 PERMISSION_DENIED`, the page says "You don't have access" and shows the server's sentence, which names the item. The page does not say "not found", and the list page does not switch to the default view. For a name that does not exist, the dashboard page still says it was not found and the list page still opens the default view. An object's own list view that the server sends whole to a user who may edit the object is listed and opens. |
| **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. |
| **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. |
| **Error Boundary** | Graceful error handling with a retry button. |
Expand Down
84 changes: 84 additions & 0 deletions packages/app-shell/src/hooks/useMetaItemRefusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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.
*/

/**
* Whether the server refused this caller a dashboard or a list view that a URL
* names and the console's list does not carry (objectui#12112).
*
* Since objectstack#22731 the server applies a dashboard's and a list view's
* `requiredPermissions`: `GET /api/v1/meta/dashboard` and `/meta/view` list
* only what the caller may open. A withheld item and one that does not exist
* are therefore the same absence in those lists, and a page that reads only the
* list says "not found" over a permission refusal. The by-name read tells them
* apart, as it does for an app: the item the caller does not hold answers `403`
* `PERMISSION_DENIED` with a sentence naming it. This asks that read, through
* the adapter's `probeMetaItemAccess` — the door `AppContent` already asks
* about a missing app — once per missed name.
*
* - `name` null: nothing has missed, or it may not be asked yet. Nothing is
* asked, and the reading says nothing.
* - No adapter, or one without the probe (a host DataSource, AGENTS #1):
* nothing is asked, so the page keeps the face it has for a missing item.
* - While the answer is out, `checking`: the page has nothing to say yet.
* - The answer is held WITH the name it is about and read back for that name
* only, so it never answers for the next URL.
* - `refusal` is set for `denied` alone. Every other verdict leaves the page's
* missing-item face: the server said absence, or said nothing.
*
* Nothing is cached across mounts: a verdict about the CALLER goes stale the
* moment they are granted the capability.
*/

import { useEffect, useState } from 'react';
import type { MetaItemAccess } from '@object-ui/data-objectstack';
import { useAdapter } from '../providers/AdapterProvider.js';

/** A by-name refusal, as the server answered it. */
export interface MetaItemRefusal {
/** The server's own sentence for the refusal; absent when it wrote none. */
readonly message?: string;
}

/** What {@link useMetaItemRefusal} knows about the missed name. */
export interface MetaItemRefusalReading {
/** The missed name is being asked about. */
readonly checking: boolean;
/** The server refused this caller the item; `null` for every other answer. */
readonly refusal: MetaItemRefusal | null;
}

const NOT_REFUSED: MetaItemRefusalReading = Object.freeze({ checking: false, refusal: null });
const CHECKING: MetaItemRefusalReading = Object.freeze({ checking: true, refusal: null });

export function useMetaItemRefusal(type: 'dashboard' | 'view', name: string | null): MetaItemRefusalReading {
const adapter = useAdapter();
const asked = name && typeof adapter?.probeMetaItemAccess === 'function' ? name : null;
const [answer, setAnswer] = useState<{ type: string; name: string; access: MetaItemAccess } | null>(null);

useEffect(() => {
if (!adapter || !asked) return;
let cancelled = false;
Promise.resolve(adapter.probeMetaItemAccess(type, asked))
.then((access) => {
if (!cancelled) setAnswer({ type, name: asked, access });
})
// The adapter's probe never throws, so a host probe that did has given
// no answer: absence of an answer, never a refusal.
.catch(() => {
if (!cancelled) setAnswer({ type, name: asked, access: { verdict: 'unreachable' } });
});
return () => {
cancelled = true;
};
}, [adapter, type, asked]);

if (!asked) return NOT_REFUSED;
if (!answer || answer.type !== type || answer.name !== asked) return CHECKING;
if (answer.access.verdict !== 'denied') return NOT_REFUSED;
return { checking: false, refusal: { message: answer.access.message } };
}
172 changes: 172 additions & 0 deletions packages/app-shell/src/views/DashboardView.refusal-12112.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* 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#12112 — a dashboard the server refuses this caller, opened by its
* direct URL, says it was refused.
*
* Since objectstack#22731 `GET /api/v1/meta/dashboard` lists only the
* dashboards the caller may open, so a dashboard gated by `requiredPermissions`
* is simply absent from the list this page reads, exactly like a name that
* does not exist. The page used to say "Dashboard Not Found" over both. The
* by-name read tells them apart: `403 PERMISSION_DENIED`, with the sentence
* `You do not have permission to open the 'NAME' dashboard.`
*
* The probe is the adapter's `probeMetaItemAccess`, whose own reading of the
* wire is pinned in `@object-ui/data-objectstack`; here it is a stub answering
* the verdict each case needs. The translation is a pass-through, so a title
* reads as the KEY it was drawn from.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { MetadataCtx } from '@object-ui/react';

vi.mock('@object-ui/plugin-dashboard', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
DashboardRenderer: () => null,
}));

const meta = vi.hoisted(() => ({ value: null as unknown }));
vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => meta.value }));

const route = vi.hoisted(() => ({ dashboardName: 'legal_board' }));
vi.mock('react-router-dom', () => ({
useParams: () => ({ dashboardName: route.dashboardName }),
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: `/dashboards/${route.dashboardName}`, search: '' }),
}));

vi.mock('./useOpenRecordList', () => ({ useOpenRecordList: () => vi.fn() }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false }),
}));
const adapterRef = vi.hoisted(() => ({ value: {} as unknown }));
vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => adapterRef.value }));
vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) }));
vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({ t: (k: string) => k }),
}));

import { DashboardView } from './DashboardView';
import { PreviewModeProvider } from '../preview/PreviewModeContext';

const REFUSAL = "You do not have permission to open the 'legal_board' dashboard.";
const OPS_BOARD = { name: 'ops', label: 'Operations', widgets: [] };

/** Mount the page over the dashboards the caller's list carries. */
function mount(dashboards: unknown[], typeStatus: string = 'ready', previewDrafts = false) {
meta.value = {
apps: [],
objects: [],
dashboards,
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: vi.fn(async () => null),
getItemsByType: () => [],
getTypeStatus: () => typeStatus,
};
const page = (
<MetadataCtx.Provider value={meta.value as never}>
<DashboardView />
</MetadataCtx.Provider>
);
return render(previewDrafts ? <PreviewModeProvider force>{page}</PreviewModeProvider> : page);
}

function probeAnswering(access: Record<string, unknown>) {
const probeMetaItemAccess = vi.fn(async () => access);
adapterRef.value = { probeMetaItemAccess };
return probeMetaItemAccess;
}

beforeEach(() => {
route.dashboardName = 'legal_board';
adapterRef.value = {};
});
afterEach(cleanup);

describe('objectui#12112 — a refused dashboard URL says it was refused', () => {
it('a dashboard the server refuses: a permission refusal, in the server\'s words — not "not found"', async () => {
const probe = probeAnswering({ verdict: 'denied', message: REFUSAL });
mount([OPS_BOARD]);

const refusal = await screen.findByTestId('dashboard-access-denied');
expect(probe).toHaveBeenCalledWith('dashboard', 'legal_board');
expect(refusal.textContent).toContain('dashboard.widgetForbiddenTitle');
expect(refusal.textContent).toContain(REFUSAL);
expect(screen.queryByText('empty.dashboardNotFound')).toBeNull();
});

it('a refusal that carries no sentence is still said as a refusal', async () => {
probeAnswering({ verdict: 'denied' });
mount([OPS_BOARD]);

const refusal = await screen.findByTestId('dashboard-access-denied');
expect(refusal.textContent).toBe('dashboard.widgetForbiddenTitle');
});

it('CONTROL: a held dashboard opens as today, and nothing is asked by name', async () => {
route.dashboardName = 'ops';
const probe = probeAnswering({ verdict: 'denied', message: REFUSAL });
const { container } = mount([OPS_BOARD]);

await waitFor(() => expect(container.querySelector('h1')?.textContent).toBe('Operations'));
expect(probe).not.toHaveBeenCalled();
expect(screen.queryByTestId('dashboard-access-denied')).toBeNull();
});

it.each(['not_found', 'unreachable', 'granted'])(
'CONTROL: a by-name answer of `%s` keeps today\'s "not found"',
async (verdict) => {
const probe = probeAnswering({ verdict });
mount([OPS_BOARD]);

await screen.findByText('empty.dashboardNotFound');
expect(probe).toHaveBeenCalledWith('dashboard', 'legal_board');
expect(screen.queryByTestId('dashboard-access-denied')).toBeNull();
},
);

it('CONTROL: a host adapter with no probe keeps today\'s "not found"', async () => {
adapterRef.value = {};
mount([OPS_BOARD]);

await screen.findByText('empty.dashboardNotFound');
expect(screen.queryByTestId('dashboard-access-denied')).toBeNull();
});

it('CONTROL: in draft preview nothing is asked — the probe reads the published world, not the draft list', async () => {
const probe = probeAnswering({ verdict: 'denied', message: REFUSAL });
mount([OPS_BOARD], 'ready', true);

await screen.findByText('empty.dashboardNotFound');
expect(probe).not.toHaveBeenCalled();
});

it('nothing is asked, and nothing is said, until the dashboard list has loaded', async () => {
const probe = probeAnswering({ verdict: 'denied', message: REFUSAL });
const { container } = mount([], 'loading');

// The page has rendered past its first frame (the skeleton the view always
// draws first), and still neither face is up.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(container.firstChild).not.toBeNull();
expect(probe).not.toHaveBeenCalled();
expect(screen.queryByText('empty.dashboardNotFound')).toBeNull();
expect(screen.queryByTestId('dashboard-access-denied')).toBeNull();
});
});
40 changes: 38 additions & 2 deletions packages/app-shell/src/views/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import {
EmptyTitle,
EmptyDescription,
} from '@object-ui/components';
import { LayoutDashboard } from 'lucide-react';
import { LayoutDashboard, Lock } from 'lucide-react';
import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js';
import { useActionModal } from '../hooks/useActionModal.js';
import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js';
import { usePreviewDrafts } from '../preview/PreviewModeContext.js';
import { SkeletonDashboard } from '../skeletons/index.js';
import { useMetadata } from '../providers/MetadataProvider.js';
import { useExpressionContext } from '../providers/ExpressionProvider.js';
Expand Down Expand Up @@ -153,11 +155,27 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
queueMicrotask(() => setIsLoading(false));
}, [dashboardName]);

const { dashboards, objects: metadataObjects } = useMetadata();
// `dashboards` is read first: its getter starts the list's fetch, and the
// status read after it is that fetch's.
const { dashboards, objects: metadataObjects, getTypeStatus } = useMetadata();
// ADR-0048 Phase 2 — prefer the dashboard owned by the current app's package.
const { app: activeApp } = useExpressionContext();
const dashboard = preferLocal(dashboards as any[], dashboardName, (activeApp as any)?._packageId);

// ---- A miss: refused, or not there? (objectui#12112) ---------------------
// The server lists only the dashboards this caller may open
// (objectstack#22731), so a dashboard withheld by its `requiredPermissions`
// is absent from `dashboards` exactly like a name that does not exist. Only
// the by-name read tells them apart, so a miss is asked there, once the list
// has answered (before that, absence is not yet a miss). Not in draft
// preview: the probe reads the published world, and this page reads the
// draft one — `AppContent` skips its app probe there for the same reason.
const previewDrafts = usePreviewDrafts();
const dashboardsStatus = getTypeStatus?.('dashboard') ?? 'ready';
const dashboardsSettled = dashboardsStatus === 'ready' || dashboardsStatus === 'error';
const missedName = !dashboard && dashboardsSettled && !previewDrafts && dashboardName ? dashboardName : null;
const access = useMetaItemRefusal('dashboard', missedName);

// ---- Runtime capability gate --------------------------------------------
// Hide widgets whose `requiresObject` is not registered (mirrors
// NavigationItem.requiresObject for nav entries). Defaults to widget.object
Expand Down Expand Up @@ -190,6 +208,24 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
}

if (!dashboard) {
if (!dashboardsSettled || access.checking) {
return <SkeletonDashboard />;
}
// Refused: said as a permission refusal, and in the server's own sentence,
// which names the dashboard. ⛔ Not "not found": the dashboard exists.
if (access.refusal) {
return (
<div className="h-full flex items-center justify-center p-8">
<Empty data-testid="dashboard-access-denied">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Lock className="h-6 w-6 text-muted-foreground" />
</div>
<EmptyTitle>{t('dashboard.widgetForbiddenTitle')}</EmptyTitle>
{access.refusal.message && <EmptyDescription>{access.refusal.message}</EmptyDescription>}
</Empty>
</div>
);
}
return (
<div className="h-full flex items-center justify-center p-8">
<Empty>
Expand Down
Loading
Loading