From db93574ee29d672c5a0d871e96f0c48d2a912f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:07:00 +0000 Subject: [PATCH 1/2] fix(app-shell): Home's activity card shows the record panel's permission copy when sys_activity refuses the read (objectui#12843) The shared activity feed swallowed every non-404 rejection into a bare `error` status, so a 403 on `sys_activity` reached Home's card as "An unexpected error occurred.". The feed now classifies the rejection with plugin-detail's `isRefusedFeedRead` (the record activity panel's verdict, objectui#11195) and marks a refused `error` snapshot; the card renders the same `detail.activityAccessDenied` key with the panel's lock. Every other failure keeps the generic copy. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/12843-home-activity-403.md | 5 + .../app-shell/src/console/home/HomeRail.tsx | 18 +- .../HomeActivity.refusedRead-12843.test.tsx | 155 ++++++++++++++++++ .../app-shell/src/hooks/sharedUserFeeds.ts | 75 +++++++-- 4 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 .changeset/12843-home-activity-403.md create mode 100644 packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx diff --git a/.changeset/12843-home-activity-403.md b/.changeset/12843-home-activity-403.md new file mode 100644 index 0000000000..1d92cd63ad --- /dev/null +++ b/.changeset/12843-home-activity-403.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +Home's "Activity feed" card now shows the record activity panel's permission sentence ("You don't have permission to view activity…") when the server refuses its `sys_activity` read, instead of "An unexpected error occurred." The shared activity feed classifies the rejection with `isRefusedFeedRead`, the verdict the record panel already reads; every other failure keeps the generic error (objectui#12843). diff --git a/packages/app-shell/src/console/home/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx index ae4ab7c7af..dd11518a05 100644 --- a/packages/app-shell/src/console/home/HomeRail.tsx +++ b/packages/app-shell/src/console/home/HomeRail.tsx @@ -15,7 +15,7 @@ */ import { ArrowRight, Bell, Check, - FileText, Database, LayoutDashboard, File, List, CircleAlert, Rocket, + FileText, Database, LayoutDashboard, File, List, CircleAlert, Rocket, Lock, } from 'lucide-react'; import { useDisplayLocale } from '@object-ui/i18n'; import { Button, cn } from '@object-ui/components'; @@ -568,6 +568,13 @@ export function HomeContinue({ * bell's Activity tab renders for the same row (objectui#12238): the label in * the reader's language, the API name only when none resolves. * + * A read the server REFUSED anyway (a grant the client believed, or no loaded + * grant to ask) says so with the record activity panel's own sentence and + * lock (objectui#12843), not the generic failure: the feed marks the refusal + * with that panel's verdict (`isRefusedFeedRead`, objectui#11195), and this + * card reads the same `detail.activityAccessDenied` key. Every other failure + * keeps the generic copy. + * * Laid out as Home's section ④ (objectui#12604): the heading above a card with * 12 px padding; each row the actor (13 px / 500), the verb and target muted, * and a 12 px line of time and object; "View all activity →" the last row. @@ -586,7 +593,14 @@ export function HomeActivity({ activity, onViewAll, t }: { activity: ActivityFee className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground" data-testid="home-activity-unanswered" > - {activity.status === 'error' ? ( + {activity.status === 'error' && activity.refused ? ( + + + ) : activity.status === 'error' ? ( <> {t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })} diff --git a/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx new file mode 100644 index 0000000000..92e5422198 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/HomeActivity.refusedRead-12843.test.tsx @@ -0,0 +1,155 @@ +/** + * 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#12843 — a `sys_activity` read the server REFUSES shows, on Home's + * activity card, the permission sentence the record activity panel shows for + * the same refusal (objectui#11195), not "An unexpected error occurred.". + * + * One verdict decides it on both surfaces: the shared feed classifies its + * rejection with plugin-detail's `isRefusedFeedRead`, the function the record + * panel reads, and the card renders the same `detail.activityAccessDenied` key. + * The pins run the real hook (`useHumanActivityFeed`, Home's cut of the shared + * feed) into the real card under the real en catalogue; only the adapter is a + * fake, and it answers each case's way: + * + * - a 403 `PERMISSION_DENIED` envelope ⇒ the record panel's sentence, read + * back from the record panel itself rendered beside it; + * - CONTROL: a 500 ⇒ the generic failure, unchanged; + * - CONTROL: an answered read ⇒ the rows. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { RecordActivityTimeline } from '@object-ui/plugin-detail'; +import { withRoster } from '../../../hooks/__tests__/activityRoster'; +import { HomeActivity } from '../HomeRail'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' } }), +})); + +const ROW = { + id: 'act_1', + type: 'updated', + summary: 'updated the lead', + object_name: 'crm_lead', + record_id: 'lead_1', + actor_name: 'Li Si', + timestamp: '2026-10-10T08:00:00Z', +}; + +/** When set, the `sys_activity` read rejects with it instead of answering. */ +let readError: unknown = null; +const fakeAdapter = { + find: (object: string) => { + if (object !== 'sys_activity') return Promise.resolve({ data: [] }); + return readError ? Promise.reject(readError) : Promise.resolve({ data: [ROW] }); + }, + getClient: () => undefined, +}; +vi.mock('../../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter })); + +const { useHumanActivityFeed, __resetSharedUserFeeds } = await import('../../../hooks/sharedUserFeeds'); + +const GENERIC_ERROR = 'An unexpected error occurred.'; + +/** Home's card, fed by Home's cut of the shared feed, with the console's translator. */ +function HomeCard() { + const { t } = useObjectTranslation(); + const activity = useHumanActivityFeed(5); + return {}} t={t} />; +} + +async function mountHome() { + const Roster = withRoster(); + render( + + + + +
+ +
+
, + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + __resetSharedUserFeeds(); + readError = null; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("Home's activity card shows the record panel's permission copy for a refused read (objectui#12843)", () => { + it('a 403 on sys_activity: the card reads the same sentence the record activity panel shows', async () => { + readError = Object.assign(new Error("operation 'find' on object 'sys_activity' is not permitted"), { + httpStatus: 403, + code: 'PERMISSION_DENIED', + }); + await mountHome(); + + const recordSentence = screen + .getByTestId('record-panel') + .querySelector('[data-denied-source="activity"]')?.textContent; + expect(recordSentence).toBeTruthy(); + const notice = screen.getByTestId('home-activity-access-denied'); + expect(notice).toHaveTextContent(recordSentence as string); + expect(screen.getByTestId('home-activity-unanswered')).not.toHaveTextContent(GENERIC_ERROR); + // Not an empty-feed claim either. + expect(screen.queryByText('No recent activity')).not.toBeInTheDocument(); + }); + + it('CONTROL: a 500 keeps the generic failure', async () => { + readError = Object.assign(new Error('Internal Server Error'), { httpStatus: 500 }); + await mountHome(); + + expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(GENERIC_ERROR); + expect(screen.queryByTestId('home-activity-access-denied')).not.toBeInTheDocument(); + }); + + it('CONTROL: an answered read renders the feed', async () => { + await mountHome(); + + expect(screen.getAllByTestId('home-activity-row')).toHaveLength(1); + expect(screen.getByText('Li Si')).toBeInTheDocument(); + expect(screen.queryByTestId('home-activity-unanswered')).not.toBeInTheDocument(); + }); +}); + +describe('the card reads the record panel key, not a sentence of its own (objectui#12843)', () => { + // A key-echo translator: what the card renders IS the key it asked for. + const echo = (key: string) => key; + + it('a refused error reading renders `detail.activityAccessDenied`', () => { + render( + {}} + t={echo} + />, + ); + expect(screen.getByTestId('home-activity-access-denied')).toHaveTextContent('detail.activityAccessDenied'); + }); + + it('an error reading without the refusal renders `errors.unknown`', () => { + render( {}} t={echo} />); + expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent('errors.unknown'); + }); +}); diff --git a/packages/app-shell/src/hooks/sharedUserFeeds.ts b/packages/app-shell/src/hooks/sharedUserFeeds.ts index 963d725049..d0a92d7371 100644 --- a/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -67,6 +67,9 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { useAuth } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { errorCodeIs } from '@object-ui/types'; +// The refused-read verdict the record activity panel reads (objectui#11195), +// so a refused `sys_activity` read means the same thing on Home (objectui#12843). +import { isRefusedFeedRead } from '@object-ui/plugin-detail'; // Re-exported from `@object-ui/react` — import it through the provider module // so a consumer that stubs the provider stubs this too. import { useAdapter } from '../providers/AdapterProvider.js'; @@ -179,6 +182,14 @@ export type SharedFeedStatus = 'idle' | 'loading' | 'ready' | 'error'; export interface SharedFeedSnapshot { value: T; status: SharedFeedStatus; + /** + * Present, and `true`, only on an `error` snapshot whose read the server + * REFUSED (objectui#12843) — the verdict is plugin-detail's + * `isRefusedFeedRead`, the one the record activity panel reads + * (objectui#11195). Absent on every other snapshot, so a feed whose runner + * never calls `markRefused` publishes exactly the shape it always did. + */ + refused?: true; } /** @@ -193,10 +204,16 @@ export interface SharedFeedSnapshot { * - `markFailed()` reports a read that should have worked and did not. The * last value stays on screen, the status goes `error`, and the next poll * backs off. A thrown error is equivalent — the runner may just let it fly. + * - `markRefused()` is `markFailed()` for a read the server REFUSED + * (objectui#12843): the same `error` state and backoff, with `refused` on + * the snapshot so a consumer can say "no permission" instead of "something + * went wrong". The runner decides which with the shared verdict; the store + * classifies nothing itself. */ type FeedRunner = (ctx: { markUnavailable: () => void; markFailed: () => void; + markRefused: () => void; }) => Promise; /** @@ -312,6 +329,7 @@ class SharedFeed { // loading state ten times a minute. if (this.snapshot.status === 'idle') this.publish(this.snapshot.value, 'loading'); let failed = false; + let refused = false; try { const next = await runner({ // Already retired ⇒ this WAS a probe, and it came back missing again. @@ -320,6 +338,10 @@ class SharedFeed { markFailed: () => { failed = true; }, + markRefused: () => { + failed = true; + refused = true; + }, }); if (next !== undefined) { // An answer with a value: the feed is alive, whatever it was before. @@ -337,7 +359,7 @@ class SharedFeed { // reason to start telling a community build its inbox is broken. Spend // the probe, keep the state, let the next one decide (#4315). if (probing) this.spendProbe(); - else this.fail(); + else this.fail(refused); } } catch { // Not swallowed into "keep the last value and say nothing": the value @@ -382,10 +404,13 @@ class SharedFeed { this.stopPolling(); } - /** A read that should have worked did not — report it and back the poll off. */ - private fail(): void { + /** + * A read that should have worked did not — report it and back the poll off. + * `refused` marks the `error` snapshot as a refusal (objectui#12843). + */ + private fail(refused = false): void { this.backoffMs = Math.min(Math.max(this.backoffMs, this.pollMs) * 2, MAX_BACKOFF_MS); - this.publish(this.snapshot.value, 'error'); + this.publish(this.snapshot.value, 'error', refused); } private schedule(): void { @@ -468,9 +493,17 @@ class SharedFeed { } } - private publish(next: T, status: SharedFeedStatus): void { - if (Object.is(next, this.snapshot.value) && status === this.snapshot.status) return; - this.snapshot = { value: next, status }; + private publish(next: T, status: SharedFeedStatus, refused = false): void { + if ( + Object.is(next, this.snapshot.value) && + status === this.snapshot.status && + refused === (this.snapshot.refused === true) + ) { + return; + } + // `refused` is written only when true, so every snapshot that is not a + // refusal keeps the two-key shape it always had. + this.snapshot = refused ? { value: next, status, refused: true } : { value: next, status }; for (const listener of [...this.listeners]) listener(); } @@ -854,6 +887,15 @@ export interface ActivityFeedReading extends SharedFeedSnapshot * See {@link useSharedActivityFeed} for why unknown permissions read `true`. */ readable: boolean; + /** + * `true` only when `status` is `error` because the server REFUSED the read + * (objectui#12843) — a refusal by `isRefusedFeedRead`, the verdict the + * record activity panel reads (objectui#11195). A consumer that shows a + * failure shows the permission copy instead; every other failure leaves it + * unset and keeps the generic one. Optional so a consumer that builds a + * reading of its own is unchanged. + */ + refused?: boolean; } const ACTIVITY_OBJECT = 'sys_activity'; @@ -980,7 +1022,7 @@ export function useSharedActivityFeed(): ActivityFeedReading { activity.settled && appsSettled && readable && adapter ? `${adapter}:${user?.id ?? ''}:${rosterKey}` : null, - async ({ markUnavailable, markFailed }) => { + async ({ markUnavailable, markFailed, markRefused }) => { if (!dataSource) return undefined; if (activity.presence === 'absent') { // This deployment declares no `sys_activity`. That IS the answer the @@ -1001,9 +1043,11 @@ export function useSharedActivityFeed(): ActivityFeedReading { }>, ).catch((err: unknown) => { // No `sys_activity` object ⇒ this deployment has no audit plugin, which - // is an answer. Anything else — a refusal included — is a read that - // failed and must say so. + // is an answer. Anything else is a read that failed and must say so — + // and a REFUSED one says which, by the record panel's own verdict + // (objectui#12843), so its consumers can show the permission copy. if (isMissingResource(err)) markUnavailable(); + else if (isRefusedFeedRead(err)) markRefused(); else markFailed(); return null; }); @@ -1011,7 +1055,12 @@ export function useSharedActivityFeed(): ActivityFeedReading { return mapActivityRows(Array.isArray(res.data) ? res.data : []); }, ); - return { value: snapshot.value, status: snapshot.status, readable }; + return { + value: snapshot.value, + status: snapshot.status, + readable, + refused: snapshot.status === 'error' && snapshot.refused === true, + }; } /** @@ -1021,7 +1070,7 @@ export function useSharedActivityFeed(): ActivityFeedReading { * shared feed's, unchanged: a cut of the rows is not a different answer. */ export function useHumanActivityFeed(limit: number): ActivityFeedReading { - const { value: all, status, readable } = useSharedActivityFeed(); + const { value: all, status, readable, refused } = useSharedActivityFeed(); const human = useMemo(() => { const kept = all.filter((a) => { const actor = a.user.trim(); @@ -1029,7 +1078,7 @@ export function useHumanActivityFeed(limit: number): ActivityFeedReading { }); return kept.slice(0, limit); }, [all, limit]); - return { value: human, status, readable }; + return { value: human, status, readable, refused }; } // ── Inbox messages ─────────────────────────────────────────────────────────── From 1fc81dacd075d38afda4038281ea65e9d95f824d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:14:22 +0000 Subject: [PATCH 2/2] fix(app-shell): type the snapshot's refusal as boolean so the activity reading extends it (objectui#12843) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- packages/app-shell/src/hooks/sharedUserFeeds.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-shell/src/hooks/sharedUserFeeds.ts b/packages/app-shell/src/hooks/sharedUserFeeds.ts index d0a92d7371..271ffa6b7d 100644 --- a/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -189,7 +189,7 @@ export interface SharedFeedSnapshot { * (objectui#11195). Absent on every other snapshot, so a feed whose runner * never calls `markRefused` publishes exactly the shape it always did. */ - refused?: true; + refused?: boolean; } /**