feat(apps): consolidate connector management (#12684)
Completes the post-managed-OAuth connector lifecycle, Paperclip Cloud provisioning defaults, governed test flows, and consolidated Apps UI.\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
141f202e40
commit
1ab159d3a7
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureAgentJwtSecret,
|
||||
ensureToolActionSigningSecret,
|
||||
mergePaperclipEnvEntries,
|
||||
readAgentJwtSecretFromEnv,
|
||||
readPaperclipEnvEntries,
|
||||
|
|
@ -24,6 +25,7 @@ describe("agent jwt env helpers", () => {
|
|||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -42,6 +44,17 @@ describe("agent jwt env helpers", () => {
|
|||
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
|
||||
});
|
||||
|
||||
it("creates an independent tool-action signing secret next to the config", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const result = ensureToolActionSigningSecret(configPath);
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.secret).toHaveLength(64);
|
||||
const entries = readPaperclipEnvEntries(resolveAgentJwtEnvFile(configPath));
|
||||
expect(entries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET).toBe(result.secret);
|
||||
expect(entries.PAPERCLIP_AGENT_JWT_SECRET).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loads secret from .env next to explicit config path", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ describe("onboard", () => {
|
|||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
delete process.env.PAPERCLIP_DB_BACKUP_DIR;
|
||||
|
|
@ -248,6 +249,8 @@ describe("onboard", () => {
|
|||
expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
||||
expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
||||
expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(instanceRoot, ".env"), "utf8"))
|
||||
.toContain("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=");
|
||||
expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1471,10 +1471,12 @@ describe("worktree helpers", () => {
|
|||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
const originalToolActionSigningSecret = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
||||
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "worktree-tool-action-secret";
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeInitCommand({
|
||||
|
|
@ -1486,6 +1488,7 @@ describe("worktree helpers", () => {
|
|||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||
const envContents = fs.readFileSync(envPath, "utf8");
|
||||
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
expect(envContents).toContain("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=worktree-tool-action-secret");
|
||||
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
||||
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
|
||||
} finally {
|
||||
|
|
@ -1495,6 +1498,11 @@ describe("worktree helpers", () => {
|
|||
} else {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
||||
}
|
||||
if (originalToolActionSigningSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalToolActionSigningSecret;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
findPaperclipConfigKeyWarnings,
|
||||
type PaperclipConfig,
|
||||
} from "../config/schema.js";
|
||||
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||
import { ensureAgentJwtSecret, ensureToolActionSigningSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||
import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
|
||||
import { promptDatabase } from "../prompts/database.js";
|
||||
import { promptLlm } from "../prompts/llm.js";
|
||||
|
|
@ -453,6 +453,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||
} else {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
const toolActionSigningSecret = ensureToolActionSigningSecret(configPath);
|
||||
if (toolActionSigningSecret.created) {
|
||||
p.log.success(`Created ${pc.cyan("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
|
||||
const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath);
|
||||
if (keyResult.status === "created") {
|
||||
|
|
@ -689,6 +693,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||
} else {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
const toolActionSigningSecret = ensureToolActionSigningSecret(configPath);
|
||||
if (toolActionSigningSecret.created) {
|
||||
p.log.success(`Created ${pc.cyan("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ import {
|
|||
prepareEmbeddedPostgresNativeRuntime,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
import { ensureAgentJwtSecret, ensureToolActionSigningSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
|
|
@ -2530,14 +2530,19 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||
const existingAgentJwtSecret =
|
||||
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
|
||||
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
|
||||
const existingToolActionSigningSecret =
|
||||
nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
|
||||
nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET);
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
...buildWorktreeEnvEntries(paths, branding),
|
||||
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
|
||||
...(existingToolActionSigningSecret ? { PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: existingToolActionSigningSecret } : {}),
|
||||
},
|
||||
paths.envPath,
|
||||
);
|
||||
ensureAgentJwtSecret(paths.configPath);
|
||||
ensureToolActionSigningSecret(paths.configPath);
|
||||
loadPaperclipEnvFile(paths.configPath);
|
||||
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { updateEnvFileContents, writeEnvFileAtomicallyIfChanged } from "@papercl
|
|||
import { resolveConfigPath } from "./store.js";
|
||||
|
||||
const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET";
|
||||
const TOOL_ACTION_SIGNING_SECRET_ENV_KEY = "PAPERCLIP_TOOL_ACTION_SIGNING_SECRET";
|
||||
const PAPERCLIP_OWNED_ENV_KEY_PATTERN = /^PAPERCLIP_[A-Z0-9_]+$/;
|
||||
function resolveEnvFilePath(configPath?: string) {
|
||||
return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env");
|
||||
|
|
@ -93,6 +94,25 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
|
|||
return { secret, created };
|
||||
}
|
||||
|
||||
export function ensureToolActionSigningSecret(configPath?: string): { secret: string; created: boolean } {
|
||||
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
||||
const existingEnv = process.env[TOOL_ACTION_SIGNING_SECRET_ENV_KEY];
|
||||
if (isNonEmpty(existingEnv)) {
|
||||
return { secret: existingEnv.trim(), created: false };
|
||||
}
|
||||
|
||||
const envFilePath = resolveEnvFilePath(configPath);
|
||||
const existingFile = readPaperclipEnvEntries(envFilePath)[TOOL_ACTION_SIGNING_SECRET_ENV_KEY];
|
||||
const secret = isNonEmpty(existingFile) ? existingFile.trim() : randomBytes(32).toString("hex");
|
||||
const created = !isNonEmpty(existingFile);
|
||||
|
||||
if (created) {
|
||||
mergePaperclipEnvEntries({ [TOOL_ACTION_SIGNING_SECRET_ENV_KEY]: secret }, envFilePath);
|
||||
}
|
||||
|
||||
return { secret, created };
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ This runbook covers Paperclip Tools & Access runtime slots for MCP connections.
|
|||
|
||||
Do not print raw bearer tokens, gateway session tokens, credential headers, environment variables, or secret values while following this runbook. The APIs below return redacted state and audit metadata; keep shell tracing disabled when exporting credentials.
|
||||
|
||||
Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set independently from auth/JWT secrets. Rotate it deliberately: changing it invalidates outstanding signed tool-action approvals, so drain or reject pending approvals before rotation.
|
||||
Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set independently from auth/JWT secrets. `paperclipai onboard` generates it for local instances, and worktree setup propagates or generates an independent value in the worktree `.env`; operator-managed deployments must set it explicitly. Rotate it deliberately: changing it invalidates outstanding signed tool-action approvals, so drain or reject pending approvals before rotation.
|
||||
|
||||
## Support Matrix
|
||||
|
||||
|
|
|
|||
|
|
@ -1435,6 +1435,7 @@ export type {
|
|||
ToolConnectionTestToolAccess,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestAgentAccessResponse,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
|
|
|
|||
|
|
@ -603,6 +603,7 @@ export type {
|
|||
ToolConnectionTestToolAccess,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestAgentAccessResponse,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
|
|
|
|||
|
|
@ -1573,13 +1573,17 @@ export interface ToolConnectionTestAgent {
|
|||
status: string;
|
||||
/** Zero-based depth in the company reporting tree; roots are highest-ranked. */
|
||||
orgDepth: number;
|
||||
effectiveAccess: ToolConnectionAccessSummary;
|
||||
}
|
||||
|
||||
export interface ToolConnectionTestAgentsResponse {
|
||||
agents: ToolConnectionTestAgent[];
|
||||
}
|
||||
|
||||
/** Display summary for one selected Test-tab agent. */
|
||||
export interface ToolConnectionTestAgentAccessResponse {
|
||||
access: ToolConnectionAccessSummary;
|
||||
}
|
||||
|
||||
/** Result of `POST /tool-connections/:id/test-calls`. */
|
||||
export interface ToolConnectionTestCallResult {
|
||||
decision: ToolConnectionTestDecision;
|
||||
|
|
|
|||
|
|
@ -2310,7 +2310,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("lists testable agents with per-connection effective access summaries", async () => {
|
||||
it("lists testable agents without calculating every agent's access summary", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `tool-tester-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
||||
|
|
@ -2335,13 +2335,18 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(res.body.agents[0]).toMatchObject({
|
||||
id: agent.id,
|
||||
orgDepth: 0,
|
||||
effectiveAccess: {
|
||||
connectionId: connection.id,
|
||||
toolCount: 1,
|
||||
allowedCount: 1,
|
||||
askFirstCount: 0,
|
||||
offCount: 0,
|
||||
},
|
||||
});
|
||||
expect(res.body.agents[0]).not.toHaveProperty("effectiveAccess");
|
||||
|
||||
const accessRes = await request(app)
|
||||
.get(`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`)
|
||||
.expect(200);
|
||||
expect(accessRes.body.access).toMatchObject({
|
||||
connectionId: connection.id,
|
||||
toolCount: 1,
|
||||
allowedCount: 1,
|
||||
askFirstCount: 0,
|
||||
offCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2412,10 +2417,10 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
|
||||
const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }));
|
||||
const res = await request(app)
|
||||
.get(`/api/tool-connections/${connection.id}/test-agents`)
|
||||
.get(`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`)
|
||||
.expect(200);
|
||||
|
||||
const summary = res.body.agents[0].effectiveAccess;
|
||||
const summary = res.body.access;
|
||||
expect(typeof summary.lastChangedAt).toBe("string");
|
||||
expect(summary.lastChangedByAgentId).toBe(agent.id);
|
||||
expect(summary.lastChangedByName).toBe(agent.name);
|
||||
|
|
@ -2523,6 +2528,44 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
]));
|
||||
});
|
||||
|
||||
it("cancels an ask-first test request when approval signing is unavailable", async () => {
|
||||
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
|
||||
const company = await createCompany(db);
|
||||
const userId = `tool-tester-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection } = await createRemoteToolFixture(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: `Ask first without signing ${randomUUID()}`,
|
||||
policyType: "require_approval",
|
||||
priority: 100,
|
||||
selectors: { connectionId: connection.id },
|
||||
});
|
||||
const app = createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "operator", userId),
|
||||
createToolGatewayService(db, { toolActionSigningSecret: " " }),
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
||||
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
|
||||
.expect(500);
|
||||
|
||||
expect(res.body).toMatchObject({
|
||||
reasonCode: "signing_secret_unconfigured",
|
||||
error: expect.stringContaining("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"),
|
||||
});
|
||||
const [actionRequest] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, company.id));
|
||||
expect(actionRequest).toMatchObject({ status: "cancelled", signedArguments: null });
|
||||
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
|
||||
expect(invocation).toMatchObject({
|
||||
status: "failed",
|
||||
errorCode: "signing_secret_unconfigured",
|
||||
});
|
||||
});
|
||||
|
||||
it("audits ask-first test calls with the real board actor and selected agent", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `tool-tester-${randomUUID()}`;
|
||||
|
|
@ -3418,6 +3461,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
.put(`/api/tool-connections/${connection.id}/installs`)
|
||||
.send({ installs: [] }),
|
||||
await request(app).get(`/api/tool-connections/${connection.id}/test-agents`),
|
||||
await request(app).get(`/api/tool-connections/${connection.id}/test-agents/${randomUUID()}/access`),
|
||||
await request(app)
|
||||
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
||||
.send({ agentId: randomUUID(), toolName: "read_notes", parameters: {} }),
|
||||
|
|
@ -3620,7 +3664,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
|
||||
it("serves the app gallery manifest through the board route", async () => {
|
||||
const company = await createCompany(db);
|
||||
const app = createRouteApp(db);
|
||||
const app = createRouteApp(db, undefined, undefined, { paperclipCloudConnector: null });
|
||||
|
||||
const res = await request(app).get(`/api/companies/${company.id}/tools/gallery`);
|
||||
|
||||
|
|
@ -8097,6 +8141,12 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
status: "pending",
|
||||
canonicalArgumentsHash: "args-hash",
|
||||
canonicalArgumentsSummary: { summary: "redacted", redactedFields: [] },
|
||||
signedArguments: signToolArguments({
|
||||
invocationId: invocation.id,
|
||||
toolName: invocation.toolName,
|
||||
canonicalArguments: canonicalToolArguments({ redacted: true }),
|
||||
signingSecret: "attention-test-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/companies/${company.id}/tools/apps/attention`);
|
||||
|
|
@ -8152,7 +8202,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
schemaHash: "s1",
|
||||
}).returning();
|
||||
const canonicalArguments = canonicalToolArguments({ key: "alpha", value: "one" });
|
||||
const invocationValues = [1, 2, 3].map(() => ({
|
||||
const invocationValues = [1, 2, 3, 4].map(() => ({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
connectionId: connection.id,
|
||||
|
|
@ -8164,7 +8214,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
approvalState: "pending" as const,
|
||||
status: "awaiting_approval" as const,
|
||||
}));
|
||||
const [validInvocation, missingSignatureInvocation, oldSecretInvocation] =
|
||||
const [validInvocation, missingSignatureInvocation, staleMissingSignatureInvocation, oldSecretInvocation] =
|
||||
await db.insert(toolInvocations).values(invocationValues).returning();
|
||||
const validSignedArguments = signToolArguments({
|
||||
invocationId: validInvocation.id,
|
||||
|
|
@ -8178,7 +8228,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
canonicalArguments,
|
||||
signingSecret: "old-secret",
|
||||
});
|
||||
const [validRequest, missingSignatureRequest, oldSecretRequest] = await db.insert(toolActionRequests).values([
|
||||
const [validRequest, missingSignatureRequest, staleMissingSignatureRequest, oldSecretRequest] = await db.insert(toolActionRequests).values([
|
||||
{
|
||||
companyId: company.id,
|
||||
invocationId: validInvocation.id,
|
||||
|
|
@ -8195,6 +8245,19 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
|
||||
signedArguments: null,
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
invocationId: staleMissingSignatureInvocation.id,
|
||||
status: "pending",
|
||||
canonicalArgumentsHash: "args-hash",
|
||||
canonicalArgumentsSummary: {
|
||||
summary: canonicalArguments,
|
||||
sha256: "args-hash",
|
||||
sizeBytes: canonicalArguments.length,
|
||||
},
|
||||
signedArguments: null,
|
||||
createdAt: new Date(Date.now() - 3 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
invocationId: oldSecretInvocation.id,
|
||||
|
|
@ -8214,6 +8277,9 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
// An unsigned request is still being created; the read hides it but keeps it
|
||||
// pending, so the creator can finish signing and the later approve succeeds.
|
||||
expect(statusById.get(missingSignatureRequest.id)).toBe("pending");
|
||||
// If the creator never finishes signing, Review retires the stale orphan
|
||||
// instead of leaving a permanent badge for a request no human can approve.
|
||||
expect(statusById.get(staleMissingSignatureRequest.id)).toBe("cancelled");
|
||||
// A request signed with a rotated/old secret is unverifiable and is cancelled.
|
||||
expect(statusById.get(oldSecretRequest.id)).toBe("cancelled");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -171,10 +171,12 @@ describe("worktree config repair", () => {
|
|||
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
|
||||
expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"');
|
||||
expect(repairedEnv).toContain("PAPERCLIP_AGENT_JWT_SECRET=shared-secret");
|
||||
expect(repairedEnv).toContain("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=");
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
||||
expect(process.env.PORT).toBe("3101");
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
|
||||
expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false");
|
||||
expect(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("disables backups in an otherwise isolated existing worktree config", async () => {
|
||||
|
|
|
|||
|
|
@ -950,6 +950,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"GET /api/tool-connections/{connectionId}/catalog",
|
||||
"GET /api/tool-connections/{connectionId}/activity",
|
||||
"GET /api/tool-connections/{connectionId}/test-agents",
|
||||
"GET /api/tool-connections/{connectionId}/test-agents/{agentId}/access",
|
||||
"POST /api/tool-connections/{connectionId}/test-calls",
|
||||
"GET /api/tool-connections/{connectionId}/test-calls/{actionRequestId}",
|
||||
"POST /api/agents/me/connections/{connectionId}/start-authorization",
|
||||
|
|
@ -7681,6 +7682,13 @@ registerCurrentRoute({
|
|||
summary: "List agents available for tool connection test calls",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/tool-connections/{connectionId}/test-agents/{agentId}/access",
|
||||
tags: ["tool-access"],
|
||||
summary: "Summarize one agent's effective access to a tool connection",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/tool-connections/{connectionId}/test-calls",
|
||||
|
|
|
|||
|
|
@ -1485,7 +1485,12 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
|
||||
router.get("/tool-connections/:connectionId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
const connection = await getAccessibleResource(
|
||||
req,
|
||||
res,
|
||||
svc.getConnection(req.params.connectionId as string),
|
||||
"Tool connection not found",
|
||||
);
|
||||
if (!connection) return;
|
||||
res.json(connection);
|
||||
});
|
||||
|
|
@ -1831,17 +1836,31 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
title: agent.title,
|
||||
status: agent.status,
|
||||
orgDepth: orgDepthByAgentId.get(agent.id) ?? 0,
|
||||
effectiveAccess: await options.toolGateway.summarizeConnectionAccessForAgent({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
agentId: agent.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
candidates.sort((a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name));
|
||||
res.json({ agents: candidates });
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/test-agents/:agentId/access", async (req, res) => {
|
||||
assertBoard(req);
|
||||
if (!options.toolGateway) {
|
||||
res.status(501).json({ error: "Tool gateway service is not configured" });
|
||||
return;
|
||||
}
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]);
|
||||
const agentId = req.params.agentId as string;
|
||||
await assertCanTestAsAgent(req, connection.companyId, agentId);
|
||||
const accessSummary = await options.toolGateway.summarizeConnectionAccessForAgent({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
agentId,
|
||||
});
|
||||
res.json({ access: accessSummary });
|
||||
});
|
||||
|
||||
router.post("/tool-connections/:connectionId/test-calls", validate(toolConnectionTestCallSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
if (!options.toolGateway) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
createPaperclipCloudConnector,
|
||||
GMAIL_CONNECTOR_SCOPES,
|
||||
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
||||
paperclipCloudConnectorCapabilitiesFromEnv,
|
||||
paperclipCloudConnectorConfigFromEnv,
|
||||
PaperclipCloudConnectorError,
|
||||
type PaperclipCloudConnectorConfig,
|
||||
|
|
@ -271,6 +272,13 @@ describe("Paperclip Cloud connector", () => {
|
|||
expect(legacyError).toMatchObject({ code: "CONNECTOR_MIGRATION_REQUIRED" });
|
||||
expect(String(legacyError)).toContain("incompatible legacy protocol");
|
||||
});
|
||||
|
||||
it("keeps gallery capability discovery available during incomplete enrollment", async () => {
|
||||
await expect(paperclipCloudConnectorCapabilitiesFromEnv({
|
||||
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app",
|
||||
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging",
|
||||
})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function seal(
|
||||
|
|
|
|||
|
|
@ -357,7 +357,11 @@ export async function paperclipCloudConnectorCapabilitiesFromEnv(
|
|||
try {
|
||||
config = paperclipCloudConnectorConfigFromEnv(env);
|
||||
} catch (error) {
|
||||
if (error instanceof PaperclipCloudConnectorError && error.code === "CONNECTOR_MIGRATION_REQUIRED") return [];
|
||||
// Gallery discovery is useful even while connector enrollment is pending or
|
||||
// local connector settings are incomplete. Treat every connector-config
|
||||
// error as "no managed profiles" here; enrollment/status surfaces still
|
||||
// report the actionable configuration problem.
|
||||
if (error instanceof PaperclipCloudConnectorError) return [];
|
||||
throw error;
|
||||
}
|
||||
if (!config) return [];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lt, max, ne, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, max, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
|
|
@ -157,7 +157,7 @@ import {
|
|||
} from "./remote-url-credentials.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||||
import { readSignedToolArgumentsPayload } from "./tool-content-guards.js";
|
||||
import { readSignedToolArgumentsPayload, TOOL_ACTION_REQUEST_SIGNING_GRACE_MS } from "./tool-content-guards.js";
|
||||
import {
|
||||
effectiveToolProfileBindings,
|
||||
narrowestScopeBindings,
|
||||
|
|
@ -5575,7 +5575,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
db
|
||||
.select()
|
||||
.from(toolActionRequests)
|
||||
.where(and(eq(toolActionRequests.companyId, companyId), eq(toolActionRequests.status, "pending"))),
|
||||
.where(and(
|
||||
eq(toolActionRequests.companyId, companyId),
|
||||
eq(toolActionRequests.status, "pending"),
|
||||
isNotNull(toolActionRequests.signedArguments),
|
||||
)),
|
||||
db
|
||||
.select()
|
||||
.from(toolInvocations)
|
||||
|
|
@ -12434,7 +12438,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
// that window. Hide such a request from the queue, but do not cancel it —
|
||||
// cancelling here races the two-step create and makes the later approve
|
||||
// fail with action_not_pending. Only cancel a request that carries a
|
||||
// signature we cannot verify (secret rotation or tampering).
|
||||
// signature we cannot verify (secret rotation or tampering), or an
|
||||
// unsigned row whose creator has exceeded the signing grace period.
|
||||
const unsignedRequestIds = new Set<string>();
|
||||
const invalidRequestIds: string[] = [];
|
||||
for (const request of requests) {
|
||||
|
|
@ -12444,7 +12449,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
continue;
|
||||
}
|
||||
if (request.signedArguments === null) {
|
||||
unsignedRequestIds.add(request.id);
|
||||
if (Date.now() - request.createdAt.getTime() >= TOOL_ACTION_REQUEST_SIGNING_GRACE_MS) {
|
||||
invalidRequestIds.push(request.id);
|
||||
} else {
|
||||
unsignedRequestIds.add(request.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let readable = false;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ export class ToolActionSigningSecretMissingError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
// Creating an approval is a two-step insert/sign operation. Readers must allow
|
||||
// a short window for the creator to attach the signature before treating a null
|
||||
// signature as an abandoned, unapprovable request.
|
||||
export const TOOL_ACTION_REQUEST_SIGNING_GRACE_MS = 2 * 60 * 1000;
|
||||
|
||||
export function resolveToolActionSigningSecret(env: ToolActionSigningSecretEnv = process.env as ToolActionSigningSecretEnv) {
|
||||
const secret = env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET?.trim();
|
||||
if (!secret) {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ import {
|
|||
readSignedToolArgumentsPayload,
|
||||
signToolArguments,
|
||||
summarizeToolValue,
|
||||
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS,
|
||||
ToolActionSigningSecretMissingError,
|
||||
ToolContentValidationError,
|
||||
validateToolContent,
|
||||
|
|
@ -162,7 +163,6 @@ const ACTION_REQUEST_EXECUTION_WAIT_MS = APPROVED_EXECUTION_TIMEOUT_MS + 5_000;
|
|||
// treat an unsigned row as abandoned after this grace time from createdAt. This
|
||||
// grace must exceed the normal sign path (approval-snapshot fetch + interaction
|
||||
// create) so a live create keeps its own row.
|
||||
const UNSIGNED_ACTION_REQUEST_ABANDON_MS = 2 * 60 * 1000;
|
||||
const MAX_REMOTE_MCP_RESPONSE_BYTES = 1_000_000;
|
||||
const ACTIVE_GATEWAY_RUN_STATUSES = new Set(["running"]);
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
|
@ -5692,7 +5692,7 @@ export function createToolGatewayService(
|
|||
const pendingUnsigned =
|
||||
pendingRequest.status === "pending"
|
||||
&& pendingRequest.signedArguments === null
|
||||
&& Date.now() - pendingRequest.createdAt.getTime() >= UNSIGNED_ACTION_REQUEST_ABANDON_MS;
|
||||
&& Date.now() - pendingRequest.createdAt.getTime() >= TOOL_ACTION_REQUEST_SIGNING_GRACE_MS;
|
||||
const pendingExpired =
|
||||
pendingRequest.status === "pending"
|
||||
&& pendingRequest.expiresAt !== null
|
||||
|
|
@ -6365,14 +6365,39 @@ export function createToolGatewayService(
|
|||
const approvalSnapshot = await connectedRemoteApprovalSnapshot(session, tool, {
|
||||
requireResolvedCredentials: true,
|
||||
});
|
||||
const signedArguments = signToolArguments({
|
||||
invocationId,
|
||||
toolName: tool.name,
|
||||
canonicalArguments,
|
||||
approvalSnapshot: approvalSnapshot ?? undefined,
|
||||
executionOnApprove: true,
|
||||
signingSecret: options.toolActionSigningSecret,
|
||||
});
|
||||
let signedArguments: ReturnType<typeof signToolArguments>;
|
||||
try {
|
||||
signedArguments = signToolArguments({
|
||||
invocationId,
|
||||
toolName: tool.name,
|
||||
canonicalArguments,
|
||||
approvalSnapshot: approvalSnapshot ?? undefined,
|
||||
executionOnApprove: true,
|
||||
signingSecret: options.toolActionSigningSecret,
|
||||
});
|
||||
} catch (error) {
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "cancelled", resolvedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(toolActionRequests.id, recorded.actionRequest.id), eq(toolActionRequests.status, "pending")));
|
||||
if (error instanceof ToolActionSigningSecretMissingError) {
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorCode: "signing_secret_unconfigured",
|
||||
errorMessage: error.message,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolInvocations.id, invocationId));
|
||||
throw new ToolGatewayHttpError(500, error.message, "signing_secret_unconfigured", {
|
||||
invocationId,
|
||||
tool: tool.name,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const previewMarkdown = buildHumanizedActionPreview({ tool, argumentsSummary: argumentValidation.summary });
|
||||
await db
|
||||
.update(toolActionRequests)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
mergePaperclipConfig,
|
||||
|
|
@ -530,6 +531,15 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): {
|
|||
}
|
||||
}
|
||||
|
||||
const existingContents = fs.existsSync(context.envPath)
|
||||
? fs.readFileSync(context.envPath, "utf8")
|
||||
: null;
|
||||
const existingEnvEntries = parseEnvFile(existingContents ?? "");
|
||||
const toolActionSigningSecret =
|
||||
nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
|
||||
nonEmpty(existingEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
|
||||
randomBytes(32).toString("hex");
|
||||
|
||||
const managedEnvEntries: Record<string, string> = {
|
||||
PAPERCLIP_HOME: context.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: context.instanceId,
|
||||
|
|
@ -538,13 +548,11 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): {
|
|||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_DB_BACKUP_ENABLED: "false",
|
||||
PAPERCLIP_WORKTREE_NAME: context.worktreeName,
|
||||
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: toolActionSigningSecret,
|
||||
};
|
||||
|
||||
process.env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
|
||||
|
||||
const existingContents = fs.existsSync(context.envPath)
|
||||
? fs.readFileSync(context.envPath, "utf8")
|
||||
: null;
|
||||
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = toolActionSigningSecret;
|
||||
const repairedContents = updateEnvFileContents(
|
||||
existingContents ?? emptyWorktreeEnvFileContents(),
|
||||
managedEnvEntries,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ test.describe.serial("not-connected app page", () => {
|
|||
applicationId = body.application.id as string;
|
||||
|
||||
// Archive the connection (Remove app), then resurrect the application so
|
||||
// it shows on /apps/connections as "Not connected" — the state in Dotta's screenshot.
|
||||
// its connector card offers a fresh Connect action.
|
||||
const archive = await request.delete(`/api/tool-connections/${connectionId}`);
|
||||
expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
|
||||
const revive = await request.patch(`/api/tool-applications/${applicationId}`, {
|
||||
|
|
@ -101,12 +101,16 @@ test.describe.serial("not-connected app page", () => {
|
|||
|
||||
test("not-connected row opens the app page, not the generic wizard", async ({ page }) => {
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
const row = page.locator("tbody tr", { hasText: "Bla" });
|
||||
const row = page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: "Bla", exact: true }) });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText("Not connected")).toBeVisible();
|
||||
await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await expect(row).toHaveAttribute("data-connected", "false");
|
||||
const connectButton = row.getByRole("button", { name: "Connect Bla" });
|
||||
await expect(connectButton).toBeVisible();
|
||||
|
||||
await row.click();
|
||||
await connectButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`), { timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Bla" })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Previous setup" })).toBeVisible();
|
||||
|
|
@ -160,9 +164,12 @@ test.describe.serial("not-connected app page", () => {
|
|||
await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible();
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
const row = page.locator("tbody tr", { hasText: "Bla" });
|
||||
const row = page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: "Bla", exact: true }) });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await expect(row.getByRole("button", { name: "Connect Bla" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
|
||||
});
|
||||
|
||||
|
|
@ -186,9 +193,14 @@ test.describe.serial("not-connected app page", () => {
|
|||
await page.getByRole("button", { name: "Remove app", exact: true }).click();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-04-app-page-danger.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Yes, remove it" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
|
||||
await expect(page.locator("tbody tr", { hasText: "Doomed app" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: "Doomed app", exact: true }) }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ async function createConnection(
|
|||
|
||||
async function gotoApps(page: Page, prefix: string) {
|
||||
await page.goto(`/${prefix}/apps/connections`);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe.serial("applications lifecycle", () => {
|
||||
|
|
@ -84,36 +84,42 @@ test.describe.serial("applications lifecycle", () => {
|
|||
|
||||
await gotoApps(page, seed.prefix);
|
||||
|
||||
// The connected app starts with a "Healthy" pill and an "Edit" action. A
|
||||
// The connected app starts with a "Connected" status. A
|
||||
// background health sweep then probes the connection endpoint. The test
|
||||
// endpoint is an unreachable loopback URL, so the probe fails and the pill
|
||||
// becomes "Needs attention" and the action becomes "Reconnect". Both are
|
||||
// becomes "Needs attention" and adds a "Reconnect" action. Both are
|
||||
// connected states that navigate to the same provider setup page. This test
|
||||
// proves the connected-vs-not-connected split, not the transient health
|
||||
// label, so accept either connected state instead of the racy exact label.
|
||||
// The pill is derived from two react-query fetches (applications +
|
||||
// connections), so keep the same generous window the rest of this spec uses.
|
||||
const connectedRow = page.locator("tbody tr", { hasText: connectedName });
|
||||
const connectorList = page.getByRole("list", { name: "Connector list" });
|
||||
const connectedRow = connectorList
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: connectedName, exact: true }) });
|
||||
await expect(connectedRow).toBeVisible();
|
||||
await expect(connectedRow.getByText(/^(Healthy|Needs attention)$/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(connectedRow.getByRole("button", { name: /^(Edit|Reconnect)$/ })).toBeVisible();
|
||||
await expect(connectedRow.getByText(/^(Connected|Needs attention)$/)).toBeVisible({ timeout: 30_000 });
|
||||
const openConnection = connectedRow.getByRole("button", { name: /^Open .* connection settings$/ });
|
||||
await expect(openConnection).toBeVisible();
|
||||
|
||||
// The not-connected app has no connection, so the health sweep never touches
|
||||
// it and its "Not connected" pill and "Connect" action stay deterministic.
|
||||
const notConnectedRow = page.locator("tbody tr", { hasText: notConnectedName });
|
||||
// it and its Connect action stay deterministic.
|
||||
const notConnectedRow = connectorList
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: notConnectedName, exact: true }) });
|
||||
await expect(notConnectedRow).toBeVisible();
|
||||
await expect(notConnectedRow.getByText("Not connected")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(notConnectedRow.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await expect(notConnectedRow).toHaveAttribute("data-connected", "false");
|
||||
await expect(notConnectedRow.getByRole("button", { name: `Connect ${notConnectedName}` })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-list.png`, fullPage: true });
|
||||
|
||||
await connectedRow.getByRole("button", { name: /^(Edit|Reconnect)$/ }).click();
|
||||
await openConnection.click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/${seed.prefix}/apps/${connected.id}/setup$`),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
await gotoApps(page, seed.prefix);
|
||||
await notConnectedRow.getByRole("button", { name: "Connect" }).click();
|
||||
await notConnectedRow.getByRole("button", { name: `Connect ${notConnectedName}` }).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/${seed.prefix}/apps/app/${notConnected.id}/setup$`),
|
||||
{ timeout: 20_000 },
|
||||
|
|
@ -152,10 +158,15 @@ test.describe.serial("applications lifecycle", () => {
|
|||
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-connected.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Yes, remove it" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
|
||||
await expect(page.locator("tbody tr", { hasText: renamed })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: renamed, exact: true }) }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("not-connected app advanced page removes the application", async ({ page, request }) => {
|
||||
|
|
@ -168,9 +179,14 @@ test.describe.serial("applications lifecycle", () => {
|
|||
await page.getByRole("button", { name: "Remove app", exact: true }).click();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-not-connected.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Yes, remove it" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
|
||||
await expect(page.locator("tbody tr", { hasText: cleanAppName })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ has: page.getByRole("heading", { name: cleanAppName, exact: true }) }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,26 +128,25 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
test("apps list dark mode with attention banner", async ({ page }) => {
|
||||
await forceDark(page);
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/needs attention/i).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-01-apps-dark.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test("attention banner dark mode", async ({ page }) => {
|
||||
test("attention details dark mode", async ({ page }) => {
|
||||
await forceDark(page);
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/connection needs attention/i).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/connect ECONNREFUSED/i).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-02-attention-dark.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test("advanced door defaults to Run your own with the merged Apps sidebar", async ({ page }) => {
|
||||
test("advanced door shows paste config with the merged Apps sidebar", async ({ page }) => {
|
||||
await forceDark(page);
|
||||
await page.goto(`/${seed.prefix}/apps/advanced`);
|
||||
await expect(page.getByRole("heading", { name: "Advanced setup" })).toBeVisible({ timeout: 30_000 });
|
||||
// Run your own is now the default tab (Apps navigation); merged sidebar shows Apps items too.
|
||||
await expect(page.getByText(/isolated workspace/i).first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("link", { name: "Connections" })).toBeVisible();
|
||||
await expect(page.getByText(/Paste the MCP config snippet/i).first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("link", { name: "Connectors" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-03-advanced-run-dark.png`, fullPage: true });
|
||||
|
||||
// Sidebar and tab switcher both link Paste a config — either lands on /paste-config.
|
||||
|
|
@ -163,10 +162,11 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
await expect(page.getByRole("heading", { name: "Access profiles" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0);
|
||||
await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0);
|
||||
await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible();
|
||||
await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toHaveCount(0);
|
||||
await expect(page.locator('a[href$="/activity"]', { hasText: "Activity" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0);
|
||||
// Apps section lives in the same sidebar now.
|
||||
await expect(page.locator('a[href$="/apps/connections"]', { hasText: "Connections" })).toBeVisible();
|
||||
await expect(page.locator('a[href$="/apps"]', { hasText: "Connectors" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-05-developer-overview-dark.png`, fullPage: true });
|
||||
});
|
||||
|
||||
|
|
@ -184,9 +184,9 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
await page.getByRole("button", { name: "Remove app", exact: true }).click();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-06-danger-zone-dark.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Yes, remove it" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-07-after-remove-dark.png`, fullPage: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,8 +117,13 @@ async function gotoApps(page: Page, prefix: string) {
|
|||
|
||||
async function gotoConnect(page: Page, prefix: string) {
|
||||
await page.goto(`/${prefix}/apps`);
|
||||
await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Connect your own tool/i }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
||||
const customConnector = page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "Connect your own tool" });
|
||||
await customConnector.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await customConnector.getByRole("button", { name: "Connect your own MCP server" }).click();
|
||||
}
|
||||
|
||||
async function gotoAdvanced(page: Page, prefix: string) {
|
||||
|
|
@ -177,7 +182,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
|
||||
// The new connection should show up on /apps/connections.
|
||||
await gotoApps(page, seed.prefix);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true });
|
||||
});
|
||||
|
||||
|
|
@ -215,8 +220,8 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
|
||||
// Needs-attention page should surface this connection.
|
||||
await gotoNeedsAttention(page, seed.prefix);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/connection needs attention/i).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("Needs attention", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-07-needs-attention.png`, fullPage: true });
|
||||
|
||||
// App detail should expose the reconnect call-to-action.
|
||||
|
|
@ -251,27 +256,15 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("/apps/advanced still mounts both tabs", async ({ page, request }) => {
|
||||
test("/apps/advanced mounts the paste-config path", async ({ page, request }) => {
|
||||
const seed = await newCompany(request, "advanced");
|
||||
|
||||
await gotoAdvanced(page, seed.prefix);
|
||||
await expect(page.getByRole("heading", { name: "Advanced setup" })).toBeVisible({ timeout: 20_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-09-advanced-default.png`, fullPage: true });
|
||||
|
||||
// M8a paste tab.
|
||||
const pasteTab = page.getByRole("tab", { name: /Paste/i }).first();
|
||||
if (await pasteTab.isVisible().catch(() => false)) {
|
||||
await pasteTab.click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-10-advanced-paste-tab.png`, fullPage: true });
|
||||
}
|
||||
|
||||
// M8b run-your-own tab.
|
||||
const ownTab = page.getByRole("tab", { name: /Run your own|Self host|Stdio|Local/i }).first();
|
||||
if (await ownTab.isVisible().catch(() => false)) {
|
||||
await ownTab.click();
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-11-advanced-own-tab.png`, fullPage: true });
|
||||
}
|
||||
await expect(page.getByRole("link", { name: "Paste a config" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Run your own|Self host|Stdio|Local/i })).toHaveCount(0);
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-10-advanced-paste-tab.png`, fullPage: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -208,10 +208,15 @@ test("store setup and task connection intent share one fake provider through con
|
|||
|
||||
// Entry point one: connect and test the provider through the Connections store.
|
||||
await page.goto(`/${seed.prefix}/apps`);
|
||||
await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /Connect your own tool/i }).click();
|
||||
const customConnector = page
|
||||
.getByRole("list", { name: "Connector list" })
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "Connect your own tool" });
|
||||
await customConnector.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await customConnector.getByRole("button", { name: "Connect your own MCP server" }).click();
|
||||
await page
|
||||
.getByPlaceholder("https://example.com/actions")
|
||||
.fill(provider.url);
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ test.describe.serial("MCP prod Phase 5a user-story harness", () => {
|
|||
const health = await request.post(`/api/tool-connections/${connectionId}/health-check`);
|
||||
expect(health.status()).toBe(502);
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
||||
await screenshot(page, "US-8", "01-needs-attention");
|
||||
|
||||
const recovered = await startMockMcp();
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ async function navigateForEvidence(page: Page, seed: Seed, connectionId: string,
|
|||
}
|
||||
if (scenario.uiEntryPath === "attention") {
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
await page.goto(`/${seed.prefix}/apps/${connectionId}`);
|
||||
|
|
|
|||
|
|
@ -245,10 +245,13 @@ describe("Skill Studio routes", () => {
|
|||
});
|
||||
|
||||
describe("Apps routes", () => {
|
||||
it("uses browse as the Apps landing page and gives connections a canonical URL", () => {
|
||||
it("uses one connector landing page and redirects retired browse, connections, and audit URLs", () => {
|
||||
expect(appSource).toContain('<Route path="apps" element={<Browse />} />');
|
||||
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connections" element={<Connections />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connections" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).not.toContain('import { Connections }');
|
||||
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
|
||||
expect(appSource).toContain('path="apps/vercel-connect"');
|
||||
expect(appSource).toContain('<AppsConnectEntryRoute credentialSource="vercel_connect" />');
|
||||
|
|
@ -259,7 +262,7 @@ describe("Apps routes", () => {
|
|||
});
|
||||
|
||||
it("redirects legacy Rules and Health links to the remaining developer surfaces", () => {
|
||||
expect(appSource).toContain('if (tab === "runtime") return "/apps/connections";');
|
||||
expect(appSource).toContain('if (tab === "runtime" || tab === "audit") return "/apps";');
|
||||
expect(appSource).toContain('if (tab === "policies") return "/apps/advanced/profiles";');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
|||
import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute";
|
||||
import { ProfileWizardRoute } from "./pages/tools/profiles/ProfileWizardRoute";
|
||||
import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute";
|
||||
import { Connections } from "./pages/apps/Connections";
|
||||
import { Browse } from "./pages/apps/Browse";
|
||||
import { AppsConnect } from "./pages/apps/AppsConnect";
|
||||
import { canEnterAppsConnect } from "./pages/apps/app-connect-policy";
|
||||
|
|
@ -157,10 +156,10 @@ function boardRoutes() {
|
|||
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
|
||||
<Route path="tools" element={<LegacyToolsRedirect />} />
|
||||
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
|
||||
<Route element={<AppsExperimentalGate />}>
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Connections />} />
|
||||
<Route element={<AppsExperimentalGate />}>
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
|
||||
<Route
|
||||
path="apps/vercel-connect"
|
||||
|
|
@ -170,8 +169,8 @@ function boardRoutes() {
|
|||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/review" element={<AppsReview />} />
|
||||
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
|
||||
<Route path="apps/attention" element={<Navigate to="/apps/connections" replace />} />
|
||||
{/* Connector health is inline on the Apps landing page; keep legacy links working. */}
|
||||
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
|
||||
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
|
||||
|
|
@ -180,6 +179,8 @@ function boardRoutes() {
|
|||
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
|
||||
<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
|
||||
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
|
||||
|
|
@ -464,8 +465,8 @@ function LegacyToolsRedirect() {
|
|||
|
||||
function legacyToolsRedirectTarget(tab?: string) {
|
||||
if (!tab) return "/apps/advanced/profiles";
|
||||
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections";
|
||||
if (tab === "runtime") return "/apps/connections";
|
||||
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps";
|
||||
if (tab === "runtime" || tab === "audit") return "/apps";
|
||||
if (tab === "policies") return "/apps/advanced/profiles";
|
||||
return `/apps/advanced/${tab}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import type {
|
|||
ToolConnectionActivityResponse,
|
||||
ToolConnectionLifecycleEventType,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestAgentAccessResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
ToolActionRequest,
|
||||
|
|
@ -427,6 +428,10 @@ export const toolsApi = {
|
|||
api.get<ToolConnectionTestAgentsResponse>(
|
||||
`/tool-connections/${connectionId}/test-agents`,
|
||||
),
|
||||
getTestAgentAccess: (connectionId: string, agentId: string) =>
|
||||
api.get<ToolConnectionTestAgentAccessResponse>(
|
||||
`/tool-connections/${connectionId}/test-agents/${agentId}/access`,
|
||||
),
|
||||
runTestCall: (
|
||||
connectionId: string,
|
||||
input: { agentId: string; toolName: string; parameters?: Record<string, unknown> },
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ describe("AppConnectionSidebar", () => {
|
|||
it("renders a back link and the connected app tabs with Test after Setup", async () => {
|
||||
await renderSidebar();
|
||||
|
||||
expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(5);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true }));
|
||||
|
|
@ -269,7 +269,7 @@ describe("AppConnectionSidebar", () => {
|
|||
|
||||
await renderSidebar(<AppDetailSidebar kind="application" applicationId="app-1" />);
|
||||
|
||||
expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(mockToolsApi.getConnection).not.toHaveBeenCalled();
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true }));
|
||||
|
|
@ -291,7 +291,7 @@ describe("AppConnectionSidebar", () => {
|
|||
await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("App");
|
||||
expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(5);
|
||||
});
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ describe("AppConnectionSidebar", () => {
|
|||
await renderSidebar(<AppDetailSidebar kind="application" applicationId="missing-app" />);
|
||||
|
||||
expect(container.textContent).toContain("App");
|
||||
expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -92,14 +92,14 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
|
|||
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
|
||||
<div className="flex shrink-0 flex-col gap-3 px-3 py-3">
|
||||
<Link
|
||||
to="/apps/connections"
|
||||
to="/apps"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">All apps</span>
|
||||
<span className="truncate">All connectors</span>
|
||||
</Link>
|
||||
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
|
||||
<AppLogo
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ describe("AppsSidebar", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders Apps and Developer sections in one sidebar", async () => {
|
||||
it("renders the consolidated connector and review doors without retired developer links", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -113,27 +113,22 @@ describe("AppsSidebar", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps");
|
||||
expect(container.textContent).toContain("Developer");
|
||||
// The Developer boundary caption frames who the door is for (PAP-13241 §5).
|
||||
expect(container.textContent).toContain("Advanced setup for developers");
|
||||
expect(container.textContent).not.toContain("Developer");
|
||||
expect(container.textContent).not.toContain("Advanced setup for developers");
|
||||
expect(container.textContent).not.toContain("Most teams");
|
||||
expect(container.textContent).not.toMatch(/you (?:won'?t|will not) need this/i);
|
||||
// "Run your own" / "Paste a config" moved to the Connect-an-app page (PAP-10922);
|
||||
// assert their absence at the item level below.
|
||||
// Paste-config discovery now lives inside the custom connector row;
|
||||
// assert both advanced setup items remain absent at the item level below.
|
||||
|
||||
// Consumer doors stay above the Developer boundary.
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/connections", label: "Connections", end: true }),
|
||||
expect.objectContaining({ to: "/apps", label: "Connectors", end: true }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/review", label: "Review" }),
|
||||
);
|
||||
const sidebarText = container.textContent ?? "";
|
||||
expect(sidebarText.indexOf("Connections")).toBeGreaterThan(sidebarText.indexOf("Developer"));
|
||||
// "Needs attention" is no longer a top-level door — it folds into Connections.
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Connections" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Needs attention" }),
|
||||
);
|
||||
|
|
@ -154,8 +149,8 @@ describe("AppsSidebar", () => {
|
|||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" }));
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Health" }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/audit", label: "Activity", end: true }),
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/audit" }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -8,21 +8,13 @@ import { useReviewCount } from "@/pages/apps/useReviewCount";
|
|||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
/**
|
||||
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
|
||||
* PAP-13254 / U3).
|
||||
* Secondary sidebar for the Apps area.
|
||||
*
|
||||
* ← Back · APPS: Browse / Review (n)
|
||||
* DEVELOPER: Connections / Activity
|
||||
* ← Back · APPS: Connectors / Review (n)
|
||||
*
|
||||
* "Browse" is the store and "Review" holds decisions waiting on the user's
|
||||
* OK. Connection management lives with the Developer tools.
|
||||
* "Needs attention" is no longer a door: health/error triage folds into
|
||||
* Connections as a status filter + banner, so approvals are never buried
|
||||
* behind an error label. The Developer section was folded in from the retired
|
||||
* ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a
|
||||
* one-line caption frames who it's for (Finding A). "Run your own" and "Paste a
|
||||
* config" moved out of the sidebar into rows on the Connect-an-app page
|
||||
* (PAP-10922).
|
||||
* The landing page combines connector discovery, account management, and
|
||||
* connection health. Review keeps governed actions waiting on the user's OK.
|
||||
* Advanced developer surfaces remain hidden unless one is explicitly enabled.
|
||||
*/
|
||||
export function AppsSidebar() {
|
||||
const { selectedCompany } = useCompany();
|
||||
|
|
@ -61,7 +53,7 @@ export function AppsSidebar() {
|
|||
Apps
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />
|
||||
<SidebarNavItem to="/apps" label="Connectors" icon={Store} end />
|
||||
<SidebarNavItem
|
||||
to="/apps/review"
|
||||
label="Review"
|
||||
|
|
@ -71,24 +63,27 @@ export function AppsSidebar() {
|
|||
badgeLabel="waiting for your OK"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Developer
|
||||
</div>
|
||||
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
|
||||
Advanced setup for developers.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/apps/connections" label="Connections" icon={AppWindow} end />
|
||||
{developerTabs.map((tab) => (
|
||||
<SidebarNavItem
|
||||
key={tab.key}
|
||||
to={advancedTabHref(tab.key)}
|
||||
label={tab.label}
|
||||
icon={tab.icon}
|
||||
end
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{developerTabs.length > 0 ? (
|
||||
<>
|
||||
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Developer
|
||||
</div>
|
||||
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
|
||||
Advanced setup for developers.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{developerTabs.map((tab) => (
|
||||
<SidebarNavItem
|
||||
key={tab.key}
|
||||
to={advancedTabHref(tab.key)}
|
||||
label={tab.label}
|
||||
icon={tab.icon}
|
||||
end
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { ReactNode } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ShieldAlert, ShieldCheck, type LucideIcon } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
|
|
@ -154,7 +153,7 @@ export function EnforcementBanner(props: EnforcementBannerProps) {
|
|||
<p>
|
||||
<span className="font-medium">{computedCount}</span> governed tool call
|
||||
{computedCount === 1 ? " was" : "s were"} denied or failed in the last hour. Access is enforced
|
||||
server-side by the tool gateway — review what was blocked and why in the audit log.
|
||||
server-side by the tool gateway — open the affected connector to review what was blocked and why.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
|
|
@ -164,12 +163,6 @@ export function EnforcementBanner(props: EnforcementBannerProps) {
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/apps/advanced/audit"
|
||||
className="shrink-0 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View audit →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ describe("Layout", () => {
|
|||
});
|
||||
|
||||
it("keeps the Apps sidebar on the M8 advanced-setup tabs", async () => {
|
||||
currentPathname = "/PAP/apps/advanced/run-your-own";
|
||||
currentPathname = "/PAP/apps/advanced/paste-config";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ export type RadioCardOption = {
|
|||
value: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
accessibleLabel?: string;
|
||||
tooltip?: string;
|
||||
/**
|
||||
* Disable this one option while its siblings stay live. For a choice the
|
||||
* viewer's capabilities forbid: the option stays legible, with its reason in
|
||||
* `description`, instead of vanishing and making the scope unexplained.
|
||||
* `description` or `tooltip`, instead of vanishing and making the scope
|
||||
* unexplained.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
|
@ -24,12 +28,16 @@ export function RadioCard({
|
|||
selected,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
selected: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
} & Omit<React.ComponentProps<"button">, "title">) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -46,10 +54,22 @@ export function RadioCard({
|
|||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
title={tooltip}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="radio-card-icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
{selected ? <Check className="h-4 w-4 shrink-0 text-primary" /> : null}
|
||||
</div>
|
||||
{description ? (
|
||||
|
|
@ -111,6 +131,9 @@ export function RadioCardGroup({
|
|||
selected={option.value === value}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
icon={option.icon}
|
||||
tooltip={option.tooltip}
|
||||
aria-label={option.accessibleLabel}
|
||||
disabled={disabled || option.disabled}
|
||||
tabIndex={option.value === value ? 0 : -1}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,13 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ClipboardPaste,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
UserRound,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
AppDefinition,
|
||||
|
|
@ -53,7 +49,6 @@ import { ApiError } from "@/api/client";
|
|||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
|
||||
import { advancedTabHref } from "@/pages/tools/tool-tabs";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
|
|
@ -583,7 +578,7 @@ export function ConnectionSetupFlow({
|
|||
setCredentials({});
|
||||
setConnectResult(null);
|
||||
setStep("key");
|
||||
navigate(withConnectionIntent("/apps/connect?byo=1&source=zapier", connectionIntentId));
|
||||
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
|
||||
return;
|
||||
}
|
||||
if (credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(picked)) {
|
||||
|
|
@ -619,31 +614,14 @@ export function ConnectionSetupFlow({
|
|||
);
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
setEntry(null);
|
||||
setGalleryName("");
|
||||
setLinkUrl("");
|
||||
setLinkName("");
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
resetGenericAuthState();
|
||||
setCredentials({});
|
||||
setCuratedOAuthClientId("");
|
||||
setCuratedOAuthClientSecret("");
|
||||
setVercelConnector("");
|
||||
setConnectionMethodKey("");
|
||||
setConfigValues({});
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
|
||||
setInstallChoice(requestedAgentId ? "specific" : "all");
|
||||
setGrantKind("organization");
|
||||
const backToGallery = () => {
|
||||
// Back is a wizard transition, so keep the selected app and entered draft
|
||||
// intact. Picking another connector will replace that state explicitly.
|
||||
setStep("gallery");
|
||||
navigate(withConnectionIntent(
|
||||
credentialSource === "vercel_connect"
|
||||
? vercelConnectSourceHref()
|
||||
: byoOnly ? "/apps/byo" : "/apps/connect?byo=1",
|
||||
: byoOnly ? "/apps/byo" : "/apps",
|
||||
connectionIntentId,
|
||||
));
|
||||
};
|
||||
|
|
@ -1499,7 +1477,12 @@ export function ConnectionSetupFlow({
|
|||
directOAuthRetryingRef.current = false;
|
||||
}
|
||||
}}
|
||||
onCancel={onCancel ?? (() => navigate("/apps/browse"))}
|
||||
onBack={() => {
|
||||
setOAuthPhase("entry");
|
||||
setOAuthError(null);
|
||||
setAppStep("access");
|
||||
}}
|
||||
onCancel={onCancel ?? (() => navigate("/apps"))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1529,11 +1512,12 @@ export function ConnectionSetupFlow({
|
|||
setGenericOAuthPending(false);
|
||||
setOAuthPhase("entry");
|
||||
}}
|
||||
onCancel={() => {
|
||||
onBack={() => {
|
||||
setGenericOAuthPending(false);
|
||||
setOAuthPhase("entry");
|
||||
setOAuthError(null);
|
||||
}}
|
||||
onCancel={onCancel ?? (() => navigate("/apps"))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1643,8 +1627,6 @@ export function ConnectionSetupFlow({
|
|||
setGrantKind(reconnectGrantKind ?? "organization");
|
||||
setStep("key");
|
||||
}}
|
||||
onRunYourOwn={() => navigate(advancedTabHref("run-your-own"))}
|
||||
onPasteConfig={() => navigate(advancedTabHref("paste-config"))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -1684,16 +1666,9 @@ export function ConnectionSetupFlow({
|
|||
setGoogleSheetsError(null);
|
||||
}}
|
||||
submitting={connectMutation.isPending}
|
||||
// Back returns to Access, not to the gallery: the design requires the
|
||||
// identity and agent selections to survive moving backward, and
|
||||
// `openGallery` resets them.
|
||||
onBack={() => {
|
||||
if (resumeConnectionId || reconnectConnectionId) {
|
||||
navigate("/apps");
|
||||
return;
|
||||
}
|
||||
setAppStep("access");
|
||||
}}
|
||||
// Back returns to Access for new, resumed, and reconnected accounts.
|
||||
// Cancel is the separate exit to the connector list.
|
||||
onBack={() => setAppStep("access")}
|
||||
onConnect={() => {
|
||||
if (isGoogleSheetsRobotMethod(entry, connectionMethodKey)) {
|
||||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||||
|
|
@ -1771,7 +1746,7 @@ export function ConnectionSetupFlow({
|
|||
matchedEntry={linkMatchedEntry}
|
||||
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
|
||||
submitting={connectMutation.isPending || genericOAuthPending}
|
||||
onBack={() => setStep("gallery")}
|
||||
onBack={backToGallery}
|
||||
onConnect={() => {
|
||||
setLinkGuidance(null);
|
||||
connectMutation.mutate(undefined);
|
||||
|
|
@ -1784,15 +1759,13 @@ export function ConnectionSetupFlow({
|
|||
link={linkUrl}
|
||||
onLinkChange={setLinkUrl}
|
||||
submitting={connectMutation.isPending}
|
||||
onBack={() => navigate("/apps")}
|
||||
onBack={backToGallery}
|
||||
onConnect={() => connectMutation.mutate(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "access" && (
|
||||
<AccessStep
|
||||
appName={appName}
|
||||
providerName={entry?.name ?? appName}
|
||||
companyId={selectedCompanyId}
|
||||
authKind={accessStepAuthKind}
|
||||
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
|
||||
|
|
@ -1804,17 +1777,11 @@ export function ConnectionSetupFlow({
|
|||
setInstallAgentIds={setInstallAgentIds}
|
||||
lockedAgentId={requestedAgentId}
|
||||
capabilities={galleryQuery.data?.capabilities}
|
||||
guidance={accessStepMethod?.guidanceMd}
|
||||
warnings={accessStepMethod?.warnings}
|
||||
setupPrerequisite={entry?.setupPrerequisite}
|
||||
docsUrl={accessStepMethod?.consoleLinks?.docs ?? entry?.docsUrl}
|
||||
submitLabel={accessSubmitLabel}
|
||||
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
|
||||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||||
pending={connectMutation.isPending || oauthStartMutation.isPending}
|
||||
// Leaving Access abandons the app choice entirely, so this resets the
|
||||
// draft and returns to the gallery the operator came from.
|
||||
onBack={() => (entry || linkUrl ? openGallery() : navigate("/apps"))}
|
||||
onBack={backToGallery}
|
||||
onContinue={() => {
|
||||
if (directOAuthEntry) {
|
||||
directOAuthAccessConfirmedRef.current = true;
|
||||
|
|
@ -1843,7 +1810,7 @@ export function ConnectionSetupFlow({
|
|||
installCount: installAgentIds.size,
|
||||
enabledCount: Object.values(enabled).filter(Boolean).length,
|
||||
})}
|
||||
onDone={onCancel ?? (() => navigate("/apps/connections"))}
|
||||
onDone={onCancel ?? (() => navigate("/apps"))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1916,6 +1883,7 @@ export function OAuthConnectStateScreen({
|
|||
error,
|
||||
authorizationHost,
|
||||
onRetry,
|
||||
onBack,
|
||||
onCancel,
|
||||
}: {
|
||||
/** A curated app. Omit for a generic endpoint and pass `identity` instead. */
|
||||
|
|
@ -1933,6 +1901,7 @@ export function OAuthConnectStateScreen({
|
|||
*/
|
||||
authorizationHost?: string | null;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const serverName = entry?.name ?? identity?.name ?? "this server";
|
||||
|
|
@ -2004,7 +1973,7 @@ export function OAuthConnectStateScreen({
|
|||
{phase === "redirecting" ? `Opening ${serverName}…` : "Preparing…"}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>Back to apps</Button>
|
||||
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
|
||||
</div>
|
||||
<p className="mt-5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
|
|
@ -2092,14 +2061,12 @@ function GalleryStep({
|
|||
source = null,
|
||||
onPick,
|
||||
onUseLink,
|
||||
onRunYourOwn,
|
||||
onPasteConfig,
|
||||
}: {
|
||||
loading: boolean;
|
||||
apps: AppDefinition[];
|
||||
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
|
||||
byo?: boolean;
|
||||
/** Canonical BYO page: keep the URL path and alternate methods, without the app gallery. */
|
||||
/** Canonical BYO page: keep the focused URL setup without the app gallery. */
|
||||
byoOnly?: boolean;
|
||||
/** Isolated Vercel catalog: no native-provider or bring-your-own setup paths. */
|
||||
vercelConnect?: boolean;
|
||||
|
|
@ -2111,8 +2078,6 @@ function GalleryStep({
|
|||
source?: string | null;
|
||||
onPick: (entry: AppDefinition) => void;
|
||||
onUseLink: (link: string) => void;
|
||||
onRunYourOwn: () => void;
|
||||
onPasteConfig: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [linkInput, setLinkInput] = useState("");
|
||||
|
|
@ -2332,59 +2297,10 @@ function GalleryStep({
|
|||
</div>
|
||||
</div> : null}
|
||||
|
||||
{!vercelConnect ? <div className="border-t border-border pt-5">
|
||||
<div className="text-sm font-semibold text-foreground">More ways to connect</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
For tools that aren’t in the gallery. You’ll need details from the tool’s docs.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ConnectMethodRow
|
||||
icon={TerminalSquare}
|
||||
title="Run your own"
|
||||
description="Register a command Paperclip runs in your workspace for a tool that isn’t listed."
|
||||
onClick={onRunYourOwn}
|
||||
/>
|
||||
<ConnectMethodRow
|
||||
icon={ClipboardPaste}
|
||||
title="Paste a config"
|
||||
description="Already have a setup snippet from a README? Paste it and we’ll connect it."
|
||||
onClick={onPasteConfig}
|
||||
/>
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectMethodRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"
|
||||
>
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">{title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAppLink(value: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
|
|
@ -3435,8 +3351,6 @@ function MethodConfigField({
|
|||
* get. Hick's Law: two choices, not a matrix. Both use full-row radio targets.
|
||||
*/
|
||||
export function AccessStep({
|
||||
appName,
|
||||
providerName,
|
||||
companyId,
|
||||
authKind,
|
||||
grantKinds,
|
||||
|
|
@ -3448,10 +3362,6 @@ export function AccessStep({
|
|||
setInstallAgentIds,
|
||||
lockedAgentId,
|
||||
capabilities,
|
||||
guidance,
|
||||
warnings,
|
||||
setupPrerequisite,
|
||||
docsUrl,
|
||||
submitLabel,
|
||||
identityLoading = false,
|
||||
preserveAgentAccess = false,
|
||||
|
|
@ -3459,8 +3369,6 @@ export function AccessStep({
|
|||
onBack,
|
||||
onContinue,
|
||||
}: {
|
||||
appName: string;
|
||||
providerName: string;
|
||||
companyId: string;
|
||||
authKind: ToolConnectionAuthKind;
|
||||
grantKinds?: ConnectionGrantKind[];
|
||||
|
|
@ -3476,10 +3384,6 @@ export function AccessStep({
|
|||
companyInstallReason?: string | null;
|
||||
editableAgentIds?: string[];
|
||||
} | null;
|
||||
guidance?: string;
|
||||
warnings?: string[];
|
||||
setupPrerequisite?: AppDefinition["setupPrerequisite"];
|
||||
docsUrl?: string;
|
||||
submitLabel: string;
|
||||
/** Wait for a durable OAuth connection before showing a reconnect identity. */
|
||||
identityLoading?: boolean;
|
||||
|
|
@ -3494,7 +3398,7 @@ export function AccessStep({
|
|||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||||
// "Agents I pick" means agents this person may actually edit. When the server
|
||||
// "Just agents I pick" means agents this person may actually edit. When the server
|
||||
// has not told us, fall back to every live agent rather than an empty list —
|
||||
// an empty picker would read as "you have no agents".
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
|
|
@ -3520,106 +3424,29 @@ export function AccessStep({
|
|||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-start gap-3 border-b border-border p-6">
|
||||
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">
|
||||
{authKind === "oauth" ? "Choose access before sign-in" : "Choose access before adding credentials"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{authKind === "oauth"
|
||||
? `Set the identity and agent reach first. Then ${providerName} will ask you to authorize that exact connection.`
|
||||
: "Set the identity and agent reach first. Then add the credential for that exact connection."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guidance || warnings?.length || setupPrerequisite || docsUrl ? (
|
||||
<div className="space-y-3 border-b border-border p-6">
|
||||
{warnings?.map((warning) => (
|
||||
<InlineBanner key={warning} tone="warning" compact>{warning}</InlineBanner>
|
||||
))}
|
||||
{guidance ? <p className="text-sm text-foreground">{guidance}</p> : null}
|
||||
{setupPrerequisite ? (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">{setupPrerequisite.title}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{setupPrerequisite.description}</p>
|
||||
{setupPrerequisite.steps?.length ? (
|
||||
<ol
|
||||
aria-label={`${setupPrerequisite.title} steps`}
|
||||
className="mt-3 list-decimal space-y-1 pl-4 text-xs text-muted-foreground"
|
||||
>
|
||||
{setupPrerequisite.steps.map((step) => <li key={step}>{step}</li>)}
|
||||
</ol>
|
||||
) : null}
|
||||
<a
|
||||
href={setupPrerequisite.actionUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||||
>
|
||||
{setupPrerequisite.actionLabel}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{docsUrl ? (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||||
>
|
||||
Review {providerName} setup requirements
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="divide-y divide-border">
|
||||
<section className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Who is this credential for?</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Whose {appName} account should agents act as?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-foreground">Which humans can use this credential?</h2>
|
||||
{identityLoading ? (
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
</div>
|
||||
) : needsIdentityChoice && allowedGrantKinds.length === 1 ? (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
|
||||
<div className="mt-4 flex items-center gap-3 rounded-md border border-border p-4">
|
||||
{allowedGrantKinds[0] === "user" ? (
|
||||
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
<UserRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
) : (
|
||||
<Building2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{allowedGrantKinds[0] === "user" ? "Just me." : "Everyone in the company"}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{allowedGrantKinds[0] === "user"
|
||||
? `Agents use this ${appName} identity only when work runs for you.`
|
||||
: `Eligible company members share this ${appName} identity.`}
|
||||
{preserveAgentAccess ? " Reconnect keeps this identity type." : null}
|
||||
</p>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{allowedGrantKinds[0] === "user" ? "Just me" : "Any human in the company"}
|
||||
</div>
|
||||
</div>
|
||||
) : needsIdentityChoice ? (
|
||||
<RadioCardGroup
|
||||
ariaLabel="Who is this credential for?"
|
||||
ariaLabel="Which humans can use this credential?"
|
||||
className="mt-4 sm:grid-cols-2"
|
||||
value={grantKind}
|
||||
onValueChange={(next) => setGrantKind(next as ConnectionGrantKind)}
|
||||
|
|
@ -3627,12 +3454,12 @@ export function AccessStep({
|
|||
{
|
||||
value: "user",
|
||||
title: "Just me",
|
||||
description: "Agents use this identity only when work runs for you.",
|
||||
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: "organization",
|
||||
title: "Everyone in the company",
|
||||
description: "Eligible company members use one shared identity.",
|
||||
title: "Any human in the company",
|
||||
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
|
||||
/>
|
||||
|
|
@ -3644,15 +3471,7 @@ export function AccessStep({
|
|||
</section>
|
||||
|
||||
<section className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Which agents can use this connection?</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Choose where this connection will be available.</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-foreground">Which agents can use this connection?</h2>
|
||||
{preserveAgentAccess ? (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
|
||||
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
|
|
@ -3677,14 +3496,19 @@ export function AccessStep({
|
|||
options={[
|
||||
{
|
||||
value: "specific",
|
||||
title: "Agents I pick",
|
||||
description: "Choose one or more agents you can edit.",
|
||||
title: "Just agents I pick",
|
||||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
title: "Any agent",
|
||||
description: canSetCompanyInstall
|
||||
? "Make this connection available to every agent."
|
||||
icon: <BotGroupIcon />,
|
||||
accessibleLabel: canSetCompanyInstall
|
||||
? "Any agent"
|
||||
: `Any agent. Unavailable: ${capabilities?.companyInstallReason ??
|
||||
"Only someone who can configure this connection can choose this."}`,
|
||||
tooltip: canSetCompanyInstall
|
||||
? undefined
|
||||
: capabilities?.companyInstallReason ??
|
||||
"Only someone who can configure this connection can choose this.",
|
||||
disabled: !canSetCompanyInstall,
|
||||
|
|
@ -3728,6 +3552,15 @@ export function AccessStep({
|
|||
);
|
||||
}
|
||||
|
||||
function BotGroupIcon() {
|
||||
return (
|
||||
<span className="relative block h-4 w-5" aria-hidden="true">
|
||||
<Bot className="absolute left-0 top-0 h-3.5 w-3.5" />
|
||||
<Bot className="absolute bottom-0 right-0 h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of what the Access step committed. Three lines, not badges: identity,
|
||||
* reach, and the existing action summary each said once.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ export const queryKeys = {
|
|||
["tools", "connection", connectionId, "activity"] as const,
|
||||
testAgents: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "test-agents"] as const,
|
||||
testAgentAccesses: () => ["tools", "test-agent-access"] as const,
|
||||
testAgentAccessesForConnection: (connectionId: string) =>
|
||||
["tools", "test-agent-access", connectionId] as const,
|
||||
testAgentAccess: (connectionId: string, agentId: string) =>
|
||||
["tools", "test-agent-access", connectionId, agentId] as const,
|
||||
testCallStatus: (connectionId: string, actionRequestId: string) =>
|
||||
[
|
||||
"tools",
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
|
|||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(variables.connection.id) }),
|
||||
]);
|
||||
},
|
||||
onError: (_error, variables) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ const listProfilesMock = vi.hoisted(() => vi.fn());
|
|||
const listPoliciesMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const listTestAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
|
||||
const updateConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -52,6 +54,9 @@ vi.mock("@/api/tools", () => ({
|
|||
listConnectionActivityMock(connectionId, limit),
|
||||
listActionRequests: (companyId: string, status: string) =>
|
||||
listActionRequestsMock(companyId, status),
|
||||
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
|
||||
getTestAgentAccess: (connectionId: string, agentId: string) =>
|
||||
getTestAgentAccessMock(connectionId, agentId),
|
||||
updateConnection: (connectionId: string, input: unknown) =>
|
||||
updateConnectionMock(connectionId, input),
|
||||
finishApp: (companyId: string, connectionId: string, input: unknown) =>
|
||||
|
|
@ -368,6 +373,7 @@ describe("AppDetail", () => {
|
|||
});
|
||||
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
|
||||
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
|
||||
listTestAgentsMock.mockResolvedValue({ agents: [] });
|
||||
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
|
||||
finishAppMock.mockResolvedValue({});
|
||||
finalizeOAuthAccessMock.mockResolvedValue({});
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ export function AppDetail() {
|
|||
}),
|
||||
onMutate: () => setPending(true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
|
||||
|
|
@ -320,6 +321,7 @@ export function AppDetail() {
|
|||
toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, next)),
|
||||
onSuccess: (snapshot) => {
|
||||
queryClient.setQueryData(queryKeys.tools.connectionInstalls(connectionId), snapshot);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
|
||||
|
|
@ -480,7 +482,7 @@ export function AppDetail() {
|
|||
body: `${appName} no longer has access and its credentials are deleted. Connecting it again needs a new sign-in or key.`,
|
||||
tone: "success",
|
||||
});
|
||||
navigate("/apps/connections");
|
||||
navigate("/apps");
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
|
|
@ -516,6 +518,7 @@ export function AppDetail() {
|
|||
const refreshTools = useMutation({
|
||||
mutationFn: () => toolsApi.refreshCatalog(connectionId),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
|
|
@ -557,7 +560,7 @@ export function AppDetail() {
|
|||
};
|
||||
|
||||
if (!connectionId || !activeTab) {
|
||||
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps/connections"} />;
|
||||
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps"} />;
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
|
|
@ -576,8 +579,8 @@ export function AppDetail() {
|
|||
return (
|
||||
<div className="max-w-3xl p-6">
|
||||
<p className="text-sm text-muted-foreground">We couldn't find that app.</p>
|
||||
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps/connections")}>
|
||||
Back to apps
|
||||
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps")}>
|
||||
Back to connectors
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function AppNotConnected() {
|
|||
body: `${appName} no longer shows in your apps. You can connect it again any time.`,
|
||||
tone: "success",
|
||||
});
|
||||
navigate("/apps/connections");
|
||||
navigate("/apps");
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
|
|
@ -147,7 +147,7 @@ export function AppNotConnected() {
|
|||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
|
||||
}
|
||||
if (!applicationId || !activeTab) {
|
||||
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps/connections"} replace />;
|
||||
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps"} replace />;
|
||||
}
|
||||
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
|
||||
return (
|
||||
|
|
@ -161,7 +161,7 @@ export function AppNotConnected() {
|
|||
return (
|
||||
<div className="max-w-3xl space-y-3 p-6 text-sm text-muted-foreground">
|
||||
<p>This app doesn’t exist anymore.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/apps/connections")}>Back to apps</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/apps")}>Back to connectors</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify"
|
|||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
|
||||
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
|
||||
const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!;
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
|
|
@ -246,14 +247,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
return root;
|
||||
}
|
||||
|
||||
it("shows only the paste-first connection choices on the BYO page", async () => {
|
||||
it("shows only MCP URL setup on the BYO page", async () => {
|
||||
await render(undefined, true);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Connect your own MCP server");
|
||||
expect(text).toContain("More ways to connect");
|
||||
expect(text).toContain("Run your own");
|
||||
expect(text).toContain("Paste a config");
|
||||
expect(text).not.toContain("More ways to connect");
|
||||
expect(text).not.toContain("Run your own");
|
||||
expect(text).not.toContain("Paste a config");
|
||||
expect(text).not.toContain("Search apps…");
|
||||
expect(text).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(text).not.toContain("Zapier");
|
||||
|
|
@ -293,24 +294,57 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
expect(container.textContent).not.toContain("Choose access before adding credentials");
|
||||
expect(container.textContent).not.toContain("Set the identity and agent reach first");
|
||||
expect(container.textContent).not.toContain("Whose GitHub account should agents act as?");
|
||||
expect(container.textContent).not.toContain("Choose where this connection will be available.");
|
||||
// Nothing about the credential itself is on screen yet.
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
|
||||
const justMe = radios.find((r) => r.textContent?.includes("Just me"));
|
||||
const wholeOrg = radios.find((r) => r.textContent?.includes("Everyone in the company"));
|
||||
const agentsIPick = radios.find((r) => r.textContent?.includes("Agents I pick"));
|
||||
const wholeOrg = radios.find((r) => r.textContent?.includes("Any human in the company"));
|
||||
const agentsIPick = radios.find((r) => r.textContent?.includes("Just agents I pick"));
|
||||
const anyAgent = radios.find((r) => r.textContent?.includes("Any agent"));
|
||||
expect(justMe).toBeTruthy();
|
||||
expect(wholeOrg).toBeTruthy();
|
||||
expect(justMe?.textContent).toBe("Just me");
|
||||
expect(wholeOrg?.textContent).toBe("Any human in the company");
|
||||
expect(agentsIPick?.textContent).toBe("Just agents I pick");
|
||||
expect(anyAgent?.textContent).toBe("Any agent");
|
||||
expect(justMe?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
|
||||
expect(wholeOrg?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
|
||||
expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
|
||||
expect(anyAgent?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(2);
|
||||
// A flexible connection method defaults to the company identity...
|
||||
expect(wholeOrg?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(justMe?.getAttribute("aria-checked")).toBe("false");
|
||||
// ...and every agent is the product default for both access and install.
|
||||
expect(agentsIPick?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(radios.find((r) => r.textContent?.includes("Any agent"))?.getAttribute("aria-checked"))
|
||||
.toBe("true");
|
||||
expect(anyAgent?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps provider guidance and card styling out of the shared access step", async () => {
|
||||
mockParams.appKey = "pagerduty";
|
||||
listGalleryMock.mockResolvedValue({ apps: [PAGERDUTY] });
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
expect(container.textContent).not.toContain("A PagerDuty API token");
|
||||
expect(container.textContent).not.toContain("Use a customer-created PagerDuty key");
|
||||
expect(container.textContent).not.toContain("Review PagerDuty setup requirements");
|
||||
|
||||
const accessHeading = Array.from(container.querySelectorAll("h2")).find(
|
||||
(heading) => heading.textContent === "Which humans can use this credential?",
|
||||
);
|
||||
const accessCard = accessHeading?.closest(".overflow-hidden");
|
||||
expect(accessCard).toBeTruthy();
|
||||
expect(accessCard?.classList.contains("bg-card")).toBe(false);
|
||||
expect(accessCard?.classList.contains("shadow-sm")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to every agent and only blocks an empty explicit selection", async () => {
|
||||
|
|
@ -321,7 +355,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("Agents I pick"))
|
||||
.find((r) => r.textContent?.includes("Just agents I pick"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
|
@ -365,6 +399,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
.toBe("true");
|
||||
});
|
||||
|
||||
it("uses Cancel to exit while the bottom Back button stays in the wizard", async () => {
|
||||
mockParams.appKey = "github";
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps");
|
||||
});
|
||||
|
||||
/**
|
||||
* Company-wide is a default, not a restriction. Flexible API-key and OAuth
|
||||
* methods must still let the operator deliberately choose a personal identity.
|
||||
|
|
@ -378,7 +423,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
);
|
||||
return {
|
||||
justMe: radios.find((r) => r.textContent?.includes("Just me")),
|
||||
wholeOrg: radios.find((r) => r.textContent?.includes("Everyone in the company")),
|
||||
wholeOrg: radios.find((r) => r.textContent?.includes("Any human in the company")),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -551,7 +596,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain("Who is this credential for?");
|
||||
expect(document.body.textContent).toContain("Which humans can use this credential?");
|
||||
expect(document.body.textContent).toContain("Which agents can use this connection?");
|
||||
expect(document.body.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||||
|
|
@ -564,25 +609,21 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain("Who is this credential for?");
|
||||
expect(document.body.textContent).toContain("Just me.");
|
||||
expect(document.body.textContent).toContain("Which humans can use this credential?");
|
||||
expect(document.body.textContent).toContain("Just me");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||||
});
|
||||
|
||||
it("defaults Google Drive setup to its write-capable connection method", async () => {
|
||||
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
|
||||
mockParams.appKey = "google-drive";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Google Developer Preview access required");
|
||||
expect(container.textContent).toContain("does not enable unrelated Paperclip customers");
|
||||
expect(container.textContent).toContain("final project-registration email");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
|
||||
link.textContent?.includes("Apply or verify Developer Preview enrollment"),
|
||||
)?.href,
|
||||
).toBe("https://developers.google.com/workspace/preview");
|
||||
expect(container.textContent).not.toContain("Google Developer Preview access required");
|
||||
expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers");
|
||||
expect(container.textContent).not.toContain("final project-registration email");
|
||||
expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment");
|
||||
await passAccessStep();
|
||||
|
||||
expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true");
|
||||
|
|
@ -591,20 +632,19 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(container.textContent).toContain("Your OAuth app");
|
||||
});
|
||||
|
||||
it("explains Shopify's public-storefront gate before collecting the store domain", async () => {
|
||||
it("keeps Shopify prerequisite copy off access before collecting the store domain", async () => {
|
||||
mockParams.appKey = "shopify";
|
||||
listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] });
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Launch the storefront before connecting");
|
||||
expect(container.textContent).toContain("Storefront visibility to Public");
|
||||
expect(container.textContent).toContain("private or password-protected storefront returns HTTP 401");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
|
||||
link.textContent?.includes("Open Shopify Admin"),
|
||||
)?.href,
|
||||
).toBe("https://admin.shopify.com/");
|
||||
expect(container.textContent).not.toContain("Launch the storefront before connecting");
|
||||
expect(container.textContent).not.toContain("Storefront visibility to Public");
|
||||
expect(container.textContent).not.toContain("Open Shopify Admin");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).not.toContain("Launch the storefront before connecting");
|
||||
expect(container.textContent).not.toContain("Open Shopify Admin");
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -631,16 +671,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((r) => r.textContent?.includes("Any agent"));
|
||||
|
||||
// Visible, with the reason, and not selectable.
|
||||
// Visible and not selectable; the reason stays available without adding
|
||||
// visual subtext to the compact option.
|
||||
expect(anyAgent).toBeTruthy();
|
||||
expect(anyAgent?.disabled).toBe(true);
|
||||
expect(anyAgent?.textContent).toContain(
|
||||
expect(anyAgent?.textContent).not.toContain(
|
||||
"Your company policy limits this choice to connection managers.",
|
||||
);
|
||||
// "Agents I pick" is the live alternative, so the step is not a dead end.
|
||||
expect(anyAgent?.getAttribute("title")).toBe(
|
||||
"Your company policy limits this choice to connection managers.",
|
||||
);
|
||||
expect(anyAgent?.getAttribute("aria-label")).toContain(
|
||||
"Your company policy limits this choice to connection managers.",
|
||||
);
|
||||
// "Just agents I pick" is the live alternative, so the step is not a dead end.
|
||||
const pick = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((r) => r.textContent?.includes("Agents I pick"));
|
||||
).find((r) => r.textContent?.includes("Just agents I pick"));
|
||||
expect(pick?.disabled).toBe(false);
|
||||
// Continue refuses the forbidden choice even though it is the current one.
|
||||
expect(buttonByText("Save and continue")?.disabled).toBe(true);
|
||||
|
|
@ -652,7 +699,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
// A deep-linked app lands on Access first: identity and reach are chosen
|
||||
// before the credential (PAP-17835).
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
|
|
@ -665,7 +712,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
// Access comes first for a curated app; the method chooser shares a screen
|
||||
// with the credential fields, so it sits behind it (PAP-17835).
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
|
|
@ -978,9 +1025,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Choose access before sign-in");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).not.toContain("Choose access before sign-in");
|
||||
const identityRadios = Array.from(document.body.querySelectorAll('[role="radio"]'));
|
||||
expect(identityRadios.find((radio) => radio.textContent?.includes("Everyone in the company"))?.getAttribute("aria-checked"))
|
||||
expect(identityRadios.find((radio) => radio.textContent?.includes("Any human in the company"))?.getAttribute("aria-checked"))
|
||||
.toBe("true");
|
||||
|
||||
await passAccessStep();
|
||||
|
|
@ -1016,6 +1064,25 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("backs from the sign-in checkpoint to Access without exiting the wizard", async () => {
|
||||
mockSearch.value = "source=notion";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||||
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
await render();
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion&stage=access");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps");
|
||||
});
|
||||
|
||||
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
|
||||
const interactionId = "11111111-1111-4111-8111-111111111111";
|
||||
mockSearch.value = `source=notion&intent=${interactionId}`;
|
||||
|
|
@ -1046,7 +1113,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
|
||||
await render();
|
||||
expect(container.textContent).toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).toContain("Existing agent access stays the same");
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
|
|
@ -1191,7 +1259,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
await render();
|
||||
expect(container.textContent).toContain(
|
||||
credentialPolicy === "per_user" ? "Just me." : "Everyone in the company",
|
||||
credentialPolicy === "per_user" ? "Just me" : "Any human in the company",
|
||||
);
|
||||
expect(container.textContent).toContain("Existing agent access stays the same");
|
||||
await passAccessStep();
|
||||
|
|
@ -1244,7 +1312,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
|
||||
await render();
|
||||
expect(container.textContent).toContain("Everyone in the company");
|
||||
expect(container.textContent).toContain("Any human in the company");
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
|
||||
|
|
@ -1450,7 +1518,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).toContain("Existing agent access stays the same");
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed", {
|
||||
|
|
@ -1495,7 +1564,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||||
expect(container.textContent).toContain("Existing agent access stays the same");
|
||||
await passAccessStep();
|
||||
await submitCuratedOAuthSetup();
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
|
||||
|
|
@ -1628,7 +1698,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||||
});
|
||||
|
||||
|
|
@ -1741,7 +1811,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
|
||||
it("keeps Zapier visible and finishes without a separate access or install step", async () => {
|
||||
mockSearch.value = "byo=1&source=zapier";
|
||||
mockSearch.value = "source=zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{ ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } },
|
||||
|
|
@ -1819,27 +1889,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
// PAP-10922: "Run your own" / "Paste a config" moved from the sidebar to rows
|
||||
// under "Connect with a link" on the gallery step.
|
||||
it("offers 'Run your own' and 'Paste a config' rows that route into the Advanced door", async () => {
|
||||
it("keeps alternate setup methods out of the connection wizard", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("More ways to connect");
|
||||
|
||||
const buttonContaining = (text: string) =>
|
||||
Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Run your own")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced");
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Paste a config")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced/paste-config");
|
||||
expect(container.textContent).not.toContain("More ways to connect");
|
||||
expect(container.textContent).not.toContain("Run your own");
|
||||
expect(container.textContent).not.toContain("Paste a config");
|
||||
});
|
||||
|
||||
// PAP-11091: discoverability copy for remote MCP URLs — the link field
|
||||
|
|
@ -1967,7 +2022,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
it("keeps the originating connection intent in wizard URLs", async () => {
|
||||
const interactionId = "11111111-1111-4111-8111-111111111111";
|
||||
mockSearch.value = `byo=1&intent=${interactionId}`;
|
||||
mockSearch.value = `intent=${interactionId}`;
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -1984,8 +2039,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("steps back from the key step to Access, and from Access to the BYO gallery", async () => {
|
||||
mockSearch.value = "byo=1";
|
||||
it("steps back from the key step to Access, and from Access to Connectors", async () => {
|
||||
mockSearch.value = "";
|
||||
mockParams.appKey = "github";
|
||||
await render();
|
||||
await passAccessStep();
|
||||
|
|
@ -1997,13 +2052,15 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps");
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
});
|
||||
|
||||
it("leaving the default name connects with the app name", async () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const listGalleryMock = vi.hoisted(() => vi.fn());
|
|||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const archiveConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
|
|
@ -17,6 +19,8 @@ vi.mock("@/api/tools", () => ({
|
|||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
listApplications: (companyId: string) => listApplicationsMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
archiveConnection: (connectionId: string, options?: { confirmComposioChildren?: boolean }) =>
|
||||
archiveConnectionMock(connectionId, options),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -44,6 +48,10 @@ vi.mock("@/context/BreadcrumbContext", () => ({
|
|||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -56,7 +64,7 @@ async function act(callback: () => void | Promise<void>) {
|
|||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
|
@ -69,7 +77,7 @@ function galleryEntry(overrides: Record<string, unknown>) {
|
|||
key: "github",
|
||||
name: "GitHub",
|
||||
logoUrl: "https://example.com/github.png",
|
||||
tagline: "Let agents open PRs and issues.",
|
||||
tagline: "Let agents open pull requests and issues.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
|
|
@ -82,33 +90,45 @@ function galleryEntry(overrides: Record<string, unknown>) {
|
|||
};
|
||||
}
|
||||
|
||||
describe("Browse store door (PAP-13254 door 1)", () => {
|
||||
function application(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "app-notion",
|
||||
name: "Notion",
|
||||
description: "Read and update workspace content.",
|
||||
status: "active",
|
||||
applicationKey: "app-gallery:notion:one",
|
||||
metadata: { sourceTemplateKey: "notion" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conn-notion",
|
||||
applicationId: "app-notion",
|
||||
name: "devinfoley@gmail.com",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
authKind: "oauth",
|
||||
healthStatus: "ok",
|
||||
healthMessage: null,
|
||||
lastError: null,
|
||||
createdByUserId: "user-1",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Connectors landing page", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
galleryEntry({
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Connect automations.",
|
||||
}),
|
||||
galleryEntry({
|
||||
key: "jira",
|
||||
name: "Jira",
|
||||
tagline: "Track projects and issues.",
|
||||
}),
|
||||
galleryEntry({
|
||||
key: "cloudflare",
|
||||
name: "Cloudflare",
|
||||
tagline: "Manage Cloudflare resources.",
|
||||
}),
|
||||
galleryEntry({
|
||||
key: "notion",
|
||||
name: "Notion",
|
||||
tagline: "Read and update workspace content.",
|
||||
}),
|
||||
galleryEntry({ key: "notion", name: "Notion", tagline: "Read and update workspace content." }),
|
||||
galleryEntry({ key: "jira", name: "Jira", tagline: "Track projects and issues." }),
|
||||
galleryEntry({
|
||||
key: "gmail",
|
||||
name: "Gmail",
|
||||
|
|
@ -118,16 +138,12 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
reason: "Gmail is not available on this Paperclip instance yet.",
|
||||
},
|
||||
}),
|
||||
galleryEntry({
|
||||
key: "acme",
|
||||
name: "Acme CRM",
|
||||
tagline: "Sync deals and contacts.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
archiveConnectionMock.mockResolvedValue(connection({ status: "archived" }));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
|
@ -135,6 +151,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -153,174 +170,66 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders the store header, popular grid, gallery, and BYO card", async () => {
|
||||
it("renders one connector list with the requested header and no gallery sections", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Browse");
|
||||
expect(text).toContain("Choose an app or connect your own MCP server.");
|
||||
expect(text).not.toContain("More integrations are coming soon.");
|
||||
expect(text).not.toContain("Other integrations are previews.");
|
||||
expect(text).toContain("Popular");
|
||||
expect(text).toContain("All apps");
|
||||
expect(text).toContain("Jira");
|
||||
expect(text).toContain("Cloudflare");
|
||||
expect(text).toContain("Acme CRM");
|
||||
expect(container.querySelector("header")?.textContent).toBe("Connectors");
|
||||
expect(
|
||||
container.querySelector('header input[aria-label="Search connectors"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector('[aria-label="Popular apps"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="All apps"]')).toBeNull();
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="All apps"] > [data-app-slug]',
|
||||
'[aria-label="Connector list"] > [data-app-slug]',
|
||||
),
|
||||
).map((tile) => tile.dataset.appSlug),
|
||||
).toEqual(["acme", "cloudflare", "gmail", "jira", "notion", "zapier"]);
|
||||
// Bring-your-own is a first-class row in the store.
|
||||
expect(text).toContain("Connect your own tool");
|
||||
expect(text).toContain("All discovered actions are enabled automatically.");
|
||||
expect(text).not.toContain("review its actions before enabling it");
|
||||
expect(text).not.toContain("Vercel Connect");
|
||||
expect(text).not.toContain("Composio");
|
||||
).map((row) => row.dataset.appSlug),
|
||||
).toEqual(["gmail", "jira", "notion", "custom-mcp"]);
|
||||
expect(container.querySelector('button[aria-label="Connect Jira"]')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>('button[aria-label="Unavailable Gmail"]')?.disabled,
|
||||
).toBe(true);
|
||||
expect(container.textContent).toContain("Connect your own tool");
|
||||
|
||||
const customConnect = container.querySelector<HTMLButtonElement>(
|
||||
'[data-app-slug="custom-mcp"] button',
|
||||
);
|
||||
await act(async () => {
|
||||
customConnect?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("Paste a config");
|
||||
expect(container.textContent).not.toContain("Run your own");
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Connect your own MCP server"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/byo");
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Paste a config"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/advanced/paste-config");
|
||||
});
|
||||
|
||||
it("does not surface Vercel Connect even when the backend capability is enabled", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
galleryEntry({
|
||||
key: "posthog",
|
||||
name: "PostHog",
|
||||
tagline: "Analyze product usage.",
|
||||
}),
|
||||
],
|
||||
credentialSources: {
|
||||
vercelConnect: {
|
||||
available: true,
|
||||
enabled: true,
|
||||
authentication: "access_token",
|
||||
manageUrl: "https://vercel.com/connect",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await renderBrowse();
|
||||
|
||||
expect(container.textContent).not.toContain("Vercel Connect");
|
||||
expect(navigateMock).not.toHaveBeenCalledWith("/apps/vercel-connect");
|
||||
});
|
||||
|
||||
it("routes every capability-backed app and explains instance-disabled apps", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const zapierTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Connect for Zapier"]',
|
||||
),
|
||||
);
|
||||
const jiraTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Connect for Jira"]',
|
||||
),
|
||||
);
|
||||
const notionTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Connect for Notion"]',
|
||||
),
|
||||
);
|
||||
const gmailTile = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Unavailable for Gmail"]',
|
||||
);
|
||||
const tile = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Unavailable for Acme CRM"]',
|
||||
);
|
||||
const byoCard = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Connect your own tool"),
|
||||
);
|
||||
|
||||
expect(zapierTiles).toHaveLength(2);
|
||||
expect(zapierTiles.every((button) => !button.disabled)).toBe(true);
|
||||
expect(notionTiles).toHaveLength(2);
|
||||
expect(notionTiles.every((button) => !button.disabled)).toBe(true);
|
||||
expect(jiraTiles).toHaveLength(2);
|
||||
expect(jiraTiles.every((button) => !button.disabled)).toBe(true);
|
||||
expect(tile?.disabled).toBe(true);
|
||||
expect(gmailTile?.disabled).toBe(true);
|
||||
expect(byoCard?.disabled).toBe(false);
|
||||
expect(tile?.textContent).toContain("Unavailable");
|
||||
expect(container.textContent).toContain(
|
||||
"Gmail is not available on this Paperclip instance yet.",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Coming soon");
|
||||
expect(zapierTiles[0]?.textContent).toContain("Connect");
|
||||
|
||||
await act(async () => {
|
||||
zapierTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=zapier");
|
||||
|
||||
await act(async () => {
|
||||
notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=notion");
|
||||
|
||||
await act(async () => {
|
||||
jiraTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=jira");
|
||||
|
||||
await act(async () => {
|
||||
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
});
|
||||
|
||||
it("filters the gallery by the search query", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
);
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, "cloudflare");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Results (1)");
|
||||
expect(text).toContain("Cloudflare");
|
||||
expect(text).not.toContain("Acme CRM");
|
||||
// Popular grid is hidden while searching.
|
||||
expect(text).not.toContain("Popular");
|
||||
});
|
||||
|
||||
it("shows the connected owner, edits existing connections, and offers a deliberate second account", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
{
|
||||
id: "app-notion",
|
||||
name: "Legacy integration",
|
||||
status: "active",
|
||||
applicationKey: "legacy:notion",
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
it("sorts connected providers first and shows account, owner, status actions, and edit menus inline", async () => {
|
||||
listApplicationsMock.mockResolvedValue({ applications: [application()] });
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-one",
|
||||
applicationId: "app-notion",
|
||||
name: "Notion",
|
||||
status: "active",
|
||||
createdByUserId: "user-1",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: {},
|
||||
},
|
||||
{ id: "conn-two", applicationId: "app-notion", status: "disabled" },
|
||||
{ id: "conn-draft", applicationId: "app-notion", status: "draft" },
|
||||
connection(),
|
||||
connection({
|
||||
id: "conn-expired",
|
||||
name: "ops@example.com",
|
||||
healthStatus: "error",
|
||||
healthMessage: "The saved sign-in expired.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
|
|
@ -332,7 +241,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
image: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -340,267 +249,190 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
|
||||
await renderBrowse();
|
||||
|
||||
const editButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Edit connections for Notion"]',
|
||||
),
|
||||
);
|
||||
expect(editButtons).toHaveLength(3);
|
||||
expect(
|
||||
editButtons.every((button) =>
|
||||
button.textContent?.includes("Edit connections"),
|
||||
),
|
||||
).toBe(true);
|
||||
// Interrupted setup remains resumable but is not represented as a
|
||||
// successful provider connection.
|
||||
expect(container.textContent).toContain("2 connected");
|
||||
expect(container.textContent).toContain("Dotta’s Notion");
|
||||
expect(
|
||||
container.querySelector('[title="Dotta"] [data-slot="avatar"]'),
|
||||
).toBeTruthy();
|
||||
const connectedAppTiles = Array.from(
|
||||
const rows = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="Connected apps"] > [data-app-slug]',
|
||||
'[aria-label="Connector list"] > [data-app-slug]',
|
||||
),
|
||||
);
|
||||
expect(connectedAppTiles).toHaveLength(1);
|
||||
expect(connectedAppTiles[0]?.dataset.appSlug).toBe("notion");
|
||||
const pageText = container.textContent ?? "";
|
||||
expect(pageText.indexOf("Popular")).toBeLessThan(
|
||||
pageText.indexOf("Connected"),
|
||||
);
|
||||
expect(pageText.indexOf("Connected")).toBeLessThan(
|
||||
pageText.indexOf("All apps"),
|
||||
);
|
||||
const popularAppTiles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="Popular apps"] > [data-app-slug]',
|
||||
),
|
||||
);
|
||||
const popularGrid = container.querySelector<HTMLElement>(
|
||||
'[aria-label="Popular apps"]',
|
||||
);
|
||||
const connectedGrid = container.querySelector<HTMLElement>(
|
||||
'[aria-label="Connected apps"]',
|
||||
);
|
||||
for (const grid of [popularGrid, connectedGrid]) {
|
||||
expect(grid?.className).toContain("lg:grid-cols-4");
|
||||
expect(grid?.className).toContain("xl:grid-cols-6");
|
||||
}
|
||||
for (const tile of popularAppTiles) {
|
||||
expect(tile.className).toContain("min-w-0");
|
||||
expect(
|
||||
tile.querySelector('[data-slot="app-tile-status"]')?.className,
|
||||
).toContain("min-h-4");
|
||||
expect(
|
||||
tile.querySelector('[data-slot="app-tile-primary-action"]')?.className,
|
||||
).toContain("w-full");
|
||||
expect(
|
||||
tile.querySelector('[data-slot="app-tile-primary-action"] button')
|
||||
?.className,
|
||||
).toContain("max-w-full");
|
||||
expect(
|
||||
tile.querySelector('[data-slot="app-tile-secondary-action"]')
|
||||
?.className,
|
||||
).toContain("min-h-5");
|
||||
}
|
||||
const allAppTiles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="All apps"] > [data-app-slug]',
|
||||
),
|
||||
);
|
||||
const notionAllAppsTile = allAppTiles.find(
|
||||
(tile) => tile.dataset.appSlug === "notion",
|
||||
);
|
||||
expect(notionAllAppsTile?.dataset.connected).toBe("true");
|
||||
expect(notionAllAppsTile?.textContent).toContain("Dotta’s Notion");
|
||||
expect(rows[0]?.dataset.appSlug).toBe("notion");
|
||||
const notion = rows[0]!;
|
||||
expect(notion.textContent).toContain("devinfoley@gmail.com");
|
||||
expect(notion.textContent).toContain("ops@example.com");
|
||||
expect(notion.textContent).toContain("Connected by");
|
||||
expect(notion.textContent).toContain("Dotta");
|
||||
expect(notion.textContent).toContain("The saved sign-in expired.");
|
||||
expect(notion.querySelector('button[aria-label="Add account Notion"]')).toBeTruthy();
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-title"]')
|
||||
?.className,
|
||||
).toContain("break-words");
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-title"]')
|
||||
?.className,
|
||||
).not.toContain("truncate");
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-header-status"]')
|
||||
?.className,
|
||||
).toContain("min-h-5");
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-connection-name"]')
|
||||
?.className,
|
||||
).toContain("break-words");
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-connection-name"]')
|
||||
?.className,
|
||||
).not.toContain("truncate");
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-details"]')
|
||||
?.className,
|
||||
).toContain("mt-auto");
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector('[data-slot="app-tile-actions"]'),
|
||||
notion.querySelector('button[aria-label="Manage devinfoley@gmail.com connection"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector(
|
||||
'button[aria-label="Add another Notion account"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector(
|
||||
'button[aria-label="Edit connections for Notion"]',
|
||||
),
|
||||
notion.querySelector('button[aria-label="Manage ops@example.com connection"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
notion
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Open devinfoley@gmail.com connection settings"]',
|
||||
)
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/app/app-notion/setup");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/conn-notion/setup");
|
||||
|
||||
const addAnother = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Add another Notion account"]',
|
||||
);
|
||||
expect(addAnother?.textContent).toContain("Add new");
|
||||
await act(async () => {
|
||||
addAnother?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
notion
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Add account Notion"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
"/apps/connect?source=notion&applicationId=app-notion&name=Notion&new=1",
|
||||
);
|
||||
|
||||
const reconnect = Array.from(notion.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Reconnect",
|
||||
);
|
||||
await act(async () => {
|
||||
reconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/conn-expired/setup");
|
||||
});
|
||||
|
||||
it("associates a legacy generic Zapier URL connection with the curated Zapier card", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
galleryEntry({
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Connect automations.",
|
||||
urlPatterns: ["https://mcp.zapier.com/*"],
|
||||
it("removes a connection from the overflow menu only after destructive confirmation", async () => {
|
||||
listApplicationsMock.mockResolvedValue({ applications: [application()] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
|
||||
|
||||
await renderBrowse();
|
||||
|
||||
const menuTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Manage devinfoley@gmail.com connection"]',
|
||||
);
|
||||
expect(menuTrigger).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
menuTrigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const removeItem = Array.from(document.body.querySelectorAll<HTMLElement>("[role=\"menuitem\"]"))
|
||||
.find((item) => item.textContent?.trim() === "Remove connection");
|
||||
expect(removeItem).toBeTruthy();
|
||||
expect(removeItem?.getAttribute("data-variant")).toBe("destructive");
|
||||
|
||||
await act(async () => {
|
||||
removeItem?.dispatchEvent(new Event("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(archiveConnectionMock).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain(
|
||||
"Remove devinfoley@gmail.com connection?",
|
||||
);
|
||||
|
||||
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Remove connection",
|
||||
);
|
||||
expect(confirmButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(archiveConnectionMock).toHaveBeenCalledWith("conn-notion", {
|
||||
confirmComposioChildren: false,
|
||||
});
|
||||
expect(pushToastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Connection removed",
|
||||
tone: "success",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an interrupted account visible and resumes setup from its account row", async () => {
|
||||
listApplicationsMock.mockResolvedValue({ applications: [application()] });
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
connection({
|
||||
id: "conn-draft",
|
||||
name: "Notion",
|
||||
status: "draft",
|
||||
healthStatus: "unchecked",
|
||||
}),
|
||||
],
|
||||
});
|
||||
listApplicationsMock.mockResolvedValueOnce({
|
||||
applications: [
|
||||
{
|
||||
id: "app-zapier-link",
|
||||
name: "Zapier for the company",
|
||||
status: "active",
|
||||
applicationKey: "app-gallery:link:legacy",
|
||||
metadata: { source: "link" },
|
||||
},
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValueOnce({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-zapier",
|
||||
applicationId: "app-zapier-link",
|
||||
name: "Zapier for the company",
|
||||
status: "active",
|
||||
config: { url: "https://mcp.zapier.com/api/v1/connect" },
|
||||
transportConfig: { url: "https://mcp.zapier.com/api/v1/connect" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrowse();
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Connect for Zapier"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="Edit connection for Zapier"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).toContain("1 connected");
|
||||
});
|
||||
|
||||
it("keeps an existing draft-only Notion connection resumable without calling it connected", async () => {
|
||||
const applicationId = "057a2df6-175f-4dde-b246-743706444122";
|
||||
const connectionId = "46dc23c1-ecfa-46f7-8e60-34a7cdbd661e";
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
{
|
||||
id: applicationId,
|
||||
name: "Notion",
|
||||
status: "active",
|
||||
applicationKey: "notion",
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
applicationId,
|
||||
name: "Notion",
|
||||
status: "draft",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrowse();
|
||||
|
||||
const finishButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Finish setup for Notion"]',
|
||||
),
|
||||
);
|
||||
expect(finishButtons).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Connect for Notion"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("Setup incomplete");
|
||||
expect(container.textContent).not.toContain("1 connected");
|
||||
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="Add another Notion account"]',
|
||||
),
|
||||
).toBeNull();
|
||||
const allAppTiles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="All apps"] > [data-app-slug]',
|
||||
),
|
||||
const finish = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Finish setup",
|
||||
);
|
||||
const notionAllAppsTile = allAppTiles.find(
|
||||
(tile) => tile.dataset.appSlug === "notion",
|
||||
);
|
||||
expect(notionAllAppsTile?.dataset.connected).toBe("false");
|
||||
expect(notionAllAppsTile?.dataset.setupPending).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
finishButtons[0]?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
finish?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
`/apps/connect?source=notion&resume=${connectionId}`,
|
||||
"/apps/connect?source=notion&resume=conn-draft",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the custom URL option available when gallery search has no matches", async () => {
|
||||
it("filters the single list without restoring section chrome", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
'input[aria-label="Search connectors"]',
|
||||
);
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, "missing app");
|
||||
setter?.call(input, "jira");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("No planned apps match");
|
||||
expect(container.textContent).toContain("Connect your own tool");
|
||||
const rows = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="Connector list"] > [data-app-slug]',
|
||||
),
|
||||
);
|
||||
expect(rows.map((row) => row.dataset.appSlug)).toEqual(["jira"]);
|
||||
expect(container.textContent).not.toContain("Popular");
|
||||
expect(container.textContent).not.toContain("All apps");
|
||||
});
|
||||
|
||||
it("shows existing accounts and an actionable warning when the gallery request fails", async () => {
|
||||
listGalleryMock.mockRejectedValue(new Error("Gallery unavailable"));
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
application({
|
||||
id: "custom-app",
|
||||
name: "Internal search",
|
||||
applicationKey: "custom:search",
|
||||
metadata: { source: "link" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
connection({
|
||||
id: "custom-connection",
|
||||
applicationId: "custom-app",
|
||||
name: "search.internal.example",
|
||||
config: {},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrowse();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Couldn’t load every connector",
|
||||
);
|
||||
expect(container.textContent).toContain("Internal search");
|
||||
expect(container.textContent).toContain("search.internal.example");
|
||||
expect(Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent === "Try again",
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -44,7 +44,6 @@ import {
|
|||
type AppGalleryDisplayEntry,
|
||||
} from "./app-definition-display";
|
||||
import { useReviewCount } from "./useReviewCount";
|
||||
import { AdvancedToolsLink } from "./store-cards";
|
||||
import { connectionNameForCredentialPolicy, connectionTypeLabel } from "./connection-identity";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
|
|
@ -531,11 +530,10 @@ export function Connections() {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Apps you connect become available to every agent unless you change “Who can use it”.
|
||||
</p>
|
||||
<AdvancedToolsLink />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,17 @@ describe("app connect policy", () => {
|
|||
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("byo=1"))).toBe(false);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps only exact custom MCP reconnects on the legacy BYO query contract", () => {
|
||||
expect(canEnterAppsConnect(new URLSearchParams(
|
||||
"byo=1&reconnect=connection-1&applicationId=application-1&link=https%3A%2F%2Fmcp.example.com",
|
||||
))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams(
|
||||
"byo=1&reconnect=connection-1&applicationId=application-1",
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("admits retained hidden-provider reconnects without opening fresh setup", () => {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,15 @@ export function resolveAppsConnectRouteKey(input: {
|
|||
}
|
||||
|
||||
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
|
||||
if (searchParams.get("byo") === "1") return true;
|
||||
if (searchParams.get("byo") === "1") {
|
||||
// The old BYO discovery page has moved to the Connectors list. Keep only
|
||||
// exact custom-connection reconnects using this legacy query contract.
|
||||
return Boolean(
|
||||
searchParams.get("reconnect")?.trim()
|
||||
&& searchParams.get("applicationId")?.trim()
|
||||
&& searchParams.get("link")?.trim(),
|
||||
);
|
||||
}
|
||||
const source = searchParams.get("source") ?? "";
|
||||
const entry = getAppStoreDefinition(source);
|
||||
// A retained connection may belong to a provider hidden from fresh catalog
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { ToolCatalogEntry } from "@paperclipai/shared";
|
|||
import { TestPanel, errorHints } from "./TestPanel";
|
||||
|
||||
const listTestAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
|
||||
const runTestCallMock = vi.hoisted(() => vi.fn());
|
||||
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
|
||||
const declineActionRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -16,6 +17,8 @@ const declineActionRequestMock = vi.hoisted(() => vi.fn());
|
|||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
|
||||
getTestAgentAccess: (connectionId: string, agentId: string) =>
|
||||
getTestAgentAccessMock(connectionId, agentId),
|
||||
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
|
||||
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
|
||||
getTestCallStatusMock(connectionId, actionRequestId),
|
||||
|
|
@ -177,12 +180,12 @@ const offEntry = catalogEntry({
|
|||
let container: HTMLDivElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let root: any;
|
||||
let client: QueryClient;
|
||||
|
||||
function renderPanel(
|
||||
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
|
||||
quarantined: ToolCatalogEntry[] = [],
|
||||
) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
|
||||
|
|
@ -194,11 +197,14 @@ beforeEach(() => {
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
listTestAgentsMock.mockReset();
|
||||
getTestAgentAccessMock.mockReset();
|
||||
runTestCallMock.mockReset();
|
||||
getTestCallStatusMock.mockReset();
|
||||
declineActionRequestMock.mockReset();
|
||||
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
|
||||
getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess });
|
||||
// Default ask-first polls report the request still waiting on approval.
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
|
|
@ -219,14 +225,30 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("TestPanel", () => {
|
||||
it("pairs the loading skeleton with explicit MCP wait copy and animation", async () => {
|
||||
it("shows a lightweight agent-loading state before requesting permissions", async () => {
|
||||
listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await act(async () => renderPanel());
|
||||
|
||||
expect(container.textContent).toContain("Loading MCP actions, this may take a minute.");
|
||||
expect(container.textContent).toContain("Loading agents…");
|
||||
expect(container.querySelector(".animate-spin")).toBeTruthy();
|
||||
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
|
||||
expect(getTestAgentAccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads permissions only for the selected agent and reuses the cached summary", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
|
||||
expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the test hierarchy and grouped actions with access badges", async () => {
|
||||
|
|
@ -472,17 +494,13 @@ describe("TestPanel", () => {
|
|||
});
|
||||
|
||||
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
|
||||
listTestAgentsMock.mockResolvedValue({
|
||||
agents: [
|
||||
agent({
|
||||
effectiveAccess: {
|
||||
...agent().effectiveAccess,
|
||||
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
lastChangedByAgentId: "agent-admin",
|
||||
lastChangedByName: "Dotta",
|
||||
},
|
||||
}),
|
||||
],
|
||||
getTestAgentAccessMock.mockResolvedValue({
|
||||
access: {
|
||||
...agent().effectiveAccess,
|
||||
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
lastChangedByAgentId: "agent-admin",
|
||||
lastChangedByName: "Dotta",
|
||||
},
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
|
|
@ -70,6 +71,13 @@ function actionSubLine(entry: ToolCatalogEntry): string | null {
|
|||
|
||||
type DecisionMeta = { label: string; className: string };
|
||||
|
||||
type TestAgentWithAccess = ToolConnectionTestAgent & {
|
||||
effectiveAccess: ToolConnectionAccessSummary;
|
||||
};
|
||||
|
||||
const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000;
|
||||
const TEST_ACCESS_GC_TIME_MS = 30 * 60_000;
|
||||
|
||||
const DECISION_META: Record<ToolConnectionTestDecision, DecisionMeta> = {
|
||||
allowed: {
|
||||
label: "Allowed",
|
||||
|
|
@ -116,10 +124,11 @@ export function TestPanel({
|
|||
/** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */
|
||||
quarantined?: ToolCatalogEntry[];
|
||||
}) {
|
||||
const hasActions = active.length > 0 || quarantined.length > 0;
|
||||
const testAgentsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.testAgents(connectionId),
|
||||
queryFn: () => toolsApi.listTestAgents(connectionId),
|
||||
enabled: !!connectionId,
|
||||
enabled: !!connectionId && hasActions,
|
||||
});
|
||||
|
||||
const agents = useMemo(
|
||||
|
|
@ -129,17 +138,27 @@ export function TestPanel({
|
|||
[testAgentsQuery.data],
|
||||
);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
const [requestedAgentId, setRequestedAgentId] = useState<string | null>(null);
|
||||
// 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;
|
||||
setAgentId(agents[0].id);
|
||||
}, [agents, agentId]);
|
||||
|
||||
const selectedAgent = agents.find((a) => a.id === agentId) ?? null;
|
||||
const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId)
|
||||
? requestedAgentId
|
||||
: agents[0]?.id ?? null;
|
||||
const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null;
|
||||
const testAgentAccessQuery = useQuery({
|
||||
queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"),
|
||||
queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!),
|
||||
enabled: !!connectionId && !!agentId && hasActions,
|
||||
staleTime: TEST_ACCESS_STALE_TIME_MS,
|
||||
gcTime: TEST_ACCESS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const selectedAgent = useMemo<TestAgentWithAccess | null>(() => (
|
||||
selectedAgentBase && testAgentAccessQuery.data
|
||||
? { ...selectedAgentBase, effectiveAccess: testAgentAccessQuery.data.access }
|
||||
: null
|
||||
), [selectedAgentBase, testAgentAccessQuery.data]);
|
||||
|
||||
// Per-action decision for the selected agent, keyed by both the upstream and
|
||||
// gateway tool names so we can match whatever the catalog stores.
|
||||
|
|
@ -182,12 +201,16 @@ export function TestPanel({
|
|||
const visibleQuarantined = quarantinedActions.filter(matches);
|
||||
const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length;
|
||||
|
||||
if (!hasActions) {
|
||||
return <EmptyState connectionId={connectionId} appName={appName} />;
|
||||
}
|
||||
|
||||
if (testAgentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading MCP actions, this may take a minute.
|
||||
Loading agents…
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
|
|
@ -196,8 +219,13 @@ export function TestPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (active.length === 0 && quarantinedActions.length === 0) {
|
||||
return <EmptyState connectionId={connectionId} appName={appName} />;
|
||||
if (testAgentsQuery.isError) {
|
||||
return (
|
||||
<TestLoadError
|
||||
message="We couldn't load the agents available for testing."
|
||||
onRetry={() => { void testAgentsQuery.refetch(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
|
|
@ -215,11 +243,34 @@ export function TestPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (testAgentAccessQuery.isError && !testAgentAccessQuery.data) {
|
||||
return (
|
||||
<TestLoadError
|
||||
message={`We couldn't load ${selectedAgentBase?.name ?? "this agent"}'s permissions.`}
|
||||
onRetry={() => { void testAgentAccessQuery.refetch(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (testAgentAccessQuery.isLoading || !selectedAgent) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agent permissions…
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedRowProps = {
|
||||
connectionId,
|
||||
appName,
|
||||
allAgents: agents,
|
||||
onSelectAgent: setAgentId,
|
||||
onSelectAgent: setRequestedAgentId,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -229,7 +280,7 @@ export function TestPanel({
|
|||
appName={appName}
|
||||
agents={agents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelect={setAgentId}
|
||||
onSelect={setRequestedAgentId}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -312,6 +363,17 @@ function EmptyState({ connectionId, appName }: { connectionId: string; appName:
|
|||
);
|
||||
}
|
||||
|
||||
function TestLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm font-medium text-foreground">{message}</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test-as header + agent picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -325,7 +387,7 @@ function TestAsHeader({
|
|||
}: {
|
||||
appName: string;
|
||||
agents: ToolConnectionTestAgent[];
|
||||
selectedAgent: ToolConnectionTestAgent;
|
||||
selectedAgent: TestAgentWithAccess;
|
||||
onSelect: (agentId: string) => void;
|
||||
connectionId: string;
|
||||
}) {
|
||||
|
|
@ -415,8 +477,7 @@ function AgentPicker({
|
|||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">No agents match.</p>
|
||||
) : (
|
||||
filtered.map((agent) => {
|
||||
const summary = agent.effectiveAccess;
|
||||
const noAccess = summary.allowedCount === 0 && summary.askFirstCount === 0;
|
||||
const detail = agent.title?.trim() || agent.role;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
|
|
@ -439,11 +500,7 @@ function AgentPicker({
|
|||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{agent.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{noAccess
|
||||
? "No access — not allowed for any action"
|
||||
: `Allowed ${summary.allowedCount} · Ask first ${summary.askFirstCount} · Off ${summary.offCount}`}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{detail}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -516,7 +573,7 @@ function ActionGroup({
|
|||
subheading?: string;
|
||||
entries: ToolCatalogEntry[];
|
||||
decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision;
|
||||
agent: ToolConnectionTestAgent;
|
||||
agent: TestAgentWithAccess;
|
||||
} & RowSharedProps) {
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -545,7 +602,7 @@ function ActionRow({
|
|||
}: {
|
||||
entry: ToolCatalogEntry;
|
||||
decision: ToolConnectionTestDecision;
|
||||
agent: ToolConnectionTestAgent;
|
||||
agent: TestAgentWithAccess;
|
||||
} & RowSharedProps) {
|
||||
const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent)));
|
||||
const title = entry.title ?? entry.toolName;
|
||||
|
|
@ -666,7 +723,7 @@ function ActionTester({
|
|||
}: {
|
||||
entry: ToolCatalogEntry;
|
||||
decision: ToolConnectionTestDecision;
|
||||
agent: ToolConnectionTestAgent;
|
||||
agent: TestAgentWithAccess;
|
||||
} & RowSharedProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
|
@ -1286,7 +1343,6 @@ function AskFirstResult({
|
|||
function OffExplanation({
|
||||
entry,
|
||||
connectionId,
|
||||
appName,
|
||||
agent,
|
||||
allAgents,
|
||||
onSelectAgent,
|
||||
|
|
@ -1294,28 +1350,21 @@ function OffExplanation({
|
|||
entry: ToolCatalogEntry;
|
||||
connectionId: string;
|
||||
appName: string;
|
||||
agent: ToolConnectionTestAgent;
|
||||
agent: TestAgentWithAccess;
|
||||
allAgents: ToolConnectionTestAgent[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
}) {
|
||||
const title = entry.title ?? entry.toolName;
|
||||
const permHref = `${appTabHref(connectionId, "permissions")}?focus=${encodeURIComponent(entry.id)}`;
|
||||
|
||||
// Decision for this action across every agent we can test as.
|
||||
// Other agents are intentionally not summarized up front. Selecting one
|
||||
// fetches and caches only that agent's access, keeping this screen fast even
|
||||
// for large companies.
|
||||
const others = allAgents.filter((a) => a.id !== agent.id);
|
||||
const decisionOf = (a: ToolConnectionTestAgent): ToolConnectionTestDecision => {
|
||||
const tool = a.effectiveAccess.tools.find(
|
||||
(t) => t.toolName === entry.toolName || t.gatewayToolName === entry.toolName,
|
||||
);
|
||||
return tool?.decision ?? "off";
|
||||
};
|
||||
const allOff = allAgents.every((a) => decisionOf(a) === "off");
|
||||
|
||||
const whyBody = entry.status === "quarantined"
|
||||
? "This action is new and hasn't been turned on yet."
|
||||
: allOff
|
||||
? "An admin set it to Off for all agents using this app."
|
||||
: `${agent.name}'s access profile sets this action to Off.`;
|
||||
: `${agent.name}'s access profile sets this action to Off.`;
|
||||
|
||||
// "Last changed by {Actor} · {relativeTime}" — only the access config carries
|
||||
// this; a quarantined action has never been configured, so there's nothing to
|
||||
|
|
@ -1326,9 +1375,6 @@ function OffExplanation({
|
|||
? `Last changed${lastChangedByName ? ` by ${lastChangedByName}` : ""} · ${relTime(new Date(lastChangedAt))}`
|
||||
: null;
|
||||
|
||||
const otherSettings = others.map((a) => ({ name: a.name, decision: decisionOf(a) }));
|
||||
const tryAgents = others.filter((a) => decisionOf(a) !== "off");
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-(--gtc-62)">
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1356,23 +1402,11 @@ function OffExplanation({
|
|||
<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>}
|
||||
{otherSettings.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-(length:--text-micro) font-medium text-muted-foreground">Other agents using {appName}:</p>
|
||||
<ul className="mt-1 space-y-0.5 text-(length:--text-micro) text-muted-foreground">
|
||||
{otherSettings.map((s) => (
|
||||
<li key={s.name}>
|
||||
{s.name}: <span className="text-foreground">{DECISION_META[s.decision].label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{tryAgents.length > 0 && (
|
||||
{others.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-(length:--text-micro) font-medium text-muted-foreground">Try as a different agent:</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{tryAgents.slice(0, 4).map((other) => (
|
||||
{others.slice(0, 4).map((other) => (
|
||||
<button
|
||||
key={other.id}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,15 @@ export function connectionDisplayNameForOwner(
|
|||
applicationName: string,
|
||||
owner: ConnectionOwnerProfile | null,
|
||||
): string {
|
||||
const connectionName = humanizeConnectionDisplayName(connection);
|
||||
const rawName = connection.name.trim();
|
||||
// Provider account identifiers are machine values, not prose. Preserve
|
||||
// their casing and punctuation so an email address or tenant hostname stays
|
||||
// recognizable in the inline account list.
|
||||
const connectionName =
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawName) ||
|
||||
/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i.test(rawName)
|
||||
? rawName
|
||||
: humanizeConnectionDisplayName(connection);
|
||||
if (!owner) return connectionName;
|
||||
if (connectionName.trim().toLocaleLowerCase() !== applicationName.trim().toLocaleLowerCase()) {
|
||||
return connectionName;
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import { ServerCog, Wrench } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { advancedTabHref } from "@/pages/tools/tool-tabs";
|
||||
import { appSourceConnectHref } from "./app-connect-policy";
|
||||
|
||||
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
|
||||
export const POPULAR_KEYS = ["zapier", "notion", "posthog", "linear", "jira", "cloudflare"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
|
||||
/** Zapier connects with the complete MCP URL issued by Zapier. */
|
||||
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
|
||||
|
||||
/** MCP-direct OAuth apps enter through the generic source deep link. */
|
||||
export const NOTION_CONNECT_HREF = appSourceConnectHref("notion");
|
||||
|
||||
/**
|
||||
* First-class "Connect your own tool" card (PAP-12371, Finding C; PAP-13254).
|
||||
* Lives in Browse as a persistent row and launches the guided URL flow.
|
||||
*/
|
||||
export function ByoConnectCard({ onConnect }: { onConnect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="flex w-full items-center gap-4 rounded-xl border border-dashed border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"
|
||||
>
|
||||
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
<ServerCog className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">Connect your own tool</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paste the URL from a custom or self-hosted MCP server. All discovered actions are enabled automatically.
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold text-primary">Connect →</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Labeled door to the developer control-plane (PAP-12371, Finding A cross-link). */
|
||||
export function AdvancedToolsLink() {
|
||||
return (
|
||||
<Link
|
||||
to={advancedTabHref("run-your-own")}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Developer tools (advanced)
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -408,7 +408,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("cancels to the original connection setup route without creating another draft", async () => {
|
||||
it("backs up to the original connection setup route without creating another draft", async () => {
|
||||
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
|
||||
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
|
||||
|
||||
|
|
@ -417,7 +417,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
|
|||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonStartingWith("Back to apps")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttonStartingWith("Back")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup");
|
||||
|
|
@ -425,6 +425,21 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
|
|||
expect(toolsApiMock.startOAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the OAuth checkpoint to the apps page", async () => {
|
||||
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
|
||||
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
|
||||
|
||||
await act(async () => {
|
||||
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonStartingWith("Cancel")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps");
|
||||
});
|
||||
|
||||
it("does not offer Continue for a stdio draft (draft-only, no link to hand off)", async () => {
|
||||
await pasteAndCheck(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
|
|||
setOAuthPhase("starting");
|
||||
oauthStartMutation.mutate(connectResult.connectionId);
|
||||
}}
|
||||
onCancel={() => navigate(`/apps/${connectResult.connectionId}/setup`)}
|
||||
onBack={() => navigate(`/apps/${connectResult.connectionId}/setup`)}
|
||||
onCancel={() => navigate("/apps")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -698,6 +698,7 @@ export function ProfilesTab({ companyId }: { companyId: string }) {
|
|||
const invalidateProfiles = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
qc.invalidateQueries({ queryKey: ["tools", companyId, "profiles", "effective"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
|
||||
};
|
||||
|
||||
const resetProfileForm = () => {
|
||||
|
|
|
|||
|
|
@ -1,290 +0,0 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock, Plus, ShieldCheck, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/i;
|
||||
|
||||
/** Slugify a display name into a `safeKeyPattern`-valid template id. */
|
||||
function toTemplateId(name: string): string {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._:-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "");
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** Split a typed command line into command + args on whitespace. */
|
||||
function splitCommand(raw: string): { command: string; args: string[] } {
|
||||
const parts = raw.trim().split(/\s+/).filter(Boolean);
|
||||
return { command: parts[0] ?? "", args: parts.slice(1) };
|
||||
}
|
||||
|
||||
type KeyRow = { id: number; value: string };
|
||||
|
||||
/**
|
||||
* M8b — "Run your own" tab on the Advanced door (PAP-10862, plan D8).
|
||||
*
|
||||
* Admin-only surface over P5a's command-template routes
|
||||
* (`POST /companies/:id/tools/stdio-templates`). Registers a command that
|
||||
* Paperclip will run in the company's isolated workspace and the keys it
|
||||
* expects. One of the two M8 screens where "MCP" vocabulary is allowed.
|
||||
*/
|
||||
export function RunYourOwnTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [keyRows, setKeyRows] = useState<KeyRow[]>([]);
|
||||
const [nextRowId, setNextRowId] = useState(1);
|
||||
|
||||
const templates = useQuery({
|
||||
queryKey: queryKeys.tools.stdioTemplates(companyId),
|
||||
queryFn: () => toolsApi.listStdioTemplates(companyId),
|
||||
});
|
||||
|
||||
const envKeys = useMemo(
|
||||
() => keyRows.map((row) => row.value.trim()).filter(Boolean),
|
||||
[keyRows],
|
||||
);
|
||||
const invalidKeys = envKeys.filter((key) => !ENV_KEY_RE.test(key));
|
||||
const parsed = splitCommand(command);
|
||||
const templateId = toTemplateId(name);
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
parsed.command.length > 0 &&
|
||||
templateId.length > 0 &&
|
||||
invalidKeys.length === 0;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.createStdioTemplate(companyId, {
|
||||
templateId,
|
||||
name: name.trim(),
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
envKeys,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Tool added", body: `"${name.trim()}" is ready to connect.`, tone: "success" });
|
||||
setName("");
|
||||
setCommand("");
|
||||
setKeyRows([]);
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.stdioTemplates(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const addKeyRow = () => {
|
||||
setKeyRows((rows) => [...rows, { id: nextRowId, value: "" }]);
|
||||
setNextRowId((id) => id + 1);
|
||||
};
|
||||
|
||||
const adminTemplates = (templates.data?.templates ?? []).filter((t) => t.source === "admin");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
For a tool that runs from a command. Paperclip runs it in your organization's own isolated workspace.
|
||||
Administrators only.
|
||||
</p>
|
||||
|
||||
<div className="space-y-5 rounded-lg border border-border bg-card p-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ryo-name">Name</Label>
|
||||
<Input
|
||||
id="ryo-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Acme tools"
|
||||
maxLength={160}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">What you'll call this tool in your apps list.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ryo-command">Command</Label>
|
||||
<Input
|
||||
id="ryo-command"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="npx -y @acme/mcp-tool"
|
||||
spellCheck={false}
|
||||
className="bg-slate-900 font-mono text-(length:--text-compact) text-slate-100 placeholder:text-slate-500 focus-visible:ring-slate-400"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">The command that runs the tool. From the tool's README.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Label>Keys it needs</Label>
|
||||
<span className="text-xs text-muted-foreground">· optional, depends on the tool</span>
|
||||
</div>
|
||||
{keyRows.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{keyRows.map((row) => {
|
||||
const value = row.value.trim();
|
||||
const invalid = value.length > 0 && !ENV_KEY_RE.test(value);
|
||||
return (
|
||||
<div key={row.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
setKeyRows((rows) =>
|
||||
rows.map((r) => (r.id === row.id ? { ...r, value: event.target.value } : r)),
|
||||
)
|
||||
}
|
||||
placeholder="API_KEY"
|
||||
spellCheck={false}
|
||||
className={`font-mono text-(length:--text-compact) ${invalid ? "border-destructive" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove key"
|
||||
onClick={() => setKeyRows((rows) => rows.filter((r) => r.id !== row.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{invalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Use letters, numbers and underscores, starting with a letter or underscore (e.g. API_KEY).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addKeyRow} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add a key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium text-foreground">
|
||||
This runs in your company's own workspace, isolated from everything else.
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
Only administrators see this option.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.isError ? <ErrorState error={createMutation.error} /> : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => createMutation.mutate()} disabled={!canSubmit || createMutation.isPending}>
|
||||
{createMutation.isPending ? "Adding…" : "Check & continue"}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Paperclip will register the command and the keys it needs.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Your own tools</h3>
|
||||
{templates.isLoading ? (
|
||||
<LoadingState />
|
||||
) : templates.isError ? (
|
||||
<ErrorState error={templates.error} onRetry={() => templates.refetch()} />
|
||||
) : adminTemplates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">You haven't added any of your own tools yet.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Name</th>
|
||||
<th className="px-4 py-2.5">Command</th>
|
||||
<th className="px-4 py-2.5">Keys</th>
|
||||
<th className="px-4 py-2.5">Added</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminTemplates.map((template) => (
|
||||
<RunYourOwnRow key={template.templateId} companyId={companyId} template={template} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunYourOwnRow({
|
||||
companyId,
|
||||
template,
|
||||
}: {
|
||||
companyId: string;
|
||||
template: import("@/api/tools").StdioTemplateSummary;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: () => toolsApi.disableStdioTemplate(companyId, template.templateId),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Tool turned off", tone: "success" });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.stdioTemplates(companyId) });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Couldn't turn it off",
|
||||
body: error instanceof Error ? error.message : undefined,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const disabled = template.status === "disabled";
|
||||
const fullCommand = [template.command ?? "", ...(template.args ?? [])].join(" ").trim();
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{template.name}</div>
|
||||
{disabled ? <Badge variant="outline">off</Badge> : null}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="font-mono text-(length:--text-micro) text-muted-foreground">{fullCommand || "—"}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{template.envKeys.length > 0 ? template.envKeys.join(", ") : "none"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RelativeTime value={template.createdAt} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{disabled ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => disableMutation.mutate()}
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,18 +29,10 @@ vi.mock("./profiles/ProfilesIndex", () => ({
|
|||
ProfilesIndex: () => <section>Tool profiles</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./AuditTab", () => ({
|
||||
AuditTab: () => <section>Audit tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./PasteConfigTab", () => ({
|
||||
PasteConfigTab: () => <section>Paste tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./RunYourOwnTab", () => ({
|
||||
RunYourOwnTab: () => <section>Run your own tab</section>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -82,19 +74,20 @@ describe("ToolsAccess", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it.each(["applications", "connections", "overview", "examples"])(
|
||||
"redirects retired %s tab links to All apps",
|
||||
it.each(["applications", "connections", "overview", "examples", "audit"])(
|
||||
"redirects retired %s tab links to Connectors",
|
||||
async (tab) => {
|
||||
mockParams.tab = tab;
|
||||
await render();
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/connections", replace: true }));
|
||||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps", replace: true }));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["runtime", "/apps/connections"],
|
||||
["runtime", "/apps"],
|
||||
["policies", "/apps/advanced/profiles"],
|
||||
["run-your-own", "/apps"],
|
||||
])("redirects the retired %s page to %s", async (tab, target) => {
|
||||
mockParams.tab = tab;
|
||||
await render();
|
||||
|
|
@ -102,6 +95,14 @@ describe("ToolsAccess", () => {
|
|||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: target, replace: true }));
|
||||
});
|
||||
|
||||
it("uses Paste a config as the only advanced setup page", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Paste tab");
|
||||
expect(container.textContent).not.toContain("Run your own");
|
||||
expect(container.querySelector('a[href="/apps/advanced/paste-config"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses Profiles as the developer entry point without a second page shell", async () => {
|
||||
await render();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import { cn } from "@/lib/utils";
|
|||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { ProfilesIndex } from "./profiles/ProfilesIndex";
|
||||
import { AuditTab } from "./AuditTab";
|
||||
import { GatewaysTab } from "./GatewaysTab";
|
||||
import { PasteConfigTab } from "./PasteConfigTab";
|
||||
import { RunYourOwnTab } from "./RunYourOwnTab";
|
||||
import { SmokeLabTab } from "./SmokeLabTab";
|
||||
import {
|
||||
ADVANCED_TABS,
|
||||
|
|
@ -22,17 +20,13 @@ function renderTab(tab: ToolTabKey, companyId: string) {
|
|||
switch (tab) {
|
||||
case "profiles":
|
||||
return <ProfilesIndex companyId={companyId} />;
|
||||
case "audit":
|
||||
return <AuditTab companyId={companyId} />;
|
||||
case "gateways":
|
||||
return <GatewaysTab companyId={companyId} />;
|
||||
case "smoke-lab":
|
||||
return <SmokeLabTab companyId={companyId} />;
|
||||
case "paste-config":
|
||||
return <PasteConfigTab companyId={companyId} />;
|
||||
case "run-your-own":
|
||||
default:
|
||||
return <RunYourOwnTab companyId={companyId} />;
|
||||
return <PasteConfigTab companyId={companyId} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +34,7 @@ export function ToolsAccess() {
|
|||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const params = useParams<{ tab?: string }>();
|
||||
const activeTab = (TOOL_TABS.find((t) => t.key === params.tab)?.key ?? "run-your-own") as ToolTabKey;
|
||||
const activeTab = (TOOL_TABS.find((t) => t.key === params.tab)?.key ?? "paste-config") as ToolTabKey;
|
||||
const advanced = isAdvancedSetupTab(activeTab);
|
||||
const tabLabel = TOOL_TABS.find((t) => t.key === activeTab)?.label;
|
||||
|
||||
|
|
@ -51,7 +45,7 @@ export function ToolsAccess() {
|
|||
...(advanced
|
||||
? [{ label: "Advanced setup" }]
|
||||
: [
|
||||
{ label: "Advanced setup", href: advancedTabHref("run-your-own") },
|
||||
{ label: "Advanced setup", href: advancedTabHref("paste-config") },
|
||||
{ label: tabLabel ?? "Developer tools" },
|
||||
]),
|
||||
]);
|
||||
|
|
@ -62,18 +56,23 @@ export function ToolsAccess() {
|
|||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to open advanced setup.</div>;
|
||||
}
|
||||
|
||||
if (params.tab === "run-your-own") {
|
||||
return <Navigate to="/apps" replace />;
|
||||
}
|
||||
|
||||
// Retired developer tabs (PAP-10915/PAP-10928) — keep old links working.
|
||||
if (
|
||||
params.tab === "applications" ||
|
||||
params.tab === "connections" ||
|
||||
params.tab === "overview" ||
|
||||
params.tab === "examples"
|
||||
params.tab === "examples" ||
|
||||
params.tab === "audit"
|
||||
) {
|
||||
return <Navigate to="/apps/connections" replace />;
|
||||
return <Navigate to="/apps" replace />;
|
||||
}
|
||||
|
||||
if (params.tab === "runtime") {
|
||||
return <Navigate to="/apps/connections" replace />;
|
||||
return <Navigate to="/apps" replace />;
|
||||
}
|
||||
|
||||
if (params.tab === "policies") {
|
||||
|
|
@ -82,7 +81,7 @@ export function ToolsAccess() {
|
|||
|
||||
if (advanced) {
|
||||
// M8a/M8b chrome (PAP-10839 wires): Advanced badge, plain-words subtitle,
|
||||
// and a two-tab switcher. The developer surface stays behind a quiet link.
|
||||
// and a focused setup tab. The developer surface stays behind a quiet link.
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5 p-4 sm:p-6">
|
||||
<header>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,10 @@ export function ProfileDetail({
|
|||
enabled: pendingNewTools > 0,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
|
||||
};
|
||||
const errorBody = (error: unknown) => String((error as Error)?.message ?? error);
|
||||
|
||||
const allowRows = useMemo(
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export function ProfileWizard({
|
|||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
|
||||
};
|
||||
|
||||
const live = useMemo(
|
||||
|
|
|
|||
|
|
@ -72,8 +72,10 @@ export function ProfilesIndex({
|
|||
[agents.data],
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
|
||||
};
|
||||
|
||||
const errorBody = (error: unknown) => String((error as Error)?.message ?? error);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import {
|
|||
FlaskConical,
|
||||
Layers,
|
||||
Network,
|
||||
ScrollText,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
|
|
@ -14,15 +12,14 @@ import {
|
|||
*/
|
||||
export const ADVANCED_TOOLS_BASE = "/apps/advanced";
|
||||
|
||||
/** Build a tab href off the Advanced base. `run-your-own` is the bare base path (the door's default tab). */
|
||||
/** Build a tab href off the Advanced base. */
|
||||
export function advancedTabHref(tab: ToolTabKey): string {
|
||||
return tab === "run-your-own" ? ADVANCED_TOOLS_BASE : `${ADVANCED_TOOLS_BASE}/${tab}`;
|
||||
return `${ADVANCED_TOOLS_BASE}/${tab}`;
|
||||
}
|
||||
|
||||
// M8a/M8b — the prosumer-facing Advanced setup tabs (PAP-10839 wires). The only
|
||||
// screens where "MCP" vocabulary is permitted (PAP-10827).
|
||||
export const ADVANCED_TABS = [
|
||||
{ key: "run-your-own", label: "Run your own", icon: TerminalSquare },
|
||||
{ key: "paste-config", label: "Paste a config", icon: ClipboardPaste },
|
||||
] as const;
|
||||
|
||||
|
|
@ -33,7 +30,6 @@ export const ADVANCED_TABS = [
|
|||
export const DEVELOPER_TABS = [
|
||||
{ key: "gateways", label: "Gateways", icon: Network },
|
||||
{ key: "profiles", label: "Profiles", icon: Layers },
|
||||
{ key: "audit", label: "Activity", icon: ScrollText },
|
||||
{ key: "smoke-lab", label: "Smoke Lab", icon: FlaskConical },
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ function OAuthStateHost({
|
|||
phase={phase}
|
||||
error={error}
|
||||
onRetry={() => undefined}
|
||||
onBack={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -291,8 +291,6 @@ function SeededAccessStep({
|
|||
<QueryClientProvider client={client}>
|
||||
<div className="bg-background p-6">
|
||||
<AccessStep
|
||||
appName="Gmail"
|
||||
providerName="Gmail"
|
||||
companyId={COMPANY}
|
||||
authKind={authKind}
|
||||
grantKind={grantKind}
|
||||
|
|
@ -312,7 +310,7 @@ function SeededAccessStep({
|
|||
}
|
||||
|
||||
export const ConnectAccessJustMePickedAgents: Story = {
|
||||
name: "3 · Connect Access — Just me + Agents I pick",
|
||||
name: "3 · Connect Access — Just me + Just agents I pick",
|
||||
render: () => (
|
||||
<SeededAccessStep
|
||||
authKind="oauth"
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ export const RadioCards: Story = {
|
|||
value: "draft",
|
||||
title: "Draft for review",
|
||||
description: "The agent proposes; you approve before it ships.",
|
||||
icon: <Users className="h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestDecision,
|
||||
|
|
@ -12,6 +13,10 @@ import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
|
|||
|
||||
const CONNECTION = "conn-sheets";
|
||||
|
||||
type StoryAgent = ToolConnectionTestAgent & {
|
||||
effectiveAccess: ToolConnectionAccessSummary;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -133,7 +138,7 @@ function buildAgent(
|
|||
name: string,
|
||||
decisions: Record<string, ToolConnectionTestDecision>,
|
||||
orgDepth = 1,
|
||||
): ToolConnectionTestAgent {
|
||||
): StoryAgent {
|
||||
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
|
||||
return {
|
||||
id,
|
||||
|
|
@ -156,7 +161,7 @@ function buildAgent(
|
|||
};
|
||||
}
|
||||
|
||||
const AGENTS: ToolConnectionTestAgent[] = [
|
||||
const AGENTS: StoryAgent[] = [
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
|
||||
buildAgent("agent-codex", "CodexCoder", {
|
||||
...DECISIONS,
|
||||
|
|
@ -221,11 +226,18 @@ function runScript(steps: Step[]) {
|
|||
tick(0);
|
||||
}
|
||||
|
||||
function seededClient(agents: ToolConnectionTestAgent[]): QueryClient {
|
||||
function seededClient(agents: StoryAgent[]): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), { agents });
|
||||
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
|
||||
agents: agents.map(({ effectiveAccess: _effectiveAccess, ...agent }) => agent),
|
||||
});
|
||||
for (const agent of agents) {
|
||||
client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, agent.id), {
|
||||
access: agent.effectiveAccess,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +251,7 @@ function TestHost({
|
|||
script?: Step[];
|
||||
runResult?: ToolConnectionTestCallResult;
|
||||
runDelayMs?: number;
|
||||
agents?: ToolConnectionTestAgent[];
|
||||
agents?: StoryAgent[];
|
||||
quarantined?: ToolCatalogEntry[];
|
||||
}) {
|
||||
const client = useMemo(() => seededClient(agents), [agents]);
|
||||
|
|
@ -368,7 +380,7 @@ export const OffAction: Story = {
|
|||
|
||||
// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
|
||||
|
||||
const AGENTS_WITH_AUDIT: ToolConnectionTestAgent[] = AGENTS.map((agent, i) =>
|
||||
const AGENTS_WITH_AUDIT: StoryAgent[] = AGENTS.map((agent, i) =>
|
||||
i === 0
|
||||
? {
|
||||
...agent,
|
||||
|
|
|
|||
Loading…
Reference in New Issue