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 ? (
+
+
+ {t('detail.activityAccessDenied', {
+ defaultValue: "You don't have permission to view activity on this record.",
+ })}
+
+ ) : 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..271ffa6b7d 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?: boolean;
}
/**
@@ -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 ───────────────────────────────────────────────────────────