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
5 changes: 5 additions & 0 deletions .changeset/12843-home-activity-403.md
Original file line number Diff line number Diff line change
@@ -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).
18 changes: 16 additions & 2 deletions packages/app-shell/src/console/home/HomeRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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 ? (
<span className="flex items-center gap-2" data-testid="home-activity-access-denied">
<Lock className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('detail.activityAccessDenied', {
defaultValue: "You don't have permission to view activity on this record.",
})}
</span>
) : activity.status === 'error' ? (
<>
<CircleAlert className="h-4 w-4 text-amber-500" />
{t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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 <HomeActivity activity={activity} onViewAll={() => {}} t={t} />;
}

async function mountHome() {
const Roster = withRoster();
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<Roster>
<HomeCard />
</Roster>
<div data-testid="record-panel">
<RecordActivityTimeline items={[]} activityDenied />
</div>
</I18nProvider>,
);
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(
<HomeActivity
activity={{ value: [], status: 'error', readable: true, refused: true }}
onViewAll={() => {}}
t={echo}
/>,
);
expect(screen.getByTestId('home-activity-access-denied')).toHaveTextContent('detail.activityAccessDenied');
});

it('an error reading without the refusal renders `errors.unknown`', () => {
render(<HomeActivity activity={{ value: [], status: 'error', readable: true }} onViewAll={() => {}} t={echo} />);
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent('errors.unknown');
});
});
75 changes: 62 additions & 13 deletions packages/app-shell/src/hooks/sharedUserFeeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -179,6 +182,14 @@ export type SharedFeedStatus = 'idle' | 'loading' | 'ready' | 'error';
export interface SharedFeedSnapshot<T> {
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;
}

/**
Expand All @@ -193,10 +204,16 @@ export interface SharedFeedSnapshot<T> {
* - `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<T> = (ctx: {
markUnavailable: () => void;
markFailed: () => void;
markRefused: () => void;
}) => Promise<T | undefined>;

/**
Expand Down Expand Up @@ -312,6 +329,7 @@ class SharedFeed<T> {
// 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.
Expand All @@ -320,6 +338,10 @@ class SharedFeed<T> {
markFailed: () => {
failed = true;
},
markRefused: () => {
failed = true;
refused = true;
},
});
if (next !== undefined) {
// An answer with a value: the feed is alive, whatever it was before.
Expand All @@ -337,7 +359,7 @@ class SharedFeed<T> {
// 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
Expand Down Expand Up @@ -382,10 +404,13 @@ class SharedFeed<T> {
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 {
Expand Down Expand Up @@ -468,9 +493,17 @@ class SharedFeed<T> {
}
}

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();
}

Expand Down Expand Up @@ -854,6 +887,15 @@ export interface ActivityFeedReading extends SharedFeedSnapshot<ActivityItem[]>
* 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';
Expand Down Expand Up @@ -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
Expand All @@ -1001,17 +1043,24 @@ 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;
});
if (!res) return undefined;
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,
};
}

/**
Expand All @@ -1021,15 +1070,15 @@ 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();
return actor.length > 0 && actor.toLowerCase() !== 'system';
});
return kept.slice(0, limit);
}, [all, limit]);
return { value: human, status, readable };
return { value: human, status, readable, refused };
}

// ── Inbox messages ───────────────────────────────────────────────────────────
Expand Down
Loading