mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat: enable MCP tools by default
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
98f032f9a9
commit
ca94577206
18 files changed
+530
-324
No files matched your search
@@ -1411,6 +1411,8 @@ export interface ToolConnectionTestAgent {
|
||||
role: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
/** Zero-based depth in the company reporting tree; roots are highest-ranked. */
|
||||
orgDepth: number;
|
||||
effectiveAccess: ToolConnectionAccessSummary;
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("connects a public unknown endpoint with conservative defaults and quarantine", async () => {
|
||||
it("connects a public unknown endpoint with every discovered tool enabled", async () => {
|
||||
installMcpOAuthFixture({ auth: "public" });
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
@@ -354,18 +354,30 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
||||
expect(result.auth ?? null).toBeNull();
|
||||
expect(result.actions.readOnly.map((action) => action.toolName)).toEqual(["list_insights"]);
|
||||
expect(result.actions.canMakeChanges.map((action) => action.toolName)).toEqual(["create_insight"]);
|
||||
// Reads are on for review, anything that can change data starts off.
|
||||
expect(result.suggestedDefaults).toMatchObject({ askFirstRiskLevels: ["write", "destructive"] });
|
||||
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId));
|
||||
expect(connection).toMatchObject({ transport: "mcp_remote", authKind: "none", status: "draft" });
|
||||
// An unknown endpoint stays labelled unverified and quarantines whatever it
|
||||
// grows later, exactly like the curated path's conservative defaults.
|
||||
expect(connection!.config).toMatchObject({ quarantineNewEntries: true, unverifiedServer: true });
|
||||
expect(connection!.config).toMatchObject({ quarantineNewEntries: false, unverifiedServer: true });
|
||||
// No curated definition was consulted: nothing recorded a template key, so
|
||||
// this connection cannot be depending on gallery metadata for anything.
|
||||
expect(connection!.config).not.toHaveProperty("sourceTemplateKey");
|
||||
expect(connection!.config).not.toHaveProperty("connectionMethodKey");
|
||||
const [profile] = await db.select().from(toolProfiles).where(eq(
|
||||
toolProfiles.profileKey,
|
||||
`app:${result.connectionId}`,
|
||||
));
|
||||
expect(profile).toBeTruthy();
|
||||
await expect(db.select().from(toolProfileEntries).where(eq(
|
||||
toolProfileEntries.profileId,
|
||||
profile!.id,
|
||||
))).resolves.toHaveLength(2);
|
||||
await expect(db.select().from(toolProfileBindings).where(eq(
|
||||
toolProfileBindings.profileId,
|
||||
profile!.id,
|
||||
))).resolves.toEqual([
|
||||
expect.objectContaining({ targetType: "company", targetId: company.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits DNS guidance for a real NXDOMAIN failure", async () => {
|
||||
|
||||
@@ -99,6 +99,24 @@ function mockToolsList(tools: unknown[]) {
|
||||
);
|
||||
}
|
||||
|
||||
const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp";
|
||||
|
||||
async function withGalleryServerUrl<T>(
|
||||
slug: string,
|
||||
serverUrl: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const method = getConnectableAppDefinition(slug)?.methods[0];
|
||||
if (!method?.defaults) throw new Error(`Missing gallery method defaults for ${slug}`);
|
||||
const originalServerUrl = method.defaults.serverUrl;
|
||||
method.defaults.serverUrl = serverUrl;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
method.defaults.serverUrl = originalServerUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function createRouteApp(
|
||||
db: ReturnType<typeof createDb>,
|
||||
actor?: Express.Request["actor"],
|
||||
@@ -147,13 +165,14 @@ async function grantBoardUser(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
permissionKeys: string[],
|
||||
membershipRole: "owner" | "admin" | "operator" | "member" | "viewer" = "operator",
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
membershipRole,
|
||||
});
|
||||
if (permissionKeys.length > 0) {
|
||||
await db.insert(principalPermissionGrants).values(permissionKeys.map((permissionKey) => ({
|
||||
@@ -1447,6 +1466,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(res.body.agents).toHaveLength(1);
|
||||
expect(res.body.agents[0]).toMatchObject({
|
||||
id: agent.id,
|
||||
orgDepth: 0,
|
||||
effectiveAccess: {
|
||||
connectionId: connection.id,
|
||||
toolCount: 1,
|
||||
@@ -1457,6 +1477,55 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists only writable agents and ranks the highest accessible agent first", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `scoped-tool-tester-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, ["tools:use"], "viewer");
|
||||
const actor = boardSessionActor(company.id, "viewer", userId);
|
||||
const root = await createAgent(db, company.id);
|
||||
const [accessibleManager] = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Accessible manager",
|
||||
role: "manager",
|
||||
reportsTo: root.id,
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
}).returning();
|
||||
const [accessibleReport] = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Accessible report",
|
||||
role: "engineer",
|
||||
reportsTo: accessibleManager!.id,
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
}).returning();
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { agentIds: [accessibleManager!.id, accessibleReport!.id] },
|
||||
grantedByUserId: "owner",
|
||||
});
|
||||
const { connection } = await createRemoteToolFixture(db, company.id);
|
||||
const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/tool-connections/${connection.id}/test-agents`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.agents.map((agent: { id: string }) => agent.id)).toEqual([
|
||||
accessibleManager!.id,
|
||||
accessibleReport!.id,
|
||||
]);
|
||||
expect(res.body.agents.map((agent: { orgDepth: number }) => agent.orgDepth)).toEqual([1, 2]);
|
||||
expect(res.body.agents).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: root.id })]));
|
||||
});
|
||||
|
||||
it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `tool-tester-${randomUUID()}`;
|
||||
@@ -5295,15 +5364,16 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
runtimeConfig: {},
|
||||
}).returning();
|
||||
|
||||
const connect = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "zapier",
|
||||
name: "Zapier workspace",
|
||||
credentialValues: { "credentials.authorization": "zap-secret" },
|
||||
}, { actorType: "user", actorId: "board" });
|
||||
const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () =>
|
||||
service.connectGalleryApp(company.id, {
|
||||
galleryKey: "zapier",
|
||||
name: "Zapier workspace",
|
||||
credentialValues: { "credentials.authorization": "zap-secret" },
|
||||
}, { actorType: "user", actorId: "board" }));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://mcp.zapier.com/api/mcp",
|
||||
PUBLIC_MCP_FIXTURE_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer zap-secret" }),
|
||||
}),
|
||||
@@ -5311,7 +5381,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(connect.connection).toMatchObject({
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: true }),
|
||||
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: false }),
|
||||
credentialSecretRefs: [
|
||||
expect.objectContaining({
|
||||
configPath: "credentials.authorization",
|
||||
@@ -5391,6 +5461,10 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
await db.update(toolConnections).set({
|
||||
config: { ...connect.connection.config, quarantineNewEntries: true },
|
||||
}).where(eq(toolConnections.id, connect.connectionId));
|
||||
|
||||
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "paperclip-catalog-refresh",
|
||||
@@ -5489,6 +5563,79 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(attentionAfterReview.apps).toEqual([]);
|
||||
});
|
||||
|
||||
it("enables every discovered tool by default while preserving tools explicitly turned off later", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
const fetchMock = mockToolsList([
|
||||
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
||||
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
||||
]);
|
||||
|
||||
const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () =>
|
||||
service.connectGalleryApp(company.id, {
|
||||
galleryKey: "zapier",
|
||||
credentialValues: { "credentials.authorization": "zap-secret" },
|
||||
}, { actorType: "user", actorId: "board" }));
|
||||
const listEntry = connect.catalog.find((entry) => entry.toolName === "list_zaps")!;
|
||||
const updateEntry = connect.catalog.find((entry) => entry.toolName === "update_zap")!;
|
||||
const [defaultProfile] = await db.select().from(toolProfiles).where(eq(
|
||||
toolProfiles.profileKey,
|
||||
`app:${connect.connectionId}`,
|
||||
));
|
||||
expect(defaultProfile).toBeTruthy();
|
||||
await expect(db.select().from(toolProfileEntries).where(eq(
|
||||
toolProfileEntries.profileId,
|
||||
defaultProfile!.id,
|
||||
))).resolves.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ catalogEntryId: listEntry.id, effect: "include" }),
|
||||
expect.objectContaining({ catalogEntryId: updateEntry.id, effect: "include" }),
|
||||
]));
|
||||
await expect(db.select().from(toolProfileBindings).where(eq(
|
||||
toolProfileBindings.profileId,
|
||||
defaultProfile!.id,
|
||||
))).resolves.toEqual([
|
||||
expect.objectContaining({ targetType: "company", targetId: company.id }),
|
||||
]);
|
||||
|
||||
await service.finishGalleryAppConnection(company.id, connect.connectionId, {
|
||||
enabledCatalogEntryIds: [listEntry.id],
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: "all_agents",
|
||||
}, { actorType: "user", actorId: "board" });
|
||||
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "paperclip-catalog-refresh",
|
||||
result: {
|
||||
tools: [
|
||||
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
||||
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
||||
{ name: "create_zap", annotations: { readOnlyHint: false } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const refresh = await service.refreshCatalog(connect.connectionId, { actorType: "user", actorId: "board" });
|
||||
|
||||
expect(refresh.quarantinedCount).toBe(0);
|
||||
expect(refresh.catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ toolName: "list_zaps", status: "active" }),
|
||||
expect.objectContaining({ toolName: "update_zap", status: "active" }),
|
||||
expect.objectContaining({ toolName: "create_zap", status: "active" }),
|
||||
]));
|
||||
const createEntry = refresh.catalog.find((entry) => entry.toolName === "create_zap")!;
|
||||
const profileEntries = await db.select().from(toolProfileEntries).where(eq(
|
||||
toolProfileEntries.profileId,
|
||||
defaultProfile!.id,
|
||||
));
|
||||
expect(profileEntries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ catalogEntryId: listEntry.id }),
|
||||
expect.objectContaining({ catalogEntryId: createEntry.id }),
|
||||
]));
|
||||
expect(profileEntries).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ catalogEntryId: updateEntry.id }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
|
||||
const company = await createCompany(db);
|
||||
const otherCompany = await createCompany(db);
|
||||
|
||||
@@ -57,6 +57,28 @@ function allowlistIds(config: Record<string, unknown> | null | undefined): strin
|
||||
return raw.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
}
|
||||
|
||||
function agentOrgDepths(rows: Array<{ id: string; reportsTo: string | null }>): Map<string, number> {
|
||||
const parentById = new Map(rows.map((row) => [row.id, row.reportsTo]));
|
||||
const depthById = new Map<string, number>();
|
||||
|
||||
const depthFor = (agentId: string, path: Set<string>): number => {
|
||||
const known = depthById.get(agentId);
|
||||
if (known !== undefined) return known;
|
||||
const parentId = parentById.get(agentId);
|
||||
if (!parentId || !parentById.has(parentId) || path.has(agentId)) {
|
||||
depthById.set(agentId, 0);
|
||||
return 0;
|
||||
}
|
||||
const nextPath = new Set(path).add(agentId);
|
||||
const depth = depthFor(parentId, nextPath) + 1;
|
||||
depthById.set(agentId, depth);
|
||||
return depth;
|
||||
};
|
||||
|
||||
for (const row of rows) depthFor(row.id, new Set());
|
||||
return depthById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a connection PATCH into operator-visible lifecycle events so the
|
||||
* per-app Activity tab can humanize them (PAP-11284). A single update may
|
||||
@@ -740,9 +762,11 @@ export function toolAccessRoutes(
|
||||
role: agents.role,
|
||||
title: agents.title,
|
||||
status: agents.status,
|
||||
reportsTo: agents.reportsTo,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, connection.companyId));
|
||||
const orgDepthByAgentId = agentOrgDepths(rows);
|
||||
const candidates = [];
|
||||
for (const agent of rows) {
|
||||
try {
|
||||
@@ -751,7 +775,12 @@ export function toolAccessRoutes(
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
...agent,
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
status: agent.status,
|
||||
orgDepth: orgDepthByAgentId.get(agent.id) ?? 0,
|
||||
effectiveAccess: await options.toolGateway.summarizeConnectionAccessForAgent({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
@@ -759,6 +788,7 @@ export function toolAccessRoutes(
|
||||
}),
|
||||
});
|
||||
}
|
||||
candidates.sort((a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name));
|
||||
res.json({ agents: candidates });
|
||||
});
|
||||
|
||||
|
||||
@@ -3088,6 +3088,75 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function enableCatalogEntriesByDefault(input: {
|
||||
connection: typeof toolConnections.$inferSelect;
|
||||
newCatalogEntryIds: string[];
|
||||
activeCatalogEntryIds: string[];
|
||||
actor?: ActorInfo;
|
||||
}) {
|
||||
const profileKey = `app:${input.connection.id}`;
|
||||
let [profile] = await db
|
||||
.select()
|
||||
.from(toolProfiles)
|
||||
.where(and(
|
||||
eq(toolProfiles.companyId, input.connection.companyId),
|
||||
eq(toolProfiles.profileKey, profileKey),
|
||||
))
|
||||
.limit(1);
|
||||
const createdProfile = !profile;
|
||||
if (!profile) {
|
||||
[profile] = await db.insert(toolProfiles).values({
|
||||
companyId: input.connection.companyId,
|
||||
profileKey,
|
||||
name: input.connection.name,
|
||||
description: `Access profile for ${input.connection.name}.`,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
metadata: { source: "app_gallery_finish", connectionId: input.connection.id },
|
||||
}).returning();
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: input.connection.companyId,
|
||||
profileId: profile.id,
|
||||
targetType: "company",
|
||||
targetId: input.connection.companyId,
|
||||
priority: 100,
|
||||
metadata: { source: "app_gallery_finish" },
|
||||
createdByAgentId: input.actor?.actorType === "agent" ? input.actor.actorId ?? null : null,
|
||||
createdByUserId: input.actor?.actorType === "user" ? input.actor.actorId ?? null : null,
|
||||
});
|
||||
}
|
||||
|
||||
// A new connection starts with every discovered action enabled. Later
|
||||
// refreshes extend that managed profile only for genuinely new actions, so
|
||||
// an action the operator deliberately turned off remains off.
|
||||
const candidateIds = [...new Set(
|
||||
createdProfile ? input.activeCatalogEntryIds : input.newCatalogEntryIds,
|
||||
)];
|
||||
if (candidateIds.length === 0) return;
|
||||
const existingEntries = await db
|
||||
.select({ catalogEntryId: toolProfileEntries.catalogEntryId })
|
||||
.from(toolProfileEntries)
|
||||
.where(and(
|
||||
eq(toolProfileEntries.companyId, input.connection.companyId),
|
||||
eq(toolProfileEntries.profileId, profile.id),
|
||||
inArray(toolProfileEntries.catalogEntryId, candidateIds),
|
||||
));
|
||||
const configuredIds = new Set(existingEntries.flatMap((entry) =>
|
||||
entry.catalogEntryId ? [entry.catalogEntryId] : [],
|
||||
));
|
||||
const entryIds = candidateIds.filter((id) => !configuredIds.has(id));
|
||||
if (entryIds.length === 0) return;
|
||||
await db.insert(toolProfileEntries).values(entryIds.map((catalogEntryId) => ({
|
||||
companyId: input.connection.companyId,
|
||||
profileId: profile.id,
|
||||
selectorType: "catalog_entry" as const,
|
||||
effect: "include" as const,
|
||||
applicationId: input.connection.applicationId,
|
||||
connectionId: input.connection.id,
|
||||
catalogEntryId,
|
||||
})));
|
||||
}
|
||||
|
||||
async function listConnectionInstalls(connectionId: string, companyId?: string): Promise<ToolConnectionInstall[]> {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
const rows = await db
|
||||
@@ -4110,7 +4179,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
? "quarantined"
|
||||
: existing?.status === "disabled"
|
||||
? "disabled"
|
||||
: existing?.status === "quarantined"
|
||||
: quarantineOnRefresh && existing?.status === "quarantined"
|
||||
? "quarantined"
|
||||
: "active";
|
||||
if (shouldQuarantine) quarantinedCount += 1;
|
||||
@@ -4131,8 +4200,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
versionHash: hash,
|
||||
schemaHash,
|
||||
lastSeenAt: now,
|
||||
quarantinedAt: shouldQuarantine ? now : existing.quarantinedAt,
|
||||
quarantineReason: shouldQuarantine ? "pending_review" : existing.quarantineReason,
|
||||
quarantinedAt: status === "quarantined"
|
||||
? shouldQuarantine ? now : existing.quarantinedAt
|
||||
: null,
|
||||
quarantineReason: status === "quarantined"
|
||||
? shouldQuarantine ? "pending_review" : existing.quarantineReason
|
||||
: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolCatalogEntries.id, existing.id))
|
||||
@@ -4188,6 +4261,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
.where(eq(toolRuntimeSlots.connectionId, connection.id));
|
||||
}
|
||||
|
||||
const activeEntries = updatedEntries.filter((entry) => entry.status === "active");
|
||||
await enableCatalogEntriesByDefault({
|
||||
connection: updatedConnection,
|
||||
newCatalogEntryIds: activeEntries
|
||||
.filter((entry) => {
|
||||
const previous = existingByName.get(entry.toolName);
|
||||
return !previous || previous.status === "quarantined";
|
||||
})
|
||||
.map((entry) => entry.id),
|
||||
activeCatalogEntryIds: activeEntries.map((entry) => entry.id),
|
||||
actor,
|
||||
});
|
||||
|
||||
await audit({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
@@ -6305,10 +6391,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
sourceTemplateKey: galleryEntry.slug,
|
||||
connectionMethodKey: method?.key,
|
||||
methodConfig: normalizedMethodConfig?.values ?? {},
|
||||
quarantineNewEntries: true,
|
||||
quarantineNewEntries: false,
|
||||
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
|
||||
}
|
||||
: { ...baseConfig, quarantineNewEntries: true, unverifiedServer: true };
|
||||
: { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true };
|
||||
// A pasted URL may arrive with a client the operator preregistered in the
|
||||
// provider's own console, because that authorization server supports neither
|
||||
// CIMD nor dynamic registration. Record the client id now; the secret becomes
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDetail } from "./AppDetail";
|
||||
import { APP_TABS } from "./app-tabs";
|
||||
|
||||
const getConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
@@ -269,6 +270,17 @@ describe("AppDetail", () => {
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("places Test immediately below Setup", () => {
|
||||
expect(APP_TABS.map((tab) => tab.key)).toEqual([
|
||||
"setup",
|
||||
"test",
|
||||
"review",
|
||||
"permissions",
|
||||
"activity",
|
||||
"advanced",
|
||||
]);
|
||||
});
|
||||
|
||||
it("pauses the app by flipping the connection enabled flag", async () => {
|
||||
await renderAppDetail();
|
||||
|
||||
@@ -311,19 +323,41 @@ describe("AppDetail", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["setup", "Agents can use this app"],
|
||||
["review", "Review 1 new action"],
|
||||
["permissions", "Action permissions"],
|
||||
["activity", "No activity yet."],
|
||||
["advanced", "Technical details"],
|
||||
])("renders the %s tab panel", async (tab, expectedText) => {
|
||||
["setup", "Agents can use this app", false],
|
||||
["review", "Review 1 new action", true],
|
||||
["permissions", "Action permissions", true],
|
||||
["activity", "No activity yet.", false],
|
||||
["advanced", "Technical details", false],
|
||||
])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => {
|
||||
mockParams.tab = tab;
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("2 actions available");
|
||||
expect(container.textContent?.includes("2 actions available")).toBe(showsActionCount);
|
||||
expect(container.textContent).toContain(expectedText);
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders setup without waiting for tool discovery", async () => {
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).not.toContain("Loading tools");
|
||||
expect(listCatalogMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("Loading tools…");
|
||||
expect(container.textContent).not.toContain("Action permissions");
|
||||
});
|
||||
|
||||
it("hides secret URL parameters in advanced technical details", async () => {
|
||||
@@ -421,6 +455,7 @@ describe("AppDetail", () => {
|
||||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).not.toContain("Read repo");
|
||||
expect(container.textContent).not.toContain("Action permissions");
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => {
|
||||
@@ -551,6 +586,7 @@ describe("AppDetail", () => {
|
||||
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
|
||||
expect(readSelect?.value).toBe("allowed");
|
||||
expect(writeSelect?.value).toBe("ask");
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists ask-first for read-only actions from the unified dropdown", async () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ export function AppDetail() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
const needsCatalog = activeTab === "review" || activeTab === "permissions" || activeTab === "test";
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connection(connectionId),
|
||||
@@ -81,22 +82,22 @@ export function AppDetail() {
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: queryKeys.tools.catalog(connectionId),
|
||||
queryFn: () => toolsApi.listCatalog(connectionId),
|
||||
enabled: !!connectionId && !!activeTab,
|
||||
enabled: !!connectionId && needsCatalog,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
});
|
||||
const policiesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
enabled: !!selectedCompanyId && (activeTab === "permissions" || activeTab === "activity"),
|
||||
});
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(connectionId),
|
||||
@@ -359,7 +360,7 @@ export function AppDetail() {
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
|
||||
}
|
||||
if (connectionQuery.isLoading || catalogQuery.isLoading) {
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
@@ -385,7 +386,11 @@ export function AppDetail() {
|
||||
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
|
||||
const readOnly = active.filter((e) => e.isReadOnly);
|
||||
const canChange = active.filter((e) => !e.isReadOnly);
|
||||
const actionCount = active.length;
|
||||
const actionCount = catalogQuery.data ? active.length : null;
|
||||
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
|
||||
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
|
||||
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
|
||||
const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 pb-12">
|
||||
@@ -435,36 +440,58 @@ export function AppDetail() {
|
||||
/>
|
||||
)}
|
||||
{activeTab === "review" && (
|
||||
<ReviewPanel
|
||||
connectionId={connectionId}
|
||||
quarantined={quarantined}
|
||||
pending={pending}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
reviewFailed
|
||||
? <ToolsLoadError onRetry={() => {
|
||||
void catalogQuery.refetch();
|
||||
void profilesQuery.refetch();
|
||||
void policiesQuery.refetch();
|
||||
}} />
|
||||
: reviewLoading
|
||||
? <ToolsLoading />
|
||||
: <ReviewPanel
|
||||
connectionId={connectionId}
|
||||
quarantined={quarantined}
|
||||
pending={pending}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "permissions" && (
|
||||
<PermissionsPanel
|
||||
appName={appName}
|
||||
access={access}
|
||||
agents={agents}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
installPending={persistInstall.isPending || installsQuery.isLoading}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: next })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
permissionsFailed
|
||||
? <ToolsLoadError onRetry={() => {
|
||||
void catalogQuery.refetch();
|
||||
void profilesQuery.refetch();
|
||||
void policiesQuery.refetch();
|
||||
void installsQuery.refetch();
|
||||
void agentsQuery.refetch();
|
||||
}} />
|
||||
: permissionsLoading
|
||||
? <ToolsLoading />
|
||||
: <PermissionsPanel
|
||||
appName={appName}
|
||||
access={access}
|
||||
agents={agents}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
installPending={persistInstall.isPending}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: next })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "test" && (
|
||||
<TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
catalogQuery.isError
|
||||
? <ToolsLoadError onRetry={() => { void catalogQuery.refetch(); }} />
|
||||
: catalogQuery.isLoading
|
||||
? <ToolsLoading />
|
||||
: <TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
<ActivityPanel
|
||||
@@ -515,7 +542,7 @@ function AppDetailHeader({
|
||||
connection: ToolConnection;
|
||||
logoEntry: AppGalleryDisplayEntry | null;
|
||||
status: StatusInfo;
|
||||
actionCount: number;
|
||||
actionCount: number | null;
|
||||
renaming: boolean;
|
||||
nameDraft: string;
|
||||
renamePending: boolean;
|
||||
@@ -573,9 +600,11 @@ function AppDetailHeader({
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||||
</span>
|
||||
{actionCount !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -583,6 +612,24 @@ function AppDetailHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsLoading() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading tools…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsLoadError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className="space-y-3 py-8">
|
||||
<p className="text-sm text-destructive">Couldn’t load tools for this app.</p>
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function unverifiedRemoteHost(connection: ToolConnection): string | null {
|
||||
const sourceTemplateKey = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
|
||||
if (
|
||||
|
||||
@@ -726,7 +726,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Step 1 of 4");
|
||||
expect(container.textContent).toContain("Step 1 of 3");
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
@@ -747,15 +747,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
|
||||
expect(container.textContent).toContain("Step 2 of 4");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue with 1 action on")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 3 of 4");
|
||||
expect(container.textContent).toContain("Step 2 of 3");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -793,7 +785,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 4 of 4");
|
||||
expect(container.textContent).toContain("Step 3 of 3");
|
||||
expect(container.textContent).toContain("Install Zapier tools?");
|
||||
expect(container.textContent).toContain("Not yet");
|
||||
|
||||
@@ -982,7 +974,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=actions");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=access");
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier" });
|
||||
});
|
||||
@@ -1156,7 +1148,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
||||
.toBe("127.0.0.1:47399/mcp");
|
||||
});
|
||||
|
||||
it("keeps the endpoint host and Unverified server visible from setup through review", async () => {
|
||||
it("keeps the endpoint host visible while skipping action review", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
|
||||
@@ -1208,13 +1200,11 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Now on the review step — the label has to survive the step change.
|
||||
expect(container.textContent).toContain("List things");
|
||||
expect(container.textContent).toContain("Who can use mcp.example.test?");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('[role="switch"]')).map((toggle) => toggle.getAttribute("aria-label")),
|
||||
).toEqual(["List things allowed", "Search things allowed", "qa_delete_widget allowed"]);
|
||||
expect(container.textContent).not.toContain("List things");
|
||||
expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("offers a curated setup as a convenience without leaving the generic flow", async () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
FieldDef,
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolAppConnectionActionSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
@@ -67,12 +66,11 @@ import {
|
||||
} from "./generic-mcp-connect";
|
||||
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
|
||||
|
||||
type Step = "gallery" | "key" | "actions" | "who" | "install" | "success";
|
||||
type Step = "gallery" | "key" | "who" | "install" | "success";
|
||||
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
|
||||
|
||||
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
|
||||
key: "setup",
|
||||
actions: "actions",
|
||||
who: "access",
|
||||
install: "install",
|
||||
success: "complete",
|
||||
@@ -86,26 +84,19 @@ function appConnectHref(appKey: string, step: Step): string {
|
||||
type AppAccessSelection = "all_agents" | { agentIds: string[] };
|
||||
type InstallMode = "none" | "specific" | "all";
|
||||
|
||||
const STEP_LABELS = ["Pick app", "Add your key", "Choose actions", "Choose access", "Install tools"];
|
||||
const STEP_LABELS = ["Pick app", "Add your key", "Choose access", "Install tools"];
|
||||
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
||||
gallery: 0,
|
||||
key: 1,
|
||||
actions: 2,
|
||||
who: 3,
|
||||
install: 4,
|
||||
};
|
||||
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||||
key: 0,
|
||||
actions: 1,
|
||||
who: 2,
|
||||
install: 3,
|
||||
};
|
||||
const ZAPIER_STEP_LABELS = ["Add MCP URL", "Choose actions", "Choose access", "Install tools"];
|
||||
|
||||
function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
|
||||
const raw = (result.suggestedDefaults as { askFirstRiskLevels?: unknown })?.askFirstRiskLevels;
|
||||
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
|
||||
}
|
||||
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||||
key: 0,
|
||||
who: 1,
|
||||
install: 2,
|
||||
};
|
||||
const ZAPIER_STEP_LABELS = ["Add MCP URL", "Choose access", "Install tools"];
|
||||
|
||||
function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
|
||||
return entry?.slug === "google-sheets";
|
||||
@@ -439,11 +430,11 @@ export function AppsConnect() {
|
||||
setConnectResult(result);
|
||||
const defaults: Record<string, boolean> = {};
|
||||
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
|
||||
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = false;
|
||||
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = true;
|
||||
setEnabled(defaults);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setAppStep("actions");
|
||||
setAppStep("who");
|
||||
},
|
||||
onError: (error) => {
|
||||
const details = error instanceof ApiError && error.body && typeof error.body === "object"
|
||||
@@ -553,19 +544,14 @@ export function AppsConnect() {
|
||||
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const askFirstLevels = connectResult ? askFirstLevelsFrom(connectResult) : [];
|
||||
const changeActions = connectResult?.actions.canMakeChanges ?? [];
|
||||
const enabledIds = Object.entries(enabled)
|
||||
.filter(([, on]) => on)
|
||||
.map(([id]) => id);
|
||||
const askFirstIds = changeActions
|
||||
.filter((a) => enabled[a.catalogEntryId] && askFirstLevels.includes(a.riskLevel))
|
||||
.map((a) => a.catalogEntryId);
|
||||
const selection: AppAccessSelection =
|
||||
access === "all" ? "all_agents" : { agentIds: Array.from(agentIds) };
|
||||
const result = await toolsApi.finishApp(selectedCompanyId!, connectResult!.connectionId, {
|
||||
enabledCatalogEntryIds: enabledIds,
|
||||
askFirstCatalogEntryIds: askFirstIds,
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: selection,
|
||||
});
|
||||
const installState = installMode === "all"
|
||||
@@ -688,9 +674,9 @@ export function AppsConnect() {
|
||||
const stepLabels = zapierSource
|
||||
? ZAPIER_STEP_LABELS
|
||||
: entry && getAvailableConnectionMethods(entry).length > 1
|
||||
? ["Pick app", "Choose connection", "Choose actions", "Choose access", "Install tools"]
|
||||
? ["Pick app", "Choose connection", "Choose access", "Install tools"]
|
||||
: isGoogleSheetsEntry(entry)
|
||||
? ["Pick app", "Share sheet", "Choose actions", "Choose access", "Install tools"]
|
||||
? ["Pick app", "Share sheet", "Choose access", "Install tools"]
|
||||
: STEP_LABELS;
|
||||
const stepIndex = zapierSource && step !== "gallery" && step !== "success"
|
||||
? ZAPIER_STEP_INDEX[step]
|
||||
@@ -841,24 +827,6 @@ export function AppsConnect() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "actions" && connectResult && (
|
||||
<ActionsStep
|
||||
appName={appName}
|
||||
result={connectResult}
|
||||
enabled={enabled}
|
||||
onToggle={(id, on) => setEnabled((prev) => ({ ...prev, [id]: on }))}
|
||||
onBulk={(ids, on) =>
|
||||
setEnabled((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = on;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onBack={() => setAppStep("key")}
|
||||
onContinue={() => setAppStep("who")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "who" && connectResult && (
|
||||
<WhoStep
|
||||
appName={appName}
|
||||
@@ -867,7 +835,7 @@ export function AppsConnect() {
|
||||
setAccess={setAccess}
|
||||
agentIds={agentIds}
|
||||
setAgentIds={setAgentIds}
|
||||
onBack={() => setAppStep("actions")}
|
||||
onBack={() => setAppStep("key")}
|
||||
onContinue={() => setAppStep("install")}
|
||||
/>
|
||||
)}
|
||||
@@ -1015,7 +983,7 @@ export function OAuthConnectStateScreen({
|
||||
subtitle="Secure MCP sign-in"
|
||||
step="key"
|
||||
activeIndex={0}
|
||||
labels={["Connect", "Review actions", "Choose access", "Install tools"]}
|
||||
labels={["Connect", "Choose access", "Install tools"]}
|
||||
appIdentity={entry ? { name: entry.name, logoUrl: entry.branding.logoUrl } : undefined}
|
||||
unverifiedHost={unverifiedHost}
|
||||
onCancel={onCancel}
|
||||
@@ -1255,7 +1223,7 @@ function GalleryStep({
|
||||
{zapierSource
|
||||
? "Paste the complete MCP URL Zapier gives you, including its token."
|
||||
: byo
|
||||
? "Paste your MCP server’s URL and we’ll walk you through permissions and review."
|
||||
? "Paste your MCP server’s URL and every discovered tool will be available immediately."
|
||||
: "Paste a setup link from an app that is not listed here."}
|
||||
</p>
|
||||
{!zapierSource && (
|
||||
@@ -2100,150 +2068,6 @@ function MethodConfigField({
|
||||
);
|
||||
}
|
||||
|
||||
function ActionGroup({
|
||||
title,
|
||||
hint,
|
||||
actions,
|
||||
enabled,
|
||||
onToggle,
|
||||
bulkLabel,
|
||||
onBulk,
|
||||
askFirstLevels,
|
||||
}: {
|
||||
title: string;
|
||||
hint: string;
|
||||
actions: ToolAppConnectionActionSummary[];
|
||||
enabled: Record<string, boolean>;
|
||||
onToggle: (id: string, on: boolean) => void;
|
||||
bulkLabel: string;
|
||||
onBulk: () => void;
|
||||
askFirstLevels: string[];
|
||||
}) {
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-2.5 sm:py-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
<span className="ml-2 text-muted-foreground">· {hint}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBulk}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{bulkLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{actions.map((action) => {
|
||||
const on = enabled[action.catalogEntryId] ?? false;
|
||||
const showAskFirst = on && askFirstLevels.includes(action.riskLevel);
|
||||
return (
|
||||
<div key={action.catalogEntryId} className="flex items-center gap-4 px-5 py-2.5 sm:py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{action.title ?? action.toolName}
|
||||
</div>
|
||||
{action.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{showAskFirst && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
Ask first
|
||||
</span>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
aria-label={`${action.title ?? action.toolName} allowed`}
|
||||
checked={on}
|
||||
onCheckedChange={(next) => onToggle(action.catalogEntryId, next)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsStep({
|
||||
appName,
|
||||
result,
|
||||
enabled,
|
||||
onToggle,
|
||||
onBulk,
|
||||
onBack,
|
||||
onContinue,
|
||||
}: {
|
||||
appName: string;
|
||||
result: ConnectToolAppResult;
|
||||
enabled: Record<string, boolean>;
|
||||
onToggle: (id: string, on: boolean) => void;
|
||||
onBulk: (ids: string[], on: boolean) => void;
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const askFirstLevels = askFirstLevelsFrom(result);
|
||||
const { readOnly, canMakeChanges } = result.actions;
|
||||
const total = readOnly.length + canMakeChanges.length;
|
||||
const enabledCount = Object.values(enabled).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-foreground">
|
||||
Connected to {appName} — it offers {total} {total === 1 ? "action" : "actions"}.
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Read-only actions are on. Anything that can change something starts off — turn on the ones you want.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionGroup
|
||||
title="Read only"
|
||||
hint="these can look but not change anything"
|
||||
actions={readOnly}
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
bulkLabel="Turn all off"
|
||||
onBulk={() => onBulk(readOnly.map((a) => a.catalogEntryId), false)}
|
||||
askFirstLevels={askFirstLevels}
|
||||
/>
|
||||
|
||||
<ActionGroup
|
||||
title="Can make changes"
|
||||
hint="these change something in another app"
|
||||
actions={canMakeChanges}
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
bulkLabel="Turn all on"
|
||||
onBulk={() => onBulk(canMakeChanges.map((a) => a.catalogEntryId), true)}
|
||||
askFirstLevels={askFirstLevels}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between sm:pt-1">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
If {appName} adds new actions later, they start off until you review them.
|
||||
</span>
|
||||
<Button onClick={onContinue} disabled={enabledCount === 0}>
|
||||
Continue with {enabledCount} {enabledCount === 1 ? "action" : "actions"} on
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WhoStep({
|
||||
appName,
|
||||
companyId,
|
||||
|
||||
@@ -26,10 +26,12 @@ export function ReviewQueueCard({
|
||||
connectionId,
|
||||
emptyState = "hidden",
|
||||
heading = "Waiting for your OK",
|
||||
plain = false,
|
||||
}: {
|
||||
connectionId?: string;
|
||||
emptyState?: "hidden" | "reassure";
|
||||
heading?: string;
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
||||
@@ -51,7 +53,7 @@ export function ReviewQueueCard({
|
||||
if (items.length === 0) {
|
||||
if (emptyState === "hidden") return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
|
||||
<div className={plain ? "py-5 text-sm text-muted-foreground" : "rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground"}>
|
||||
Nothing is waiting for your OK right now.
|
||||
</div>
|
||||
);
|
||||
@@ -68,14 +70,22 @@ export function ReviewQueueCard({
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} />
|
||||
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} plain={plain} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionRequestListItem }) {
|
||||
function ReviewRow({
|
||||
companyId,
|
||||
item,
|
||||
plain,
|
||||
}: {
|
||||
companyId: string;
|
||||
item: ToolActionRequestListItem;
|
||||
plain: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [resolving, setResolving] = useState<null | "allow" | "always" | "decline">(null);
|
||||
@@ -140,7 +150,7 @@ function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionReq
|
||||
const preview = item.request.previewMarkdown?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4">
|
||||
<div className={plain ? "py-3" : "rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4"}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-bold text-foreground">{actionLabel(item)}</span>
|
||||
{item.applicationName && (
|
||||
|
||||
@@ -44,8 +44,8 @@ function KeySection({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -62,7 +62,7 @@ function KeySection({
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="border-t border-border px-5 py-4">
|
||||
<div className="pt-4">
|
||||
<ReconnectForm
|
||||
connection={connection}
|
||||
galleryEntry={galleryEntry}
|
||||
@@ -255,7 +255,7 @@ function ReconnectForm({
|
||||
|
||||
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">Technical details</h2>
|
||||
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
@@ -278,11 +278,11 @@ export function DangerZone({
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
return (
|
||||
<section className="rounded-xl border border-destructive/40 bg-card">
|
||||
<div className="border-b border-destructive/40 px-5 py-3 text-sm font-bold text-destructive">
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-bold text-destructive">
|
||||
Danger zone
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Remove this app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -110,8 +110,8 @@ function AccessSection({
|
||||
const canSave = draft.mode === "all" || draft.agentIds.size > 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Who can use it</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
|
||||
@@ -124,7 +124,7 @@ function AccessSection({
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-3 border-t border-border px-5 py-4">
|
||||
<div className="space-y-3 pt-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -206,8 +206,8 @@ function InstalledSection({
|
||||
const installedCount = install.onAll ? liveAgents.length : install.agentIds.size;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Installed on agents</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
@@ -228,7 +228,7 @@ function InstalledSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border px-5 py-4">
|
||||
<div className="space-y-3 pt-4">
|
||||
<InlineBanner tone="info" compact>
|
||||
{installInfoNotice(appName)}
|
||||
</InlineBanner>
|
||||
@@ -255,12 +255,7 @@ function InstalledSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border px-3 py-2.5",
|
||||
install.onAll ? "border-foreground bg-muted/40" : "border-border bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<label className="flex items-start gap-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={install.onAll}
|
||||
disabled={disabled}
|
||||
@@ -417,8 +412,8 @@ function ActionGroup({
|
||||
}, [focusId]);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="border-b border-border px-5 py-3 text-sm">
|
||||
<div>
|
||||
<div className="pb-2 text-sm">
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
<span className="ml-2 text-muted-foreground">- {hint}</span>
|
||||
</div>
|
||||
@@ -431,7 +426,7 @@ function ActionGroup({
|
||||
key={action.id}
|
||||
ref={focused ? focusRef : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-5 py-3",
|
||||
"flex items-center gap-4 py-3",
|
||||
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
|
||||
)}
|
||||
data-action-id={action.id}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function ReviewPanel({
|
||||
connectionId={connectionId}
|
||||
heading="Waiting for your OK"
|
||||
emptyState={showsQuarantinedActions ? "hidden" : "reassure"}
|
||||
plain
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
|
||||
["Response mode", typeof config.mode === "string" ? config.mode : "tools"],
|
||||
];
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">PostHog access scope</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
This connection is pinned to the project and analytics surface below.
|
||||
@@ -109,7 +109,7 @@ function OAuthConnectionSection({
|
||||
onStart: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
@@ -154,7 +154,7 @@ function GoogleSheetsAllowlistSection({
|
||||
onUpdateConfig(googleSheetsConfigWithAllowlist(connection.config, nextIds));
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Sheets agents can use</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
@@ -247,7 +247,7 @@ export function AppLifecycleSection({
|
||||
}) {
|
||||
const enabled = connection.enabled !== false && connection.status !== "disabled";
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
@@ -284,8 +284,8 @@ export function QuarantinedActionsReview({
|
||||
const count = entries.length;
|
||||
const selectedIds = entries.filter((entry) => enabledIds.has(entry.id)).map((entry) => entry.id);
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-amber-500/40 bg-amber-500/[0.08]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3">
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">
|
||||
Review {count} new {count === 1 ? "action" : "actions"}
|
||||
@@ -313,12 +313,12 @@ export function QuarantinedActionsReview({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-amber-500/25 border-y border-amber-500/25 bg-background">
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => {
|
||||
const enabled = enabledIds.has(entry.id);
|
||||
const label = entry.title ?? entry.toolName;
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div key={entry.id} className="flex items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
{entry.description && (
|
||||
@@ -342,7 +342,7 @@ export function QuarantinedActionsReview({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{selectedIds.length} of {count} will be on
|
||||
</span>
|
||||
|
||||
@@ -133,6 +133,7 @@ function agent(overrides: Record<string, unknown> = {}) {
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
orgDepth: 1,
|
||||
effectiveAccess: {
|
||||
connectionId: "conn-1",
|
||||
toolCount: 3,
|
||||
@@ -231,6 +232,23 @@ describe("TestPanel", () => {
|
||||
expect(container.textContent).toContain("Allowed");
|
||||
expect(container.textContent).toContain("Ask first");
|
||||
expect(container.textContent).toContain("Off");
|
||||
expect(container.querySelector(".bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to the highest-ranked accessible agent", async () => {
|
||||
listTestAgentsMock.mockResolvedValue({
|
||||
agents: [
|
||||
agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
|
||||
agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
|
||||
agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Root agent");
|
||||
expect(container.textContent).not.toContain("A report");
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no actions", async () => {
|
||||
|
||||
@@ -136,18 +136,20 @@ export function TestPanel({
|
||||
});
|
||||
|
||||
const agents = useMemo(
|
||||
() => [...(testAgentsQuery.data?.agents ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
() => [...(testAgentsQuery.data?.agents ?? [])].sort(
|
||||
(a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name),
|
||||
),
|
||||
[testAgentsQuery.data],
|
||||
);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
// Default to the first agent (alphabetical) that can run at least one action;
|
||||
// otherwise the first agent we can test as at all.
|
||||
// The API returns only agents this user may write to. Prefer the highest
|
||||
// agent in that accessible slice of the org tree, regardless of whether a
|
||||
// lower-ranked agent happens to have a broader app policy today.
|
||||
useEffect(() => {
|
||||
if (agentId && agents.some((a) => a.id === agentId)) return;
|
||||
if (agents.length === 0) return;
|
||||
const withAccess = agents.find((a) => a.effectiveAccess.allowedCount > 0);
|
||||
setAgentId((withAccess ?? agents[0]).id);
|
||||
setAgentId(agents[0].id);
|
||||
}, [agents, agentId]);
|
||||
|
||||
// Switches the header from "TEST AS" card to the compact "Testing as …" line.
|
||||
@@ -212,7 +214,7 @@ export function TestPanel({
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No agents to test as</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
|
||||
@@ -266,7 +268,7 @@ export function TestPanel({
|
||||
</div>
|
||||
|
||||
{visibleCount === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No actions match “{query}”. Clear the search to see them all.
|
||||
</div>
|
||||
) : (
|
||||
@@ -311,7 +313,7 @@ export function TestPanel({
|
||||
|
||||
function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-base font-bold text-foreground">Nothing to test yet</p>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">
|
||||
Once {appName} is connected, the actions it offers will show up here so you can try them out.
|
||||
@@ -344,7 +346,7 @@ function TestAsHeader({
|
||||
}) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Testing as{" "}
|
||||
<AgentPicker
|
||||
@@ -361,7 +363,7 @@ function TestAsHeader({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Test as</p>
|
||||
@@ -546,7 +548,7 @@ function ActionGroup({
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h3>
|
||||
{subheading && <p className="mb-1.5 -mt-1 text-xs text-muted-foreground">{subheading}</p>}
|
||||
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => (
|
||||
<ActionRow
|
||||
key={entry.id}
|
||||
@@ -599,7 +601,7 @@ function ActionRow({
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border bg-muted/20 px-4 py-4">
|
||||
<div className="border-t border-border py-4 pl-11">
|
||||
<ActionTester entry={entry} decision={decision} agent={agent} {...shared} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
@@ -1358,7 +1360,7 @@ function OffExplanation({
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-(--gtc-62)">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Ban className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{title} is off for {agent.name}.</p>
|
||||
@@ -1378,7 +1380,7 @@ function OffExplanation({
|
||||
<p className="text-xs text-muted-foreground">No call will be made — this action is off for {agent.name}.</p>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-md border border-border bg-card p-3">
|
||||
<aside>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Why this is off</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{whyBody}</p>
|
||||
{auditHint && <p className="mt-1.5 text-(length:--text-micro) text-muted-foreground">{auditHint}</p>}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-
|
||||
|
||||
export const APP_TABS = [
|
||||
{ key: "setup", label: "Setup", icon: Settings2 },
|
||||
{ key: "test", label: "Test", icon: Beaker },
|
||||
{ key: "review", label: "Review", icon: Inbox },
|
||||
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
|
||||
{ key: "activity", label: "Activity", icon: Activity },
|
||||
{ key: "test", label: "Test", icon: Beaker },
|
||||
{ key: "advanced", label: "Advanced", icon: Wrench },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -128,7 +128,12 @@ const DECISIONS: Record<string, ToolConnectionTestDecision> = {
|
||||
delete_row: "off",
|
||||
};
|
||||
|
||||
function buildAgent(id: string, name: string, decisions: Record<string, ToolConnectionTestDecision>): ToolConnectionTestAgent {
|
||||
function buildAgent(
|
||||
id: string,
|
||||
name: string,
|
||||
decisions: Record<string, ToolConnectionTestDecision>,
|
||||
orgDepth = 1,
|
||||
): ToolConnectionTestAgent {
|
||||
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
|
||||
return {
|
||||
id,
|
||||
@@ -136,6 +141,7 @@ function buildAgent(id: string, name: string, decisions: Record<string, ToolConn
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
orgDepth,
|
||||
effectiveAccess: {
|
||||
connectionId: CONNECTION,
|
||||
toolCount: tools.length,
|
||||
@@ -151,7 +157,7 @@ function buildAgent(id: string, name: string, decisions: Record<string, ToolConn
|
||||
}
|
||||
|
||||
const AGENTS: ToolConnectionTestAgent[] = [
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS),
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
|
||||
buildAgent("agent-codex", "CodexCoder", {
|
||||
...DECISIONS,
|
||||
create_sheet: "ask_first",
|
||||
|
||||
Reference in new issue
Block a user