Skip to content

Commit 4aeb054

Browse files
fix(plugin-dashboard): leave a tile out when the server refuses its data to the viewer (#27065)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8FDqjYqfv8yYJMJVHqWEn2v)_ Refs #26971 (item 1: a viewer who cannot read a tile's data does not see a permission-error tile) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:仪表盘在非设计模式下,服务器以读权限拒绝(403 / PERMISSION_DENIED)某个数据集磁贴的查询后,该磁贴连同卡片框和标题整个不再渲染,其余磁贴自动重排。 - 为什么改:HotCRM 销售业绩仪表盘里,无权读取某数据集的用户会看到一块「无权查看」的错误磁贴,应用无法只对该用户隐藏它(#26971 验收项 1)。 - 风险与回滚:仅渲染层;403 仍是唯一判据,不做客户端猜测。设计模式与可编辑网格仍显示「无权访问」状态。回滚即还原 DashboardRenderer.tsx 的两处改动。 - 先看:`ui/packages/plugin-dashboard/src/DashboardRenderer.tsx`(refusedTiles 状态与 renderWidget 中的 return null)、`DatasetWidget.tsx`(onForbidden 回调)、新测试文件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-dashboard/src/__tests__/DashboardRenderer.refusedTile-26971.test.tsx`,预期 3 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes - `DatasetWidget` takes an optional `onForbidden` callback and calls it when its query is refused as a read refusal (`classifyLoadError(e) === 'forbidden'`). It still draws the objectui#10899 no-access state itself, so any host that keeps the tile on screen is unchanged. `DatasetWidget` is not exported from the package entry. - `DashboardRenderer` records each refused tile (keyed by the tile key and the dataset it names) and, outside design mode, renders nothing for it: no Card frame, no title. The desktop layout is span-flow (array order is visual order) and the mobile layout is two stacks, so the remaining tiles reflow with no gap. - In design mode the tile stays, selectable, with the no-access state. `DashboardGridLayout` (react-grid-layout, explicit x/y) is not changed and keeps the no-access state; the console's `DashboardView` renders `DashboardRenderer`. - Changeset: `@object-ui/plugin-dashboard` patch. ## Why A viewer holding only some permission sets saw one dashboard tile answer "You don't have permission to view the data behind this widget" among tiles that answered, and the app had no key to hide that one tile. Triage routed the card to target 2 (a read-aware default, no new widget key); the seat chose "leave the tile out" because the quiet cell already exists since objectui#10899. ## Tests New `__tests__/DashboardRenderer.refusedTile-26971.test.tsx`, two table tiles, one whose dataset query rejects with the refusal shape (`httpStatus: 403`, `code: 'PERMISSION_DENIED'`): 1. View mode: the readable tile renders; the refused tile was queried, and afterwards neither its title nor any no-access text is in the document. 2. CONTROL, design mode: the refused tile stays (`dashboard-preview-widget-quota_attainment`) and shows the no-access state. 3. CONTROL: a non-refusal failure (400) keeps the tile, its title and its error text. `DatasetWidget.forbiddenState-10899.test.tsx` stays green (it pins the widget-level state, which stays). <details><summary>Ablation (head 52ed283)</summary> `node scripts/ablation-replace.mjs` deleted the line that returns null for a refused tile outside design mode (anchor hit once, blob changed). The view-mode case went red ("drops the refused tile, frame and title included, and keeps the readable one"), both controls stayed green (1 failed / 2 passed). The tool restored the file: blob equals HEAD, `git diff HEAD` empty. The test imports the renderer by relative path, so no dist leg applies. </details> ## Acceptance notes - Nothing is hidden before the server answers: the tile shows its normal loading skeleton, then leaves. A refused tile is not re-queried until the dashboard remounts, so a permission granted mid-session shows after a reload. - If every tile of a dashboard is refused, the viewer sees the header and an empty grid; no message is drawn for that case. - Object-bound inline widgets (an `object` key, no `dataset`) are not pre-filtered by the viewer's read permission in this PR (the dispatch's optional `DashboardView` pre-filter was not built). - Acceptance item 2 (the spec describes the behaviour) is outside this PR: it needs a sentence in `packages/spec/src/ui/dashboard.zod.ts` beside `requiresObject`, which the seat files as a spec-lane card. ## Verification <details><summary>Local gates (head 52ed283, base cb8988e)</summary> - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-dashboard^...' build` under the lock: VERDICT command-exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 commands, all exit 0 (including `node scripts/check-sdui-manifest.mjs`); `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --filter @object-ui/plugin-dashboard run type-check`: exit 0. `pnpm --dir ui run type-check:scripts`: exit 0. `pnpm exec eslint` on the three changed source files: exit 0. `pnpm check:changeset-fixed`: exit 0. `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - Whole package `pnpm --dir ui exec vitest run packages/plugin-dashboard/` under the lock: 190 test files passed, 1858 tests passed, 6 skipped. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on the changed files; the repo scan is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief above is Chinese by repo rule). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbacRXrqkMrm5LGBGYE --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbacRXrqkMrm5LGBGYE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec66ac0 commit 4aeb054

4 files changed

Lines changed: 185 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
fix(plugin-dashboard): a dashboard tile whose data the viewer may not read is left out, not drawn as a permission error
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A viewer who may not read the data behind one dataset tile used to see that tile draw "You don't have permission to view the data behind this widget" among tiles that answered. Outside design mode, `DashboardRenderer` now leaves such a tile out of the layout, frame and title included, once the server refuses its query as a read refusal (403 / `PERMISSION_DENIED`), and the remaining tiles reflow. The server's refusal stays the only verdict: every tile is still queried, and nothing is hidden on a guess.
11+
12+
- In design mode the tile stays and draws the no-access state, so an author can still see and select it.
13+
- A failure that is not a read refusal keeps the tile and its error, as before.
14+
- The editable grid layout (`DashboardGridLayout`) keeps the tile and its no-access state, unchanged.

‎ui/packages/plugin-dashboard/src/DashboardRenderer.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,20 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
381381
const hasExplicitColumns = schema.columns != null || inferredColumns !== 4;
382382
const [isMobile, setIsMobile] = useState(false);
383383

384+
// objectstack#26971 — the dataset-bound tiles whose query the server
385+
// refused as a READ refusal, keyed by the tile and the dataset it names.
386+
// Outside design mode such a tile is left out of the layout, so a viewer
387+
// who may not read the data behind it does not see a permission-error
388+
// tile and the other tiles reflow (span-flow: array order is visual
389+
// order). In design mode the tile stays and draws `DatasetWidget`'s
390+
// no-access state (objectui#10899): the author must still see and select
391+
// it. Nothing here guesses a permission: the server's 403 is the only
392+
// verdict, reported by `DatasetWidget`'s `onForbidden`.
393+
const [refusedTiles, setRefusedTiles] = useState<ReadonlySet<string>>(() => new Set());
394+
const markTileRefused = useCallback((tileKey: string) => {
395+
setRefusedTiles((prev) => (prev.has(tileKey) ? prev : new Set(prev).add(tileKey)));
396+
}, []);
397+
384398
// Dashboard-level filters (framework#2501). Filter values live as
385399
// dashboard variables — the outer DashboardRenderer mounts a
386400
// PageVariablesProvider when the schema declares filters, so
@@ -1087,6 +1101,8 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
10871101
const resolvedTitle = tWidgetTitle(widget);
10881102
const resolvedDescription = tWidgetDescription(widget);
10891103
const widgetKey = widget.id || resolvedTitle || `widget-${index}`;
1104+
const refusedTileKey = datasetWidget ? `${widgetKey}::${String(datasetWidget.dataset)}` : undefined;
1105+
if (!designMode && refusedTileKey && refusedTiles.has(refusedTileKey)) return null;
10901106
const isSelected = designMode && selectedWidgetId === widget.id;
10911107

10921108
const designModeProps = designMode ? {
@@ -1156,6 +1172,7 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
11561172
? <DatasetWidget
11571173
widget={datasetWidget}
11581174
dataSource={dataSource}
1175+
onForbidden={refusedTileKey ? () => markTileRefused(refusedTileKey) : undefined}
11591176
heading={datasetMetric
11601177
? { title: resolvedTitle || '', description: resolvedDescription || undefined }
11611178
: undefined}

‎ui/packages/plugin-dashboard/src/DatasetWidget.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,19 @@ function MetricHeadingRow({ heading, label }: { heading: DatasetMetricHeading; l
658658
);
659659
}
660660

661-
export function DatasetWidget({ widget, dataSource, heading }: { widget: any; dataSource: unknown; heading?: DatasetMetricHeading }) {
661+
export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
662+
widget: any;
663+
dataSource: unknown;
664+
heading?: DatasetMetricHeading;
665+
/**
666+
* Called when the server refuses this widget's query as a READ refusal
667+
* (`classifyLoadError(e) === 'forbidden'`). A host that can leave the tile
668+
* out of its layout does so for a viewer (objectstack#26971); the widget
669+
* itself still draws its no-access state (objectui#10899) for a host that
670+
* keeps it on screen. The 403 stays the enforcement: this only reports it.
671+
*/
672+
onForbidden?: () => void;
673+
}) {
662674
const datasetName = String(widget?.dataset ?? '');
663675
const dimensions: string[] = useMemo(() => (Array.isArray(widget?.dimensions) ? widget.dimensions.filter(Boolean) : []), [widget]);
664676
const values: string[] = useMemo(() => (Array.isArray(widget?.values) ? widget.values.filter(Boolean) : []), [widget]);
@@ -951,6 +963,7 @@ export function DatasetWidget({ widget, dataSource, heading }: { widget: any; da
951963
// agnostic `classifyLoadError`, never by matching the message text.
952964
if (classifyLoadError(e) === 'forbidden') {
953965
setState({ status: 'error', rows: [], forbidden: true });
966+
onForbidden?.();
954967
return;
955968
}
956969
setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) });
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26971 item 1 — a viewer who may not read the data behind a
11+
* dataset tile does not see a permission-error tile, and the other tiles
12+
* still render.
13+
*
14+
* Measured on HotCRM's Sales Performance dashboard: a viewer holding only a
15+
* marketing permission set saw 14 tiles answer and the 15th draw "You don't
16+
* have permission to view the data behind this widget" (its dataset query
17+
* answered 403). Outside design mode `DashboardRenderer` now leaves that tile
18+
* out of the layout, Card frame and title included, once the server refuses
19+
* the query as a READ refusal (`DatasetWidget`'s `onForbidden`). The 403 stays
20+
* the enforcement: nothing is hidden before the server answers.
21+
*
22+
* CONTROLS: in design mode the refused tile stays and draws the no-access
23+
* state (objectui#10899), so the author can still see and select it; and a
24+
* failure that is not a read refusal keeps the tile and its error alert.
25+
*
26+
* The refusal carries the two fields `@object-ui/data-objectstack`'s
27+
* `AnalyticsForbiddenError` declares (`httpStatus: 403`,
28+
* `code: 'PERMISSION_DENIED'`), as `DatasetWidget.forbiddenState-10899.test.tsx`
29+
* builds it.
30+
*/
31+
32+
import '@testing-library/jest-dom/vitest';
33+
import * as React from 'react';
34+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
35+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
36+
import type { DashboardComponentSchema } from '@object-ui/types';
37+
// Module scope (AGENTS.md 测试纪律): the table tile renders onward through the
38+
// registry this barrel populates.
39+
import '@object-ui/components';
40+
import { DashboardRenderer } from '../DashboardRenderer';
41+
42+
beforeEach(() => {
43+
// The dimension-label metadata probe (`useDatasetDimensionMeta`) falls back
44+
// to the global fetch; answer it locally so no case reaches the network.
45+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' })));
46+
});
47+
afterEach(() => {
48+
vi.unstubAllGlobals();
49+
cleanup();
50+
});
51+
52+
const dashboard = (...widgets: Record<string, unknown>[]): DashboardComponentSchema =>
53+
({ type: 'dashboard', name: 'sales_performance', widgets }) as unknown as DashboardComponentSchema;
54+
55+
const READABLE = {
56+
id: 'pipeline_by_stage',
57+
type: 'table',
58+
title: 'Pipeline by Stage',
59+
dataset: 'opportunity_metrics',
60+
dimensions: ['stage'],
61+
values: ['deal_count'],
62+
};
63+
const REFUSED = {
64+
id: 'quota_attainment',
65+
type: 'table',
66+
title: 'Quota Attainment by Rep',
67+
dataset: 'forecast_metrics',
68+
dimensions: ['owner'],
69+
values: ['attainment'],
70+
};
71+
72+
function readRefusal() {
73+
return Object.assign(new Error('Analytics query refused: this user may not read the data behind dataset "forecast_metrics".'), {
74+
name: 'AnalyticsForbiddenError',
75+
httpStatus: 403,
76+
code: 'PERMISSION_DENIED',
77+
});
78+
}
79+
80+
/** Answers every dataset but `forecast_metrics`, which it refuses with `failure()`. */
81+
function source(failure: () => Error = readRefusal) {
82+
return {
83+
queryDataset: vi.fn(async (dataset: string) => {
84+
if (dataset === 'forecast_metrics') throw failure();
85+
return {
86+
rows: [{ stage: 'Closed Won', deal_count: 7 }],
87+
fields: [
88+
{ name: 'stage', label: 'Stage', type: 'text' },
89+
{ name: 'deal_count', label: 'Deals', type: 'number' },
90+
],
91+
object: 'opportunity',
92+
};
93+
}),
94+
};
95+
}
96+
97+
describe('DashboardRenderer — a tile whose data the viewer may not read is left out (objectstack#26971)', () => {
98+
it('drops the refused tile, frame and title included, and keeps the readable one', async () => {
99+
const src = source();
100+
render(<DashboardRenderer schema={dashboard(READABLE, REFUSED)} dataSource={src} />);
101+
102+
// The readable tile answers.
103+
expect(await screen.findByText('Closed Won')).toBeInTheDocument();
104+
expect(screen.getByText('Pipeline by Stage')).toBeInTheDocument();
105+
// The refused tile was asked (no client-side guess), and once the server
106+
// refused it, nothing of it is left: no no-access state, no titled frame.
107+
expect(src.queryDataset).toHaveBeenCalledWith('forecast_metrics', expect.anything());
108+
await waitFor(() => expect(screen.queryByText('Quota Attainment by Rep')).toBeNull());
109+
expect(screen.queryByTestId('dataset-widget-forbidden')).toBeNull();
110+
expect(screen.queryByText(/don’t have (access|permission)/)).toBeNull();
111+
});
112+
113+
it('CONTROL — in design mode the refused tile stays and draws the no-access state', async () => {
114+
render(
115+
<DashboardRenderer
116+
schema={dashboard(READABLE, REFUSED)}
117+
dataSource={source()}
118+
designMode
119+
onWidgetClick={() => {}}
120+
/>,
121+
);
122+
123+
expect(await screen.findByTestId('dataset-widget-forbidden')).toHaveTextContent('You don’t have access');
124+
expect(screen.getByTestId('dashboard-preview-widget-quota_attainment')).toHaveTextContent('Quota Attainment by Rep');
125+
expect(await screen.findByText('Closed Won')).toBeInTheDocument();
126+
});
127+
128+
it('CONTROL — a failure that is not a read refusal keeps the tile and its error', async () => {
129+
render(
130+
<DashboardRenderer
131+
schema={dashboard(READABLE, REFUSED)}
132+
dataSource={source(() => new Error('Dataset query failed: 400 Bad Request — relationship not declared in include'))}
133+
/>,
134+
);
135+
136+
expect(await screen.findByText(/relationship not declared in include/)).toBeInTheDocument();
137+
expect(screen.getByText('Quota Attainment by Rep')).toBeInTheDocument();
138+
expect(await screen.findByText('Closed Won')).toBeInTheDocument();
139+
});
140+
});

0 commit comments

Comments
 (0)