feat(activity): add two-tier all-actors audit feed (#10831)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators need one activity feed for human, agent, plugin, and system changes > - The existing audit endpoint returns only rows that have agent attribution > - The full audit view also requires a dedicated permission > - This pull request adds an explicit all-actors scope with basic and privileged access tiers > - The benefit is that company members can inspect the shared activity history while sensitive attribution and export controls stay protected ## Linked Issues or Issue Description **What existing behavior does this improve?** The company audit activity endpoint and the board audit route. **Subsystem affected** Server REST API and board UI routing/API contracts. **Current behavior** The agent-action audit endpoint excludes activity without an agent ID. It also rejects company members who do not have the full audit permission. **Proposed behavior** Callers can opt into `actorScope=all`. A company member receives all actor kinds with sensitive attribution fields removed. A permitted board user receives complete rows and can use attribution filters. The default scope and CSV permission remain unchanged. **Reason and benefit** The board needs one chronological activity source for user, agent, plugin, and system actions. A two-tier response keeps the feed useful without widening access to detailed attribution or export capabilities. **Breaking changes** None. The endpoint keeps the existing agent-only scope and permission behavior by default. ## What Changed - Added `actorScope=all` to the unified audit query and included activity from every actor type. - Added a company-readable basic tier that removes run, responsible-user, agent, and details attribution. - Kept attribution filters and CSV export behind `audit:view_agent_actions`. - Added route and integration coverage for basic readers, permitted readers, pagination, filter denial, and all actor kinds. - Added the missing unprefixed `/audit` redirect and company route classification. ## Verification - `pnpm exec vitest run server/src/__tests__/activity-routes.test.ts server/src/__tests__/agent-action-audit-routes.test.ts ui/src/lib/company-routes.test.ts --reporter=verbose` (35 tests passed) - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` ## Risks - The all-actors query can return more rows than the legacy agent-only query. Cursor pagination and existing limits bound each request. - The basic tier intentionally exposes action and actor-kind context. It removes detailed run, agent, responsible-user, and details attribution. - The legacy endpoint behavior remains the default, which reduces compatibility risk. > The roadmap marks activity log and action attribution as shipped. This change improves that existing capability and does not introduce a separate workflow system. ## Model Used - OpenAI Codex, `gpt-5.6-sol`, 114K context, agentic reasoning with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f0ed524ffa
commit
68ddd6a7a0
|
|
@ -21,6 +21,11 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentActionAuditService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity.js", () => ({
|
||||
|
|
@ -37,6 +42,10 @@ vi.mock("../services/index.js", () => ({
|
|||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/agent-action-audit.js", () => ({
|
||||
agentActionAuditService: () => mockAgentActionAuditService,
|
||||
}));
|
||||
|
||||
async function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
|
|
@ -98,12 +107,103 @@ describe.sequential("activity routes", () => {
|
|||
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockIssueService)) mock.mockReset();
|
||||
mockAccessService.decide.mockReset();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAgentActionAuditService.list.mockReset();
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: "company_scope:read",
|
||||
reason: "allow_test",
|
||||
explanation: "Allowed by test mock.",
|
||||
});
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("returns redacted all-actors rows to a basic company reader", async () => {
|
||||
mockAgentActionAuditService.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "activity-1",
|
||||
companyId: "company-1",
|
||||
actorType: "plugin",
|
||||
actorId: "plugin-1",
|
||||
action: "plugin.synced",
|
||||
entityType: "company",
|
||||
entityId: "company-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
responsibleUserId: "user-2",
|
||||
details: { privateAttribution: true },
|
||||
createdAt: "2026-08-04T00:00:00.000Z",
|
||||
entity: { issue: null, comment: null, document: null },
|
||||
}],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/audit/agent-actions?actorScope=all");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorScope: "all",
|
||||
limit: 50,
|
||||
});
|
||||
expect(res.body.items[0]).toMatchObject({
|
||||
actorType: "plugin",
|
||||
actorId: "plugin-1",
|
||||
action: "plugin.synced",
|
||||
entityType: "company",
|
||||
entityId: "company-1",
|
||||
createdAt: "2026-08-04T00:00:00.000Z",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
responsibleUserId: null,
|
||||
details: null,
|
||||
});
|
||||
expect(res.body.accessTier).toBe("basic");
|
||||
});
|
||||
|
||||
it("rejects attribution filters for a basic all-actors reader", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/audit/agent-actions?actorScope=all&runId=00000000-0000-4000-8000-000000000001");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("audit:view_agent_actions");
|
||||
expect(mockAgentActionAuditService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps attribution for a permitted all-actors reader", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAgentActionAuditService.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "activity-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
responsibleUserId: "user-2",
|
||||
details: { attribution: true },
|
||||
}],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/audit/agent-actions?actorScope=all&actorType=system");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorScope: "all",
|
||||
actorType: "system",
|
||||
limit: 50,
|
||||
});
|
||||
expect(res.body.items[0]).toMatchObject({
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
responsibleUserId: "user-2",
|
||||
details: { attribution: true },
|
||||
});
|
||||
expect(res.body.accessTier).toBe("full");
|
||||
});
|
||||
|
||||
it("limits company activity lists by default", async () => {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,42 @@ describePostgres("agent action audit routes", () => {
|
|||
return { company, agent, otherAgent, issue, comment, issueDocument, run };
|
||||
}
|
||||
|
||||
async function seedActorOnlyRows(companyId: string) {
|
||||
return db.insert(activityLog).values([
|
||||
{
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
action: "company.updated",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
responsibleUserId: "sensitive-responsible-user",
|
||||
details: { changed: "name" },
|
||||
createdAt: new Date("2026-07-17T00:00:06.000Z"),
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "scheduler",
|
||||
action: "heartbeat.scheduled",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: { schedule: "private-schedule" },
|
||||
createdAt: new Date("2026-07-17T00:00:05.000Z"),
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: "example-plugin",
|
||||
action: "plugin.synced",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: { pluginConfig: "private-config" },
|
||||
createdAt: new Date("2026-07-17T00:00:04.000Z"),
|
||||
},
|
||||
]).returning();
|
||||
}
|
||||
|
||||
it("denies agents and board users without the audit permission", async () => {
|
||||
const { company, agent } = await seed();
|
||||
const agentResponse = await request(await createApp(db, {
|
||||
|
|
@ -122,13 +158,146 @@ describePostgres("agent action audit routes", () => {
|
|||
})).get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(agentResponse.status).toBe(403);
|
||||
|
||||
const agentAllActorsResponse = await request(await createApp(db, {
|
||||
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all`);
|
||||
expect(agentAllActorsResponse.status, JSON.stringify(agentAllActorsResponse.body)).toBe(200);
|
||||
expect(agentAllActorsResponse.body.items).toHaveLength(3);
|
||||
expect(agentAllActorsResponse.body.items.every((item: { responsibleUserId: string | null }) => (
|
||||
item.responsibleUserId === null
|
||||
))).toBe(true);
|
||||
|
||||
const boardResponse = await request(await createApp(db, {
|
||||
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(boardResponse.status).toBe(403);
|
||||
expect(boardResponse.body.error).toContain("audit:view_agent_actions");
|
||||
|
||||
const invalidBoardResponse = await request(await createApp(db, {
|
||||
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions?limit=invalid`);
|
||||
expect(invalidBoardResponse.status).toBe(403);
|
||||
expect(invalidBoardResponse.body.error).toContain("audit:view_agent_actions");
|
||||
}, 30_000);
|
||||
|
||||
it("lets a company member read basic all-actor rows without attribution", async () => {
|
||||
const { company } = await seed();
|
||||
await seedActorOnlyRows(company.id);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: "reader",
|
||||
status: "active",
|
||||
membershipRole: "viewer",
|
||||
});
|
||||
const app = await createApp(db, {
|
||||
type: "board",
|
||||
userId: "reader",
|
||||
companyIds: [company.id],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const cursorQuery = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
|
||||
const response = await request(app)
|
||||
.get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all&limit=2${cursorQuery}`);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
items.push(...response.body.items);
|
||||
expect(response.body.accessTier).toBe("basic");
|
||||
cursor = response.body.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
expect(items).toHaveLength(6);
|
||||
expect(new Set(items.map((item) => item.actorType))).toEqual(
|
||||
new Set(["agent", "user", "system", "plugin"]),
|
||||
);
|
||||
for (const item of items) {
|
||||
expect(item).toMatchObject({
|
||||
agentId: null,
|
||||
runId: null,
|
||||
responsibleUserId: null,
|
||||
details: null,
|
||||
});
|
||||
expect(item).toEqual(expect.objectContaining({
|
||||
actorType: expect.any(String),
|
||||
actorId: expect.any(String),
|
||||
action: expect.any(String),
|
||||
entityType: expect.any(String),
|
||||
entityId: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects the full filter set for a basic all-actors reader", async () => {
|
||||
const { company, agent, issue, run } = await seed();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: "reader",
|
||||
status: "active",
|
||||
membershipRole: "viewer",
|
||||
});
|
||||
const app = await createApp(db, {
|
||||
type: "board",
|
||||
userId: "reader",
|
||||
companyIds: [company.id],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const filters = [
|
||||
`agentId=${agent.id}`,
|
||||
"responsibleUserId=legacy-user",
|
||||
`runId=${run.id}`,
|
||||
"entityType=issue",
|
||||
`entityId=${issue.id}`,
|
||||
"action=issue.",
|
||||
"actorType=agent",
|
||||
"from=2026-07-17T00%3A00%3A00.000Z",
|
||||
"to=2026-07-18T00%3A00%3A00.000Z",
|
||||
];
|
||||
|
||||
for (const filter of filters) {
|
||||
const response = await request(app).get(
|
||||
`/api/companies/${company.id}/audit/agent-actions?actorScope=all&${filter}`,
|
||||
);
|
||||
expect(response.status, filter).toBe(403);
|
||||
expect(response.body.error).toContain("audit:view_agent_actions");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the default scope unchanged and gives permitted readers the full all-actors view", async () => {
|
||||
const { company } = await seed();
|
||||
const actorOnlyRows = await seedActorOnlyRows(company.id);
|
||||
const app = await createApp(db, {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: [company.id],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const defaultResponse = await request(app)
|
||||
.get(`/api/companies/${company.id}/audit/agent-actions`);
|
||||
expect(defaultResponse.status, JSON.stringify(defaultResponse.body)).toBe(200);
|
||||
expect(defaultResponse.body.items).toHaveLength(3);
|
||||
expect(defaultResponse.body.items.map((item: { id: string }) => item.id)).not.toContain(actorOnlyRows[0]!.id);
|
||||
|
||||
const allResponse = await request(app)
|
||||
.get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all`);
|
||||
expect(allResponse.status, JSON.stringify(allResponse.body)).toBe(200);
|
||||
expect(defaultResponse.body.accessTier).toBe("full");
|
||||
expect(allResponse.body.accessTier).toBe("full");
|
||||
expect(allResponse.body.items).toHaveLength(6);
|
||||
expect(allResponse.body.items.find((item: { id: string }) => item.id === actorOnlyRows[0]!.id)).toMatchObject({
|
||||
responsibleUserId: "sensitive-responsible-user",
|
||||
details: { changed: "name" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a client error for invalid audit query parameters", async () => {
|
||||
const { company } = await seed();
|
||||
const response = await request(await createApp(db, {
|
||||
|
|
@ -137,6 +306,12 @@ describePostgres("agent action audit routes", () => {
|
|||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe("Invalid agent action audit query");
|
||||
|
||||
const scopeResponse = await request(await createApp(db, {
|
||||
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions?actorScope=unknown`);
|
||||
expect(scopeResponse.status).toBe(400);
|
||||
expect(scopeResponse.body.error).toBe("Invalid agent action audit query");
|
||||
|
||||
const cursorResponse = await request(await createApp(db, {
|
||||
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
|
||||
})).get(`/api/companies/${company.id}/audit/agent-actions?cursor=invalid`);
|
||||
|
|
|
|||
|
|
@ -99,7 +99,10 @@ const createActivitySchema = z.object({
|
|||
details: z.record(z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]);
|
||||
|
||||
const agentActionAuditQuerySchema = z.object({
|
||||
actorScope: agentActionAuditActorScopeSchema.default("agents"),
|
||||
agentId: z.string().uuid().optional(),
|
||||
responsibleUserId: z.string().min(1).optional(),
|
||||
runId: z.string().uuid().optional(),
|
||||
|
|
@ -121,14 +124,54 @@ export function activityRoutes(db: Db) {
|
|||
const issueSvc = issueService(db);
|
||||
const agentAudit = agentActionAuditService(db);
|
||||
|
||||
async function hasAgentAuditPermission(req: import("express").Request, companyId: string) {
|
||||
if (req.actor.type !== "board") return false;
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
||||
return Boolean(
|
||||
req.actor.userId
|
||||
&& await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"),
|
||||
);
|
||||
}
|
||||
|
||||
async function assertAgentAuditPermission(req: import("express").Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
if (req.actor.userId && await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions")) return;
|
||||
if (await hasAgentAuditPermission(req, companyId)) return;
|
||||
throw forbidden("Missing permission: audit:view_agent_actions");
|
||||
}
|
||||
|
||||
function hasAttributionFilters(query: z.infer<typeof agentActionAuditQuerySchema>) {
|
||||
return query.agentId !== undefined
|
||||
|| query.responsibleUserId !== undefined
|
||||
|| query.runId !== undefined
|
||||
|| query.entityType !== undefined
|
||||
|| query.entityId !== undefined
|
||||
|| query.action !== undefined
|
||||
|| query.actorType !== undefined
|
||||
|| query.from !== undefined
|
||||
|| query.to !== undefined;
|
||||
}
|
||||
|
||||
function stripAuditAttribution<T extends {
|
||||
items: Array<{
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
details: unknown;
|
||||
}>;
|
||||
}>(result: T): T {
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((item) => ({
|
||||
...item,
|
||||
agentId: null,
|
||||
runId: null,
|
||||
responsibleUserId: null,
|
||||
details: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
|
|
@ -194,12 +237,38 @@ export function activityRoutes(db: Db) {
|
|||
|
||||
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertAgentAuditPermission(req, companyId);
|
||||
const parsedActorScope = agentActionAuditActorScopeSchema.safeParse(req.query.actorScope ?? "agents");
|
||||
if (!parsedActorScope.success) {
|
||||
throw badRequest("Invalid agent action audit query", parsedActorScope.error.issues);
|
||||
}
|
||||
if (parsedActorScope.data === "agents") {
|
||||
// Keep the legacy authorization-before-validation behavior for callers
|
||||
// that omit the new flag.
|
||||
await assertAgentAuditPermission(req, companyId);
|
||||
} else {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
|
||||
}
|
||||
|
||||
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
|
||||
}
|
||||
res.json(await agentAudit.list({ companyId, ...parsedQuery.data }));
|
||||
if (parsedQuery.data.actorScope === "agents") {
|
||||
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
|
||||
res.json({ ...result, accessTier: "full" });
|
||||
return;
|
||||
}
|
||||
|
||||
const canViewAttribution = await hasAgentAuditPermission(req, companyId);
|
||||
if (!canViewAttribution && hasAttributionFilters(parsedQuery.data)) {
|
||||
throw forbidden("Audit filters require permission: audit:view_agent_actions");
|
||||
}
|
||||
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
|
||||
res.json({
|
||||
...(canViewAttribution ? result : stripAuditAttribution(result)),
|
||||
accessTier: canViewAttribution ? "full" : "basic",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => {
|
||||
|
|
@ -238,6 +307,7 @@ export function activityRoutes(db: Db) {
|
|||
rowCount: rows.length,
|
||||
truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor),
|
||||
filters: {
|
||||
actorScope: filters.actorScope,
|
||||
agentId: filters.agentId ?? null,
|
||||
responsibleUserId: filters.responsibleUserId ?? null,
|
||||
runId: filters.runId ?? null,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { visibleIssueCondition } from "./issue-visibility.js";
|
|||
|
||||
export interface AgentActionAuditFilters {
|
||||
companyId: string;
|
||||
actorScope?: "agents" | "all";
|
||||
agentId?: string;
|
||||
responsibleUserId?: string;
|
||||
runId?: string;
|
||||
|
|
@ -53,7 +54,10 @@ export function agentActionAuditService(db: Db) {
|
|||
const cursor = decodeCursor(filters.cursor);
|
||||
if (filters.cursor && !cursor) throw badRequest("Invalid audit cursor");
|
||||
const effectiveResponsibleUserId = sql<string | null>`coalesce(${activityLog.responsibleUserId}, ${heartbeatRuns.responsibleUserId})`;
|
||||
const conditions = [eq(activityLog.companyId, filters.companyId), isNotNull(activityLog.agentId)];
|
||||
const conditions = [eq(activityLog.companyId, filters.companyId)];
|
||||
// Preserve the historical agent-audit query unless the caller opts into
|
||||
// the unified all-actors feed explicitly.
|
||||
if (filters.actorScope !== "all") conditions.push(isNotNull(activityLog.agentId));
|
||||
if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId));
|
||||
if (filters.responsibleUserId) conditions.push(or(
|
||||
eq(activityLog.responsibleUserId, filters.responsibleUserId),
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { api } from "./client";
|
|||
* Consumes the unified read API shipped by Phase 2c
|
||||
* (`server/src/routes/activity.ts` → `services/agent-action-audit.ts`):
|
||||
* GET /companies/:companyId/audit/agent-actions
|
||||
* Gated server-side by the `audit:view_agent_actions` board permission — the
|
||||
* client renders an upsell/permission-denied state when the request 403s
|
||||
* (see `ui/src/pages/CompanyAudit.tsx`). CSV export streams from the sibling
|
||||
* `.csv` endpoint, which logs the export action itself.
|
||||
* The default agent scope is gated server-side by the
|
||||
* `audit:view_agent_actions` board permission. The explicit all-actors scope
|
||||
* also supports a company-read basic tier with attribution stripped. CSV
|
||||
* export streams from the permission-gated sibling `.csv` endpoint, which
|
||||
* logs the export action itself.
|
||||
*/
|
||||
|
||||
/** Render-ready entity snippets attached to each row at read time (no N+1). */
|
||||
|
|
@ -39,10 +40,14 @@ export interface AuditActionRecord {
|
|||
export interface AuditActionsResponse {
|
||||
items: AuditActionRecord[];
|
||||
nextCursor: string | null;
|
||||
/** Controls whether attribution filters and CSV export are available. */
|
||||
accessTier: "basic" | "full";
|
||||
}
|
||||
|
||||
/** Server-side filters for the audit feed. All optional. */
|
||||
export interface AuditActionFilters {
|
||||
/** Defaults to `agents`; `all` opts into the unified all-actors feed. */
|
||||
actorScope?: "agents" | "all";
|
||||
agentId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
runId?: string | null;
|
||||
|
|
@ -60,6 +65,7 @@ export interface AuditActionFilters {
|
|||
|
||||
function buildAuditQuery(filters: AuditActionFilters): URLSearchParams {
|
||||
const search = new URLSearchParams();
|
||||
if (filters.actorScope) search.set("actorScope", filters.actorScope);
|
||||
if (filters.agentId) search.set("agentId", filters.agentId);
|
||||
if (filters.responsibleUserId) search.set("responsibleUserId", filters.responsibleUserId);
|
||||
if (filters.runId) search.set("runId", filters.runId);
|
||||
|
|
@ -76,9 +82,9 @@ function buildAuditQuery(filters: AuditActionFilters): URLSearchParams {
|
|||
|
||||
export const auditApi = {
|
||||
/**
|
||||
* Cursor-paginated agent-action feed. Returns `{ items, nextCursor }`.
|
||||
* Throws `ApiError` with status 403 when the caller lacks
|
||||
* `audit:view_agent_actions`.
|
||||
* Cursor-paginated activity/audit feed. The default agent scope throws a
|
||||
* 403 without `audit:view_agent_actions`; `actorScope: "all"` returns a
|
||||
* redacted basic tier to ordinary company readers.
|
||||
*/
|
||||
listAgentActions: (companyId: string, filters: AuditActionFilters = {}) => {
|
||||
const search = buildAuditQuery(filters);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const queryKeys = {
|
|||
agentActions: (
|
||||
companyId: string,
|
||||
filters: {
|
||||
actorScope?: "agents" | "all" | null;
|
||||
agentId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
runId?: string | null;
|
||||
|
|
@ -70,6 +71,7 @@ export const queryKeys = {
|
|||
"audit",
|
||||
companyId,
|
||||
"agent-actions",
|
||||
filters.actorScope ?? "agents",
|
||||
filters.agentId ?? "__all",
|
||||
filters.responsibleUserId ?? "__all",
|
||||
filters.runId ?? "__all",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ describe("AuditFeed", () => {
|
|||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null });
|
||||
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "full" });
|
||||
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "Fable", icon: null }]);
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: null, image: null } }],
|
||||
|
|
@ -116,6 +116,7 @@ describe("AuditFeed", () => {
|
|||
);
|
||||
});
|
||||
await flushReact();
|
||||
return client;
|
||||
}
|
||||
|
||||
function clickButton(text: string) {
|
||||
|
|
@ -129,6 +130,10 @@ describe("AuditFeed", () => {
|
|||
it("renders the humanized sentence, the task link, the excerpt, and the on-behalf chip", async () => {
|
||||
await render();
|
||||
|
||||
expect(listAgentActionsMock).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ actorScope: "all" }),
|
||||
);
|
||||
expect(container.textContent).toContain("Fable");
|
||||
expect(container.textContent).toContain("commented on");
|
||||
const taskLink = container.querySelector('a[href="/issues/PAP-1"]');
|
||||
|
|
@ -151,11 +156,104 @@ describe("AuditFeed", () => {
|
|||
expect(container.textContent).not.toContain("Recorded by Paperclip");
|
||||
});
|
||||
|
||||
it("hides attribution filters and export for a basic all-actors reader", async () => {
|
||||
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "basic" });
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("commented on");
|
||||
expect(container.textContent).not.toContain("All agents");
|
||||
expect(container.textContent).not.toContain("All responsible users");
|
||||
expect(container.textContent).not.toContain("Export CSV");
|
||||
});
|
||||
|
||||
it("clears privileged filters and recovers the basic feed after an access downgrade", async () => {
|
||||
let permissionRevoked = false;
|
||||
listAgentActionsMock.mockImplementation((_companyId: string, filters: { from?: string }) => {
|
||||
if (permissionRevoked && filters.from) {
|
||||
return Promise.reject(
|
||||
new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
items: [record()],
|
||||
nextCursor: null,
|
||||
accessTier: permissionRevoked ? "basic" : "full",
|
||||
});
|
||||
});
|
||||
await render();
|
||||
|
||||
const fromDate = container.querySelector<HTMLInputElement>('input[aria-label="From date"]');
|
||||
expect(fromDate).toBeTruthy();
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
expect(setInputValue).toBeTruthy();
|
||||
await act(async () => {
|
||||
permissionRevoked = true;
|
||||
setInputValue!.call(fromDate, "2026-08-01");
|
||||
fromDate!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)).toBe(true);
|
||||
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
|
||||
expect.objectContaining({ actorScope: "all", from: undefined }),
|
||||
);
|
||||
expect(container.textContent).toContain("commented on");
|
||||
expect(container.textContent).not.toContain("Paperclip Enterprise view");
|
||||
expect(container.textContent).not.toContain("All agents");
|
||||
expect(container.textContent).not.toContain("Export CSV");
|
||||
});
|
||||
|
||||
it("drops cached privileged pages when pagination observes an access downgrade", async () => {
|
||||
let permissionRevoked = false;
|
||||
listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => {
|
||||
if (filters.cursor === "cursor-2") {
|
||||
return Promise.resolve({
|
||||
items: [record({
|
||||
id: "evt-2",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
responsibleUserId: null,
|
||||
details: null,
|
||||
})],
|
||||
nextCursor: null,
|
||||
accessTier: "basic",
|
||||
});
|
||||
}
|
||||
if (permissionRevoked) {
|
||||
return Promise.resolve({
|
||||
items: [record({ agentId: null, runId: null, responsibleUserId: null, details: null })],
|
||||
nextCursor: null,
|
||||
accessTier: "basic",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ items: [record()], nextCursor: "cursor-2", accessTier: "full" });
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("on behalf of Dotta");
|
||||
expect(container.textContent).toContain("Export CSV");
|
||||
permissionRevoked = true;
|
||||
await clickButton("Load more");
|
||||
await flushReact();
|
||||
|
||||
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
|
||||
expect.objectContaining({ actorScope: "all", cursor: undefined }),
|
||||
);
|
||||
expect(container.textContent).toContain("commented on");
|
||||
expect(container.textContent).not.toContain("on behalf of Dotta");
|
||||
expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).toBeFalsy();
|
||||
expect(container.textContent).not.toContain("All agents");
|
||||
expect(container.textContent).not.toContain("Export CSV");
|
||||
});
|
||||
|
||||
it("hides the agent filter and pins the query when lockedAgentId is set", async () => {
|
||||
await render({ lockedAgentId: "agent-1" });
|
||||
|
||||
const [, filters] = listAgentActionsMock.mock.calls[0];
|
||||
expect((filters as { agentId?: string }).agentId).toBe("agent-1");
|
||||
expect(filters).toEqual(expect.objectContaining({ actorScope: "agents", agentId: "agent-1" }));
|
||||
// No "All agents" option means the agent filter is hidden on the per-agent tab.
|
||||
expect(container.textContent).not.toContain("All agents");
|
||||
});
|
||||
|
|
@ -171,9 +269,9 @@ describe("AuditFeed", () => {
|
|||
it("loads more when a cursor is returned", async () => {
|
||||
listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => {
|
||||
if (filters.cursor === "cursor-2") {
|
||||
return Promise.resolve({ items: [record({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null });
|
||||
return Promise.resolve({ items: [record({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null, accessTier: "full" });
|
||||
}
|
||||
return Promise.resolve({ items: [record()], nextCursor: "cursor-2" });
|
||||
return Promise.resolve({ items: [record()], nextCursor: "cursor-2", accessTier: "full" });
|
||||
});
|
||||
await render();
|
||||
|
||||
|
|
@ -196,7 +294,10 @@ describe("AuditFeed", () => {
|
|||
await clickButton("Export CSV");
|
||||
await flushReact();
|
||||
|
||||
expect(exportCsvMock).toHaveBeenCalledWith("company-1", expect.any(Object));
|
||||
expect(exportCsvMock).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ actorScope: "all" }),
|
||||
);
|
||||
expect(createUrl).toHaveBeenCalled();
|
||||
expect(revokeUrl).not.toHaveBeenCalled();
|
||||
const deferredRevoke = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5_000)?.[0];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Download, ScrollText, ShieldAlert } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
|
@ -256,6 +256,10 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
|
|||
);
|
||||
|
||||
const filters: AuditActionFilters = {
|
||||
// The company feed is the shared all-actors view. The per-agent tab keeps
|
||||
// the legacy privileged scope because it always carries an attribution
|
||||
// filter and must not silently downgrade to the basic tier.
|
||||
actorScope: lockedAgentId ? "agents" : "all",
|
||||
agentId: lockedAgentId ?? (agent === ALL ? undefined : agent),
|
||||
responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser,
|
||||
action: actionDomain === ALL ? undefined : actionDomain,
|
||||
|
|
@ -275,6 +279,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
|
|||
|
||||
const feed = useInfiniteQuery({
|
||||
queryKey: queryKeys.audit.agentActions(companyId, {
|
||||
actorScope: filters.actorScope,
|
||||
agentId: filters.agentId,
|
||||
responsibleUserId: filters.responsibleUserId,
|
||||
action: filters.action,
|
||||
|
|
@ -295,6 +300,33 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
|
|||
);
|
||||
|
||||
const permissionDenied = feed.error instanceof ApiError && feed.error.status === 403;
|
||||
const hasBasicPage = feed.data?.pages.some((page) => page.accessTier === "basic") ?? false;
|
||||
const hasFullPage = feed.data?.pages.some((page) => page.accessTier === "full") ?? false;
|
||||
// Access may be revoked between cursor requests. Treat the least-privileged
|
||||
// page as authoritative until every cached page has been fetched again.
|
||||
const accessTier = hasBasicPage ? "basic" : feed.data?.pages[0]?.accessTier;
|
||||
const hasMixedAccessTiers = hasBasicPage && hasFullPage;
|
||||
const canUseAdvancedControls = lockedAgentId
|
||||
? true
|
||||
: accessTier === "full";
|
||||
const recoveringFromAccessDowngrade = Boolean(
|
||||
!lockedAgentId
|
||||
&& ((permissionDenied && hasActiveFilters) || hasMixedAccessTiers),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lockedAgentId && (accessTier === "basic" || recoveringFromAccessDowngrade)) {
|
||||
setAgent(ALL);
|
||||
setResponsibleUser(ALL);
|
||||
setActionDomain(ALL);
|
||||
setEntityType(ALL);
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
}
|
||||
if (hasMixedAccessTiers) {
|
||||
void feed.refetch();
|
||||
}
|
||||
}, [accessTier, feed, hasMixedAccessTiers, lockedAgentId, recoveringFromAccessDowngrade]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setAgent(ALL);
|
||||
|
|
@ -309,6 +341,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
|
|||
setExporting(true);
|
||||
try {
|
||||
const blob = await auditApi.exportAgentActionsCsv(companyId, {
|
||||
actorScope: filters.actorScope,
|
||||
agentId: filters.agentId,
|
||||
responsibleUserId: filters.responsibleUserId,
|
||||
action: filters.action,
|
||||
|
|
@ -338,7 +371,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
|
|||
}
|
||||
};
|
||||
|
||||
if (permissionDenied) {
|
||||
if (permissionDenied && !recoveringFromAccessDowngrade) {
|
||||
return <AuditUpsell />;
|
||||
}
|
||||
|
||||
|
|
@ -349,100 +382,108 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
|
|||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Audit</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Everything your agents did, newest first — each line is one recorded action, with the
|
||||
person responsible for it. Click through to the task or run for the full context.
|
||||
Everything your company did, newest first — each line is one recorded action. Full
|
||||
audit access also shows responsible-person and run attribution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!lockedAgentId ? (
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Agent" />
|
||||
{canUseAdvancedControls ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!lockedAgentId ? (
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All agents</SelectItem>
|
||||
{(agents.data ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Responsible user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All agents</SelectItem>
|
||||
{(agents.data ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
<SelectItem value={ALL}>All responsible users</SelectItem>
|
||||
{(userDirectory.data?.users ?? []).map((u) => (
|
||||
<SelectItem key={u.principalId} value={u.principalId}>
|
||||
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Responsible user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All responsible users</SelectItem>
|
||||
{(userDirectory.data?.users ?? []).map((u) => (
|
||||
<SelectItem key={u.principalId} value={u.principalId}>
|
||||
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={actionDomain} onValueChange={setActionDomain}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_DOMAINS.map((d) => (
|
||||
<SelectItem key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Entity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTITY_TYPES.map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
aria-label="From date"
|
||||
value={dateFrom}
|
||||
max={dateTo || undefined}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
aria-label="To date"
|
||||
value={dateTo}
|
||||
min={dateFrom || undefined}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
<Select value={actionDomain} onValueChange={setActionDomain}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_DOMAINS.map((d) => (
|
||||
<SelectItem key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Entity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTITY_TYPES.map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
aria-label="From date"
|
||||
value={dateFrom}
|
||||
max={dateTo || undefined}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
aria-label="To date"
|
||||
value={dateTo}
|
||||
min={dateFrom || undefined}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || feed.isLoading || items.length === 0}
|
||||
>
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
{exporting ? "Exporting…" : "Export CSV"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || feed.isLoading || items.length === 0}
|
||||
>
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
{exporting ? "Exporting…" : "Export CSV"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{feed.isLoading ? (
|
||||
{recoveringFromAccessDowngrade ? (
|
||||
<Card>
|
||||
<CardContent className="py-14 text-center text-sm text-muted-foreground">
|
||||
Refreshing audit access…
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : feed.isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-14 text-center text-sm text-muted-foreground">Loading…</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ import { EmptyState } from "../../components/EmptyState";
|
|||
import { AuditFeed } from "./AuditFeed";
|
||||
|
||||
/**
|
||||
* Company-level agent audit page — a permission-gated
|
||||
* rich view in the unified codebase, matching the `tools:view_audit` precedent.
|
||||
* The feed itself renders the upsell/permission-denied state when the caller
|
||||
* lacks `audit:view_agent_actions` (server-authoritative, see `AuditFeed`).
|
||||
* Company-level audit page. All company readers receive the redacted shared
|
||||
* feed; attribution filters and export remain permission-gated server-side.
|
||||
*/
|
||||
export function CompanyAudit() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
|
|
|||
Loading…
Reference in New Issue