feat(apps): add connection intent setup experience (#12347)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The backend now turns agent requests into durable connection intents. > - Operators need a clear path to inspect, configure, and finish those requests. > - The experience must preserve identity, agent access, and interrupted setup state. > - This pull request adds the connection intent setup experience across the app UI. > - The benefit is one guided flow from agent request to governed connection. ## Linked Issues or Issue Description Refs #11965 This is stack 9 of 11. It depends on stack 8 and replaces another reviewable part of #11965. ## What Changed - Add connection intent cards and setup flow integration. - Add browse, connection, app detail, and sidebar experience updates. - Preserve exact draft identity and access choices across resume and OAuth recovery. - Add focused UI, architecture, policy, and end-to-end coverage. - Keep transient retained-connection lookup failures retryable instead of misclassifying them as missing targets. - Align the dark-mode E2E contract with the intentionally hidden Gateways and Profiles sidebar tabs. ## Verification - `pnpm -r typecheck` - Focused UI result: 372 tests passed across 20 files. - AppsConnect regression suite: 80/80 passed, including failed connection and application lookups during retained reconnect. - `pnpm --filter @paperclipai/ui exec vitest run src/components/AppsSidebar.test.tsx` (1 passed) - `pnpm check:token-gates` - `pnpm --filter @paperclipai/db check:migrations` - `pnpm build` ## Risks - An interrupted OAuth flow can leave a durable draft that needs resume. - The UI resumes the exact draft and keeps its identity and agent access settings. - Retained reconnect retries refetch connections and applications together to avoid mixing partial snapshots. - Gateways and Profiles remain route-accessible but intentionally absent from the sidebar until their existing ship gate is lifted. - The change does not add a database migration. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have linked the public source pull request with `Refs #` - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d387cc0ff0
commit
c90d904779
|
|
@ -123,7 +123,9 @@ test.describe.serial("not-connected app page", () => {
|
|||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Check link" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 20_000 });
|
||||
// Reconnect retains the previous identity and application, so the generic
|
||||
// check can commit the restored connection transactionally.
|
||||
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const apps = await request.get(`/api/companies/${seed.companyId}/tools/applications`);
|
||||
const appsBody = await apps.json();
|
||||
|
|
@ -180,6 +182,7 @@ test.describe.serial("not-connected app page", () => {
|
|||
|
||||
await page.goto(`/${seed.prefix}/apps/app/${secondBody.application.id}/advanced`);
|
||||
await expect(page.getByText("Danger zone")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByText("Danger zone", { exact: true }).click();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ test("captures the current app removal confirmations", async ({ page }) => {
|
|||
|
||||
await page.goto(`/${prefix}/apps/app/${application.id}/advanced`);
|
||||
await expect(page.getByRole("heading", { name: "Demo Notes" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("Danger zone")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Danger zone" }).click();
|
||||
await page.getByRole("button", { name: "Remove app", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
|
||||
await page.screenshot({ path: "test-results/pap-10817-delete-dialog.png", fullPage: true });
|
||||
|
|
@ -41,6 +41,7 @@ test("captures the current app removal confirmations", async ({ page }) => {
|
|||
|
||||
await page.goto(`/${prefix}/apps/${connection.id}/advanced`);
|
||||
await expect(page.getByRole("heading", { name: "Primary connection" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Danger zone" }).click();
|
||||
await page.getByRole("button", { name: "Remove app", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
|
||||
await page.screenshot({ path: "test-results/pap-10817-delete-dialog-guarded.png", fullPage: true });
|
||||
|
|
|
|||
|
|
@ -130,12 +130,8 @@ test.describe.serial("applications lifecycle", () => {
|
|||
|
||||
await page.goto(`/${seed.prefix}/apps/${connection.id}/setup`);
|
||||
await expect(page.getByRole("heading", { name: appName })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Agents can use this app" })).toBeVisible();
|
||||
|
||||
await page.getByRole("switch", { name: "Pause this app" }).click();
|
||||
await expect(page.getByRole("heading", { name: "This app is paused" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("switch", { name: "Resume this app" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Agents can use this app" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
|
||||
await expect(page.getByText("Anyone in your company can use this connection")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Rename app" }).click();
|
||||
await page.getByLabel("App name").fill(renamed);
|
||||
|
|
@ -143,8 +139,15 @@ test.describe.serial("applications lifecycle", () => {
|
|||
await expect(page.getByRole("heading", { name: renamed })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-detail.png`, fullPage: true });
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/${connection.id}/advanced`);
|
||||
await expect(page.getByText("Danger zone")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Danger zone" }).click();
|
||||
const pauseConnection = page.getByRole("switch", { name: "Pause connection" });
|
||||
await pauseConnection.click();
|
||||
await expect(pauseConnection).toBeChecked({ timeout: 15_000 });
|
||||
await expect(page.getByText("App paused").first()).toBeVisible();
|
||||
await pauseConnection.click();
|
||||
await expect(pauseConnection).not.toBeChecked({ timeout: 15_000 });
|
||||
await expect(page.getByText("App resumed").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Remove app", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-connected.png`, fullPage: true });
|
||||
|
|
@ -161,7 +164,7 @@ test.describe.serial("applications lifecycle", () => {
|
|||
|
||||
await page.goto(`/${seed.prefix}/apps/app/${cleanApp.id}/advanced`);
|
||||
await expect(page.getByRole("heading", { name: cleanAppName })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("Danger zone")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Danger zone" }).click();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -157,12 +157,12 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-04-advanced-paste-dark.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test("developer tabs share the merged Apps sidebar", async ({ page }) => {
|
||||
test("developer routes share the merged Apps sidebar without hidden tabs", async ({ page }) => {
|
||||
await forceDark(page);
|
||||
await page.goto(`/${seed.prefix}/apps/advanced/profiles`);
|
||||
await expect(page.getByRole("heading", { name: "Access profiles" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toBeVisible();
|
||||
await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toBeVisible();
|
||||
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.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0);
|
||||
// Apps section lives in the same sidebar now.
|
||||
|
|
@ -180,6 +180,7 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
await page.getByLabel("App name").fill("QA Renamed App");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "QA Renamed App" })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByText("Danger zone", { exact: true }).click();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -166,9 +166,9 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
// Submit (button label is "Check link").
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
|
||||
// Link-mode setup uses the safe organization/any-agent defaults, enables
|
||||
// discovered actions, and applies risk-based ask-first defaults in one
|
||||
// commit. Classification remains covered by the server suite.
|
||||
// The Access choice was captured before credentials. A successful generic
|
||||
// probe now commits discovered actions and risk defaults transactionally,
|
||||
// so the key check lands directly on success.
|
||||
await expect(page.getByRole("heading", { name: /is ready\.$/i })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-05-success.png`, fullPage: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,373 @@
|
|||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type Seed = { companyId: string; prefix: string };
|
||||
type Agent = { id: string; name: string };
|
||||
|
||||
async function json<T = Json>(
|
||||
response: Awaited<ReturnType<APIRequestContext["get"]>>,
|
||||
): Promise<T> {
|
||||
expect(
|
||||
response.ok(),
|
||||
`${response.url()} failed ${response.status()}: ${await response.text()}`,
|
||||
).toBe(true);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function newCompany(request: APIRequestContext): Promise<Seed> {
|
||||
const company = await json<{ id: string; issuePrefix: string }>(
|
||||
await request.post("/api/companies", {
|
||||
data: { name: `Connection intent E2E ${Date.now()}` },
|
||||
}),
|
||||
);
|
||||
await json(
|
||||
await request.patch("/api/instance/settings/experimental", {
|
||||
data: { enableApps: true },
|
||||
}),
|
||||
);
|
||||
return { companyId: company.id, prefix: company.issuePrefix };
|
||||
}
|
||||
|
||||
async function createAgent(
|
||||
request: APIRequestContext,
|
||||
companyId: string,
|
||||
name: string,
|
||||
): Promise<Agent> {
|
||||
return await json<Agent>(
|
||||
await request.post(`/api/companies/${companyId}/agents`, {
|
||||
data: {
|
||||
name,
|
||||
role: "qa",
|
||||
title: "Connection intent fixture agent",
|
||||
capabilities: "Exercises deterministic connection intent wiring.",
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: ["--input-type=module", "-e", "process.exit(0)"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function startFakeProvider() {
|
||||
const captures: Array<{ method: string; toolName: string | null }> = [];
|
||||
const server: Server = createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
const payload = JSON.parse(
|
||||
Buffer.concat(chunks).toString("utf8") || "{}",
|
||||
) as {
|
||||
id?: string | number;
|
||||
method?: string;
|
||||
params?: { name?: string };
|
||||
};
|
||||
captures.push({
|
||||
method: String(payload.method ?? "<unknown>"),
|
||||
toolName: payload.params?.name ?? null,
|
||||
});
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
if (payload.method === "tools/list") {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: payload.id ?? null,
|
||||
result: {
|
||||
tools: [
|
||||
{
|
||||
name: "notion:list_pages",
|
||||
title: "List fixture pages",
|
||||
description:
|
||||
"Reads deterministic pages from the fake Notion provider.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (payload.method === "tools/call") {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: payload.id ?? null,
|
||||
result: {
|
||||
content: [{ type: "text", text: "Fixture page inventory" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.end(
|
||||
JSON.stringify({ jsonrpc: "2.0", id: payload.id ?? null, result: {} }),
|
||||
);
|
||||
});
|
||||
const port = await listenOnFetchAllowedPort(server);
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/`,
|
||||
captures,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
function connectionAwareScript(connectionId: string) {
|
||||
return `
|
||||
const post = async (url, body, token = process.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN) => {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { authorization: \`Bearer \${token}\`, "content-type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) throw new Error(\`\${response.status}: \${await response.text()}\`);
|
||||
return await response.json();
|
||||
};
|
||||
const search = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL, { query: "notion" });
|
||||
const notion = search.results.find((result) => result.service === "notion");
|
||||
if (!notion) throw new Error("Notion was not advertised");
|
||||
if (notion.state !== "ready") {
|
||||
const requested = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL, { service: notion.service });
|
||||
if (requested.state !== "needs_user_action") throw new Error("Expected a user-action request");
|
||||
console.log("waiting for connection intent");
|
||||
process.exit(0);
|
||||
}
|
||||
const apiHeaders = { authorization: \`Bearer \${process.env.PAPERCLIP_API_KEY}\`, "content-type": "application/json" };
|
||||
const sessionResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/sessions\`, {
|
||||
method: "POST",
|
||||
headers: apiHeaders,
|
||||
body: JSON.stringify({ runId: process.env.PAPERCLIP_RUN_ID, ttlMs: 60000 })
|
||||
});
|
||||
if (!sessionResponse.ok) throw new Error(await sessionResponse.text());
|
||||
const session = await sessionResponse.json();
|
||||
const toolsResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools\`, {
|
||||
headers: { "x-paperclip-tool-gateway-token": session.token }
|
||||
});
|
||||
const tools = await toolsResponse.json();
|
||||
const tool = tools.find((entry) => entry.connectionId === ${JSON.stringify(connectionId)} && entry.upstreamToolName === "notion:list_pages");
|
||||
if (!tool) throw new Error("Continuation did not receive the installed Notion tool");
|
||||
const call = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools/call\`, {
|
||||
method: "POST",
|
||||
headers: { "x-paperclip-tool-gateway-token": session.token, "content-type": "application/json" },
|
||||
body: JSON.stringify({ tool: tool.name, parameters: {} })
|
||||
});
|
||||
if (!call.ok) throw new Error(await call.text());
|
||||
console.log(await call.text());
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function waitForAgentRun(
|
||||
request: APIRequestContext,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
) {
|
||||
let terminalRun: { id: string; status: string } | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const runs = await json<Array<{ id: string; status: string }>>(
|
||||
await request.get(
|
||||
`/api/companies/${companyId}/heartbeat-runs?agentId=${agentId}&limit=10`,
|
||||
),
|
||||
);
|
||||
terminalRun =
|
||||
runs.find((run) => !["queued", "running"].includes(run.status)) ??
|
||||
null;
|
||||
return terminalRun?.status ?? null;
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe("succeeded");
|
||||
if (!terminalRun)
|
||||
throw new Error("Agent run completed without a run receipt");
|
||||
return terminalRun;
|
||||
}
|
||||
|
||||
test("store setup and task connection intent share one fake provider through continuation", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const provider = await startFakeProvider();
|
||||
try {
|
||||
const seed = await newCompany(request);
|
||||
const holder = await createAgent(
|
||||
request,
|
||||
seed.companyId,
|
||||
"Existing access holder",
|
||||
);
|
||||
|
||||
// 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({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /Connect your own tool/i }).click();
|
||||
await page
|
||||
.getByPlaceholder("https://example.com/actions")
|
||||
.fill(provider.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
// A no-auth read-only provider can complete the access/install defaults in
|
||||
// one commit. Other methods exercise the same intermediate steps in the
|
||||
// shared-flow component suite.
|
||||
await expect(page.getByRole("heading", { name: /is ready/i })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const connections = await json<{
|
||||
connections: Array<{ id: string; name: string; config: Json }>;
|
||||
}>(await request.get(`/api/companies/${seed.companyId}/tools/connections`));
|
||||
expect(connections.connections).toHaveLength(1);
|
||||
const connection = connections.connections[0]!;
|
||||
const connectionId = connection.id;
|
||||
await json(
|
||||
await request.patch(`/api/tool-connections/${connectionId}`, {
|
||||
data: {
|
||||
config: {
|
||||
...connection.config,
|
||||
url: provider.url,
|
||||
sourceTemplateKey: "notion",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await json(
|
||||
await request.put(`/api/tool-connections/${connectionId}/installs`, {
|
||||
data: {
|
||||
installs: [{ targetType: "agent", targetId: holder.id }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/${connectionId}/test`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test an action" }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /List fixture pages/i }).click();
|
||||
await page.getByRole("button", { name: "Run", exact: true }).click();
|
||||
await expect(page.getByText("Fixture page inventory")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Entry point two: a scripted agent requests Notion, then the same shared
|
||||
// provider is reused from the task dialog and appears in the fresh run.
|
||||
const scout = await createAgent(
|
||||
request,
|
||||
seed.companyId,
|
||||
"Connection requester",
|
||||
);
|
||||
await json(
|
||||
await request.patch(`/api/agents/${scout.id}`, {
|
||||
data: {
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
connectionAwareScript(connectionId),
|
||||
],
|
||||
},
|
||||
replaceAdapterConfig: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const issue = await json<{ id: string; identifier: string }>(
|
||||
await request.post(`/api/companies/${seed.companyId}/issues`, {
|
||||
data: {
|
||||
title: "Read our Notion pages",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: scout.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Assigning an in-progress task is the production wake path. Waiting for
|
||||
// that run avoids creating a second artificial request from an explicit
|
||||
// heartbeat invocation.
|
||||
const firstRun = await waitForAgentRun(request, seed.companyId, scout.id);
|
||||
|
||||
const taskUrl = `/${seed.prefix}/issues/${issue.identifier}`;
|
||||
await page.goto(taskUrl);
|
||||
await expect(
|
||||
page.getByText("Connection requester needs Notion"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: "Connect / Use existing" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Use an existing connection" }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: new RegExp(escapeRegExp(connection.name)) })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("Notion connected")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page).toHaveURL(new RegExp(`${taskUrl}$`));
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("connection-intent-focus-target")
|
||||
.filter({ hasText: "Notion connected" }),
|
||||
).toBeFocused();
|
||||
expect(await page.locator("body").innerText()).not.toMatch(
|
||||
/\/authorize\?|authorizationUrl/,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const runs = await json<Array<{ id: string; status: string }>>(
|
||||
await request.get(
|
||||
`/api/companies/${seed.companyId}/heartbeat-runs?agentId=${scout.id}&limit=10`,
|
||||
),
|
||||
);
|
||||
return runs.find((run) => run.id !== firstRun.id)?.status ?? null;
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe("succeeded");
|
||||
await expect
|
||||
.poll(() =>
|
||||
provider.captures.some(
|
||||
(capture) =>
|
||||
capture.method === "tools/call" &&
|
||||
capture.toolName === "notion:list_pages",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const interactions = await json<Array<{ kind: string; status: string }>>(
|
||||
await request.get(`/api/issues/${issue.id}/interactions`),
|
||||
);
|
||||
const connectionIntents = interactions.filter(
|
||||
(interaction) => interaction.kind === "connection_intent",
|
||||
);
|
||||
expect(
|
||||
connectionIntents.filter(
|
||||
(interaction) => interaction.status === "accepted",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
connectionIntents.filter(
|
||||
(interaction) => interaction.status === "pending",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
connectionIntents.every((interaction) =>
|
||||
["accepted", "expired"].includes(interaction.status),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(holder.id).not.toBe(scout.id);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -250,6 +250,9 @@ describe("Apps routes", () => {
|
|||
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/byo" element={<AppsConnect byoOnly />} />');
|
||||
expect(appSource).toContain('path="apps/vercel-connect"');
|
||||
expect(appSource).toContain('<AppsConnectEntryRoute credentialSource="vercel_connect" />');
|
||||
expect(appSource).toContain('<AppsConnect credentialSource={credentialSource} />');
|
||||
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
|
||||
import { Navigate, Outlet, Route, Routes, useActiveCompanyPrefix, useLocation, useParams } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "@/i18n";
|
||||
|
|
@ -157,6 +158,10 @@ function boardRoutes() {
|
|||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Connections />} />
|
||||
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
|
||||
<Route
|
||||
path="apps/vercel-connect"
|
||||
element={<AppsConnectEntryRoute credentialSource="vercel_connect" />}
|
||||
/>
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
|
|
@ -350,10 +355,16 @@ function boardRoutes() {
|
|||
);
|
||||
}
|
||||
|
||||
function AppsConnectEntryRoute() {
|
||||
function AppsConnectEntryRoute({
|
||||
credentialSource = "paperclip_vault",
|
||||
}: {
|
||||
credentialSource?: ToolConnectionCredentialSource;
|
||||
} = {}) {
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
return canEnterAppsConnect(searchParams) ? <AppsConnect /> : <Navigate to="/apps" replace />;
|
||||
return canEnterAppsConnect(searchParams)
|
||||
? <AppsConnect credentialSource={credentialSource} />
|
||||
: <Navigate to="/apps" replace />;
|
||||
}
|
||||
|
||||
function InboxRootRedirect() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ export const connectionIntentsApi = {
|
|||
api.get<ConnectionIntentSetupOptions>(
|
||||
`/connection-intents/${interactionId}/setup-options`,
|
||||
),
|
||||
setPhase: (
|
||||
interactionId: string,
|
||||
phase: ConnectionIntentInteraction["payload"]["phase"],
|
||||
) =>
|
||||
api.post<ConnectionIntentInteraction>(
|
||||
`/connection-intents/${interactionId}/phase`,
|
||||
{ phase },
|
||||
),
|
||||
complete: (interactionId: string, connectionId: string) =>
|
||||
api.post<ConnectionIntentInteraction>(
|
||||
`/connection-intents/${interactionId}/complete`,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import type {
|
|||
} from "@/pages/apps/composio-services";
|
||||
import type {
|
||||
ToolApplication,
|
||||
ConnectToolApp,
|
||||
ToolConnection,
|
||||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionRemovalSummary,
|
||||
ConnectToolAppResult,
|
||||
ConnectToolApp,
|
||||
FinishToolAppResult,
|
||||
ToolCatalogEntry,
|
||||
ToolRuntimeSlot,
|
||||
|
|
@ -61,9 +61,11 @@ import type {
|
|||
CreateToolTrustRuleFromActionRequest,
|
||||
ToolRedactedValueSummary,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantDelegation,
|
||||
ConnectionGrantsResponse,
|
||||
ToolConnectionCreateCapabilities,
|
||||
ToolAppMetadataPreflightResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
|
|
@ -87,6 +89,15 @@ export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
|
|||
export type ToolGalleryResponse = {
|
||||
apps: AppDefinition[];
|
||||
capabilities: ToolConnectionCreateCapabilities;
|
||||
credentialSources: {
|
||||
vercelConnect: {
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
authentication: "workload_oidc" | "access_token" | null;
|
||||
manageUrl: string;
|
||||
reason: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
|
||||
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
|
||||
|
|
@ -268,12 +279,30 @@ export const toolsApi = {
|
|||
// --- Applications ---
|
||||
listGallery: (companyId: string) =>
|
||||
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
|
||||
preflightAppMetadata: (companyId: string, galleryKey: string, methodKey?: string | null) => {
|
||||
const query = methodKey ? `?methodKey=${encodeURIComponent(methodKey)}` : "";
|
||||
return api.get<ToolAppMetadataPreflightResult>(
|
||||
`/companies/${companyId}/tools/apps/${encodeURIComponent(galleryKey)}/preflight${query}`,
|
||||
);
|
||||
},
|
||||
connectApp: (companyId: string, input: ConnectToolApp) =>
|
||||
api.post<ConnectToolAppResult>(`/companies/${companyId}/tools/apps/connect`, input),
|
||||
startOAuth: (connectionId: string, interactionId?: string) =>
|
||||
api.post<ToolOAuthStartResult>(
|
||||
`/tools/oauth/${connectionId}/start`,
|
||||
interactionId ? { interactionId } : {},
|
||||
startOAuth: (
|
||||
connectionId: string,
|
||||
input: {
|
||||
asCurrentUser?: boolean;
|
||||
interactionId?: string;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<ToolOAuthStartResult>(`/tools/oauth/${connectionId}/start`, input),
|
||||
finalizeOAuthAccess: (
|
||||
companyId: string,
|
||||
connectionId: string,
|
||||
input: { grantKind: ConnectionGrantKind },
|
||||
) =>
|
||||
api.post<FinishToolAppResult>(
|
||||
`/companies/${companyId}/tools/apps/${connectionId}/finalize-oauth-access`,
|
||||
input,
|
||||
),
|
||||
finishApp: (companyId: string, connectionId: string, input: {
|
||||
enabledCatalogEntryIds: string[];
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { AppLogo } from "@/pages/apps/AppLogo";
|
|||
import {
|
||||
appApplicationSourceSlug,
|
||||
appConnectionSourceSlug,
|
||||
appDefinitionDarkLogoUrl,
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
appDefinitionSlug,
|
||||
|
|
@ -106,6 +107,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
|
|||
brandKey={brandKey}
|
||||
logoUrl={appDefinitionLogoUrl(logoEntry)}
|
||||
allowRemoteFallback={!applicationsQuery.isPending}
|
||||
darkLogoUrl={appDefinitionDarkLogoUrl(logoEntry)}
|
||||
size={28}
|
||||
/>
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
|
||||
|
|
|
|||
|
|
@ -146,8 +146,11 @@ describe("AppsSidebar", () => {
|
|||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Applications" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles", end: true }),
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/gateways", label: "Gateways" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" }));
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Health" }));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { SidebarNavItem } from "./SidebarNavItem";
|
|||
* PAP-13254 / U3).
|
||||
*
|
||||
* ← Back · APPS: Browse / Review (n)
|
||||
* DEVELOPER: Connections / Gateways / Profiles / Activity
|
||||
* DEVELOPER: Connections / Activity
|
||||
*
|
||||
* "Browse" is the store and "Review" holds decisions waiting on the user's
|
||||
* OK. Connection management lives with the Developer tools.
|
||||
|
|
@ -30,9 +30,12 @@ export function AppsSidebar() {
|
|||
|
||||
const reviewCount = useReviewCount();
|
||||
const { enabled: smokeLabEnabled } = useSmokeLabEnabled();
|
||||
const developerTabs = DEVELOPER_TABS.filter(
|
||||
(tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled,
|
||||
);
|
||||
const developerTabs = DEVELOPER_TABS.filter((tab) => {
|
||||
// Temporarily hide Gateways and Profiles until they are ready to ship.
|
||||
// Keep their tab definitions and routes intact so we can bring them back later.
|
||||
if (tab.key === "gateways" || tab.key === "profiles") return false;
|
||||
return !isExperimentalToolTab(tab.key) || smokeLabEnabled;
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, Clock, Loader2, Plug, XCircle } from "lucide-react";
|
||||
import type {
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentSetupOptions,
|
||||
} from "@paperclipai/shared";
|
||||
import { connectionIntentsApi } from "@/api/connection-intents";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "@/pages/apps/app-connect-policy";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export interface ConnectionIntentInteractionBodyProps {
|
||||
interaction: ConnectionIntentInteraction;
|
||||
currentUserId?: string | null;
|
||||
addresseeLabel: string;
|
||||
}
|
||||
|
||||
export function ConnectionIntentInteractionBody({
|
||||
interaction,
|
||||
currentUserId,
|
||||
addresseeLabel,
|
||||
}: ConnectionIntentInteractionBodyProps) {
|
||||
const [current, setCurrent] = useState(interaction);
|
||||
const [options, setOptions] = useState<ConnectionIntentSetupOptions | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setCurrent(interaction), [interaction]);
|
||||
|
||||
const isAddressee = Boolean(currentUserId && current.addresseeUserId === currentUserId);
|
||||
const connectHref = isMcpDirectOAuthConnectSlug(current.payload.serviceSlug)
|
||||
? appSourceConnectHref(current.payload.serviceSlug, current.id)
|
||||
: `/apps/connect?${new URLSearchParams({
|
||||
byo: "1",
|
||||
appKey: current.payload.serviceSlug,
|
||||
intent: current.id,
|
||||
}).toString()}`;
|
||||
|
||||
async function loadOptions() {
|
||||
setExpanded(true);
|
||||
setPendingAction("load");
|
||||
setError(null);
|
||||
try {
|
||||
setOptions(await connectionIntentsApi.setupOptions(current.id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Couldn’t load connection options.");
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function complete(connectionId: string) {
|
||||
setPendingAction(connectionId);
|
||||
setError(null);
|
||||
try {
|
||||
setCurrent(await connectionIntentsApi.complete(current.id, connectionId));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Couldn’t use this connection.");
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
setPendingAction("decline");
|
||||
setError(null);
|
||||
try {
|
||||
setCurrent(await connectionIntentsApi.decline(current.id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Couldn’t decline this request.");
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.status === "accepted" || current.status === "rejected" || current.status === "expired") {
|
||||
const connected = current.status === "accepted";
|
||||
const StatusIcon = connected ? CheckCircle2 : XCircle;
|
||||
const title = connected
|
||||
? `${current.payload.serviceName} connected`
|
||||
: current.status === "rejected"
|
||||
? "Connection declined"
|
||||
: "Connection request expired";
|
||||
return (
|
||||
<div className="flex items-start gap-3" data-testid="connection-intent-terminal">
|
||||
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{connected
|
||||
? `${current.payload.requestingAgentName} can use this connection on its continuation run.`
|
||||
: `${current.payload.requestingAgentName} can continue without this connection.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAddressee) {
|
||||
return (
|
||||
<div className="flex items-start gap-3" data-testid="connection-intent-waiting">
|
||||
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Waiting for {addresseeLabel}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Only the addressed person can choose or create a connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="connection-intent-actions">
|
||||
<div className="flex items-start gap-3">
|
||||
<AppLogo
|
||||
name={current.payload.serviceName}
|
||||
brandKey={current.payload.serviceSlug}
|
||||
logoUrl={current.payload.serviceLogoUrl}
|
||||
size={40}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{current.payload.requestingAgentName} needs {current.payload.serviceName}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Reuse an eligible connection or connect a new identity for this agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={() => void loadOptions()} disabled={pendingAction !== null}>
|
||||
{pendingAction === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
|
||||
Connect / Use existing
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => void decline()}
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && pendingAction === "load" ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">Loading connection options…</p>
|
||||
) : null}
|
||||
{expanded && options ? (
|
||||
<div className="mt-3 space-y-2 rounded-md border border-border bg-muted/30 p-3">
|
||||
{options.existingConnections.map((connection) => (
|
||||
<Button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => void complete(connection.id)}
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
{pendingAction === connection.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
|
||||
Use {connection.name}
|
||||
</Button>
|
||||
))}
|
||||
<Button asChild type="button" variant="outline" className="w-full justify-start">
|
||||
<Link to={connectHref}>Connect a new {current.payload.serviceName} identity</Link>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Finishing setup will grant the new identity and resolve this request automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="mt-3 text-sm text-destructive" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { act as reactAct, type ComponentProps, type ReactNode } from "react";
|
|||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { ApiError } from "../api/client";
|
||||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
|
|
@ -48,6 +49,8 @@ import {
|
|||
companyCappedRequestConfirmationInteraction,
|
||||
legacyRestrictedRequestConfirmationInteraction,
|
||||
pendingConnectionIntentInteraction,
|
||||
retryConnectionIntentInteraction,
|
||||
connectedConnectionIntentInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
|
||||
let root: Root | null = null;
|
||||
|
|
@ -89,17 +92,22 @@ function renderCard(
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingAskUserQuestionsInteraction}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingAskUserQuestionsInteraction}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -116,7 +124,7 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("IssueThreadInteractionCard", () => {
|
||||
it("offers connection resolution actions to the addressed user", async () => {
|
||||
it("opens the shared connection setup for the addressed user", async () => {
|
||||
connectionIntentsApiMocks.setupOptions.mockResolvedValue({ existingConnections: [] });
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionIntentInteraction,
|
||||
|
|
@ -133,12 +141,15 @@ describe("IssueThreadInteractionCard", () => {
|
|||
loadButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const connectLink = Array.from(host.querySelectorAll("a")).find((link) =>
|
||||
link.textContent === "Connect a new Notion identity",
|
||||
);
|
||||
expect(connectLink?.getAttribute("href")).toBe(
|
||||
"/apps/connect?source=notion&intent=interaction-connection-intent-default",
|
||||
await vi.waitFor(() =>
|
||||
expect(connectionIntentsApiMocks.setupOptions).toHaveBeenCalledWith(
|
||||
"interaction-connection-intent-default",
|
||||
),
|
||||
);
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog?.textContent).toContain("Connect Notion");
|
||||
expect(dialog?.textContent).toContain("Complete connection setup without leaving this task.");
|
||||
});
|
||||
|
||||
it("keeps connection resolution controls exclusive to the addressed user", () => {
|
||||
|
|
@ -1391,6 +1402,55 @@ describe("IssueThreadInteractionCard connection-authorization card", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("IssueThreadInteractionCard connection-intent card", () => {
|
||||
const USER_LABELS = new Map([[issueThreadInteractionFixtureMeta.currentUserId, "Carol"]]);
|
||||
|
||||
it("offers the addressed user the shared setup entry point and Not now", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionIntentInteraction,
|
||||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
});
|
||||
expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull();
|
||||
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
|
||||
expect(labels).toContain("Connect / Use existing");
|
||||
expect(labels).toContain("Not now");
|
||||
expect(host.textContent).toContain("Access is added only for this agent");
|
||||
});
|
||||
|
||||
it("shows another viewer only who it is waiting for", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionIntentInteraction,
|
||||
currentUserId: "user-someone-else",
|
||||
userLabelMap: USER_LABELS,
|
||||
});
|
||||
expect(host.querySelector('[data-testid="connection-intent-waiting"]')?.textContent)
|
||||
.toContain("Waiting for Carol");
|
||||
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeNull();
|
||||
expect(host.querySelectorAll("button")).toHaveLength(0);
|
||||
expect(host.innerHTML).not.toContain("authorizationUrl");
|
||||
});
|
||||
|
||||
it("renders retry and connected terminal states without generic confirmation actions", () => {
|
||||
const retry = renderCard({
|
||||
interaction: retryConnectionIntentInteraction,
|
||||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
});
|
||||
expect(retry.textContent).toContain("Authorization didn’t finish");
|
||||
expect(retry.textContent).toContain("Try again");
|
||||
|
||||
act(() => root?.unmount());
|
||||
retry.remove();
|
||||
root = null;
|
||||
const connected = renderCard({
|
||||
interaction: connectedConnectionIntentInteraction,
|
||||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
});
|
||||
expect(connected.querySelector('[data-testid="connection-intent-terminal"]')?.textContent)
|
||||
.toContain("Notion connected");
|
||||
expect(connected.textContent).not.toContain("Approve");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueThreadInteractionCard resolver audience", () => {
|
||||
it("shows an open audience on a pending card created without a restriction", () => {
|
||||
const host = renderCard({ interaction: pendingRequestConfirmationInteraction });
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import { Textarea } from "./ui/textarea";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProposalJustification } from "../pages/secrets/proposal-review";
|
||||
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
|
||||
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
|
|
@ -200,6 +200,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
|
|||
return "Checkbox confirmation";
|
||||
case "request_item_verdicts":
|
||||
return "Item verdicts";
|
||||
case "connection_intent":
|
||||
return "Connection request";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
|
|
@ -4082,7 +4084,7 @@ export function IssueThreadInteractionCard({
|
|||
below, because the closing sentence depends on whether the
|
||||
reader is the person who may consent. Rendering the summary here
|
||||
as well would be the second body PAP-17859 removed. */}
|
||||
{interaction.summary && !connectionAuthorization ? (
|
||||
{interaction.summary && !connectionAuthorization && interaction.kind !== "connection_intent" ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
{interaction.summary}
|
||||
</p>
|
||||
|
|
@ -4162,18 +4164,18 @@ export function IssueThreadInteractionCard({
|
|||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_item_verdicts" ? (
|
||||
<RequestItemVerdictsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "connection_intent" ? (
|
||||
<ConnectionIntentInteractionBody
|
||||
interaction={interaction}
|
||||
currentUserId={currentUserId}
|
||||
addresseeLabel={addresseeLabel ?? "the addressed person"}
|
||||
/>
|
||||
) : interaction.kind === "request_item_verdicts" ? (
|
||||
<RequestItemVerdictsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
|
|
|
|||
|
|
@ -644,7 +644,7 @@ describe("Layout", () => {
|
|||
|
||||
// Reserved Apps subroutes are not connection ids. They must keep the
|
||||
// top-level Apps sidebar, never mount a detail sidebar for a phantom app.
|
||||
it.each(["browse", "connections", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
|
||||
it.each(["browse", "connections", "vercel-connect", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
|
||||
currentPathname = `/PAP/apps/${route}`;
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const RESERVED_APP_SUBPATHS = new Set([
|
|||
"browse",
|
||||
"connections",
|
||||
"connect",
|
||||
"vercel-connect",
|
||||
"review",
|
||||
"attention",
|
||||
"gateways",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act as reactAct, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ConnectionIntentInteraction,
|
||||
ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
connectedConnectionIntentInteraction,
|
||||
issueThreadInteractionFixtureMeta,
|
||||
pendingConnectionIntentInteraction,
|
||||
retryConnectionIntentInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
|
||||
|
||||
const setupOptionsMock = vi.hoisted(() => vi.fn());
|
||||
const completeMock = vi.hoisted(() => vi.fn());
|
||||
const declineMock = vi.hoisted(() => vi.fn());
|
||||
const setPhaseMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/connection-intents", () => ({
|
||||
connectionIntentsApi: {
|
||||
setupOptions: (...args: unknown[]) => setupOptionsMock(...args),
|
||||
complete: (...args: unknown[]) => completeMock(...args),
|
||||
decline: (...args: unknown[]) => declineMock(...args),
|
||||
setPhase: (...args: unknown[]) => setPhaseMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./ConnectionSetupFlow", () => ({
|
||||
ConnectionSetupFlow: (props: {
|
||||
requestedAgentId?: string;
|
||||
existingConnections?: ToolConnection[];
|
||||
onUseExisting?: (id: string) => Promise<void>;
|
||||
onComplete?: (completion: { connectionId: string }) => void;
|
||||
onPhaseChange?: (phase: "needs_retry") => void;
|
||||
onCancel?: () => void;
|
||||
}) => (
|
||||
<div data-testid="shared-connection-setup">
|
||||
<span data-testid="requested-agent">{props.requestedAgentId}</span>
|
||||
<span data-testid="existing-count">
|
||||
{props.existingConnections?.length ?? 0}
|
||||
</span>
|
||||
{props.existingConnections?.map((connection) => (
|
||||
<button
|
||||
key={connection.id}
|
||||
onClick={() => void props.onUseExisting?.(connection.id)}
|
||||
>
|
||||
Use {connection.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => props.onComplete?.({ connectionId: "connection-new" })}
|
||||
>
|
||||
Connect new
|
||||
</button>
|
||||
<button onClick={() => props.onPhaseChange?.("needs_retry")}>
|
||||
Simulate retry
|
||||
</button>
|
||||
<button onClick={props.onCancel}>Cancel setup</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement | null = null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
await reactAct(callback);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAssertion(assertion: () => void, attempts = 20) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function terminal(
|
||||
status: ConnectionIntentInteraction["status"],
|
||||
outcome: "declined" | "superseded" | "expired",
|
||||
): ConnectionIntentInteraction {
|
||||
return {
|
||||
...pendingConnectionIntentInteraction,
|
||||
id: `interaction-${outcome}`,
|
||||
status,
|
||||
resolvedAt: new Date("2026-08-26T12:00:00.000Z"),
|
||||
result: { version: 1, outcome },
|
||||
} as ConnectionIntentInteraction;
|
||||
}
|
||||
|
||||
function renderBody(
|
||||
interaction: ConnectionIntentInteraction = pendingConnectionIntentInteraction,
|
||||
currentUserId:
|
||||
string | null = issueThreadInteractionFixtureMeta.currentUserId,
|
||||
) {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
void act(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConnectionIntentInteractionBody
|
||||
interaction={interaction}
|
||||
currentUserId={currentUserId}
|
||||
addresseeLabel="Carol"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return Array.from(document.body.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setupOptionsMock.mockReset();
|
||||
completeMock.mockReset();
|
||||
declineMock.mockReset();
|
||||
setPhaseMock.mockReset();
|
||||
setupOptionsMock.mockResolvedValue({
|
||||
requestedAgentId:
|
||||
pendingConnectionIntentInteraction.payload.requestingAgentId,
|
||||
existingConnections: [],
|
||||
});
|
||||
completeMock.mockResolvedValue({});
|
||||
declineMock.mockResolvedValue({});
|
||||
setPhaseMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(() => root?.unmount());
|
||||
host?.remove();
|
||||
document.body
|
||||
.querySelectorAll("[data-radix-focus-guard]")
|
||||
.forEach((node) => node.remove());
|
||||
root = null;
|
||||
host = null;
|
||||
});
|
||||
|
||||
describe("ConnectionIntentInteractionBody states and audience", () => {
|
||||
it.each([
|
||||
[pendingConnectionIntentInteraction, "Connect / Use existing"],
|
||||
[
|
||||
{
|
||||
...pendingConnectionIntentInteraction,
|
||||
payload: {
|
||||
...pendingConnectionIntentInteraction.payload,
|
||||
phase: "authorizing",
|
||||
},
|
||||
},
|
||||
"Authorizing…",
|
||||
],
|
||||
[retryConnectionIntentInteraction, "Try again"],
|
||||
[connectedConnectionIntentInteraction, "Notion connected"],
|
||||
[terminal("rejected", "declined"), "Connection declined"],
|
||||
[terminal("expired", "superseded"), "Request superseded"],
|
||||
[terminal("expired", "expired"), "Connection request expired"],
|
||||
] as const)("renders the %s state", (interaction, expected) => {
|
||||
renderBody(interaction as ConnectionIntentInteraction);
|
||||
expect(document.body.textContent).toContain(expected);
|
||||
});
|
||||
|
||||
it("shows non-addressees only the waiting state and never loads setup", () => {
|
||||
renderBody(pendingConnectionIntentInteraction, "other-user");
|
||||
expect(document.body.textContent).toContain("Waiting for Carol");
|
||||
expect(button("Connect / Use existing")).toBeUndefined();
|
||||
expect(button("Not now")).toBeUndefined();
|
||||
expect(setupOptionsMock).not.toHaveBeenCalled();
|
||||
expect(document.body.innerHTML).not.toMatch(
|
||||
/authorizationUrl|bearer|credential/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionIntentInteractionBody dialog behavior", () => {
|
||||
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
|
||||
let resolveSetup!: (value: unknown) => void;
|
||||
setupOptionsMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveSetup = resolve;
|
||||
}),
|
||||
);
|
||||
renderBody();
|
||||
|
||||
await act(() => button("Connect / Use existing")?.click());
|
||||
expect(document.body.textContent).toContain("Loading connection options…");
|
||||
|
||||
await act(async () => {
|
||||
resolveSetup({
|
||||
requestedAgentId: "agent-requesting",
|
||||
existingConnections: [
|
||||
{ id: "connection-one", name: "Carol's Notion" },
|
||||
{ id: "connection-two", name: "Team Notion" },
|
||||
],
|
||||
});
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="requested-agent"]')?.textContent,
|
||||
).toBe("agent-requesting");
|
||||
expect(
|
||||
document.querySelector('[data-testid="existing-count"]')?.textContent,
|
||||
).toBe("2");
|
||||
expect(button("Use Carol's Notion")).toBeDefined();
|
||||
expect(button("Use Team Notion")).toBeDefined();
|
||||
expect(button("Connect new")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps a load failure open and recovers through the query retry", async () => {
|
||||
setupOptionsMock.mockRejectedValueOnce(new Error("setup unavailable"));
|
||||
renderBody();
|
||||
await act(() => button("Connect / Use existing")?.click());
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"Couldn’t load connection setup",
|
||||
);
|
||||
expect(document.body.textContent).toContain("setup unavailable");
|
||||
setupOptionsMock.mockResolvedValueOnce({
|
||||
requestedAgentId: "agent-requesting",
|
||||
existingConnections: [],
|
||||
});
|
||||
await act(() => button("Try again")?.click());
|
||||
await flush();
|
||||
expect(
|
||||
document.querySelector('[data-testid="shared-connection-setup"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("completes an existing connection, closes, restores focus, and invalidates each task query once", async () => {
|
||||
setupOptionsMock.mockResolvedValue({
|
||||
requestedAgentId: "agent-requesting",
|
||||
existingConnections: [{ id: "connection-one", name: "Carol's Notion" }],
|
||||
});
|
||||
renderBody();
|
||||
const trigger = button("Connect / Use existing")!;
|
||||
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
await act(() => trigger.click());
|
||||
await flush();
|
||||
await act(() => button("Use Carol's Notion")?.click());
|
||||
await flush();
|
||||
|
||||
expect(completeMock).toHaveBeenCalledWith(
|
||||
pendingConnectionIntentInteraction.id,
|
||||
"connection-one",
|
||||
);
|
||||
expect(
|
||||
document.querySelector('[data-testid="shared-connection-setup"]'),
|
||||
).toBeNull();
|
||||
await waitForAssertion(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
document.querySelector(
|
||||
'[data-testid="connection-intent-focus-target"]',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(invalidation).toHaveBeenCalledTimes(2);
|
||||
expect(invalidation).toHaveBeenCalledWith({
|
||||
queryKey: ["issues", "interactions"],
|
||||
});
|
||||
expect(invalidation).toHaveBeenCalledWith({
|
||||
queryKey: ["issues", "detail"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the dialog open and surfaces completion failures", async () => {
|
||||
completeMock.mockRejectedValue(new Error("install commit failed"));
|
||||
renderBody();
|
||||
await act(() => button("Connect / Use existing")?.click());
|
||||
await flush();
|
||||
await act(() => button("Connect new")?.click());
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="shared-connection-setup"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.body.querySelector('[role="alert"]')?.textContent,
|
||||
).toContain("install commit failed");
|
||||
});
|
||||
|
||||
it("declines once with pending controls disabled and invalidates each task query once", async () => {
|
||||
let finishDecline!: () => void;
|
||||
declineMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishDecline = resolve;
|
||||
}),
|
||||
);
|
||||
renderBody();
|
||||
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const decline = button("Not now")!;
|
||||
|
||||
await act(() => decline.click());
|
||||
await flush();
|
||||
expect(decline.disabled).toBe(true);
|
||||
decline.click();
|
||||
expect(declineMock).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finishDecline());
|
||||
await flush();
|
||||
|
||||
expect(invalidation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("turns shared-flow retry signals into the server-authored retry phase", async () => {
|
||||
renderBody();
|
||||
await act(() => button("Connect / Use existing")?.click());
|
||||
await flush();
|
||||
await act(() => button("Simulate retry")?.click());
|
||||
await flush();
|
||||
expect(setPhaseMock).toHaveBeenCalledWith(
|
||||
pendingConnectionIntentInteraction.id,
|
||||
"needs_retry",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
Plug,
|
||||
RotateCcw,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
|
||||
import { connectionIntentsApi } from "@/api/connection-intents";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ConnectionSetupFlow,
|
||||
type ConnectionSetupCompletion,
|
||||
} from "./ConnectionSetupFlow";
|
||||
|
||||
export interface ConnectionIntentInteractionBodyProps {
|
||||
interaction: ConnectionIntentInteraction;
|
||||
currentUserId?: string | null;
|
||||
addresseeLabel: string;
|
||||
}
|
||||
|
||||
export function ConnectionIntentInteractionBody({
|
||||
interaction,
|
||||
currentUserId,
|
||||
addresseeLabel,
|
||||
}: ConnectionIntentInteractionBodyProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const focusTargetRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isAddressee = Boolean(
|
||||
currentUserId && interaction.addresseeUserId === currentUserId,
|
||||
);
|
||||
const isPending = interaction.status === "pending";
|
||||
|
||||
const invalidateTask = async (
|
||||
updatedInteraction?: ConnectionIntentInteraction,
|
||||
) => {
|
||||
if (updatedInteraction) {
|
||||
queryClient.setQueriesData<ConnectionIntentInteraction[]>(
|
||||
{ queryKey: ["issues", "interactions"] },
|
||||
(current) =>
|
||||
current?.map((candidate) =>
|
||||
candidate.id === updatedInteraction.id
|
||||
? updatedInteraction
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
}
|
||||
await Promise.all([
|
||||
// Task routes may key these caches by either UUID or human identifier.
|
||||
// Prefix invalidation reaches the mounted task without requiring that
|
||||
// routing identity to leak into the reusable interaction card.
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "interactions"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "detail"] }),
|
||||
]);
|
||||
};
|
||||
const returnFocusToCard = () => {
|
||||
window.requestAnimationFrame(() => focusTargetRef.current?.focus());
|
||||
};
|
||||
|
||||
const setupQuery = useQuery({
|
||||
queryKey: ["connection-intent", interaction.id, "setup-options"],
|
||||
queryFn: () => connectionIntentsApi.setupOptions(interaction.id),
|
||||
enabled: open && isAddressee && isPending,
|
||||
refetchInterval: open && isPending ? 2_000 : false,
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (connectionId: string) =>
|
||||
connectionIntentsApi.complete(interaction.id, connectionId),
|
||||
onSuccess: async (updatedInteraction) => {
|
||||
await invalidateTask(updatedInteraction);
|
||||
setOpen(false);
|
||||
returnFocusToCard();
|
||||
},
|
||||
});
|
||||
const declineMutation = useMutation({
|
||||
mutationFn: () => connectionIntentsApi.decline(interaction.id),
|
||||
onSuccess: async (updatedInteraction) => {
|
||||
await invalidateTask(updatedInteraction);
|
||||
setOpen(false);
|
||||
returnFocusToCard();
|
||||
},
|
||||
});
|
||||
const phaseMutation = useMutation({
|
||||
mutationFn: (phase: ConnectionIntentInteraction["payload"]["phase"]) =>
|
||||
connectionIntentsApi.setPhase(interaction.id, phase),
|
||||
onSuccess: invalidateTask,
|
||||
});
|
||||
const mutatePhase = phaseMutation.mutate;
|
||||
const handlePhaseChange = useCallback(
|
||||
(phase: ConnectionIntentInteraction["payload"]["phase"]) =>
|
||||
mutatePhase(phase),
|
||||
[mutatePhase],
|
||||
);
|
||||
|
||||
const finishNewConnection = async (completion: ConnectionSetupCompletion) => {
|
||||
if (completion.resolvedByCallback) {
|
||||
await invalidateTask();
|
||||
setOpen(false);
|
||||
returnFocusToCard();
|
||||
return;
|
||||
}
|
||||
completeMutation.mutate(completion.connectionId);
|
||||
};
|
||||
|
||||
const resultOutcome = interaction.result?.outcome;
|
||||
const status =
|
||||
interaction.status === "accepted"
|
||||
? {
|
||||
icon: CheckCircle2,
|
||||
title: `${interaction.payload.serviceName} connected`,
|
||||
body: `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`,
|
||||
}
|
||||
: interaction.status === "rejected"
|
||||
? {
|
||||
icon: XCircle,
|
||||
title: "Connection declined",
|
||||
body: `${interaction.payload.requestingAgentName} was notified and can continue without it.`,
|
||||
}
|
||||
: interaction.status === "expired"
|
||||
? {
|
||||
icon: Clock,
|
||||
title:
|
||||
resultOutcome === "superseded"
|
||||
? "Request superseded"
|
||||
: "Connection request expired",
|
||||
body:
|
||||
resultOutcome === "superseded"
|
||||
? "A newer run requested this connection. Use the latest card instead."
|
||||
: "This request is no longer active.",
|
||||
}
|
||||
: null;
|
||||
const StatusIcon = status?.icon;
|
||||
|
||||
if (status && StatusIcon) {
|
||||
return (
|
||||
<div
|
||||
ref={focusTargetRef}
|
||||
tabIndex={-1}
|
||||
data-testid="connection-intent-focus-target"
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
data-testid="connection-intent-terminal"
|
||||
>
|
||||
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{status.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{status.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAddressee) {
|
||||
return (
|
||||
<div
|
||||
ref={focusTargetRef}
|
||||
tabIndex={-1}
|
||||
data-testid="connection-intent-focus-target"
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
data-testid="connection-intent-waiting"
|
||||
>
|
||||
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Waiting for {addresseeLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Only the addressed person can choose an identity or authorize this
|
||||
connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const needsRetry = interaction.payload.phase === "needs_retry";
|
||||
const authorizing = interaction.payload.phase === "authorizing";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={focusTargetRef}
|
||||
tabIndex={-1}
|
||||
data-testid="connection-intent-focus-target"
|
||||
>
|
||||
<div data-testid="connection-intent-actions">
|
||||
<div className="flex items-start gap-3">
|
||||
<AppLogo
|
||||
name={interaction.payload.serviceName}
|
||||
logoUrl={interaction.payload.serviceLogoUrl}
|
||||
darkLogoUrl={interaction.payload.serviceDarkLogoUrl}
|
||||
size={40}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{interaction.payload.requestingAgentName} needs{" "}
|
||||
{interaction.payload.serviceName}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connect your identity or reuse an eligible connection. Access is
|
||||
added only for this agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsRetry ? (
|
||||
<p className="mt-4 flex items-center gap-2 text-sm text-destructive">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Authorization didn’t finish. Your previous choices are safe; try
|
||||
again.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" disabled={authorizing}>
|
||||
{authorizing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plug className="h-4 w-4" />
|
||||
)}
|
||||
{authorizing
|
||||
? "Authorizing…"
|
||||
: needsRetry
|
||||
? "Try again"
|
||||
: "Connect / Use existing"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="!max-w-(--pct-90) max-h-(--sz-85vh) w-full overflow-y-auto sm:max-w-5xl"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
focusTargetRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>
|
||||
Connect {interaction.payload.serviceName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete connection setup without leaving this task.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{setupQuery.isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading
|
||||
connection options…
|
||||
</div>
|
||||
) : setupQuery.isError ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
Couldn’t load connection setup
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{setupQuery.error instanceof Error
|
||||
? setupQuery.error.message
|
||||
: "Try again."}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => setupQuery.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : setupQuery.data ? (
|
||||
<ConnectionSetupFlow
|
||||
host="dialog"
|
||||
serviceSlug={interaction.payload.serviceSlug}
|
||||
requestedAgentId={setupQuery.data.requestedAgentId}
|
||||
interactionId={interaction.id}
|
||||
existingConnections={setupQuery.data.existingConnections}
|
||||
onUseExisting={async (connectionId) => {
|
||||
await completeMutation.mutateAsync(connectionId);
|
||||
}}
|
||||
onComplete={(completion) => {
|
||||
void finishNewConnection(completion);
|
||||
}}
|
||||
onOAuthDeclined={() => declineMutation.mutate()}
|
||||
onPhaseChange={handlePhaseChange}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={declineMutation.isPending || authorizing}
|
||||
onClick={() => declineMutation.mutate()}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{completeMutation.isError ||
|
||||
declineMutation.isError ||
|
||||
phaseMutation.isError ? (
|
||||
<p className="mt-3 text-sm text-destructive" role="alert">
|
||||
{(completeMutation.error ??
|
||||
declineMutation.error ??
|
||||
phaseMutation.error) instanceof Error
|
||||
? (
|
||||
completeMutation.error ??
|
||||
declineMutation.error ??
|
||||
phaseMutation.error
|
||||
)?.message
|
||||
: "Couldn’t update this connection request."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function source(relativePath: string) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
describe("shared connection setup architecture", () => {
|
||||
it("keeps both hosts thin and routes provider setup through ConnectionSetupFlow", () => {
|
||||
const pageHost = source("../../pages/apps/AppsConnect.tsx");
|
||||
const taskHost = source("./ConnectionIntentInteractionBody.tsx");
|
||||
|
||||
for (const host of [pageHost, taskHost]) {
|
||||
expect(host).toContain("ConnectionSetupFlow");
|
||||
expect(host).not.toContain("CONNECTABLE_APP_DEFINITIONS");
|
||||
expect(host).not.toContain("ProviderCredentialField");
|
||||
expect(host).not.toContain("getAvailableConnectionMethods");
|
||||
expect(host).not.toContain("toolsApi.connectApp");
|
||||
expect(host).not.toContain("toolsApi.startOAuth");
|
||||
}
|
||||
|
||||
expect(pageHost).not.toContain("connectionIntentsApi");
|
||||
expect(taskHost).not.toContain("connect-helpers");
|
||||
expect(taskHost).not.toContain("connect-ui");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
|
||||
import { getConnectableAppDefinition } from "@paperclipai/shared";
|
||||
import {
|
||||
isConnectionDefinitionUnavailable,
|
||||
isVercelConnectUnavailable,
|
||||
readConnectionIntentOAuthOutcome,
|
||||
retainedReconnectMatches,
|
||||
requestedConnectionSetupResolution,
|
||||
requestedConnectionEntry,
|
||||
} from "./ConnectionSetupFlow";
|
||||
|
||||
const origin = "https://paperclip.test";
|
||||
const interactionId = "interaction-123";
|
||||
|
||||
function event(data: unknown, eventOrigin = origin) {
|
||||
return { origin: eventOrigin, data };
|
||||
}
|
||||
|
||||
describe("connection intent OAuth window messages", () => {
|
||||
it.each(["connected", "declined", "failed"] as const)(
|
||||
"accepts a matching %s outcome",
|
||||
(outcome) => {
|
||||
expect(
|
||||
readConnectionIntentOAuthOutcome(
|
||||
event({
|
||||
type: "paperclip.connection-intent.oauth",
|
||||
interactionId,
|
||||
outcome,
|
||||
}),
|
||||
origin,
|
||||
interactionId,
|
||||
),
|
||||
).toBe(outcome);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"foreign origin",
|
||||
event(
|
||||
{
|
||||
type: "paperclip.connection-intent.oauth",
|
||||
interactionId,
|
||||
outcome: "connected",
|
||||
},
|
||||
"https://attacker.test",
|
||||
),
|
||||
],
|
||||
[
|
||||
"wrong interaction",
|
||||
event({
|
||||
type: "paperclip.connection-intent.oauth",
|
||||
interactionId: "other",
|
||||
outcome: "connected",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"wrong message type",
|
||||
event({ type: "other", interactionId, outcome: "connected" }),
|
||||
],
|
||||
[
|
||||
"unknown outcome",
|
||||
event({
|
||||
type: "paperclip.connection-intent.oauth",
|
||||
interactionId,
|
||||
outcome: "authorized",
|
||||
}),
|
||||
],
|
||||
["non-object payload", event("connected")],
|
||||
])("ignores a %s message", (_label, candidate) => {
|
||||
expect(
|
||||
readConnectionIntentOAuthOutcome(candidate, origin, interactionId),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("retained reconnect definition lookup", () => {
|
||||
const connection = { applicationId: "app-1" } as ToolConnection;
|
||||
|
||||
it("restores a hidden provider only for its exact retained application", () => {
|
||||
const githubApplication = {
|
||||
id: "app-1",
|
||||
applicationKey: "github",
|
||||
metadata: { sourceTemplateKey: "github" },
|
||||
} as unknown as ToolApplication;
|
||||
|
||||
expect(requestedConnectionEntry({
|
||||
requestedAppKey: "github",
|
||||
galleryApps: [],
|
||||
reconnectConnection: connection,
|
||||
applications: [githubApplication],
|
||||
})?.slug).toBe("github");
|
||||
expect(requestedConnectionEntry({
|
||||
requestedAppKey: "notion",
|
||||
galleryApps: [],
|
||||
reconnectConnection: connection,
|
||||
applications: [githubApplication],
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("ends unavailable retained reconnects instead of leaving them loading", () => {
|
||||
expect(requestedConnectionSetupResolution({
|
||||
reconnectConnectionId: "connection-1",
|
||||
hasRequestedEntry: false,
|
||||
supportedMethodCount: 0,
|
||||
unsupportedOAuth: false,
|
||||
vercelUnavailable: false,
|
||||
definitionUnavailable: false,
|
||||
})).toBe("reconnect_unavailable");
|
||||
expect(requestedConnectionSetupResolution({
|
||||
reconnectConnectionId: "connection-1",
|
||||
hasRequestedEntry: true,
|
||||
supportedMethodCount: 0,
|
||||
unsupportedOAuth: false,
|
||||
vercelUnavailable: false,
|
||||
definitionUnavailable: false,
|
||||
})).toBe("reconnect_unavailable");
|
||||
expect(requestedConnectionSetupResolution({
|
||||
reconnectConnectionId: null,
|
||||
hasRequestedEntry: false,
|
||||
supportedMethodCount: 0,
|
||||
unsupportedOAuth: false,
|
||||
vercelUnavailable: false,
|
||||
definitionUnavailable: false,
|
||||
})).toBe("fallback");
|
||||
expect(requestedConnectionSetupResolution({
|
||||
reconnectConnectionId: "connection-1",
|
||||
hasRequestedEntry: true,
|
||||
supportedMethodCount: 1,
|
||||
unsupportedOAuth: false,
|
||||
vercelUnavailable: false,
|
||||
definitionUnavailable: false,
|
||||
})).toBe("ready");
|
||||
});
|
||||
|
||||
it("does not expose a hidden provider without an exact reconnect target", () => {
|
||||
expect(requestedConnectionEntry({
|
||||
requestedAppKey: "github",
|
||||
galleryApps: [],
|
||||
reconnectConnection: null,
|
||||
applications: [],
|
||||
})).toBeNull();
|
||||
const visibleNotion = getConnectableAppDefinition("notion")!;
|
||||
expect(requestedConnectionEntry({
|
||||
requestedAppKey: "notion",
|
||||
galleryApps: [visibleNotion],
|
||||
reconnectConnection: null,
|
||||
applications: [],
|
||||
})).toBe(visibleNotion);
|
||||
});
|
||||
|
||||
it("does not apply fresh-setup availability to an exact retained reconnect", () => {
|
||||
expect(isVercelConnectUnavailable({
|
||||
credentialSource: "vercel_connect",
|
||||
available: false,
|
||||
retainedReconnectMatches: true,
|
||||
})).toBe(false);
|
||||
expect(isVercelConnectUnavailable({
|
||||
credentialSource: "vercel_connect",
|
||||
available: false,
|
||||
retainedReconnectMatches: false,
|
||||
})).toBe(true);
|
||||
expect(isConnectionDefinitionUnavailable({
|
||||
available: false,
|
||||
reconnectConnectionId: "connection-1",
|
||||
reconnectSourceMatches: true,
|
||||
})).toBe(false);
|
||||
expect(isConnectionDefinitionUnavailable({
|
||||
available: false,
|
||||
reconnectConnectionId: "connection-1",
|
||||
reconnectSourceMatches: false,
|
||||
})).toBe(true);
|
||||
expect(isConnectionDefinitionUnavailable({
|
||||
available: false,
|
||||
reconnectConnectionId: undefined,
|
||||
reconnectSourceMatches: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retained reconnect target matching", () => {
|
||||
const connection = { applicationId: "app-1" } as ToolConnection;
|
||||
const application = { id: "app-1", applicationKey: "custom-mcp" } as ToolApplication;
|
||||
|
||||
it("accepts a generic reconnect only for its exact retained application", () => {
|
||||
expect(retainedReconnectMatches({
|
||||
requestedAppKey: undefined,
|
||||
byo: true,
|
||||
applicationId: "app-1",
|
||||
reconnectConnection: connection,
|
||||
reconnectApplication: application,
|
||||
})).toBe(true);
|
||||
expect(retainedReconnectMatches({
|
||||
requestedAppKey: undefined,
|
||||
byo: true,
|
||||
applicationId: "app-other",
|
||||
reconnectConnection: connection,
|
||||
reconnectApplication: application,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps curated reconnects bound to their provider", () => {
|
||||
const curated = {
|
||||
id: "app-1",
|
||||
applicationKey: "github",
|
||||
metadata: { sourceTemplateKey: "github" },
|
||||
} as unknown as ToolApplication;
|
||||
expect(retainedReconnectMatches({
|
||||
requestedAppKey: "github",
|
||||
byo: false,
|
||||
applicationId: "app-1",
|
||||
reconnectConnection: connection,
|
||||
reconnectApplication: curated,
|
||||
})).toBe(true);
|
||||
expect(retainedReconnectMatches({
|
||||
requestedAppKey: "notion",
|
||||
byo: false,
|
||||
applicationId: "app-1",
|
||||
reconnectConnection: connection,
|
||||
reconnectApplication: curated,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,4 @@
|
|||
import { legacyIssueThreadInteractionResolverPolicyAlias } from "@paperclipai/shared";
|
||||
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
|
|
@ -8,6 +7,7 @@ import type {
|
|||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
ConnectionIntentInteraction,
|
||||
IssueThreadInteractionBase,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
|
|
@ -24,45 +24,6 @@ export const issueThreadInteractionFixtureMeta = {
|
|||
currentUserId: "user-board",
|
||||
} as const;
|
||||
|
||||
export const pendingConnectionIntentInteraction: ConnectionIntentInteraction = {
|
||||
id: "interaction-connection-intent-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "connection_intent",
|
||||
title: "Connect Notion",
|
||||
summary: "Researcher needs this connection to continue.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
createdAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: null,
|
||||
serviceDarkLogoUrl: null,
|
||||
requestingAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested",
|
||||
},
|
||||
result: null,
|
||||
resolverPolicy: "human_only",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "governed_action",
|
||||
legacyResolverPolicyAliases: {
|
||||
requested: "board_only",
|
||||
effective: "board_only",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolver-audience snapshot fields shared by every interaction fixture.
|
||||
*
|
||||
|
|
@ -910,6 +871,105 @@ export const resolvedConnectionAuthorizationInteraction = createConnectionAuthor
|
|||
result: { version: 1, outcome: "accepted" },
|
||||
});
|
||||
|
||||
function createConnectionIntentInteraction(
|
||||
overrides: Partial<ConnectionIntentInteraction> = {},
|
||||
): ConnectionIntentInteraction {
|
||||
return {
|
||||
id: "interaction-connection-intent-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "connection_intent",
|
||||
title: "Connect Notion",
|
||||
summary: "Researcher needs this connection to continue.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
createdAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: null,
|
||||
requestingAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "requested",
|
||||
},
|
||||
result: null,
|
||||
resolverPolicy: "human_only",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "governed_action",
|
||||
legacyResolverPolicyAliases: {
|
||||
requested: "board_only",
|
||||
effective: "board_only",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const pendingConnectionIntentInteraction = createConnectionIntentInteraction();
|
||||
export const retryConnectionIntentInteraction = createConnectionIntentInteraction({
|
||||
id: "interaction-connection-intent-retry",
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: null,
|
||||
requestingAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "needs_retry",
|
||||
},
|
||||
});
|
||||
export const authorizingConnectionIntentInteraction = createConnectionIntentInteraction({
|
||||
id: "interaction-connection-intent-authorizing",
|
||||
payload: {
|
||||
version: 1,
|
||||
serviceSlug: "notion",
|
||||
serviceName: "Notion",
|
||||
serviceLogoUrl: null,
|
||||
requestingAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
requestingAgentName: "Researcher",
|
||||
phase: "authorizing",
|
||||
},
|
||||
});
|
||||
export const connectedConnectionIntentInteraction = createConnectionIntentInteraction({
|
||||
id: "interaction-connection-intent-connected",
|
||||
status: "accepted",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "connected",
|
||||
connectionId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
|
||||
});
|
||||
export const declinedConnectionIntentInteraction = createConnectionIntentInteraction({
|
||||
id: "interaction-connection-intent-declined",
|
||||
status: "rejected",
|
||||
result: { version: 1, outcome: "declined", reason: "Not right now" },
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
|
||||
});
|
||||
export const supersededConnectionIntentInteraction = createConnectionIntentInteraction({
|
||||
id: "interaction-connection-intent-superseded",
|
||||
status: "expired",
|
||||
result: { version: 1, outcome: "superseded" },
|
||||
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
|
||||
});
|
||||
export const expiredConnectionIntentInteraction = createConnectionIntentInteraction({
|
||||
id: "interaction-connection-intent-expired",
|
||||
status: "expired",
|
||||
result: { version: 1, outcome: "expired" },
|
||||
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
|
||||
});
|
||||
|
||||
export const executedSecretProposalInteraction = createSecretProposalConfirmationInteraction({
|
||||
id: "interaction-secret-proposal-executed",
|
||||
status: "accepted",
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const APP_COPY: Record<string, AppCopy> = {
|
|||
},
|
||||
posthog: {
|
||||
tagline: "Explore product usage, errors, flags, and experiments.",
|
||||
short: "Choose one project and the analytics tools your agents can use.",
|
||||
short: "Sign in with PostHog. Project pinning and access controls are optional.",
|
||||
},
|
||||
linear: {
|
||||
tagline: "Create, update and read tickets.",
|
||||
|
|
@ -76,12 +76,40 @@ const APP_COPY: Record<string, AppCopy> = {
|
|||
},
|
||||
"google-sheets": {
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
short: "Share each sheet with the robot email, then paste the links.",
|
||||
short: "Read spreadsheets or update the files you choose.",
|
||||
},
|
||||
gmail: {
|
||||
tagline: "Read mail and create drafts for your review.",
|
||||
short: "Read mail and create drafts for your review.",
|
||||
},
|
||||
"google-drive": {
|
||||
tagline: "Find, read, and create files in Drive.",
|
||||
short: "Find, read, and create files in Drive.",
|
||||
},
|
||||
"google-docs": {
|
||||
tagline: "Read and update documents.",
|
||||
short: "Read and update documents.",
|
||||
},
|
||||
"google-slides": {
|
||||
tagline: "Read and update presentations.",
|
||||
short: "Read and update presentations.",
|
||||
},
|
||||
"google-calendar": {
|
||||
tagline: "Review calendars and manage events.",
|
||||
short: "Review calendars and manage events.",
|
||||
},
|
||||
"google-chat": {
|
||||
tagline: "Read conversations and send messages.",
|
||||
short: "Read conversations and send messages.",
|
||||
},
|
||||
"google-people": {
|
||||
tagline: "Look up contacts and people in your directory.",
|
||||
short: "Look up contacts and people in your directory.",
|
||||
},
|
||||
"google-workspace-search": {
|
||||
tagline: "Search across your Google workspace.",
|
||||
short: "Search across your Google workspace.",
|
||||
},
|
||||
hubspot: {
|
||||
tagline: "Look up contacts and update deal stages.",
|
||||
short: "Look up contacts and update deal stages.",
|
||||
|
|
|
|||
|
|
@ -12,13 +12,71 @@ import {
|
|||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
AskUserQuestionsQuestion,
|
||||
ConnectionIntentInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "./issue-thread-interactions";
|
||||
import { pendingConnectionIntentInteraction } from "../fixtures/issueThreadInteractionFixtures";
|
||||
import type { IssueTimelineEvent } from "./issue-timeline-events";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { registerUIAdapter, unregisterUIAdapter } from "../adapters/registry";
|
||||
|
||||
describe("connection intents in the task feed", () => {
|
||||
it("orders the card chronologically and collapses live/polling snapshots into one updated message", () => {
|
||||
const pending = {
|
||||
...pendingConnectionIntentInteraction,
|
||||
createdAt: new Date("2026-08-26T12:02:00.000Z"),
|
||||
updatedAt: new Date("2026-08-26T12:02:00.000Z"),
|
||||
};
|
||||
const connected: ConnectionIntentInteraction = {
|
||||
...pending,
|
||||
status: "accepted",
|
||||
updatedAt: new Date("2026-08-26T12:04:00.000Z"),
|
||||
resolvedAt: new Date("2026-08-26T12:04:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "connected",
|
||||
connectionId: "connection-1",
|
||||
},
|
||||
};
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
id: "before",
|
||||
body: "Before",
|
||||
createdAt: new Date("2026-08-26T12:01:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "after",
|
||||
body: "After",
|
||||
createdAt: new Date("2026-08-26T12:03:00.000Z"),
|
||||
}),
|
||||
],
|
||||
// Models the brief overlap where live invalidation and polling each
|
||||
// supplied a snapshot of the same interaction id.
|
||||
interactions: [pending, connected],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
});
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
"before",
|
||||
`interaction:${pending.id}`,
|
||||
"after",
|
||||
]);
|
||||
const interactionMessages = messages.filter(
|
||||
(message) => message.id === `interaction:${pending.id}`,
|
||||
);
|
||||
expect(interactionMessages).toHaveLength(1);
|
||||
const custom = interactionMessages[0]?.metadata?.custom as {
|
||||
interaction?: ConnectionIntentInteraction;
|
||||
};
|
||||
expect(custom.interaction?.status).toBe("accepted");
|
||||
expect(custom.interaction?.result?.outcome).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
function createAgent(id: string, name: string): Agent {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -46,7 +104,9 @@ function createAgent(id: string, name: string): Agent {
|
|||
} as Agent;
|
||||
}
|
||||
|
||||
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
|
||||
function createComment(
|
||||
overrides: Partial<IssueChatComment> = {},
|
||||
): IssueChatComment {
|
||||
const authorAgentId = overrides.authorAgentId ?? null;
|
||||
return {
|
||||
id: "comment-1",
|
||||
|
|
@ -99,9 +159,12 @@ function createInteraction(
|
|||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
effectiveResolverPolicySource:
|
||||
overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases ?? {
|
||||
requested: "board_or_agents",
|
||||
effective: "board_or_agents",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -134,18 +197,29 @@ function createRequestConfirmation(
|
|||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
effectiveResolverPolicySource:
|
||||
overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases ?? {
|
||||
requested: "board_or_agents",
|
||||
effective: "board_or_agents",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildAssistantPartsFromTranscript", () => {
|
||||
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " },
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:00:00.000Z",
|
||||
text: "Working on it. ",
|
||||
},
|
||||
{ kind: "assistant", ts: "2026-04-06T12:00:01.000Z", text: "Done." },
|
||||
{ kind: "thinking", ts: "2026-04-06T12:00:02.000Z", text: "Need to inspect files." },
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-06T12:00:02.000Z",
|
||||
text: "Need to inspect files.",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-06T12:00:03.000Z",
|
||||
|
|
@ -160,12 +234,22 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
content: "file contents",
|
||||
isError: false,
|
||||
},
|
||||
{ kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" },
|
||||
{
|
||||
kind: "stderr",
|
||||
ts: "2026-04-06T12:00:05.000Z",
|
||||
text: "warn: noisy setup output",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.parts).toHaveLength(3);
|
||||
expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." });
|
||||
expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." });
|
||||
expect(result.parts[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Working on it. Done.",
|
||||
});
|
||||
expect(result.parts[1]).toMatchObject({
|
||||
type: "reasoning",
|
||||
text: "Need to inspect files.",
|
||||
});
|
||||
expect(result.parts[2]).toMatchObject({
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-1",
|
||||
|
|
@ -194,7 +278,11 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
content: "ok",
|
||||
isError: false,
|
||||
},
|
||||
{ kind: "thinking", ts: "2026-04-06T12:00:04.000Z", text: "Need one more check." },
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-06T12:00:04.000Z",
|
||||
text: "Need one more check.",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-06T12:00:05.000Z",
|
||||
|
|
@ -213,16 +301,30 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
|
||||
expect(result.parts).toMatchObject([
|
||||
{ type: "text", text: "First." },
|
||||
{ type: "tool-call", toolCallId: "tool-1", toolName: "read_file", result: "ok" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "read_file",
|
||||
result: "ok",
|
||||
},
|
||||
{ type: "text", text: "Second." },
|
||||
{ type: "reasoning", text: "Need one more check." },
|
||||
{ type: "tool-call", toolCallId: "tool-2", toolName: "write_file", result: "saved" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-2",
|
||||
toolName: "write_file",
|
||||
result: "saved",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-06T12:00:00.000Z",
|
||||
text: "Checking the task.",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-06T12:00:01.000Z",
|
||||
|
|
@ -237,7 +339,11 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
content: "search completed",
|
||||
isError: false,
|
||||
},
|
||||
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:00:03.000Z",
|
||||
text: "Found the relevant code.",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.parts).toMatchObject([
|
||||
|
|
@ -251,28 +357,36 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
},
|
||||
{ type: "text", text: "Found the relevant code." },
|
||||
]);
|
||||
expect(result.segments).toEqual([{
|
||||
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
|
||||
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
|
||||
}]);
|
||||
expect(result.segments).toEqual([
|
||||
{
|
||||
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
|
||||
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks only the latest chain-of-thought segment active while a run is live", () => {
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 0,
|
||||
segmentCount: 2,
|
||||
})).toBe(false);
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
})).toBe(true);
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: false,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
})).toBe(false);
|
||||
expect(
|
||||
isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 0,
|
||||
segmentCount: 2,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCoTSegmentActive({
|
||||
isMessageRunning: false,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps run errors while suppressing init and system transcript noise", () => {
|
||||
|
|
@ -318,10 +432,29 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
|
||||
it("preserves diff transcript output as a fenced diff block", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Applied the patch." },
|
||||
{ kind: "diff", ts: "2026-04-06T12:00:01.000Z", changeType: "file_header", text: "ui/src/lib/issue-chat-messages.ts" },
|
||||
{ kind: "diff", ts: "2026-04-06T12:00:02.000Z", changeType: "add", text: "+function formatDiffBlock(lines: string[]) {" },
|
||||
{ kind: "diff", ts: "2026-04-06T12:00:03.000Z", changeType: "add", text: "+ return ````diff`;" },
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:00:00.000Z",
|
||||
text: "Applied the patch.",
|
||||
},
|
||||
{
|
||||
kind: "diff",
|
||||
ts: "2026-04-06T12:00:01.000Z",
|
||||
changeType: "file_header",
|
||||
text: "ui/src/lib/issue-chat-messages.ts",
|
||||
},
|
||||
{
|
||||
kind: "diff",
|
||||
ts: "2026-04-06T12:00:02.000Z",
|
||||
changeType: "add",
|
||||
text: "+function formatDiffBlock(lines: string[]) {",
|
||||
},
|
||||
{
|
||||
kind: "diff",
|
||||
ts: "2026-04-06T12:00:03.000Z",
|
||||
changeType: "add",
|
||||
text: "+ return ````diff`;",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.parts).toMatchObject([
|
||||
|
|
@ -374,7 +507,10 @@ describe("buildIssueChatMessages", () => {
|
|||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" },
|
||||
resultJson: {
|
||||
operatorInterrupted: true,
|
||||
interruptionSource: "issue_comment_interrupt",
|
||||
},
|
||||
},
|
||||
{
|
||||
runId: "run-plain",
|
||||
|
|
@ -389,10 +525,18 @@ describe("buildIssueChatMessages", () => {
|
|||
liveRuns: [],
|
||||
});
|
||||
|
||||
const interrupted = messages.find((message) => message.id === "run-assistant:run-int");
|
||||
const plain = messages.find((message) => message.id === "run-assistant:run-plain");
|
||||
expect(interrupted?.metadata?.custom).toMatchObject({ runOperatorInterrupted: true });
|
||||
expect(plain?.metadata?.custom).toMatchObject({ runOperatorInterrupted: false });
|
||||
const interrupted = messages.find(
|
||||
(message) => message.id === "run-assistant:run-int",
|
||||
);
|
||||
const plain = messages.find(
|
||||
(message) => message.id === "run-assistant:run-plain",
|
||||
);
|
||||
expect(interrupted?.metadata?.custom).toMatchObject({
|
||||
runOperatorInterrupted: true,
|
||||
});
|
||||
expect(plain?.metadata?.custom).toMatchObject({
|
||||
runOperatorInterrupted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts deleted comment bodies while preserving tombstone metadata", () => {
|
||||
|
|
@ -437,7 +581,9 @@ describe("buildIssueChatMessages", () => {
|
|||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [],
|
||||
agentMap: new Map([["agent-1", createAgent("agent-1", "Low Trust Reviewer")]]),
|
||||
agentMap: new Map([
|
||||
["agent-1", createAgent("agent-1", "Low Trust Reviewer")],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(messages[0]?.metadata.custom.sourceTrust).toMatchObject({
|
||||
|
|
@ -448,7 +594,9 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
it("prefers derived agent attribution when a board-authored comment is proven to come from a run", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
|
||||
const agentMap = new Map<string, Agent>([
|
||||
["agent-1", createAgent("agent-1", "Claude")],
|
||||
]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
|
|
@ -481,7 +629,9 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
it("does not reattribute a genuine board/user comment that has no derived agent", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
|
||||
const agentMap = new Map<string, Agent>([
|
||||
["agent-1", createAgent("agent-1", "Claude")],
|
||||
]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
|
|
@ -515,7 +665,9 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
it("renders a comment as agent-authored when runAgentId is set from activity log", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
|
||||
const agentMap = new Map<string, Agent>([
|
||||
["agent-1", createAgent("agent-1", "Claude")],
|
||||
]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
|
|
@ -548,7 +700,9 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
it("orders events before comments and appends active live runs as running assistant messages", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
|
||||
const agentMap = new Map<string, Agent>([
|
||||
["agent-1", createAgent("agent-1", "CodexCoder")],
|
||||
]);
|
||||
const comments = [
|
||||
createComment(),
|
||||
createComment({
|
||||
|
|
@ -607,7 +761,13 @@ describe("buildIssueChatMessages", () => {
|
|||
transcriptsByRunId: new Map([
|
||||
[
|
||||
"run-live-1",
|
||||
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:04:01.000Z",
|
||||
text: "Streaming reply",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-live-1",
|
||||
|
|
@ -666,8 +826,16 @@ describe("buildIssueChatMessages", () => {
|
|||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(terminalMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toBeUndefined();
|
||||
expect(liveMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toMatchObject({
|
||||
expect(
|
||||
terminalMessages.find(
|
||||
(message) => message.id === "run-assistant:run-live-terminal",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
liveMessages.find(
|
||||
(message) => message.id === "run-assistant:run-live-terminal",
|
||||
),
|
||||
).toMatchObject({
|
||||
status: { type: "running" },
|
||||
metadata: { custom: { waitingText: "Working..." } },
|
||||
});
|
||||
|
|
@ -708,7 +876,13 @@ describe("buildIssueChatMessages", () => {
|
|||
transcriptsByRunId: new Map([
|
||||
[
|
||||
"run-live-1",
|
||||
[{ kind: "assistant", ts: "2026-04-06T12:03:01.000Z", text: "Working on it." }],
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:03:01.000Z",
|
||||
text: "Working on it.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-live-1",
|
||||
|
|
@ -756,7 +930,10 @@ describe("buildIssueChatMessages", () => {
|
|||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
legacyResolverPolicyAliases: {
|
||||
requested: "board_or_agents",
|
||||
effective: "board_or_agents",
|
||||
},
|
||||
payload: { version: 1, questions },
|
||||
result: null,
|
||||
} as AskUserQuestionsInteraction;
|
||||
|
|
@ -774,7 +951,12 @@ describe("buildIssueChatMessages", () => {
|
|||
// A truly unanswerable card (no options, no free-text) — must be
|
||||
// filtered out entirely.
|
||||
askInteraction("interaction-degenerate", [
|
||||
{ id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Anything?",
|
||||
selectionMode: "single",
|
||||
options: [],
|
||||
},
|
||||
]),
|
||||
// A legitimate yes/no question survives.
|
||||
askInteraction("interaction-legit", [
|
||||
|
|
@ -797,7 +979,10 @@ describe("buildIssueChatMessages", () => {
|
|||
|
||||
const ids = messages.map((message) => `${message.role}:${message.id}`);
|
||||
// The legit card is present; the degenerate one leaves no message at all.
|
||||
expect(ids).toEqual(["user:comment-1", "system:interaction:interaction-legit"]);
|
||||
expect(ids).toEqual([
|
||||
"user:comment-1",
|
||||
"system:interaction:interaction-legit",
|
||||
]);
|
||||
expect(ids).not.toContain("system:interaction:interaction-degenerate");
|
||||
});
|
||||
|
||||
|
|
@ -972,7 +1157,9 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
|
||||
const agentMap = new Map<string, Agent>([
|
||||
["agent-1", createAgent("agent-1", "CodexCoder")],
|
||||
]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
|
|
@ -991,8 +1178,16 @@ describe("buildIssueChatMessages", () => {
|
|||
[
|
||||
"run-history-1",
|
||||
[
|
||||
{ kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." },
|
||||
{ kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." },
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-06T12:01:10.000Z",
|
||||
text: "Checking the current issue thread.",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:02:30.000Z",
|
||||
text: "Updated the thread renderer.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
|
|
@ -1072,16 +1267,22 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
const textParts = messages[0]?.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text) ?? [];
|
||||
const textParts =
|
||||
messages[0]?.content
|
||||
.filter(
|
||||
(part): part is { type: "text"; text: string } =>
|
||||
part.type === "text",
|
||||
)
|
||||
.map((part) => part.text) ?? [];
|
||||
expect(textParts.join("\n")).not.toContain("Older update 1");
|
||||
expect(messages[0]?.content).toContainEqual(expect.objectContaining({
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-keep",
|
||||
toolName: "search",
|
||||
result: "search completed",
|
||||
}));
|
||||
expect(messages[0]?.content).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-keep",
|
||||
toolName: "search",
|
||||
result: "search completed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors a wider transcript window declared by an adapter's UI module", () => {
|
||||
|
|
@ -1096,7 +1297,10 @@ describe("buildIssueChatMessages", () => {
|
|||
parseStdoutLine: () => [],
|
||||
ConfigFields: () => null,
|
||||
buildAdapterConfig: () => ({}),
|
||||
transcriptPresentation: { maxVisibleEntries: 400, liveReasoningView: "scrollLog" },
|
||||
transcriptPresentation: {
|
||||
maxVisibleEntries: 400,
|
||||
liveReasoningView: "scrollLog",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -1154,9 +1358,13 @@ describe("buildIssueChatMessages", () => {
|
|||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
const textParts = messages[0]?.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text) ?? [];
|
||||
const textParts =
|
||||
messages[0]?.content
|
||||
.filter(
|
||||
(part): part is { type: "text"; text: string } =>
|
||||
part.type === "text",
|
||||
)
|
||||
.map((part) => part.text) ?? [];
|
||||
expect(textParts.join("\n")).toContain("Older update 1");
|
||||
expect(textParts.join("\n")).toContain("Recent update 79");
|
||||
} finally {
|
||||
|
|
@ -1184,7 +1392,16 @@ describe("buildIssueChatMessages", () => {
|
|||
},
|
||||
],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
[
|
||||
"run-1",
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:01:05.000Z",
|
||||
text: "Working on it.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-1",
|
||||
currentUserId: "user-1",
|
||||
|
|
@ -1206,7 +1423,16 @@ describe("buildIssueChatMessages", () => {
|
|||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
[
|
||||
"run-1",
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:01:05.000Z",
|
||||
text: "Working on it.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-1",
|
||||
currentUserId: "user-1",
|
||||
|
|
@ -1214,7 +1440,10 @@ describe("buildIssueChatMessages", () => {
|
|||
|
||||
expect(liveMessages).toHaveLength(1);
|
||||
expect(cancelledMessages).toHaveLength(1);
|
||||
expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } });
|
||||
expect(liveMessages[0]).toMatchObject({
|
||||
id: "run-assistant:run-1",
|
||||
status: { type: "running" },
|
||||
});
|
||||
expect(cancelledMessages[0]).toMatchObject({
|
||||
id: "run-assistant:run-1",
|
||||
status: { type: "complete", reason: "stop" },
|
||||
|
|
@ -1240,7 +1469,16 @@ describe("buildIssueChatMessages", () => {
|
|||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-paused", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
[
|
||||
"run-paused",
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:01:05.000Z",
|
||||
text: "Working on it.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-paused",
|
||||
currentUserId: "user-1",
|
||||
|
|
@ -1272,7 +1510,16 @@ describe("buildIssueChatMessages", () => {
|
|||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-interrupted", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
[
|
||||
"run-interrupted",
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:01:05.000Z",
|
||||
text: "Working on it.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-interrupted",
|
||||
currentUserId: "user-1",
|
||||
|
|
@ -1324,56 +1571,66 @@ describe("buildIssueChatMessages", () => {
|
|||
|
||||
describe("stabilizeThreadMessages", () => {
|
||||
it("reveals live streamed additions at word boundaries instead of character boundaries", () => {
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"Writing ",
|
||||
"Writing the pla",
|
||||
)).toBe("Writing the ");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"Writing ",
|
||||
"Writing the plan ",
|
||||
)).toBe("Writing the plan ");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"Writing ",
|
||||
"Writing the plan.",
|
||||
)).toBe("Writing the plan.");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"Writing ",
|
||||
"Writing draft",
|
||||
)).toBe("Writing draft");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction("Writing ", "Writing the pla"),
|
||||
).toBe("Writing the ");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction("Writing ", "Writing the plan "),
|
||||
).toBe("Writing the plan ");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction("Writing ", "Writing the plan."),
|
||||
).toBe("Writing the plan.");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction("Writing ", "Writing draft"),
|
||||
).toBe("Writing draft");
|
||||
});
|
||||
|
||||
it("holds sliding-window removals until an older paragraph or group boundary drops", () => {
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"First sentence. Second sentence is visible",
|
||||
"irst sentence. Second sentence is visible now ",
|
||||
)).toBe("irst sentence. Second sentence is visible now ");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"First sentence. Second sentence is visible",
|
||||
"Second sentence is visible now ",
|
||||
)).toBe("Second sentence is visible now ");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"Paragraph one.\n\nParagraph two is visible",
|
||||
"Paragraph two is visible now ",
|
||||
)).toBe("Paragraph two is visible now ");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"The answer is 42",
|
||||
"42 is the answer",
|
||||
)).toBe("42 is the answer");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"quick brown fox jumps over the lazy dog near the river",
|
||||
)).toBe("quick brown fox jumps over the lazy dog near the river");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"First sentence. Second sentence is visible",
|
||||
"irst sentence. Second sentence is visible now ",
|
||||
),
|
||||
).toBe("irst sentence. Second sentence is visible now ");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"First sentence. Second sentence is visible",
|
||||
"Second sentence is visible now ",
|
||||
),
|
||||
).toBe("Second sentence is visible now ");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"Paragraph one.\n\nParagraph two is visible",
|
||||
"Paragraph two is visible now ",
|
||||
),
|
||||
).toBe("Paragraph two is visible now ");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"The answer is 42",
|
||||
"42 is the answer",
|
||||
),
|
||||
).toBe("42 is the answer");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"quick brown fox jumps over the lazy dog near the river",
|
||||
),
|
||||
).toBe("quick brown fox jumps over the lazy dog near the river");
|
||||
});
|
||||
|
||||
it("keeps live streamed retractions readable until a whole line disappears", () => {
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"First line\nSecond line\nThird line is complete",
|
||||
"First line\nSecond line\nThird line",
|
||||
)).toBe("First line\nSecond line\nThird line is complete");
|
||||
expect(preserveReadableStreamingRetraction(
|
||||
"First line\nSecond line\nThird line is complete",
|
||||
"First line\nSecond line",
|
||||
)).toBe("First line\nSecond line");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"First line\nSecond line\nThird line is complete",
|
||||
"First line\nSecond line\nThird line",
|
||||
),
|
||||
).toBe("First line\nSecond line\nThird line is complete");
|
||||
expect(
|
||||
preserveReadableStreamingRetraction(
|
||||
"First line\nSecond line\nThird line is complete",
|
||||
"First line\nSecond line",
|
||||
),
|
||||
).toBe("First line\nSecond line");
|
||||
|
||||
const liveRun: LiveRunForIssue = {
|
||||
id: "run-live-retract",
|
||||
|
|
@ -1387,20 +1644,28 @@ describe("stabilizeThreadMessages", () => {
|
|||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
};
|
||||
const buildLiveMessages = (text: string) => buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [liveRun],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-live-retract", [{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text }]],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-live-retract",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
const buildLiveMessages = (text: string) =>
|
||||
buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [liveRun],
|
||||
transcriptsByRunId: new Map([
|
||||
[
|
||||
"run-live-retract",
|
||||
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text }],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-live-retract",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
const fullText = "First line\nSecond line\nThird line is complete";
|
||||
const firstStable = stabilizeThreadMessages(buildLiveMessages(fullText), [], new Map());
|
||||
const firstStable = stabilizeThreadMessages(
|
||||
buildLiveMessages(fullText),
|
||||
[],
|
||||
new Map(),
|
||||
);
|
||||
const partialRetractionStable = stabilizeThreadMessages(
|
||||
buildLiveMessages("First line\nSecond line\nThird line"),
|
||||
firstStable.messages,
|
||||
|
|
|
|||
|
|
@ -274,6 +274,30 @@ function sortByCreated<T extends { createdAt: Date | string; id: string }>(items
|
|||
});
|
||||
}
|
||||
|
||||
function dedupeInteractionsById(
|
||||
interactions: readonly IssueThreadInteraction[],
|
||||
): IssueThreadInteraction[] {
|
||||
const byId = new Map<string, IssueThreadInteraction>();
|
||||
for (const interaction of interactions) {
|
||||
const previous = byId.get(interaction.id);
|
||||
if (!previous) {
|
||||
byId.set(interaction.id, interaction);
|
||||
continue;
|
||||
}
|
||||
const previousUpdatedAt = toTimestamp(previous.updatedAt);
|
||||
const nextUpdatedAt = toTimestamp(interaction.updatedAt);
|
||||
if (
|
||||
nextUpdatedAt > previousUpdatedAt
|
||||
|| (nextUpdatedAt === previousUpdatedAt
|
||||
&& previous.status === "pending"
|
||||
&& interaction.status !== "pending")
|
||||
) {
|
||||
byId.set(interaction.id, interaction);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
export function latestSameRunHandoffTimestamp(args: {
|
||||
interactionCreatedAtMs: number;
|
||||
sourceRunId: string;
|
||||
|
|
@ -1105,7 +1129,11 @@ export function buildIssueChatMessages(args: {
|
|||
});
|
||||
}
|
||||
|
||||
for (const interaction of sortByCreated(interactions)) {
|
||||
// A live-event cache patch and the polling response can briefly contain the
|
||||
// same interaction. Collapse by id before constructing messages, preferring
|
||||
// the newest/terminal snapshot so a resolved connection card updates in its
|
||||
// existing thread slot instead of flashing a duplicate beside itself.
|
||||
for (const interaction of sortByCreated(dedupeInteractionsById(interactions))) {
|
||||
// A card IssueThreadInteractionCard never renders — a degenerate
|
||||
// `ask_user_questions` (e.g. the onboarding `Test / A` placeholder) or a
|
||||
// stale sibling superseded by a newer question (PAP-437) — is skipped here so
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ export type {
|
|||
AskUserQuestionsQuestion,
|
||||
AskUserQuestionsQuestionOption,
|
||||
AskUserQuestionsResult,
|
||||
ConnectionIntentInteraction,
|
||||
ConnectionIntentPayload,
|
||||
ConnectionIntentResult,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionActorFields,
|
||||
IssueThreadInteractionBase,
|
||||
|
|
@ -40,6 +43,7 @@ import type {
|
|||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
AskUserQuestionsQuestion,
|
||||
ConnectionIntentInteraction,
|
||||
IssueThreadInteraction,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
|
|
@ -73,6 +77,7 @@ export function isIssueThreadInteraction(
|
|||
|| candidate.kind === "request_confirmation"
|
||||
|| candidate.kind === "request_checkbox_confirmation"
|
||||
|| candidate.kind === "request_item_verdicts"
|
||||
|| candidate.kind === "connection_intent"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -240,13 +245,14 @@ export function buildIssueThreadInteractionSummary(
|
|||
}
|
||||
|
||||
if (interaction.kind === "connection_intent") {
|
||||
const serviceName = interaction.payload.serviceName;
|
||||
const outcome = interaction.result?.outcome;
|
||||
if (outcome === "connected") return `Connected ${serviceName}`;
|
||||
if (outcome === "declined") return `Declined ${serviceName} connection`;
|
||||
if (outcome === "superseded") return `${serviceName} connection request was superseded`;
|
||||
if (outcome === "expired") return `${serviceName} connection request expired`;
|
||||
return `Requested a ${serviceName} connection`;
|
||||
if (interaction.status === "accepted") return `${interaction.payload.serviceName} connected`;
|
||||
if (interaction.status === "rejected") return `${interaction.payload.serviceName} declined`;
|
||||
if (interaction.status === "expired") {
|
||||
return interaction.result?.outcome === "superseded"
|
||||
? `${interaction.payload.serviceName} request superseded`
|
||||
: `${interaction.payload.serviceName} request expired`;
|
||||
}
|
||||
return `Connect ${interaction.payload.serviceName}`;
|
||||
}
|
||||
|
||||
const count = interaction.payload.questions.length;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Root } from "react-dom/client";
|
||||
import {
|
||||
getOrCreatePaperclipReactRoot,
|
||||
type PaperclipReactRootHost,
|
||||
} from "./react-root";
|
||||
|
||||
describe("getOrCreatePaperclipReactRoot", () => {
|
||||
it("reuses the existing root when the entry module runs again", () => {
|
||||
const host: PaperclipReactRootHost = {};
|
||||
const container = {} as Parameters<typeof getOrCreatePaperclipReactRoot>[1];
|
||||
const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root;
|
||||
const createRoot = vi.fn(() => root);
|
||||
|
||||
expect(getOrCreatePaperclipReactRoot(host, container, createRoot)).toBe(root);
|
||||
expect(getOrCreatePaperclipReactRoot(host, container, createRoot)).toBe(root);
|
||||
expect(createRoot).toHaveBeenCalledTimes(1);
|
||||
expect(createRoot).toHaveBeenCalledWith(container);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
export interface PaperclipReactRootHost {
|
||||
__paperclipReactRoot?: Root;
|
||||
}
|
||||
|
||||
type CreateRoot = (container: Parameters<typeof createRoot>[0]) => Root;
|
||||
|
||||
/**
|
||||
* Keep one React root per browser window even if Vite evaluates the entry
|
||||
* module more than once during a development reload.
|
||||
*/
|
||||
export function getOrCreatePaperclipReactRoot(
|
||||
host: object,
|
||||
container: Parameters<typeof createRoot>[0],
|
||||
create: CreateRoot = createRoot,
|
||||
): Root {
|
||||
const rootHost = host as PaperclipReactRootHost;
|
||||
if (rootHost.__paperclipReactRoot) return rootHost.__paperclipReactRoot;
|
||||
|
||||
const root = create(container);
|
||||
rootHost.__paperclipReactRoot = root;
|
||||
return root;
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { StrictMode } from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "@/lib/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App } from "./App";
|
||||
|
|
@ -20,6 +19,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||
import { initPluginBridge } from "./plugins/bridge-init";
|
||||
import { PluginLauncherProvider } from "./plugins/launchers";
|
||||
import { startPerfMeasureReaper } from "./lib/perf-measure-reaper";
|
||||
import { getOrCreatePaperclipReactRoot } from "./lib/react-root";
|
||||
import { startServiceWorkerUpdates } from "./lib/service-worker-updates";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import "./index.css";
|
||||
|
|
@ -57,7 +57,10 @@ function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNod
|
|||
return <BreadcrumbProvider companyName={selectedCompany?.name ?? null}>{children}</BreadcrumbProvider>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("Paperclip root element is missing");
|
||||
|
||||
getOrCreatePaperclipReactRoot(window, rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
|
|||
|
|
@ -137,10 +137,18 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
|||
import { InlineEditor } from "@/components/InlineEditor";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||
import { MembershipAction } from "@/components/MembershipAction";
|
||||
import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection";
|
||||
import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import {
|
||||
connectedConnectionIntentInteraction,
|
||||
issueThreadInteractionFixtureMeta,
|
||||
pendingConnectionIntentInteraction,
|
||||
retryConnectionIntentInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
|
||||
import {
|
||||
EnvInputsList,
|
||||
|
|
@ -1346,6 +1354,21 @@ export function DesignGuide() {
|
|||
</SubSection>
|
||||
</Section>
|
||||
|
||||
<Section title="App logos">
|
||||
<SubSection title="Official marks and runtime fallback">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo
|
||||
name="Notion"
|
||||
logoUrl="/brands/apps/notion.svg"
|
||||
darkLogoUrl="/brands/apps/notion-dark.svg"
|
||||
size={36}
|
||||
/>
|
||||
<AppLogo name="Jira" logoUrl="/brands/apps/jira.svg" darkLogoUrl="/brands/apps/jira-dark.svg" size={44} />
|
||||
<AppLogo name="Fallback" logoUrl="/brands/apps/does-not-exist.svg" size={36} />
|
||||
</div>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* IDENTITY */}
|
||||
{/* ============================================================ */}
|
||||
|
|
@ -2056,6 +2079,28 @@ export function DesignGuide() {
|
|||
<EnvironmentVariablesEditorShowcase />
|
||||
</Section>
|
||||
|
||||
<Section title="Connection Intent">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The task card is the dialog host for the shared connection setup flow. Provider forms,
|
||||
validation, OAuth, access selection, and completion come from the same feature module as
|
||||
the full-page Apps setup; this card owns only audience, dialog, and task refresh behavior.
|
||||
</p>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingConnectionIntentInteraction}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={retryConnectionIntentInteraction}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={connectedConnectionIntentInteraction}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Resizable Panels">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Design-system wrapper over <span className="font-mono">react-resizable-panels</span>{" "}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
|||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn());
|
||||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const refreshCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -55,11 +56,15 @@ vi.mock("@/api/tools", () => ({
|
|||
updateConnectionMock(connectionId, input),
|
||||
finishApp: (companyId: string, connectionId: string, input: unknown) =>
|
||||
finishAppMock(companyId, connectionId, input),
|
||||
finalizeOAuthAccess: (companyId: string, connectionId: string, input: unknown) =>
|
||||
finalizeOAuthAccessMock(companyId, connectionId, input),
|
||||
putConnectionInstalls: (connectionId: string, installs: unknown) =>
|
||||
putConnectionInstallsMock(connectionId, installs),
|
||||
archiveConnection: vi.fn(),
|
||||
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
|
||||
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
|
||||
startOAuth: (connectionId: string, input?: unknown) => input === undefined
|
||||
? startOAuthMock(connectionId)
|
||||
: startOAuthMock(connectionId, input),
|
||||
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
|
||||
revokeConnectionGrant: (connectionId: string, grantId: string) =>
|
||||
revokeConnectionGrantMock(connectionId, grantId),
|
||||
|
|
@ -365,6 +370,7 @@ describe("AppDetail", () => {
|
|||
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
|
||||
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
|
||||
finishAppMock.mockResolvedValue({});
|
||||
finalizeOAuthAccessMock.mockResolvedValue({});
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] });
|
||||
startOAuthMock.mockResolvedValue({
|
||||
|
|
@ -412,8 +418,8 @@ describe("AppDetail", () => {
|
|||
// Services (PAP-17865) sits below Test rather than above it, so the
|
||||
// Setup→Test adjacency this test exists to protect still holds.
|
||||
"services",
|
||||
"review",
|
||||
"permissions",
|
||||
"review",
|
||||
"activity",
|
||||
]);
|
||||
});
|
||||
|
|
@ -421,10 +427,19 @@ describe("AppDetail", () => {
|
|||
it("pauses the app by flipping the connection enabled flag", async () => {
|
||||
await renderAppDetail();
|
||||
|
||||
const dangerZone = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Danger zone"));
|
||||
expect(dangerZone).toBeTruthy();
|
||||
await act(async () => {
|
||||
dangerZone!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[role="switch"][aria-label="Pause this app"]',
|
||||
'button[role="switch"][aria-label="Pause connection"]',
|
||||
);
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -486,9 +501,9 @@ describe("AppDetail", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["setup", "Agents can use this app", false],
|
||||
["setup", "Danger zone", false],
|
||||
["review", "Review 1 new action", true],
|
||||
["permissions", "Action permissions", true],
|
||||
["permissions", "Agent access", true],
|
||||
["activity", "No activity yet.", false],
|
||||
])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => {
|
||||
mockParams.tab = tab;
|
||||
|
|
@ -512,14 +527,31 @@ describe("AppDetail", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("renders setup without waiting for tool discovery", async () => {
|
||||
it("renders setup while its permission summary loads", async () => {
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).toContain("Danger zone");
|
||||
expect(container.textContent).toContain("Loading permissions…");
|
||||
expect(container.textContent).not.toContain("Loading tools");
|
||||
expect(listCatalogMock).not.toHaveBeenCalled();
|
||||
expect(listCatalogMock).toHaveBeenCalledWith("conn-1");
|
||||
});
|
||||
|
||||
it("shows permission totals on Setup and opens Permissions", async () => {
|
||||
await renderAppDetail();
|
||||
|
||||
const summary = "Allowed for 1 action · Ask first for 1 action · Off for 0";
|
||||
expect(container.textContent).toContain(summary);
|
||||
|
||||
const summaryButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes(summary));
|
||||
expect(summaryButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
summaryButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions");
|
||||
});
|
||||
|
||||
it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => {
|
||||
|
|
@ -557,6 +589,24 @@ describe("AppDetail", () => {
|
|||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/test", { replace: true });
|
||||
});
|
||||
|
||||
it("normalizes the retired post-OAuth identity choice back to fixed Setup", async () => {
|
||||
mockParams.tab = "setup";
|
||||
mockSearchParams.value = new URLSearchParams("oauth=choose-access");
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
name: "Notion",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
}));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Only you can use this connection");
|
||||
expect(container.textContent).not.toContain("Who can use this connection?");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup", { replace: true });
|
||||
expect(finalizeOAuthAccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides secret URL parameters in setup technical details", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(
|
||||
|
|
@ -568,6 +618,12 @@ describe("AppDetail", () => {
|
|||
);
|
||||
|
||||
await renderAppDetail();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Connection details"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"https://mcp.zapier.com/api/v1/connect?token=REDACTED®ion=us",
|
||||
|
|
@ -643,15 +699,38 @@ describe("AppDetail", () => {
|
|||
expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block");
|
||||
});
|
||||
|
||||
it("keeps setup focused while including technical details and the danger zone", async () => {
|
||||
it("keeps setup focused with secondary details folded away", async () => {
|
||||
mockParams.tab = "setup";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Give agents a governed way to inspect repositories and pull requests.");
|
||||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).toContain("Technical details");
|
||||
expect(container.textContent).not.toContain("Give agents a governed way to inspect repositories and pull requests.");
|
||||
expect(container.textContent).toContain("Connection details");
|
||||
expect(container.textContent).toContain("Danger zone");
|
||||
expect(container.textContent).not.toContain("This connection always acts as the identity chosen during setup.");
|
||||
expect(container.textContent).not.toContain("Remote HTTP");
|
||||
expect(container.textContent).not.toContain("Pause connection");
|
||||
expect(container.textContent).not.toContain("Stored securely.");
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Connection details"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Remote HTTP");
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Danger zone"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Pause connection");
|
||||
expect(container.textContent).toContain("Reconnect");
|
||||
expect(container.textContent).toContain("Replace the stored credential.");
|
||||
expect(container.textContent).not.toContain("Read repo");
|
||||
expect(container.textContent).not.toContain("Action permissions");
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
|
|
@ -672,10 +751,10 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
// The old generic "Connect with <provider>" block is gone: identity is now
|
||||
// expressed per-identity, and a connection with no organization grant offers
|
||||
// an explicit connect action instead of a single ambiguous button.
|
||||
expect(container.textContent).toContain("Identities");
|
||||
// The old generic "Connect with <provider>" block is gone: the connection's
|
||||
// fixed identity type is explicit even before that identity is connected.
|
||||
expect(container.textContent).toContain("Account");
|
||||
expect(container.textContent).toContain("Anyone in your company can use this connection");
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(
|
||||
|
|
@ -734,15 +813,16 @@ describe("AppDetail", () => {
|
|||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Dotta’s Notion");
|
||||
expect(container.textContent).toContain("Connected by");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
// Identity is legible per identity, and the shared one names its audience.
|
||||
// "workspace authorization" is deliberately gone: it could only ever
|
||||
// describe one shared identity (PAP-17835).
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Notion workspace");
|
||||
expect(container.textContent).toContain("All organization members");
|
||||
expect(container.textContent).not.toContain("Connected by");
|
||||
expect(container.textContent).toContain("Anyone in your company can use this connection");
|
||||
expect(container.textContent).not.toContain("workspace authorization");
|
||||
expect(findButton("Reconnect")).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Reconnect",
|
||||
|
|
@ -812,8 +892,8 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Read only");
|
||||
expect(container.textContent).toContain("Can make changes");
|
||||
expect(container.textContent).toContain("Read (1)");
|
||||
expect(container.textContent).toContain("Write (1)");
|
||||
expect(container.textContent).toContain("Read repo");
|
||||
expect(container.textContent).toContain("Write issue");
|
||||
expect(container.textContent).toContain("Review 1 new action");
|
||||
|
|
@ -869,7 +949,7 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
expect(container.textContent).toContain("Agent access");
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Choose agents"))
|
||||
|
|
@ -889,13 +969,7 @@ describe("AppDetail", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17859: agent availability is stated once. The tab used to stack a
|
||||
* legacy "Who can use it" editor — its own Change button, per-agent remove
|
||||
* buttons and Save — on top of "Available to agents", so the same fact had
|
||||
* two visible editors and the reader had to guess which one won.
|
||||
*/
|
||||
it("shows one agent-availability model on Permissions, not the legacy access editor", async () => {
|
||||
it("persists agent access independently from always-installed agents", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listProfilesMock.mockResolvedValue({
|
||||
profiles: [{
|
||||
|
|
@ -910,12 +984,88 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]');
|
||||
const anyAgent = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? [])
|
||||
.find((radio) => radio.textContent?.includes("Any agent"));
|
||||
expect(anyAgent).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
anyAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
|
||||
askFirstCatalogEntryIds: ["catalog-write"],
|
||||
access: "all_agents",
|
||||
});
|
||||
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps always-installed agents in the access allowlist", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionInstallsMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
installs: [{
|
||||
id: "install-agent-1",
|
||||
companyId: "company-1",
|
||||
connectionId: "conn-1",
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: new Date(),
|
||||
}],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]');
|
||||
const pickedAgents = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? [])
|
||||
.find((radio) => radio.textContent?.includes("Agents I pick"));
|
||||
await act(async () => {
|
||||
pickedAgents?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
|
||||
askFirstCatalogEntryIds: ["catalog-write"],
|
||||
access: { agentIds: ["agent-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("separates agent access from agents that always install the app", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listProfilesMock.mockResolvedValue({
|
||||
profiles: [{
|
||||
profileKey: "app:conn-1",
|
||||
entries: [
|
||||
{ effect: "include", catalogEntryId: "catalog-read" },
|
||||
{ effect: "include", catalogEntryId: "catalog-write" },
|
||||
],
|
||||
bindings: [{ targetType: "agent", targetId: "agent-1" }],
|
||||
}],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Agent access");
|
||||
expect(container.textContent).toContain("Always installed");
|
||||
expect(container.textContent).toContain("Agent access only makes it available when needed.");
|
||||
const alwaysInstalledHeading = Array.from(container.querySelectorAll("h2"))
|
||||
.find((heading) => heading.textContent === "Always installed");
|
||||
const agentAccessHeading = Array.from(container.querySelectorAll("h2"))
|
||||
.find((heading) => heading.textContent === "Agent access");
|
||||
expect(alwaysInstalledHeading).toBeTruthy();
|
||||
expect(agentAccessHeading).toBeTruthy();
|
||||
expect(
|
||||
alwaysInstalledHeading!.compareDocumentPosition(agentAccessHeading!)
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("Who can use it");
|
||||
expect(container.textContent).not.toContain("Only specific agents");
|
||||
expect(container.querySelector('button[aria-label="Remove Coder access"]')).toBeNull();
|
||||
// Exactly one radiogroup on the tab: the install model.
|
||||
expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(1);
|
||||
expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(2);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.trim() === "Change",
|
||||
|
|
@ -947,8 +1097,8 @@ describe("AppDetail", () => {
|
|||
await renderAppDetail();
|
||||
|
||||
// State is still legible.
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
expect(container.textContent).toContain("Action permissions");
|
||||
expect(container.textContent).toContain("Agent access");
|
||||
expect(container.textContent).toContain("Actions");
|
||||
expect(container.textContent).toContain("Read repo");
|
||||
|
||||
// Nothing to mutate: no radios, no permission selects, no refresh, no save.
|
||||
|
|
@ -1130,7 +1280,7 @@ describe("AppDetail", () => {
|
|||
expect(container.textContent).toContain("Needs attention");
|
||||
expect(container.textContent).toContain("This app needs reconnecting");
|
||||
expect(container.textContent).toContain("Token expired.");
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
expect(container.textContent).toContain("Agent access");
|
||||
});
|
||||
|
||||
it("shows terminal OAuth failures as reconnect-required sign-in", async () => {
|
||||
|
|
@ -1158,6 +1308,83 @@ describe("AppDetail", () => {
|
|||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
|
||||
});
|
||||
|
||||
it("reconnects an OAuth warning through the connection's existing personal identity", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
createdByUserId: "user-1",
|
||||
healthStatus: "failed",
|
||||
healthMessage: "Authorization expired (invalid_grant).",
|
||||
}));
|
||||
|
||||
await renderAppDetail();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Reconnect")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-1", { asCurrentUser: true });
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
|
||||
});
|
||||
|
||||
it("does not offer a personal reconnect to someone other than its fixed owner", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
createdByUserId: "user-2",
|
||||
healthStatus: "failed",
|
||||
healthMessage: "Authorization expired (invalid_grant).",
|
||||
}));
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })],
|
||||
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
|
||||
currentUserId: "user-1",
|
||||
members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Reconnect required");
|
||||
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
|
||||
expect(findButton("Reconnect")).toBeUndefined();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer personal key replacement to someone other than its fixed owner", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
authKind: "api_key",
|
||||
credentialPolicy: "per_user",
|
||||
createdByUserId: "user-2",
|
||||
healthStatus: "failed",
|
||||
healthMessage: "The key was rejected.",
|
||||
}));
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })],
|
||||
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
|
||||
currentUserId: "user-1",
|
||||
members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
await act(async () => {
|
||||
findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Reconnect");
|
||||
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
|
||||
expect(Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.trim() === "Reconnect",
|
||||
)).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17099 — the server refuses to hand out an unsafe authorization endpoint,
|
||||
* but this is the boundary where one would actually execute, so the board must
|
||||
|
|
@ -1214,14 +1441,15 @@ describe("AppDetail", () => {
|
|||
.find((button) => button.textContent?.trim() === label);
|
||||
}
|
||||
|
||||
it("answers 'who does this act as' in the header on every tab", async () => {
|
||||
it("keeps the app header concise on every tab", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Acts as each person");
|
||||
expect(container.textContent).toContain("Each person connects their own account.");
|
||||
expect(container.textContent).toContain("Connected");
|
||||
expect(container.textContent).not.toContain("This connection is for one person.");
|
||||
expect(container.textContent).not.toContain("Connected by");
|
||||
});
|
||||
|
||||
it("lets a regular member connect their own identity and never someone else's", async () => {
|
||||
|
|
@ -1232,8 +1460,12 @@ describe("AppDetail", () => {
|
|||
await renderAppDetail();
|
||||
|
||||
// Missing personal identity is explicit, never a silent fallback.
|
||||
expect(container.textContent).toContain("Your identity");
|
||||
expect(container.textContent).toContain("You have not connected your account.");
|
||||
expect(container.textContent).toContain("Account");
|
||||
expect(container.textContent).toContain("Only you can use this connection");
|
||||
expect(container.textContent).toContain("Personal account");
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(container.textContent).not.toContain("Organization identity");
|
||||
expect(findButton("Connect organization identity")).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -1249,12 +1481,12 @@ describe("AppDetail", () => {
|
|||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
|
||||
});
|
||||
|
||||
it("lets the personal identity owner grant a named agent autonomous access", async () => {
|
||||
it("opens Permissions from app access instead of personal identity delegations", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [personalGrant({ delegations: [] })],
|
||||
grants: [personalGrant({ delegations: [{ id: "delegation-1", agentId: "agent-1" }] })],
|
||||
capabilities: fullCapabilities(),
|
||||
currentUserId: "user-1",
|
||||
members: [{ userId: "user-1", name: "Dotta", email: "dotta@example.com" }],
|
||||
|
|
@ -1262,34 +1494,18 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(findButton("Allow autonomous access")).toBeTruthy();
|
||||
expect(findButton("Every agent")).toBeTruthy();
|
||||
expect(findButton("No agents")).toBeUndefined();
|
||||
await act(async () => {
|
||||
findButton("Allow autonomous access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
findButton("Every agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const agentCheckbox = document.querySelector<HTMLInputElement>('button[role="checkbox"][aria-label="Allow Coder"]');
|
||||
expect(agentCheckbox).toBeTruthy();
|
||||
await act(async () => {
|
||||
agentCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Save")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(createConnectionGrantDelegationMock).toHaveBeenCalledWith(
|
||||
"conn-1",
|
||||
"grant-user",
|
||||
"agent-1",
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions");
|
||||
});
|
||||
|
||||
it("keeps a viewer read-only across identities and installs", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "shared" }));
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [organizationGrant({ capabilities: { canRevoke: false, canEditAudience: false } })],
|
||||
|
|
@ -1308,20 +1524,21 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Anyone in your company can use this connection");
|
||||
|
||||
// State stays legible...
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Connected");
|
||||
expect(container.textContent).toContain("All organization members");
|
||||
expect(container.textContent).toContain("Anyone in your company can use this connection");
|
||||
expect(container.textContent).not.toContain("Personal account");
|
||||
// ...and every mutation control is absent rather than disabled.
|
||||
expect(findButton("Connect as me")).toBeUndefined();
|
||||
expect(findButton("Manage audience")).toBeUndefined();
|
||||
expect(findButton("Manage access")).toBeUndefined();
|
||||
expect(findButton("Revoke")).toBeUndefined();
|
||||
expect(findButton("Connect organization identity")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives a manager oversight of other people's identities", async () => {
|
||||
it("shows one fixed personal identity without an organization switch", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
getConnectionMock.mockResolvedValue(perUserConnection({ createdByUserId: "user-2" }));
|
||||
revokeConnectionGrantMock.mockResolvedValue({ id: "grant-other", kind: "user" });
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
|
|
@ -1339,28 +1556,22 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Other personal identities · 1");
|
||||
expect(container.textContent).toContain("Only you can use this connection");
|
||||
expect(container.textContent).toContain("Carol");
|
||||
expect(container.textContent).not.toContain("Organization identity");
|
||||
expect(container.textContent).not.toContain("Other personal identities");
|
||||
expect(findButton("Connect organization identity")).toBeUndefined();
|
||||
expect(findButton("Agents")).toBeUndefined();
|
||||
|
||||
// A manager can still revoke the displayed identity from the folded danger
|
||||
// zone, but cannot reconnect as Carol or switch the identity type.
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Other personal identities"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Carol");
|
||||
|
||||
// Scope to Carol's own row: the organization identity also offers Revoke,
|
||||
// and picking the first match would silently test the wrong grant.
|
||||
const carolRow = Array.from(container.querySelectorAll("div"))
|
||||
.filter((row) => row.textContent?.includes("Carol")
|
||||
&& Array.from(row.querySelectorAll("button")).some((b) => b.textContent?.trim() === "Revoke"))
|
||||
.at(-1);
|
||||
expect(carolRow).toBeTruthy();
|
||||
await act(async () => {
|
||||
Array.from(carolRow!.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Revoke")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
findButton("Revoke")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
@ -1399,10 +1610,10 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("1 selected member");
|
||||
expect(container.textContent).toContain("Anyone in your company can use this connection");
|
||||
|
||||
await act(async () => {
|
||||
findButton("Manage audience")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
@ -1444,7 +1655,7 @@ describe("AppDetail", () => {
|
|||
await renderAppDetail();
|
||||
|
||||
await act(async () => {
|
||||
findButton("Manage audience")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
|
|||
|
|
@ -34,19 +34,23 @@ import { UnverifiedServerBadge } from "./UnverifiedServerBadge";
|
|||
import {
|
||||
appApplicationSourceSlug,
|
||||
appConnectionSourceSlug,
|
||||
appDefinitionDarkLogoUrl,
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
appDefinitionSlug,
|
||||
type AppGalleryDisplayEntry,
|
||||
} from "./app-definition-display";
|
||||
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
|
||||
import { SetupPanel, connectionProviderName } from "./app-detail/SetupPanel";
|
||||
import { SetupPanel } from "./app-detail/SetupPanel";
|
||||
import { ServicesPanel } from "./app-detail/ServicesPanel";
|
||||
import { ComposioProvenanceChip } from "./ComposioProvenanceChip";
|
||||
import { ConnectionProvenanceChip } from "./ComposioProvenanceChip";
|
||||
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
|
||||
import { actsAsSummary } from "./connection-identity";
|
||||
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
|
||||
import { TestPanel } from "./app-detail/TestPanel";
|
||||
import {
|
||||
formatActionPermissionSummary,
|
||||
summarizeActionPermissions,
|
||||
} from "./app-detail/action-permission-summary";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
import { ActivityPanel } from "./app-detail/ActivityPanel";
|
||||
import {
|
||||
|
|
@ -58,10 +62,8 @@ import {
|
|||
} from "./app-detail/AdvancedPanel";
|
||||
import type { AccessDraft } from "./app-detail/types";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
connectionOwnerProfile,
|
||||
type ConnectionOwnerProfile,
|
||||
} from "./connection-owner";
|
||||
|
||||
export { DangerZone, connectionAddress, connectionTransportLabel };
|
||||
|
|
@ -76,7 +78,7 @@ export function AppDetail() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
const needsCatalog = activeTab === "review" || activeTab === "permissions" || activeTab === "test";
|
||||
const needsCatalog = activeTab === "setup" || activeTab === "review" || activeTab === "permissions" || activeTab === "test";
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connection(connectionId),
|
||||
|
|
@ -111,12 +113,16 @@ export function AppDetail() {
|
|||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
enabled: !!selectedCompanyId && (
|
||||
activeTab === "setup" || activeTab === "review" || activeTab === "permissions"
|
||||
),
|
||||
});
|
||||
const policiesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
enabled: !!selectedCompanyId && (
|
||||
activeTab === "setup" || activeTab === "review" || activeTab === "permissions"
|
||||
),
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
|
|
@ -141,19 +147,57 @@ export function AppDetail() {
|
|||
queryFn: () => authApi.getSession(),
|
||||
enabled: activeTab === "activity",
|
||||
});
|
||||
// Identity grants drive the Setup tab's Identities section and the header's
|
||||
// "acts as" sentence, and Permissions reads `capabilities` from the same
|
||||
// response so install controls follow one server verdict (PAP-17835).
|
||||
// Identity grants drive reconnect authorization on every tab as well as the
|
||||
// Setup identities and Permissions controls. A personal reconnect belongs to
|
||||
// one fixed user, so the banner must not offer that action to anyone else.
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionGrants(connectionId),
|
||||
queryFn: () => toolsApi.listConnectionGrants(connectionId),
|
||||
enabled: !!connectionId && (activeTab === "setup" || activeTab === "permissions"),
|
||||
enabled: !!connectionId && !!activeTab,
|
||||
});
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const application = connection
|
||||
? (applicationsQuery.data?.applications ?? []).find((candidate) => candidate.id === connection.applicationId)
|
||||
: undefined;
|
||||
const grantRows = grantsQuery.data?.grants ?? [];
|
||||
const retainedPersonalGrant = connection?.credentialPolicy === "per_user"
|
||||
? grantRows.find((grant) => (
|
||||
grant.kind === "user" && grant.subjectUserId === connection.createdByUserId
|
||||
))
|
||||
?? grantRows.find((grant) => grant.kind === "user" && grant.status === "active")
|
||||
?? grantRows.find((grant) => grant.kind === "user")
|
||||
?? null
|
||||
: null;
|
||||
const currentUserPersonalGrant = grantRows.find((grant) => (
|
||||
grant.kind === "user" && grant.subjectUserId === grantsQuery.data?.currentUserId
|
||||
)) ?? null;
|
||||
const retainedOrganizationGrant = grantRows.find((grant) => (
|
||||
grant.kind === "organization" && grant.isDefault
|
||||
)) ?? grantRows.find((grant) => grant.kind === "organization") ?? null;
|
||||
const managedIdentityGrant = connection?.credentialPolicy === "per_user"
|
||||
? retainedPersonalGrant
|
||||
: connection?.credentialPolicy === "per_user_with_fallback"
|
||||
? currentUserPersonalGrant ?? retainedOrganizationGrant
|
||||
: retainedOrganizationGrant;
|
||||
const managedPersonalUserId = managedIdentityGrant?.kind === "user"
|
||||
? managedIdentityGrant.subjectUserId ?? connection?.createdByUserId ?? null
|
||||
: null;
|
||||
const canReconnect = managedIdentityGrant?.kind === "user"
|
||||
? Boolean(
|
||||
managedPersonalUserId
|
||||
&& managedPersonalUserId === grantsQuery.data?.currentUserId
|
||||
&& grantsQuery.data?.capabilities.canConnectAsCurrentUser,
|
||||
)
|
||||
: grantsQuery.data?.capabilities.canConfigure === true;
|
||||
const reconnectUnavailableMessage = grantsQuery.isLoading
|
||||
? "Checking who can reconnect this identity…"
|
||||
: grantsQuery.isError
|
||||
? "We couldn't verify who can reconnect this identity. Reload the page to try again."
|
||||
: managedIdentityGrant?.kind === "user"
|
||||
&& managedPersonalUserId !== grantsQuery.data?.currentUserId
|
||||
? "The person this connection belongs to must reconnect it."
|
||||
: "You don't have permission to reconnect this identity.";
|
||||
const composioChildConnectionCount = (connectionsQuery.data?.connections ?? []).filter(
|
||||
(candidate) => candidate.status !== "archived"
|
||||
&& candidate.config?.provider === "composio"
|
||||
|
|
@ -179,6 +223,14 @@ export function AppDetail() {
|
|||
: "App";
|
||||
const successNoticeShownFor = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "setup" || searchParams.get("oauth") !== "choose-access") return;
|
||||
// Older OAuth states may still return to the retired post-authorization
|
||||
// identity screen. Identity is now selected before consent, so normalize
|
||||
// the stale URL without asking a contradictory second question.
|
||||
navigate(appTabHref(connectionId, "setup"), { replace: true });
|
||||
}, [activeTab, connectionId, navigate, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "test"
|
||||
|
|
@ -392,49 +444,6 @@ export function AppDetail() {
|
|||
}),
|
||||
});
|
||||
|
||||
const replaceDelegations = useMutation({
|
||||
mutationFn: async ({
|
||||
grantId,
|
||||
currentDelegations,
|
||||
agentIds,
|
||||
}: {
|
||||
grantId: string;
|
||||
currentDelegations: Array<{ id: string; agentId: string }>;
|
||||
agentIds: string[];
|
||||
}) => {
|
||||
const desired = new Set(agentIds);
|
||||
const existing = new Map(currentDelegations.map((delegation) => [delegation.agentId, delegation]));
|
||||
await Promise.all([
|
||||
...currentDelegations
|
||||
.filter((delegation) => !desired.has(delegation.agentId))
|
||||
.map((delegation) => toolsApi.revokeConnectionGrantDelegation(
|
||||
connectionId,
|
||||
grantId,
|
||||
delegation.id,
|
||||
)),
|
||||
...agentIds
|
||||
.filter((agentId) => !existing.has(agentId))
|
||||
.map((agentId) => toolsApi.createConnectionGrantDelegation(connectionId, grantId, agentId)),
|
||||
]);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: "Autonomous access saved",
|
||||
body: "Only the agents you selected can use your identity in autonomous runs.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: "Couldn't save autonomous access",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// A denied or conflicting audience save keeps the dialog open with the
|
||||
// selection intact, so the error is surfaced inline rather than as a toast.
|
||||
const [audienceError, setAudienceError] = useState<string | null>(null);
|
||||
|
|
@ -575,20 +584,32 @@ export function AppDetail() {
|
|||
}
|
||||
|
||||
const status = statusFor(connection);
|
||||
const providerName = connectionProviderName(logoEntry, baseAppName);
|
||||
const needsReconnect = status.tone === "attention" && connection.healthStatus !== "unknown";
|
||||
const quarantined = catalog.filter((e) => e.status === "quarantined");
|
||||
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
|
||||
const readOnly = active.filter((e) => e.isReadOnly);
|
||||
const canChange = active.filter((e) => !e.isReadOnly);
|
||||
const actionCount = catalogQuery.data ? active.length : null;
|
||||
const setupPermissionsLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
|
||||
const setupPermissionsSummary = setupPermissionsLoading || catalogQuery.isError
|
||||
|| profilesQuery.isError || policiesQuery.isError
|
||||
? null
|
||||
: formatActionPermissionSummary(summarizeActionPermissions(active, enabledIds, askFirstIds));
|
||||
// Setup summarizes app access, not personal-identity delegations. Identity
|
||||
// delegation answers who an agent may act as; the profile binding below is
|
||||
// the source of truth for which agents may use the connection at all.
|
||||
const setupAgentsSummary = access.mode === "all"
|
||||
? "Every agent"
|
||||
: access.agentIds.size === 0
|
||||
? "No agents"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"}`;
|
||||
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
|
||||
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
|
||||
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
|
||||
const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 pb-12">
|
||||
<div className="max-w-4xl space-y-10 pb-12">
|
||||
<AppDetailHeader
|
||||
appName={appName}
|
||||
connection={connection}
|
||||
|
|
@ -596,8 +617,7 @@ export function AppDetail() {
|
|||
brandKey={brandKey}
|
||||
allowRemoteLogo={!applicationsQuery.isPending}
|
||||
status={status}
|
||||
actionCount={actionCount}
|
||||
owner={owner}
|
||||
actionCount={activeTab === "setup" ? null : actionCount}
|
||||
renaming={renaming}
|
||||
nameDraft={nameDraft}
|
||||
renamePending={rename.isPending}
|
||||
|
|
@ -617,6 +637,8 @@ export function AppDetail() {
|
|||
<ReconnectCard
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
canReconnect={canReconnect}
|
||||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||||
onReconnected={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
|
|
@ -626,69 +648,76 @@ export function AppDetail() {
|
|||
)}
|
||||
|
||||
{activeTab === "setup" && (
|
||||
<div className="space-y-8">
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
|
||||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
identities={
|
||||
<IdentitiesSection
|
||||
appName={appName}
|
||||
providerName={providerName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
grantsQuery={grantsQuery.data}
|
||||
agents={agents}
|
||||
agentsLoading={agentsQuery.isLoading}
|
||||
agentsError={agentsQuery.isError}
|
||||
loading={grantsQuery.isLoading}
|
||||
error={grantsQuery.isError}
|
||||
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
|
||||
revokePending={revokeGrant.isPending}
|
||||
delegationPending={replaceDelegations.isPending}
|
||||
audiencePending={replaceAudience.isPending}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={audienceOpenGrantId}
|
||||
onOpenAudience={(grantId) => {
|
||||
setAudienceError(null);
|
||||
setAudienceOpenGrantId(grantId);
|
||||
}}
|
||||
onCloseAudience={() => {
|
||||
setAudienceOpenGrantId(null);
|
||||
setAudienceError(null);
|
||||
}}
|
||||
onConnectAsMe={() => startPersonalAuth.mutate()}
|
||||
// The organization identity is a shared credential, so it goes
|
||||
// through the connection-level OAuth start, not a personal one.
|
||||
onConnectOrganization={() => startOAuth.mutate()}
|
||||
onReconnectOrganization={() => startOAuth.mutate()}
|
||||
onRevokeGrant={(grant) => revokeGrant.mutate(grant.id)}
|
||||
onReplaceDelegations={(grant, agentIds) => replaceDelegations.mutate({
|
||||
grantId: grant.id,
|
||||
currentDelegations: grant.delegations ?? [],
|
||||
agentIds,
|
||||
})}
|
||||
onReplaceAudience={(grant, memberUserIds) =>
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName={appName}
|
||||
galleryEntry={logoEntry}
|
||||
childConnectionCount={composioChildConnectionCount}
|
||||
removing={removeApp.isPending}
|
||||
onRemove={() => removeApp.mutate()}
|
||||
onReplaced={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-12">
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
agentsSummary={setupAgentsSummary}
|
||||
permissionsSummary={setupPermissionsSummary}
|
||||
permissionsLoading={setupPermissionsLoading}
|
||||
onOpenPermissions={() => navigate(appTabHref(connectionId, "permissions"))}
|
||||
identities={
|
||||
<IdentitiesSection
|
||||
appName={appName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
ownerUserId={connection.createdByUserId}
|
||||
connectedUser={owner}
|
||||
grantsQuery={grantsQuery.data}
|
||||
loading={grantsQuery.isLoading}
|
||||
error={grantsQuery.isError}
|
||||
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
|
||||
audiencePending={replaceAudience.isPending}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={audienceOpenGrantId}
|
||||
onOpenAudience={(grantId) => {
|
||||
setAudienceError(null);
|
||||
setAudienceOpenGrantId(grantId);
|
||||
}}
|
||||
onCloseAudience={() => {
|
||||
setAudienceOpenGrantId(null);
|
||||
setAudienceError(null);
|
||||
}}
|
||||
onConnectAsMe={() => startPersonalAuth.mutate()}
|
||||
// The organization identity is a shared credential, so it goes
|
||||
// through the connection-level OAuth start, not a personal one.
|
||||
onConnectOrganization={() => startOAuth.mutate()}
|
||||
onReplaceAudience={(grant, memberUserIds) =>
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName={appName}
|
||||
galleryEntry={logoEntry}
|
||||
childConnectionCount={composioChildConnectionCount}
|
||||
removing={removeApp.isPending}
|
||||
onRemove={() => removeApp.mutate()}
|
||||
canReplaceCredential={canReconnect}
|
||||
credentialUnavailableMessage={reconnectUnavailableMessage}
|
||||
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
|
||||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
identityGrant={managedIdentityGrant}
|
||||
identityCurrentUserId={grantsQuery.data?.currentUserId ?? null}
|
||||
identityProviderName={baseAppName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
identityActionPending={
|
||||
startPersonalAuth.isPending || startOAuth.isPending || revokeGrant.isPending
|
||||
}
|
||||
onReconnectIdentity={managedIdentityGrant ? () => {
|
||||
if (managedIdentityGrant.kind === "user") startPersonalAuth.mutate();
|
||||
else startOAuth.mutate();
|
||||
} : undefined}
|
||||
onRevokeIdentity={(grant) => revokeGrant.mutate(grant.id)}
|
||||
onReplaced={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "services" && (
|
||||
<ServicesPanel connectionId={connectionId} appName={appName} />
|
||||
|
|
@ -722,7 +751,9 @@ export function AppDetail() {
|
|||
? <ToolsLoading />
|
||||
: <PermissionsPanel
|
||||
capabilities={grantsQuery.data?.capabilities}
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
|
|
@ -732,6 +763,7 @@ export function AppDetail() {
|
|||
pending={pending}
|
||||
installPending={persistInstall.isPending}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: accessIncludingInstalls(next, install) })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
|
|
@ -770,7 +802,6 @@ function AppDetailHeader({
|
|||
allowRemoteLogo,
|
||||
status,
|
||||
actionCount,
|
||||
owner,
|
||||
renaming,
|
||||
nameDraft,
|
||||
renamePending,
|
||||
|
|
@ -786,7 +817,6 @@ function AppDetailHeader({
|
|||
allowRemoteLogo: boolean;
|
||||
status: StatusInfo;
|
||||
actionCount: number | null;
|
||||
owner: ConnectionOwnerProfile | null;
|
||||
renaming: boolean;
|
||||
nameDraft: string;
|
||||
renamePending: boolean;
|
||||
|
|
@ -796,19 +826,18 @@ function AppDetailHeader({
|
|||
onRenameSubmit: (value: string) => void;
|
||||
}) {
|
||||
const unverifiedHost = unverifiedRemoteHost(connection);
|
||||
const actsAs = actsAsSummary(connection.credentialPolicy);
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<header>
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo
|
||||
name={appName}
|
||||
brandKey={brandKey}
|
||||
logoUrl={appDefinitionLogoUrl(logoEntry)}
|
||||
darkLogoUrl={appDefinitionDarkLogoUrl(logoEntry)}
|
||||
allowRemoteFallback={allowRemoteLogo}
|
||||
size={44}
|
||||
/>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
{renaming ? (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
|
|
@ -833,7 +862,7 @@ function AppDetailHeader({
|
|||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{appName}</h1>
|
||||
<h1 className="truncate text-xl font-bold">{appName}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -845,32 +874,20 @@ function AppDetailHeader({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{connectionDisplaySecondaryHint(connection) && (
|
||||
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
|
||||
)}
|
||||
{/* One sentence, not a cluster of badges: whether an agent acts as you
|
||||
or as the organization is the first thing every tab has to answer
|
||||
(PAP-17835). It lives in the header so it carries across tabs. */}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{actsAs.title}</span>
|
||||
{" · "}
|
||||
{actsAs.detail}
|
||||
</p>
|
||||
{owner && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Connected by</span>
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
)}
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
|
||||
<ComposioProvenanceChip connection={connection} className="mt-1" />
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
{actionCount !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||||
</span>
|
||||
)}
|
||||
{connectionDisplaySecondaryHint(connection) ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionDisplaySecondaryHint(connection)}
|
||||
</span>
|
||||
) : null}
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} /> : null}
|
||||
<ConnectionProvenanceChip connection={connection} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -973,11 +990,10 @@ function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<s
|
|||
* Who may use this connection, read back from the app profile's bindings.
|
||||
*
|
||||
* `finishApp` replaces a profile's whole binding set, so every save from this
|
||||
* page — including an action-permission toggle — has to restate this. Since
|
||||
* PAP-17859 there is no user-facing editor for it: the Permissions tab shows
|
||||
* agent availability once, as installs. This function therefore only ever
|
||||
* *preserves* what the server already has, and falls back to the install state
|
||||
* when the profile has no bindings yet.
|
||||
* page — including an action-permission toggle — has to restate this. The
|
||||
* Permissions tab exposes the bindings as Agent access, while installs remain
|
||||
* a separate "always loaded" choice. If the profile has no bindings yet, the
|
||||
* install state remains the safest legacy fallback.
|
||||
*
|
||||
* That fallback is the important part. It used to return "all agents" for an
|
||||
* unbound profile, which turned any unrelated save into a silent company-wide
|
||||
|
|
@ -1000,6 +1016,16 @@ function accessFrom(
|
|||
: { mode: "specific", agentIds: new Set(install.agentIds) };
|
||||
}
|
||||
|
||||
function accessIncludingInstalls(next: AccessDraft, install: InstallState): AccessDraft {
|
||||
if (install.onAll || next.mode === "all") {
|
||||
return { mode: "all", agentIds: new Set() };
|
||||
}
|
||||
return {
|
||||
mode: "specific",
|
||||
agentIds: new Set([...next.agentIds, ...install.agentIds]),
|
||||
};
|
||||
}
|
||||
|
||||
function galleryEntryFor(
|
||||
apps: AppGalleryDisplayEntry[],
|
||||
connection: ToolConnection | undefined,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetAppBrandManifestCacheForTests } from "@/lib/app-brand-assets";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("AppLogo", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
resetAppBrandManifestCacheForTests();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ schemaVersion: 1, providers: [] }),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders decorative local light and dark provider marks", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AppLogo
|
||||
name="Notion"
|
||||
logoUrl="/brands/apps/notion.svg"
|
||||
darkLogoUrl="/brands/apps/notion-dark.svg"
|
||||
size={36}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
expect(images).toHaveLength(2);
|
||||
expect(images.map((image) => image.getAttribute("src"))).toEqual([
|
||||
"/brands/apps/notion.svg",
|
||||
"/brands/apps/notion-dark.svg",
|
||||
]);
|
||||
expect(images.every((image) => image.alt === "")).toBe(true);
|
||||
expect(images[0]?.className).toContain("dark:hidden");
|
||||
expect(images[1]?.className).toContain("dark:block");
|
||||
});
|
||||
|
||||
it("uses the deterministic letter tile only after a runtime image failure", async () => {
|
||||
await act(async () => {
|
||||
root.render(<AppLogo name="Jira" logoUrl="/brands/apps/missing.svg" />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const image = container.querySelector("img");
|
||||
expect(image).toBeTruthy();
|
||||
|
||||
act(() => image?.dispatchEvent(new Event("error")));
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.textContent).toBe("J");
|
||||
});
|
||||
});
|
||||
|
|
@ -24,19 +24,21 @@ interface AppLogoProps {
|
|||
brandKey?: string | null;
|
||||
logoUrl?: string | null;
|
||||
allowRemoteFallback?: boolean;
|
||||
darkLogoUrl?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App icon for the gallery and connected-apps surfaces. Renders the manifest
|
||||
* favicon when available, falling back to a coloured letter tile (deterministic
|
||||
* colour per app name) when the image is missing or fails to load.
|
||||
* official local provider mark when available, including a dark-mode variant.
|
||||
* The deterministic letter tile is reserved for runtime image failures.
|
||||
*/
|
||||
export function AppLogo({
|
||||
name,
|
||||
brandKey,
|
||||
logoUrl,
|
||||
darkLogoUrl,
|
||||
allowRemoteFallback = true,
|
||||
size = 36,
|
||||
className,
|
||||
|
|
@ -57,17 +59,19 @@ export function AppLogo({
|
|||
const resolvedLogoUrl = localLookupComplete
|
||||
? localAssets?.light ?? (allowRemoteFallback ? logoUrl : null)
|
||||
: null;
|
||||
const lightLogoUrl = resolvedLogoUrl && !failedLogoUrls.has(resolvedLogoUrl)
|
||||
const resolvedDarkLogoUrl = localLookupComplete
|
||||
? localAssets?.dark ?? (allowRemoteFallback ? darkLogoUrl : null)
|
||||
: null;
|
||||
const lightLogoUrlForRender = resolvedLogoUrl && !failedLogoUrls.has(resolvedLogoUrl)
|
||||
? resolvedLogoUrl
|
||||
: null;
|
||||
const requestedDarkLogoUrl = localAssets?.dark ?? null;
|
||||
const darkLogoUrl = requestedDarkLogoUrl && !failedLogoUrls.has(requestedDarkLogoUrl)
|
||||
? requestedDarkLogoUrl
|
||||
const darkLogoUrlForRender = resolvedDarkLogoUrl && !failedLogoUrls.has(resolvedDarkLogoUrl)
|
||||
? resolvedDarkLogoUrl
|
||||
: null;
|
||||
const hasDistinctThemeLogos = Boolean(
|
||||
resolvedLogoUrl && requestedDarkLogoUrl && resolvedLogoUrl !== requestedDarkLogoUrl,
|
||||
resolvedLogoUrl && resolvedDarkLogoUrl && resolvedLogoUrl !== resolvedDarkLogoUrl,
|
||||
);
|
||||
const fallbackLogoUrl = lightLogoUrl ?? darkLogoUrl;
|
||||
const fallbackLogoUrl = lightLogoUrlForRender ?? darkLogoUrlForRender;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
|
@ -85,7 +89,7 @@ export function AppLogo({
|
|||
|
||||
useEffect(() => {
|
||||
setFailedLogoUrls(new Set());
|
||||
}, [resolvedLogoUrl, localAssets?.dark]);
|
||||
}, [resolvedDarkLogoUrl, resolvedLogoUrl]);
|
||||
|
||||
const markLogoFailed = (url: string) => {
|
||||
setFailedLogoUrls((current) => new Set(current).add(url));
|
||||
|
|
@ -99,19 +103,19 @@ export function AppLogo({
|
|||
>
|
||||
{hasDistinctThemeLogos ? (
|
||||
<>
|
||||
{lightLogoUrl ? (
|
||||
{lightLogoUrlForRender ? (
|
||||
<img
|
||||
src={lightLogoUrl}
|
||||
src={lightLogoUrlForRender}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="h-full w-full object-contain dark:hidden"
|
||||
onError={() => markLogoFailed(lightLogoUrl)}
|
||||
className="h-full w-full object-contain p-1.5 dark:hidden"
|
||||
onError={() => markLogoFailed(lightLogoUrlForRender)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center font-bold text-white dark:hidden",
|
||||
"flex h-full w-full items-center justify-center text-sm font-bold text-white dark:hidden",
|
||||
colorFor(name),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
|
@ -119,19 +123,19 @@ export function AppLogo({
|
|||
{letter}
|
||||
</span>
|
||||
)}
|
||||
{darkLogoUrl ? (
|
||||
{darkLogoUrlForRender ? (
|
||||
<img
|
||||
src={darkLogoUrl}
|
||||
src={darkLogoUrlForRender}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="hidden h-full w-full object-contain dark:block"
|
||||
onError={() => markLogoFailed(darkLogoUrl)}
|
||||
className="hidden h-full w-full object-contain p-1.5 dark:block"
|
||||
onError={() => markLogoFailed(darkLogoUrlForRender)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden h-full w-full items-center justify-center font-bold text-white dark:flex",
|
||||
"hidden h-full w-full items-center justify-center text-sm font-bold text-white dark:flex",
|
||||
colorFor(name),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
|
@ -146,7 +150,7 @@ export function AppLogo({
|
|||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="h-full w-full object-contain"
|
||||
className="h-full w-full object-contain p-1.5"
|
||||
onError={() => markLogoFailed(fallbackLogoUrl)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -157,11 +161,11 @@ export function AppLogo({
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg font-bold text-white",
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white",
|
||||
colorFor(name),
|
||||
className,
|
||||
)}
|
||||
style={{ ...dimension, fontSize: Math.round(size * 0.42) }}
|
||||
style={dimension}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AppNotConnected } from "./AppNotConnected";
|
|||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -23,6 +24,7 @@ vi.mock("@/api/tools", () => ({
|
|||
listApplications: (companyId: string) => listApplicationsMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
|
||||
listConnectionActivity: (connectionId: string, limit: number) =>
|
||||
listConnectionActivityMock(connectionId, limit),
|
||||
listActionRequests: (companyId: string, status: string) =>
|
||||
|
|
@ -122,6 +124,8 @@ function connection(overrides: Record<string, unknown> = {}) {
|
|||
name: "GitHub",
|
||||
connectionKind: "managed",
|
||||
transport: "mcp_remote",
|
||||
authKind: "api_key",
|
||||
credentialPolicy: "shared",
|
||||
status: "archived",
|
||||
transportConfig: { url: "https://github.example/mcp" },
|
||||
config: { url: "https://github.example/mcp" },
|
||||
|
|
@ -156,6 +160,21 @@ describe("AppNotConnected", () => {
|
|||
apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }],
|
||||
});
|
||||
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-old", uid: "conn-old" },
|
||||
grants: [],
|
||||
capabilities: {
|
||||
canConfigure: true,
|
||||
canCreateOrganizationGrant: true,
|
||||
canSetCompanyInstall: true,
|
||||
canConnectAsCurrentUser: true,
|
||||
canManageAgentInstalls: true,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: [],
|
||||
},
|
||||
currentUserId: "user-1",
|
||||
members: [],
|
||||
});
|
||||
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
mockAgentsList.mockResolvedValue([]);
|
||||
|
|
@ -341,4 +360,97 @@ describe("AppNotConnected", () => {
|
|||
expect(container.textContent).toContain("https://github.example/mcp");
|
||||
expect(container.textContent).toContain("Danger zone");
|
||||
});
|
||||
|
||||
it("carries the retained identity into the reconnect flow", async () => {
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [connection({ credentialPolicy: "per_user", createdByUserId: "user-1" })],
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Reconnect")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/apps/connect?applicationId=app-1&name=GitHub&new=1&reconnect=conn-old&identity=user&source=github&link=https%3A%2F%2Fgithub.example%2Fmcp",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a removed Vercel-backed connection in the isolated Vercel setup", async () => {
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [connection({ credentialSource: "vercel_connect" })],
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Reconnect")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/apps/vercel-connect?applicationId=app-1&name=GitHub&new=1&reconnect=conn-old&identity=organization&source=github&link=https%3A%2F%2Fgithub.example%2Fmcp",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefills a custom MCP reconnect from the stored transport URL", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [application({ applicationKey: "custom-mcp", name: "Bla", metadata: null })],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [connection({
|
||||
name: "Bla",
|
||||
config: { url: "http://127.0.0.1:49287" },
|
||||
transportConfig: { url: "http://127.0.0.1:49287" },
|
||||
})],
|
||||
});
|
||||
listGalleryMock.mockResolvedValue({ apps: [] });
|
||||
|
||||
await renderPage();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Reconnect")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/apps/connect?applicationId=app-1&name=Bla&new=1&reconnect=conn-old&identity=organization&byo=1&link=http%3A%2F%2F127.0.0.1%3A49287",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer a removed personal reconnect to someone other than its fixed owner", async () => {
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [connection({ credentialPolicy: "per_user", createdByUserId: "user-2" })],
|
||||
});
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-old", uid: "conn-old" },
|
||||
grants: [{
|
||||
id: "grant-user-2",
|
||||
kind: "user",
|
||||
subjectUserId: "user-2",
|
||||
status: "revoked",
|
||||
credentialSecretRefs: [],
|
||||
}],
|
||||
capabilities: {
|
||||
canConfigure: true,
|
||||
canCreateOrganizationGrant: true,
|
||||
canSetCompanyInstall: true,
|
||||
canConnectAsCurrentUser: true,
|
||||
canManageAgentInstalls: true,
|
||||
canViewOtherPersonalIdentities: true,
|
||||
editableAgentIds: [],
|
||||
},
|
||||
currentUserId: "user-1",
|
||||
members: [],
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
|
||||
expect(Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Reconnect",
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import {
|
||||
connectionDisplaySecondaryHint,
|
||||
isConnectableAppSlug,
|
||||
isToolConnectionAttentionHealth,
|
||||
} from "@paperclipai/shared";
|
||||
import { Navigate, useNavigate, useParams } from "@/lib/router";
|
||||
|
|
@ -20,12 +21,12 @@ import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/compa
|
|||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
appDefinitionDarkLogoUrl,
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
appDefinitionSlug,
|
||||
type AppGalleryDisplayEntry,
|
||||
} from "./app-definition-display";
|
||||
import { isMcpDirectOAuthConnectSlug } from "./app-connect-policy";
|
||||
import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail";
|
||||
import { ActivityPanel } from "./app-detail/ActivityPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
|
|
@ -99,6 +100,11 @@ export function AppNotConnected() {
|
|||
queryFn: () => toolsApi.listConnectionActivity(previousConnection!.id, 20),
|
||||
enabled: !!previousConnection && activeTab === "activity",
|
||||
});
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionGrants(previousConnection?.id ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnectionGrants(previousConnection!.id),
|
||||
enabled: !!previousConnection && activeTab === "setup",
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
|
|
@ -164,20 +170,48 @@ export function AppNotConnected() {
|
|||
}
|
||||
|
||||
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
|
||||
const logoUrl =
|
||||
(appSourceSlug
|
||||
? appDefinitionLogoUrl(gallery.find((entry) => appDefinitionSlug(entry) === appSourceSlug))
|
||||
: undefined) ??
|
||||
appDefinitionLogoUrl(
|
||||
gallery.find((entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase()),
|
||||
const logoEntry = (appSourceSlug
|
||||
? gallery.find((entry) => appDefinitionSlug(entry) === appSourceSlug)
|
||||
: undefined) ?? gallery.find(
|
||||
(entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase(),
|
||||
);
|
||||
const logoUrl = appDefinitionLogoUrl(logoEntry);
|
||||
const darkLogoUrl = appDefinitionDarkLogoUrl(logoEntry);
|
||||
|
||||
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
|
||||
const retainedPersonalGrant = previousConnection?.credentialPolicy === "per_user"
|
||||
? grantsQuery.data?.grants.find((grant) => (
|
||||
grant.kind === "user" && grant.subjectUserId === previousConnection.createdByUserId
|
||||
))
|
||||
?? grantsQuery.data?.grants.find((grant) => grant.kind === "user" && grant.status === "active")
|
||||
?? grantsQuery.data?.grants.find((grant) => grant.kind === "user")
|
||||
?? null
|
||||
: null;
|
||||
const retainedPersonalUserId = previousConnection?.credentialPolicy === "per_user"
|
||||
? previousConnection.createdByUserId ?? retainedPersonalGrant?.subjectUserId ?? null
|
||||
: null;
|
||||
const canReconnect = !previousConnection
|
||||
|| (previousConnection.credentialPolicy === "per_user"
|
||||
? Boolean(
|
||||
retainedPersonalUserId
|
||||
&& retainedPersonalUserId === grantsQuery.data?.currentUserId
|
||||
&& grantsQuery.data?.capabilities.canConnectAsCurrentUser,
|
||||
)
|
||||
: grantsQuery.data?.capabilities.canConfigure === true);
|
||||
const reconnectUnavailableMessage = grantsQuery.isLoading
|
||||
? "Checking who can reconnect this identity…"
|
||||
: grantsQuery.isError
|
||||
? "We couldn't verify who can reconnect this identity. Reload the page to try again."
|
||||
: previousConnection?.credentialPolicy === "per_user"
|
||||
&& retainedPersonalUserId !== grantsQuery.data?.currentUserId
|
||||
? "The person this connection belongs to must reconnect it."
|
||||
: "You don't have permission to reconnect this identity.";
|
||||
const connectHref = newConnectionHref({
|
||||
applicationId,
|
||||
appName: application.name,
|
||||
previousAddress,
|
||||
sourceSlug: appSourceSlug,
|
||||
previousConnection,
|
||||
sourceSlug: isConnectableAppSlug(appSourceSlug) ? appSourceSlug : null,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -186,6 +220,7 @@ export function AppNotConnected() {
|
|||
applicationName={application.name}
|
||||
description={application.description}
|
||||
logoUrl={logoUrl}
|
||||
darkLogoUrl={darkLogoUrl}
|
||||
connectedCount={activeConnections.length}
|
||||
/>
|
||||
|
||||
|
|
@ -197,6 +232,8 @@ export function AppNotConnected() {
|
|||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
userProfileById={userProfileById}
|
||||
canReconnect={canReconnect}
|
||||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||||
onConnect={() => navigate(connectHref)}
|
||||
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
|
||||
/>
|
||||
|
|
@ -259,16 +296,18 @@ function ApplicationHeader({
|
|||
applicationName,
|
||||
description,
|
||||
logoUrl,
|
||||
darkLogoUrl,
|
||||
connectedCount,
|
||||
}: {
|
||||
applicationName: string;
|
||||
description: string | null;
|
||||
logoUrl: string | undefined;
|
||||
darkLogoUrl: string | undefined;
|
||||
connectedCount: number;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-center gap-4">
|
||||
<AppLogo name={applicationName} logoUrl={logoUrl} size={48} />
|
||||
<AppLogo name={applicationName} logoUrl={logoUrl} darkLogoUrl={darkLogoUrl} size={48} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-2xl font-bold tracking-tight">{applicationName}</h1>
|
||||
|
|
@ -290,6 +329,8 @@ function SetupTab({
|
|||
previousConnection,
|
||||
previousAddress,
|
||||
userProfileById,
|
||||
canReconnect,
|
||||
reconnectUnavailableMessage,
|
||||
onConnect,
|
||||
onEdit,
|
||||
}: {
|
||||
|
|
@ -298,6 +339,8 @@ function SetupTab({
|
|||
previousConnection: ToolConnection | null;
|
||||
previousAddress: string | null;
|
||||
userProfileById: ReadonlyMap<string, CompanyUserProfile>;
|
||||
canReconnect: boolean;
|
||||
reconnectUnavailableMessage: string;
|
||||
onConnect: () => void;
|
||||
onEdit: (connectionId: string) => void;
|
||||
}) {
|
||||
|
|
@ -368,13 +411,20 @@ function SetupTab({
|
|||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{previousConnection
|
||||
? "We kept the previous setup. Add a working key to bring it back online."
|
||||
? previousConnection.authKind === "oauth"
|
||||
? "We kept the previous setup. Sign in again to bring it back online."
|
||||
: "We kept the previous setup. Add a working key to bring it back online."
|
||||
: "Agents can't use it until it's connected."}
|
||||
</p>
|
||||
{previousConnection && !canReconnect ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{reconnectUnavailableMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button onClick={onConnect}>
|
||||
{previousConnection ? "Reconnect" : "Connect"}
|
||||
</Button>
|
||||
{!previousConnection || canReconnect ? (
|
||||
<Button onClick={onConnect}>
|
||||
{previousConnection ? "Reconnect" : "Connect"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -465,16 +515,34 @@ function newConnectionHref({
|
|||
applicationId,
|
||||
appName,
|
||||
previousAddress,
|
||||
previousConnection,
|
||||
sourceSlug,
|
||||
}: {
|
||||
applicationId: string;
|
||||
appName: string;
|
||||
previousAddress: string | null;
|
||||
previousConnection: ToolConnection | null;
|
||||
sourceSlug: string | null;
|
||||
}): string {
|
||||
const params = new URLSearchParams({ applicationId, name: appName, new: "1" });
|
||||
if (previousConnection) {
|
||||
params.set("reconnect", previousConnection.id);
|
||||
params.set("identity", previousConnection.credentialPolicy === "per_user" ? "user" : "organization");
|
||||
}
|
||||
if (sourceSlug) params.set("source", sourceSlug);
|
||||
if (!isMcpDirectOAuthConnectSlug(sourceSlug)) params.set("byo", "1");
|
||||
if (previousAddress && /^https?:\/\//i.test(previousAddress)) params.set("link", previousAddress);
|
||||
return `/apps/connect?${params.toString()}`;
|
||||
else params.set("byo", "1");
|
||||
const storedLink = [
|
||||
previousConnection?.config?.url,
|
||||
previousConnection?.config?.endpoint,
|
||||
previousConnection?.config?.remoteUrl,
|
||||
previousConnection?.transportConfig.url,
|
||||
previousConnection?.transportConfig.endpoint,
|
||||
previousConnection?.transportConfig.remoteUrl,
|
||||
previousAddress,
|
||||
].find((value): value is string => typeof value === "string" && /^https?:\/\//i.test(value));
|
||||
if (storedLink) params.set("link", storedLink);
|
||||
const path = previousConnection?.credentialSource === "vercel_connect"
|
||||
? "/apps/vercel-connect"
|
||||
: "/apps/connect";
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -27,7 +27,9 @@ vi.mock("@/api/access", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
|
|
@ -69,7 +71,10 @@ function galleryEntry(overrides: Record<string, unknown>) {
|
|||
logoUrl: "https://example.com/github.png",
|
||||
tagline: "Let agents open PRs and issues.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: { transport: "mcp_remote", url: "https://api.github.com/mcp" },
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://api.github.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {},
|
||||
urlPatterns: [],
|
||||
|
|
@ -84,13 +89,40 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
beforeEach(() => {
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
galleryEntry({ key: "zapier", name: "Zapier", tagline: "Connect automations." }),
|
||||
galleryEntry({ key: "github", name: "GitHub", tagline: "Open PRs and issues." }),
|
||||
galleryEntry({ key: "slack", name: "Slack", tagline: "Post messages to channels." }),
|
||||
galleryEntry({ key: "notion", name: "Notion", tagline: "Read and update workspace content." }),
|
||||
galleryEntry({ key: "composio", name: "Composio", tagline: "Connect hosted toolkits." }),
|
||||
galleryEntry({ key: "gmail", name: "Gmail", tagline: "Search and draft email." }),
|
||||
galleryEntry({ key: "acme", name: "Acme CRM", tagline: "Sync deals and contacts." }),
|
||||
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: "gmail",
|
||||
name: "Gmail",
|
||||
tagline: "Search and draft email.",
|
||||
availability: {
|
||||
available: false,
|
||||
reason: "Gmail is not available on this Paperclip instance yet.",
|
||||
},
|
||||
}),
|
||||
galleryEntry({
|
||||
key: "acme",
|
||||
name: "Acme CRM",
|
||||
tagline: "Sync deals and contacts.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
|
|
@ -107,7 +139,9 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
});
|
||||
|
||||
async function renderBrowse() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
|
@ -129,58 +163,97 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
expect(text).not.toContain("Other integrations are previews.");
|
||||
expect(text).toContain("Popular");
|
||||
expect(text).toContain("All apps");
|
||||
expect(text).toContain("GitHub");
|
||||
expect(text).toContain("Slack");
|
||||
expect(text).toContain("Jira");
|
||||
expect(text).toContain("Cloudflare");
|
||||
expect(text).toContain("Acme CRM");
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="All apps"] > [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");
|
||||
});
|
||||
|
||||
it("enables Notion, Zapier, Gmail, and custom URLs while fading unfinished integrations", async () => {
|
||||
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"]'),
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Connect for Zapier"]',
|
||||
),
|
||||
);
|
||||
const githubTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for GitHub"]'),
|
||||
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"]'),
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Connect for Notion"]',
|
||||
),
|
||||
);
|
||||
const composioTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Composio"]'),
|
||||
);
|
||||
const gmailTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Gmail"]'),
|
||||
const gmailTile = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Unavailable for Gmail"]',
|
||||
);
|
||||
const tile = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Coming soon for Acme CRM"]',
|
||||
'button[aria-label="Unavailable for Acme CRM"]',
|
||||
);
|
||||
const byoCard = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Connect your own tool"),
|
||||
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(composioTiles).toHaveLength(1);
|
||||
expect(composioTiles[0]?.disabled).toBe(false);
|
||||
expect(gmailTiles).toHaveLength(1);
|
||||
expect(gmailTiles[0]?.disabled).toBe(false);
|
||||
expect(githubTiles.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("Coming soon");
|
||||
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?byo=1&source=zapier");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=zapier");
|
||||
|
||||
await act(async () => {
|
||||
notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -188,14 +261,9 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=notion");
|
||||
|
||||
await act(async () => {
|
||||
composioTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
jiraTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=composio&stage=setup");
|
||||
|
||||
await act(async () => {
|
||||
gmailTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=gmail&stage=access");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=jira");
|
||||
|
||||
await act(async () => {
|
||||
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -206,21 +274,23 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
it("filters the gallery by the search query", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
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, "slack");
|
||||
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("Slack");
|
||||
expect(text).toContain("Cloudflare");
|
||||
expect(text).not.toContain("Acme CRM");
|
||||
// Popular grid is hidden while searching.
|
||||
expect(text).not.toContain("Popular");
|
||||
|
|
@ -229,7 +299,13 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
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: {} },
|
||||
{
|
||||
id: "app-notion",
|
||||
name: "Legacy integration",
|
||||
status: "active",
|
||||
applicationKey: "legacy:notion",
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
|
|
@ -248,16 +324,18 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
],
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
users: [
|
||||
{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrowse();
|
||||
|
|
@ -267,12 +345,112 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
'button[aria-label="Edit connections for Notion"]',
|
||||
),
|
||||
);
|
||||
expect(editButtons).toHaveLength(2);
|
||||
expect(editButtons.every((button) => button.textContent?.includes("Edit connections"))).toBe(true);
|
||||
// draft connections count toward the total (they are real, pending-setup connections)
|
||||
expect(container.textContent).toContain("3 connected");
|
||||
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();
|
||||
expect(
|
||||
container.querySelector('[title="Dotta"] [data-slot="avatar"]'),
|
||||
).toBeTruthy();
|
||||
const connectedAppTiles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'[aria-label="Connected apps"] > [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(
|
||||
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"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector(
|
||||
'button[aria-label="Add another Notion account"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
notionAllAppsTile?.querySelector(
|
||||
'button[aria-label="Edit connections for Notion"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -291,7 +469,55 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("treats an existing draft-only Notion connection as editable", async () => {
|
||||
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/*"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
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({
|
||||
|
|
@ -320,28 +546,50 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
|
||||
await renderBrowse();
|
||||
|
||||
const editButtons = Array.from(
|
||||
const finishButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Edit connection for Notion"]',
|
||||
'button[aria-label="Finish setup for Notion"]',
|
||||
),
|
||||
);
|
||||
expect(editButtons).toHaveLength(2);
|
||||
expect(container.querySelector('button[aria-label="Connect for Notion"]')).toBeNull();
|
||||
expect(container.textContent).toContain("1 connected");
|
||||
expect(finishButtons).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Add another Notion account"]')?.textContent,
|
||||
).toContain("Add new");
|
||||
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 notionAllAppsTile = allAppTiles.find(
|
||||
(tile) => tile.dataset.appSlug === "notion",
|
||||
);
|
||||
expect(notionAllAppsTile?.dataset.connected).toBe("false");
|
||||
expect(notionAllAppsTile?.dataset.setupPending).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
finishButtons[0]?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith(`/apps/${connectionId}/setup`);
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
`/apps/connect?source=notion&resume=${connectionId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the custom URL option available when gallery search has no matches", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
);
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Link2, Search } from "lucide-react";
|
||||
import {
|
||||
appSupportsCatalogSetup,
|
||||
getAppDefinitionForUrl,
|
||||
getAppStoreDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -13,6 +19,7 @@ import { buildCompanyUserProfileMap } from "@/lib/company-members";
|
|||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
appDefinitionDarkLogoUrl,
|
||||
appDefinitionDescription,
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
|
|
@ -23,10 +30,12 @@ import {
|
|||
AdvancedToolsLink,
|
||||
BYO_CONNECT_HREF,
|
||||
ByoConnectCard,
|
||||
NOTION_CONNECT_HREF,
|
||||
POPULAR_KEYS,
|
||||
ZAPIER_CONNECT_HREF,
|
||||
} from "./store-cards";
|
||||
import {
|
||||
appSourceConnectHref,
|
||||
appSourceResumeHref,
|
||||
} from "./app-connect-policy";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
|
|
@ -36,12 +45,10 @@ import {
|
|||
|
||||
function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
|
||||
const slug = appDefinitionSlug(entry);
|
||||
if (slug === "notion") return NOTION_CONNECT_HREF;
|
||||
if (slug === "zapier") return ZAPIER_CONNECT_HREF;
|
||||
if (slug === "posthog") return "/apps/connect?byo=1&appKey=posthog&stage=setup";
|
||||
if (slug === "composio") return "/apps/connect?byo=1&appKey=composio&stage=setup";
|
||||
if (slug === "gmail") return "/apps/connect?byo=1&appKey=gmail&stage=access";
|
||||
return null;
|
||||
const definition = getAppStoreDefinition(slug);
|
||||
return appSupportsCatalogSetup(definition)
|
||||
? appSourceConnectHref(slug)
|
||||
: null;
|
||||
}
|
||||
|
||||
function additionalConnectionHref(
|
||||
|
|
@ -61,10 +68,11 @@ function additionalConnectionHref(
|
|||
/**
|
||||
* Door 1 — Browse (the store) (PAP-13254 / U3 §4).
|
||||
*
|
||||
* A persistent, browsable storefront: search + a Popular grid + the full
|
||||
* gallery + a first-class bring-your-own card + a labelled Developer link.
|
||||
* Browse remains the single discoverability surface. Notion uses MCP-direct
|
||||
* OAuth, while Zapier and bring-your-own MCP servers use the URL flow.
|
||||
* A persistent, browsable storefront: search + Popular and Connected grids +
|
||||
* the full gallery + a first-class bring-your-own card + a labelled Developer
|
||||
* link.
|
||||
* Browse remains the single discoverability surface. Capability-backed apps
|
||||
* share the curated setup route; Zapier branches to its generated-URL screen.
|
||||
*/
|
||||
export function Browse() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -96,7 +104,9 @@ export function Browse() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const userDirectoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
|
||||
queryKey: queryKeys.access.companyUserDirectory(
|
||||
selectedCompanyId ?? "__none__",
|
||||
),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -104,9 +114,9 @@ export function Browse() {
|
|||
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
|
||||
const popular = useMemo(
|
||||
() =>
|
||||
POPULAR_KEYS.map((key) => gallery.find((entry) => appDefinitionSlug(entry) === key)).filter(
|
||||
(entry): entry is AppGalleryDisplayEntry => Boolean(entry),
|
||||
),
|
||||
POPULAR_KEYS.map((key) =>
|
||||
gallery.find((entry) => appDefinitionSlug(entry) === key),
|
||||
).filter((entry): entry is AppGalleryDisplayEntry => Boolean(entry)),
|
||||
[gallery],
|
||||
);
|
||||
|
||||
|
|
@ -121,42 +131,92 @@ export function Browse() {
|
|||
}, [gallery, trimmed]);
|
||||
const connectionSummaryBySlug = useMemo(() => {
|
||||
const connections = connectionsQuery.data?.connections ?? [];
|
||||
const gallerySlugs = new Set(gallery.map((entry) => appDefinitionSlug(entry)));
|
||||
const gallerySlugs = new Set(
|
||||
gallery.map((entry) => appDefinitionSlug(entry)),
|
||||
);
|
||||
const gallerySlugByName = new Map(
|
||||
gallery.map((entry) => [appDefinitionName(entry).trim().toLowerCase(), appDefinitionSlug(entry)]),
|
||||
gallery.map((entry) => [
|
||||
appDefinitionName(entry).trim().toLowerCase(),
|
||||
appDefinitionSlug(entry),
|
||||
]),
|
||||
);
|
||||
const connectionsByApplicationId = new Map<string, typeof connections>();
|
||||
for (const connection of connections) {
|
||||
if (connection.status === "archived") continue;
|
||||
connectionsByApplicationId.set(
|
||||
connection.applicationId,
|
||||
[...(connectionsByApplicationId.get(connection.applicationId) ?? []), connection],
|
||||
);
|
||||
connectionsByApplicationId.set(connection.applicationId, [
|
||||
...(connectionsByApplicationId.get(connection.applicationId) ?? []),
|
||||
connection,
|
||||
]);
|
||||
}
|
||||
|
||||
const summaries = new Map<string, {
|
||||
applicationId: string;
|
||||
count: number;
|
||||
primaryConnection: (typeof connections)[number] | null;
|
||||
}>();
|
||||
const summaries = new Map<
|
||||
string,
|
||||
{
|
||||
applicationId: string;
|
||||
connectedCount: number;
|
||||
draftCount: number;
|
||||
primaryConnection: (typeof connections)[number] | null;
|
||||
}
|
||||
>();
|
||||
for (const application of applicationsQuery.data?.applications ?? []) {
|
||||
if (application.status === "archived") continue;
|
||||
const appConnections = connectionsByApplicationId.get(application.id) ?? [];
|
||||
const appConnections =
|
||||
connectionsByApplicationId.get(application.id) ?? [];
|
||||
const configuredConnectionSlug = appConnections
|
||||
.map((connection) => connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey)
|
||||
.find((value): value is string => typeof value === "string" && gallerySlugs.has(value));
|
||||
.map(
|
||||
(connection) =>
|
||||
connection.config?.sourceTemplateKey ??
|
||||
connection.transportConfig?.sourceTemplateKey,
|
||||
)
|
||||
.find(
|
||||
(value): value is string =>
|
||||
typeof value === "string" && gallerySlugs.has(value),
|
||||
);
|
||||
// Older branded URL flows (notably Zapier) were persisted as generic
|
||||
// `link` applications even though their public endpoint matched a curated
|
||||
// provider. Keep those already-working connections attached to the store
|
||||
// card without rewriting credentials or relying on a display-name guess.
|
||||
const endpointMatchedSlug = appConnections
|
||||
.flatMap((connection) => [
|
||||
connection.config?.url,
|
||||
connection.transportConfig?.url,
|
||||
])
|
||||
.map((value) =>
|
||||
typeof value === "string"
|
||||
? appDefinitionSlug(getAppDefinitionForUrl(value, gallery)) || null
|
||||
: null,
|
||||
)
|
||||
.find((value): value is string => Boolean(value));
|
||||
const applicationSlug = appApplicationSourceSlug(application);
|
||||
const slug = applicationSlug && gallerySlugs.has(applicationSlug)
|
||||
? applicationSlug
|
||||
: configuredConnectionSlug
|
||||
?? gallerySlugByName.get(application.name.trim().toLowerCase())
|
||||
?? null;
|
||||
const slug =
|
||||
applicationSlug &&
|
||||
applicationSlug !== "link" &&
|
||||
gallerySlugs.has(applicationSlug)
|
||||
? applicationSlug
|
||||
: (configuredConnectionSlug ??
|
||||
endpointMatchedSlug ??
|
||||
gallerySlugByName.get(application.name.trim().toLowerCase()) ??
|
||||
null);
|
||||
if (!slug) continue;
|
||||
const current = summaries.get(slug);
|
||||
const connectedConnections = appConnections.filter(
|
||||
(connection) => connection.status !== "draft",
|
||||
);
|
||||
const draftConnections = appConnections.filter(
|
||||
(connection) => connection.status === "draft",
|
||||
);
|
||||
summaries.set(slug, {
|
||||
applicationId: current?.applicationId ?? application.id,
|
||||
count: (current?.count ?? 0) + appConnections.length,
|
||||
primaryConnection: current?.primaryConnection ?? appConnections[0] ?? null,
|
||||
connectedCount:
|
||||
(current?.connectedCount ?? 0) + connectedConnections.length,
|
||||
draftCount: (current?.draftCount ?? 0) + draftConnections.length,
|
||||
// Interrupted OAuth attempts remain resumable, but are not successful
|
||||
// connections and must not receive the green Connected treatment.
|
||||
primaryConnection:
|
||||
current?.primaryConnection ??
|
||||
connectedConnections[0] ??
|
||||
draftConnections[0] ??
|
||||
null,
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
|
|
@ -165,32 +225,108 @@ export function Browse() {
|
|||
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
|
||||
[userDirectoryQuery.data],
|
||||
);
|
||||
const sortedPopular = useMemo(
|
||||
() =>
|
||||
popular
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.sort((left, right) => {
|
||||
const leftSummary = connectionSummaryBySlug.get(
|
||||
appDefinitionSlug(left.entry),
|
||||
);
|
||||
const rightSummary = connectionSummaryBySlug.get(
|
||||
appDefinitionSlug(right.entry),
|
||||
);
|
||||
const leftRank =
|
||||
(leftSummary?.connectedCount ?? 0) > 0
|
||||
? 2
|
||||
: (leftSummary?.draftCount ?? 0) > 0
|
||||
? 1
|
||||
: 0;
|
||||
const rightRank =
|
||||
(rightSummary?.connectedCount ?? 0) > 0
|
||||
? 2
|
||||
: (rightSummary?.draftCount ?? 0) > 0
|
||||
? 1
|
||||
: 0;
|
||||
return rightRank - leftRank || left.index - right.index;
|
||||
})
|
||||
.map(({ entry }) => entry),
|
||||
[connectionSummaryBySlug, popular],
|
||||
);
|
||||
const connectedApps = useMemo(
|
||||
() =>
|
||||
gallery.filter(
|
||||
(entry) =>
|
||||
(connectionSummaryBySlug.get(appDefinitionSlug(entry))
|
||||
?.connectedCount ?? 0) > 0,
|
||||
),
|
||||
[connectionSummaryBySlug, gallery],
|
||||
);
|
||||
const sortedFiltered = useMemo(
|
||||
() =>
|
||||
[...filtered].sort(
|
||||
(left, right) =>
|
||||
appDefinitionName(left).localeCompare(
|
||||
appDefinitionName(right),
|
||||
undefined,
|
||||
{
|
||||
sensitivity: "base",
|
||||
},
|
||||
) || appDefinitionSlug(left).localeCompare(appDefinitionSlug(right)),
|
||||
),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to browse apps.</div>;
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
Select an organization to browse apps.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const loading = galleryQuery.isLoading || applicationsQuery.isLoading || connectionsQuery.isLoading;
|
||||
const loading =
|
||||
galleryQuery.isLoading ||
|
||||
applicationsQuery.isLoading ||
|
||||
connectionsQuery.isLoading;
|
||||
|
||||
const tileProps = (entry: AppGalleryDisplayEntry) => {
|
||||
const summary = connectionSummaryBySlug.get(appDefinitionSlug(entry));
|
||||
const connectHref = connectHrefFor(entry);
|
||||
const available = entry.availability?.available !== false;
|
||||
const primaryConnection = summary?.primaryConnection ?? null;
|
||||
const owner = primaryConnection ? connectionOwnerProfile(primaryConnection, userProfileById) : null;
|
||||
const addAnotherHref = summary
|
||||
? additionalConnectionHref(entry, summary.applicationId)
|
||||
const owner = primaryConnection
|
||||
? connectionOwnerProfile(primaryConnection, userProfileById)
|
||||
: null;
|
||||
const addAnotherHref =
|
||||
summary && summary.connectedCount > 0
|
||||
? additionalConnectionHref(entry, summary.applicationId)
|
||||
: null;
|
||||
const setupPending =
|
||||
(summary?.connectedCount ?? 0) === 0 && (summary?.draftCount ?? 0) > 0;
|
||||
return {
|
||||
connectedCount: summary?.count ?? 0,
|
||||
connectedCount: summary?.connectedCount ?? 0,
|
||||
setupPending,
|
||||
connectionName: primaryConnection
|
||||
? connectionDisplayNameForOwner(primaryConnection, appDefinitionName(entry), owner)
|
||||
? connectionDisplayNameForOwner(
|
||||
primaryConnection,
|
||||
appDefinitionName(entry),
|
||||
owner,
|
||||
)
|
||||
: null,
|
||||
owner,
|
||||
onPrimary: primaryConnection
|
||||
? () => navigate(summary && summary.count > 1
|
||||
? `/apps/app/${summary.applicationId}/setup`
|
||||
: `/apps/${primaryConnection.id}/setup`)
|
||||
? () =>
|
||||
navigate(
|
||||
setupPending
|
||||
? appSourceResumeHref(
|
||||
appDefinitionSlug(entry),
|
||||
primaryConnection.id,
|
||||
)
|
||||
: summary && summary.connectedCount > 1
|
||||
? `/apps/app/${summary.applicationId}/setup`
|
||||
: `/apps/${primaryConnection.id}/setup`,
|
||||
)
|
||||
: available && connectHref
|
||||
? () => navigate(connectHref)
|
||||
: undefined,
|
||||
|
|
@ -227,13 +363,37 @@ export function Browse() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!trimmed && popular.length > 0 && (
|
||||
{!trimmed && sortedPopular.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Popular
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{popular.map((entry) => (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
|
||||
aria-label="Popular apps"
|
||||
>
|
||||
{sortedPopular.map((entry) => (
|
||||
<AppTile
|
||||
key={appDefinitionSlug(entry)}
|
||||
entry={entry}
|
||||
{...tileProps(entry)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!trimmed && connectedApps.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Connected
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
|
||||
aria-label="Connected apps"
|
||||
>
|
||||
{connectedApps.map((entry) => (
|
||||
<AppTile
|
||||
key={appDefinitionSlug(entry)}
|
||||
entry={entry}
|
||||
|
|
@ -247,16 +407,19 @@ export function Browse() {
|
|||
|
||||
<section className="space-y-3">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{trimmed ? `Results (${filtered.length})` : "All apps"}
|
||||
{trimmed ? `Results (${sortedFiltered.length})` : "All apps"}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<p className="flex items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-4 py-6 text-sm text-muted-foreground">
|
||||
<Link2 className="h-4 w-4" />
|
||||
No planned apps match “{query.trim()}”.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((entry) => (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
aria-label={trimmed ? "App search results" : "All apps"}
|
||||
>
|
||||
{sortedFiltered.map((entry) => (
|
||||
<AppTile
|
||||
key={appDefinitionSlug(entry)}
|
||||
entry={entry}
|
||||
|
|
@ -283,6 +446,7 @@ function AppTile({
|
|||
onPrimary,
|
||||
onAddAnother,
|
||||
connectedCount,
|
||||
setupPending,
|
||||
connectionName,
|
||||
owner,
|
||||
compact = false,
|
||||
|
|
@ -291,99 +455,220 @@ function AppTile({
|
|||
onPrimary?: () => void;
|
||||
onAddAnother?: () => void;
|
||||
connectedCount: number;
|
||||
setupPending: boolean;
|
||||
connectionName: string | null;
|
||||
owner: ConnectionOwnerProfile | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const disabled = !onPrimary;
|
||||
const unavailableReason =
|
||||
entry.availability?.available === false
|
||||
? (entry.availability.reason ?? "This app is disabled on this instance.")
|
||||
: null;
|
||||
const connected = connectedCount > 0;
|
||||
const appName = appDefinitionName(entry);
|
||||
const actionLabel = connected
|
||||
? connectedCount > 1 ? "Edit connections" : "Edit connection"
|
||||
: disabled ? "Coming soon" : "Connect";
|
||||
? connectedCount > 1
|
||||
? "Edit connections"
|
||||
: "Edit connection"
|
||||
: setupPending
|
||||
? "Finish setup"
|
||||
: disabled
|
||||
? "Unavailable"
|
||||
: "Connect";
|
||||
const connectedActionClass = connected
|
||||
? "border-emerald-500/50 text-emerald-700 hover:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: undefined;
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={disabled
|
||||
? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60"
|
||||
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center"}
|
||||
data-app-slug={appDefinitionSlug(entry)}
|
||||
data-connected={connected ? "true" : "false"}
|
||||
data-setup-pending={setupPending ? "true" : "false"}
|
||||
className={
|
||||
disabled
|
||||
? "flex h-full min-w-0 cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60"
|
||||
: "flex h-full min-w-0 flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center"
|
||||
}
|
||||
>
|
||||
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
|
||||
<AppLogo
|
||||
name={appName}
|
||||
logoUrl={appDefinitionLogoUrl(entry)}
|
||||
darkLogoUrl={appDefinitionDarkLogoUrl(entry)}
|
||||
size={36}
|
||||
/>
|
||||
<span className="text-xs font-medium text-foreground">{appName}</span>
|
||||
{connected && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" /> {connectedCount} connected
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
className={connectedActionClass}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
<div
|
||||
data-slot="app-tile-status"
|
||||
className="flex min-h-4 max-w-full items-center justify-center"
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{connected && onAddAnother && (
|
||||
<button
|
||||
{connected ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" /> {connectedCount} connected
|
||||
</span>
|
||||
) : setupPending ? (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Setup incomplete
|
||||
</span>
|
||||
) : unavailableReason ? (
|
||||
<span
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={unavailableReason}
|
||||
>
|
||||
{unavailableReason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-slot="app-tile-primary-action" className="w-full min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onAddAnother}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Add another ${appName} account`}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
className={cn(
|
||||
"w-full max-w-full overflow-hidden text-ellipsis",
|
||||
connectedActionClass,
|
||||
)}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
>
|
||||
Add new
|
||||
</button>
|
||||
)}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
data-slot="app-tile-secondary-action"
|
||||
className="flex min-h-5 items-center justify-center"
|
||||
>
|
||||
{connected && onAddAnother ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddAnother}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Add another ${appName} account`}
|
||||
>
|
||||
Add new
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={disabled
|
||||
? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
|
||||
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left"}
|
||||
data-app-slug={appDefinitionSlug(entry)}
|
||||
data-connected={connected ? "true" : "false"}
|
||||
data-setup-pending={setupPending ? "true" : "false"}
|
||||
className={
|
||||
disabled
|
||||
? "flex h-full cursor-not-allowed flex-col rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
|
||||
: "flex h-full flex-col rounded-xl border border-border bg-card px-4 py-4 text-left"
|
||||
}
|
||||
>
|
||||
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">{appName}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{appDefinitionDescription(entry)}</div>
|
||||
{connected && owner && (
|
||||
<div className="mt-2">
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
{connected && connectionName && (
|
||||
<span className="max-w-40 truncate text-xs text-muted-foreground">{connectionName}</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
className={connectedActionClass}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{connected && onAddAnother && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddAnother}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Add another ${appName} account`}
|
||||
<div className="flex items-start gap-3">
|
||||
<AppLogo
|
||||
name={appName}
|
||||
logoUrl={appDefinitionLogoUrl(entry)}
|
||||
darkLogoUrl={appDefinitionDarkLogoUrl(entry)}
|
||||
size={36}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
data-slot="app-tile-title"
|
||||
className="break-words text-sm font-semibold leading-tight text-foreground"
|
||||
>
|
||||
Add new
|
||||
</button>
|
||||
)}
|
||||
{appName}
|
||||
</div>
|
||||
<div
|
||||
data-slot="app-tile-header-status"
|
||||
className="mt-1 flex min-h-5 items-center"
|
||||
>
|
||||
{connected && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
{connectedCount > 1
|
||||
? `${connectedCount} connected`
|
||||
: "Connected"}
|
||||
</span>
|
||||
)}
|
||||
{setupPending && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Setup incomplete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
|
||||
{appDefinitionDescription(entry)}
|
||||
</div>
|
||||
{unavailableReason ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{unavailableReason}
|
||||
</div>
|
||||
) : null}
|
||||
{connected || setupPending ? (
|
||||
<div
|
||||
data-slot="app-tile-details"
|
||||
className="mt-auto border-t border-border pt-3"
|
||||
>
|
||||
{connectionName && (
|
||||
<div
|
||||
data-slot="app-tile-connection-name"
|
||||
className="break-words text-sm font-medium text-foreground"
|
||||
>
|
||||
{connectionName}
|
||||
</div>
|
||||
)}
|
||||
{owner ? (
|
||||
<div className="mt-2 min-w-0">
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-slot="app-tile-actions"
|
||||
className="mt-3 flex items-center justify-end gap-2"
|
||||
>
|
||||
{onAddAnother && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onAddAnother}
|
||||
aria-label={`Add another ${appName} account`}
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
className={connectedActionClass}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-slot="app-tile-actions"
|
||||
className="mt-auto flex justify-end pt-3"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,21 +13,37 @@ import { composioChildParentConnectionId, composioChildToolkitSlug } from "./com
|
|||
* relationship wherever the child appears, and links to the broker's Services tab
|
||||
* so the parent is one click away.
|
||||
*/
|
||||
export function ComposioProvenanceChip({
|
||||
export function ConnectionProvenanceChip({
|
||||
connection,
|
||||
className,
|
||||
}: {
|
||||
connection: { config?: Record<string, unknown> | null } | null | undefined;
|
||||
connection: {
|
||||
config?: Record<string, unknown> | null;
|
||||
credentialSource?: string;
|
||||
externalCredential?: { connectorUid?: string } | null;
|
||||
} | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const toolkitSlug = composioChildToolkitSlug(connection);
|
||||
if (!toolkitSlug) return null;
|
||||
const parentConnectionId = composioChildParentConnectionId(connection);
|
||||
|
||||
const chipClass = cn(
|
||||
"inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground",
|
||||
className,
|
||||
);
|
||||
if (connection?.credentialSource === "vercel_connect") {
|
||||
const connectorUid = connection.externalCredential?.connectorUid;
|
||||
return (
|
||||
<span
|
||||
className={chipClass}
|
||||
title={connectorUid ? `Credentials managed by Vercel Connect (${connectorUid})` : "Credentials managed by Vercel Connect"}
|
||||
>
|
||||
<Blocks className="h-3 w-3" />
|
||||
via Vercel Connect
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const toolkitSlug = composioChildToolkitSlug(connection);
|
||||
if (!toolkitSlug) return null;
|
||||
const parentConnectionId = composioChildParentConnectionId(connection);
|
||||
const label = (
|
||||
<>
|
||||
<Blocks className="h-3 w-3" />
|
||||
|
|
@ -52,3 +68,6 @@ export function ComposioProvenanceChip({
|
|||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backward-compatible name for callers outside the Apps v2 surfaces. */
|
||||
export const ComposioProvenanceChip = ConnectionProvenanceChip;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ function connection(overrides: Record<string, unknown>) {
|
|||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
credentialPolicy: "shared",
|
||||
enabled: true,
|
||||
lastUsedAt: null,
|
||||
createdByAgentId: null,
|
||||
|
|
@ -231,6 +232,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
name: "GitHub",
|
||||
healthStatus: "healthy",
|
||||
createdByUserId: "user-1",
|
||||
credentialPolicy: "per_user",
|
||||
}),
|
||||
connection({
|
||||
id: "c-attention",
|
||||
|
|
@ -295,7 +297,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
expect(text).toContain("1 connection needs attention");
|
||||
// 3. New header columns are present.
|
||||
const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent?.trim());
|
||||
expect(headers).toEqual(["Connection", "Connected by", "Status", "Actions", "Last used", ""]);
|
||||
expect(headers).toEqual(["Connection", "Type", "Connected by", "Status", "Actions", "Last used", ""]);
|
||||
expect(text).toContain("Personal");
|
||||
expect(text).toContain("Company");
|
||||
// 4. Actions column reflects enabled catalog entries per account; missing profile => 0 on.
|
||||
expect(text).toContain("3 on");
|
||||
expect(text).toContain("0 on");
|
||||
|
|
@ -318,6 +322,8 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
expect(rowButtonLabel("Notion")).toBe("Edit");
|
||||
// 8. Generic connection names inherit the originating user's first name.
|
||||
expect(text).toContain("Dotta’s GitHub");
|
||||
expect(text).toContain("Slack for the company");
|
||||
expect(text).toContain("Slack Team for the company");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
// Custom account labels remain untouched.
|
||||
expect(text).toContain("Slack Team");
|
||||
|
|
@ -374,7 +380,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
await renderApps();
|
||||
|
||||
const deleteButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Delete GitHub connection"]',
|
||||
'button[aria-label="Delete GitHub for the company connection"]',
|
||||
);
|
||||
expect(deleteButton).toBeTruthy();
|
||||
|
||||
|
|
@ -431,7 +437,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
|
||||
await renderApps();
|
||||
const deleteButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Delete Composio connection"]',
|
||||
'button[aria-label="Delete Composio for the company connection"]',
|
||||
);
|
||||
await act(async () => {
|
||||
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -465,7 +471,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
await renderApps();
|
||||
|
||||
const deleteButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Delete GitHub connection"]',
|
||||
'button[aria-label="Delete GitHub for the company connection"]',
|
||||
);
|
||||
await act(async () => {
|
||||
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -515,7 +521,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
await renderApps();
|
||||
|
||||
const deleteButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Delete GitHub connection"]',
|
||||
'button[aria-label="Delete GitHub for the company connection"]',
|
||||
);
|
||||
await act(async () => {
|
||||
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { ComposioProvenanceChip } from "./ComposioProvenanceChip";
|
||||
import { ConnectionProvenanceChip } from "./ComposioProvenanceChip";
|
||||
import { composioChildParentConnectionId } from "./composio-services";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
appDefinitionDarkLogoUrl,
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
appDefinitionSlug,
|
||||
|
|
@ -44,6 +45,7 @@ import {
|
|||
} from "./app-definition-display";
|
||||
import { useReviewCount } from "./useReviewCount";
|
||||
import { AdvancedToolsLink } from "./store-cards";
|
||||
import { connectionNameForCredentialPolicy, connectionTypeLabel } from "./connection-identity";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
|
|
@ -71,6 +73,7 @@ type AppRow = {
|
|||
actionCount: number;
|
||||
lastUsedAt: Date | string | null;
|
||||
logoUrl?: string | null;
|
||||
darkLogoUrl?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -232,10 +235,11 @@ export function Connections() {
|
|||
return applications.flatMap((application): AppRow[] => {
|
||||
const appConnections = connectionsByApplication.get(application.id) ?? [];
|
||||
const appSourceSlug = appApplicationSourceSlug(application);
|
||||
const galleryEntry = logoByKey.get(appSourceSlug ?? "") ??
|
||||
const resolvedGalleryEntry = logoByKey.get(appSourceSlug ?? "") ??
|
||||
logoByName.get(application.name.toLowerCase());
|
||||
const logoUrl = appDefinitionLogoUrl(galleryEntry);
|
||||
const logoUrl = appDefinitionLogoUrl(resolvedGalleryEntry);
|
||||
const brandKey = appSourceSlug ?? application.name;
|
||||
const darkLogoUrl = appDefinitionDarkLogoUrl(resolvedGalleryEntry);
|
||||
const agentAvailableConnectionCount = appConnections.filter(
|
||||
(connection) => connection.status === "active" && connection.enabled,
|
||||
).length;
|
||||
|
|
@ -251,14 +255,22 @@ export function Connections() {
|
|||
actionCount: 0,
|
||||
lastUsedAt: null,
|
||||
logoUrl,
|
||||
darkLogoUrl,
|
||||
}];
|
||||
}
|
||||
return appConnections.map((connection) => {
|
||||
const owner = connectionOwnerProfile(connection, userProfileById);
|
||||
const type = connectionTypeLabel(connection.credentialPolicy);
|
||||
const displayName = type === "Company"
|
||||
? connectionNameForCredentialPolicy(
|
||||
humanizeConnectionDisplayName(connection),
|
||||
connection.credentialPolicy,
|
||||
)
|
||||
: connectionDisplayNameForOwner(connection, application.name, owner);
|
||||
return {
|
||||
application,
|
||||
connection,
|
||||
displayName: connectionDisplayNameForOwner(connection, application.name, owner),
|
||||
displayName,
|
||||
brandKey,
|
||||
owner,
|
||||
remainingAgentAvailableConnectionCount: Math.max(
|
||||
|
|
@ -270,6 +282,7 @@ export function Connections() {
|
|||
actionCount: actionCountByConnection.get(`app:${connection.id}`) ?? 0,
|
||||
lastUsedAt: connection.lastUsedAt ?? null,
|
||||
logoUrl,
|
||||
darkLogoUrl,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
@ -362,6 +375,7 @@ export function Connections() {
|
|||
<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">Connection</th>
|
||||
<th className="px-4 py-2.5">Type</th>
|
||||
<th className="px-4 py-2.5">Connected by</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Actions</th>
|
||||
|
|
@ -408,12 +422,13 @@ export function Connections() {
|
|||
name={row.displayName}
|
||||
brandKey={row.brandKey}
|
||||
logoUrl={row.logoUrl}
|
||||
darkLogoUrl={row.darkLogoUrl}
|
||||
size={32}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{row.displayName}</span>
|
||||
<ComposioProvenanceChip connection={row.connection} />
|
||||
<ConnectionProvenanceChip connection={row.connection} />
|
||||
</div>
|
||||
{hint && (
|
||||
<div className="truncate text-xs text-muted-foreground">{hint}</div>
|
||||
|
|
@ -421,6 +436,11 @@ export function Connections() {
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{connection ? connectionTypeLabel(connection.credentialPolicy) : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ConnectionOwnerIdentity owner={row.owner} />
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ function source(relativePath: string) {
|
|||
describe("Apps agent selector contract", () => {
|
||||
it("keeps every agent chooser under /apps searchable", () => {
|
||||
const appConnect = source("./AppsConnect.tsx");
|
||||
const connectionSetupFlow = source("../../features/connections/ConnectionSetupFlow.tsx");
|
||||
const permissions = source("./app-detail/PermissionsPanel.tsx");
|
||||
const tester = source("./app-detail/TestPanel.tsx");
|
||||
const profiles = source("../tools/ProfilesTab.tsx");
|
||||
const audit = source("../tools/AuditTab.tsx");
|
||||
|
||||
expect(appConnect).toContain("<AgentMultiSelect");
|
||||
expect(appConnect).toContain("<ConnectionSetupFlow");
|
||||
expect(connectionSetupFlow).toContain("<AgentMultiSelect");
|
||||
expect(permissions).toContain("<AgentMultiSelect");
|
||||
expect(tester).toContain('placeholder="Search agents…"');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,73 @@
|
|||
import { APP_STORE_DEFINITIONS, appSupportsCatalogSetup } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MCP_DIRECT_OAUTH_CONNECT_SLUGS,
|
||||
appSourceConnectHref,
|
||||
appSourceResumeHref,
|
||||
canEnterAppsConnect,
|
||||
isMcpDirectOAuthConnectSlug,
|
||||
resolveAppsConnectRouteKey,
|
||||
vercelConnectSourceHref,
|
||||
} from "./app-connect-policy";
|
||||
|
||||
describe("app connect policy", () => {
|
||||
it("allowlists exactly Notion for MCP-direct OAuth", () => {
|
||||
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(["notion"]);
|
||||
it("derives automatic OAuth entry points from app capabilities", () => {
|
||||
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(expect.arrayContaining(["jira", "notion", "sentry"]));
|
||||
expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true);
|
||||
expect(isMcpDirectOAuthConnectSlug("jira")).toBe(true);
|
||||
expect(isMcpDirectOAuthConnectSlug("asana")).toBe(false);
|
||||
expect(isMcpDirectOAuthConnectSlug("github")).toBe(false);
|
||||
expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false);
|
||||
expect(isMcpDirectOAuthConnectSlug(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("admits the Notion deep link without opening other source slugs", () => {
|
||||
it("admits every capability-backed catalog deep link", () => {
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=notion"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=jira"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=asana"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(false);
|
||||
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);
|
||||
});
|
||||
|
||||
it("admits retained hidden-provider reconnects without opening fresh setup", () => {
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=github&reconnect=connection-1"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=unknown&reconnect=connection-1"))).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a generic source deep link", () => {
|
||||
expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion");
|
||||
expect(appSourceConnectHref("notion", "intent-1"))
|
||||
.toBe("/apps/connect?source=notion&intent=intent-1");
|
||||
expect(appSourceResumeHref("notion", "11111111-1111-4111-8111-111111111111")).toBe(
|
||||
"/apps/connect?source=notion&resume=11111111-1111-4111-8111-111111111111",
|
||||
);
|
||||
expect(vercelConnectSourceHref()).toBe("/apps/vercel-connect");
|
||||
expect(vercelConnectSourceHref("notion")).toBe("/apps/vercel-connect?source=notion");
|
||||
});
|
||||
|
||||
it("routes every capability-backed catalog definition through its source deep link", () => {
|
||||
const connectableApps = APP_STORE_DEFINITIONS.filter(appSupportsCatalogSetup);
|
||||
|
||||
expect(connectableApps.length).toBeGreaterThan(0);
|
||||
for (const app of connectableApps) {
|
||||
const href = appSourceConnectHref(app.slug);
|
||||
const searchParams = new URL(href, "http://paperclip.test").searchParams;
|
||||
|
||||
expect(canEnterAppsConnect(searchParams), app.slug).toBe(true);
|
||||
expect(resolveAppsConnectRouteKey({ sourceSlug: searchParams.get("source") }), app.slug).toBe(app.slug);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit route precedence while accepting every authentication mode", () => {
|
||||
expect(resolveAppsConnectRouteKey({ serviceSlug: "jira", appKey: "asana", sourceSlug: "mem0" })).toBe("jira");
|
||||
expect(resolveAppsConnectRouteKey({ appKey: "asana", sourceSlug: "mem0" })).toBe("asana");
|
||||
expect(resolveAppsConnectRouteKey({ sourceSlug: "mem0" })).toBe("mem0");
|
||||
expect(resolveAppsConnectRouteKey({ sourceSlug: "context7" })).toBe("context7");
|
||||
expect(resolveAppsConnectRouteKey({ sourceSlug: "supabase" })).toBe("supabase");
|
||||
expect(resolveAppsConnectRouteKey({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
/** OAuth apps that are safe to connect directly through the MCP OAuth broker. */
|
||||
export const MCP_DIRECT_OAUTH_CONNECT_SLUGS = ["notion"] as const;
|
||||
import {
|
||||
APP_STORE_DEFINITIONS,
|
||||
appSupportsCatalogSetup,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
getAvailableConnectionMethods,
|
||||
getAppStoreDefinition,
|
||||
getConnectableAppDefinition,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const MCP_DIRECT_OAUTH_CONNECT_SLUGS = APP_STORE_DEFINITIONS
|
||||
.filter((app) => getAvailableConnectionMethods(app).some((method) =>
|
||||
connectionMethodSupportsAutomaticOAuth(method)
|
||||
))
|
||||
.map((app) => app.slug);
|
||||
|
||||
export function isMcpDirectOAuthConnectSlug(slug: string | null | undefined): boolean {
|
||||
return MCP_DIRECT_OAUTH_CONNECT_SLUGS.some((allowedSlug) => allowedSlug === slug);
|
||||
|
|
@ -11,6 +23,31 @@ export function appSourceConnectHref(slug: string, interactionId?: string | null
|
|||
return `/apps/connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
|
||||
return searchParams.get("byo") === "1" || isMcpDirectOAuthConnectSlug(searchParams.get("source"));
|
||||
/** Resume one exact draft through the same setup wizard used for a new app. */
|
||||
export function appSourceResumeHref(slug: string, connectionId: string): string {
|
||||
return `/apps/connect?${new URLSearchParams({ source: slug, resume: connectionId }).toString()}`;
|
||||
}
|
||||
|
||||
export function vercelConnectSourceHref(slug?: string): string {
|
||||
if (!slug) return "/apps/vercel-connect";
|
||||
return `/apps/vercel-connect?${new URLSearchParams({ source: slug }).toString()}`;
|
||||
}
|
||||
|
||||
export function resolveAppsConnectRouteKey(input: {
|
||||
serviceSlug?: string | null;
|
||||
appKey?: string | null;
|
||||
sourceSlug?: string | null;
|
||||
}): string | undefined {
|
||||
return input.serviceSlug ?? input.appKey ?? input.sourceSlug ?? undefined;
|
||||
}
|
||||
|
||||
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
|
||||
if (searchParams.get("byo") === "1") return true;
|
||||
const source = searchParams.get("source") ?? "";
|
||||
const entry = getAppStoreDefinition(source);
|
||||
// A retained connection may belong to a provider hidden from fresh catalog
|
||||
// setup. Admit only known providers here; the setup flow then proves the
|
||||
// exact reconnect target is visible to the selected company before rendering.
|
||||
if (getConnectableAppDefinition(source) && searchParams.get("reconnect")?.trim()) return true;
|
||||
return appSupportsCatalogSetup(entry);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export function appDefinitionLogoUrl(entry: AppGalleryDisplayEntry | null | unde
|
|||
return entry?.branding?.logoUrl ?? entry?.logoUrl;
|
||||
}
|
||||
|
||||
export function appDefinitionDarkLogoUrl(entry: AppGalleryDisplayEntry | null | undefined): string | undefined {
|
||||
return entry?.branding?.darkLogoUrl;
|
||||
}
|
||||
|
||||
export function appApplicationSourceSlug(application: ToolApplication | null | undefined): string | null {
|
||||
if (!application) return null;
|
||||
const metadata = application.metadata;
|
||||
|
|
|
|||
|
|
@ -74,9 +74,9 @@ function RecentActivity({
|
|||
}, [events, lifecycleEvents, issues, actionRequests, nameById, connectionId, appName, userLabelById]);
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Recent activity</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Recent activity</h2>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2 py-4">
|
||||
|
|
|
|||
|
|
@ -37,24 +37,40 @@ function renderComposioDangerZone() {
|
|||
return container;
|
||||
}
|
||||
|
||||
function expandDangerZone(node: HTMLDivElement) {
|
||||
const trigger = Array.from(node.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Danger zone"));
|
||||
expect(trigger).toBeTruthy();
|
||||
act(() => trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app deletes the operator's credentials and revokes agent access
|
||||
* (PAP-17119). The confirmation has to say so before the operator commits — a
|
||||
* "you can connect it again later" reassurance implies the pasted key survives,
|
||||
* so these assertions exist to stop that copy coming back.
|
||||
* (PAP-17119). The compact confirmation still names both effects before the
|
||||
* operator commits.
|
||||
*/
|
||||
describe("DangerZone", () => {
|
||||
it("promises credential deletion and re-authentication before the operator confirms", () => {
|
||||
const text = renderDangerZone().textContent ?? "";
|
||||
it("keeps dangerous actions folded by default", () => {
|
||||
const node = renderDangerZone();
|
||||
|
||||
expect(text).toContain("Deletes the saved credentials for PostHog");
|
||||
expect(text).toContain("takes agent access away right away");
|
||||
expect(text).toContain("needs a new sign-in or key");
|
||||
expect(text).not.toContain("You can connect it again later");
|
||||
expect(node.textContent).toContain("Danger zone");
|
||||
expect(node.textContent).not.toContain("Remove app");
|
||||
expect(node.textContent).not.toContain("Deletes credentials");
|
||||
});
|
||||
|
||||
it("promises credential deletion and re-authentication before the operator confirms", () => {
|
||||
const node = renderDangerZone();
|
||||
expandDangerZone(node);
|
||||
const text = node.textContent ?? "";
|
||||
|
||||
expect(text).toContain("Deletes credentials for PostHog");
|
||||
expect(text).toContain("removes agent access");
|
||||
expect(text).toContain("requires a new sign-in or key");
|
||||
});
|
||||
|
||||
it("keeps the warning visible in the confirming state", () => {
|
||||
const node = renderDangerZone();
|
||||
expandDangerZone(node);
|
||||
const trigger = Array.from(node.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Remove app");
|
||||
expect(trigger).toBeTruthy();
|
||||
|
|
@ -63,13 +79,13 @@ describe("DangerZone", () => {
|
|||
|
||||
const text = node.textContent ?? "";
|
||||
expect(text).toContain("Yes, remove it");
|
||||
expect(text).toContain("Deletes the saved credentials for PostHog");
|
||||
expect(text).toContain("needs a new sign-in or key");
|
||||
expect(text).toContain("Deletes credentials for PostHog");
|
||||
expect(text).toContain("requires a new sign-in or key");
|
||||
});
|
||||
|
||||
it("names every child service that parent removal will take down", () => {
|
||||
const node = renderComposioDangerZone();
|
||||
expect(node.textContent).toContain("removes 2 connected services");
|
||||
expect(node.textContent).toContain("Agents lose access to all of them right away");
|
||||
expandDangerZone(node);
|
||||
expect(node.textContent).toContain("2 connected services");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ArrowUpRight, Loader2, Lock } from "lucide-react";
|
||||
import type { AppDefinition, ToolConnection } from "@paperclipai/shared";
|
||||
import { ArrowUpRight, ChevronRight, Loader2, Lock } from "lucide-react";
|
||||
import type {
|
||||
AppDefinition,
|
||||
ConnectionGrant,
|
||||
ToolConnection,
|
||||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
import { RevokeGrantDialog } from "./IdentitiesSection";
|
||||
|
||||
export function AdvancedPanel({
|
||||
connection,
|
||||
|
|
@ -20,21 +33,56 @@ export function AdvancedPanel({
|
|||
removing,
|
||||
onRemove,
|
||||
onReplaced,
|
||||
canReplaceCredential = true,
|
||||
credentialUnavailableMessage = "You don't have permission to replace this identity's credential.",
|
||||
appToggleDisabled,
|
||||
onToggleApp,
|
||||
identityGrant = null,
|
||||
identityCurrentUserId = null,
|
||||
identityProviderName,
|
||||
credentialPolicy,
|
||||
identityActionPending = false,
|
||||
onReconnectIdentity,
|
||||
onRevokeIdentity,
|
||||
}: Pick<AppDetailSectionProps, "connection" | "appName" | "galleryEntry"> & {
|
||||
removing: boolean;
|
||||
childConnectionCount?: number;
|
||||
onRemove: () => void;
|
||||
onReplaced: () => void;
|
||||
canReplaceCredential?: boolean;
|
||||
credentialUnavailableMessage?: string;
|
||||
appToggleDisabled: boolean;
|
||||
onToggleApp: () => void;
|
||||
identityGrant?: ConnectionGrant | null;
|
||||
identityCurrentUserId?: string | null;
|
||||
identityProviderName?: string;
|
||||
credentialPolicy?: ToolConnectionCredentialPolicy;
|
||||
identityActionPending?: boolean;
|
||||
onReconnectIdentity?: () => void;
|
||||
onRevokeIdentity?: (grant: ConnectionGrant) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeySection connection={connection} galleryEntry={galleryEntry} onReplaced={onReplaced} />
|
||||
<div className="space-y-4 border-t border-border pt-8">
|
||||
<TechnicalDetails connection={connection} />
|
||||
<DangerZone
|
||||
appName={appName}
|
||||
connection={connection}
|
||||
galleryEntry={galleryEntry}
|
||||
childConnectionCount={childConnectionCount}
|
||||
removing={removing}
|
||||
onRemove={onRemove}
|
||||
onReplaced={onReplaced}
|
||||
canReplaceCredential={canReplaceCredential}
|
||||
credentialUnavailableMessage={credentialUnavailableMessage}
|
||||
toggleDisabled={appToggleDisabled}
|
||||
onToggleConnection={onToggleApp}
|
||||
identityGrant={identityGrant}
|
||||
identityCurrentUserId={identityCurrentUserId}
|
||||
identityProviderName={identityProviderName ?? appName}
|
||||
credentialPolicy={credentialPolicy}
|
||||
identityActionPending={identityActionPending}
|
||||
onReconnectIdentity={onReconnectIdentity}
|
||||
onRevokeIdentity={onRevokeIdentity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -44,10 +92,14 @@ function KeySection({
|
|||
connection,
|
||||
galleryEntry,
|
||||
onReplaced,
|
||||
canReplace,
|
||||
unavailableMessage,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppDefinition | null;
|
||||
onReplaced: () => void;
|
||||
canReplace: boolean;
|
||||
unavailableMessage: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
|
|
@ -56,15 +108,15 @@ function KeySection({
|
|||
<div className="flex items-start gap-3">
|
||||
<Lock className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Key</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Your key is stored securely. Replace it if it stopped working or you rotated it.
|
||||
<h2 className="text-sm font-medium text-foreground">Reconnect</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{canReplace ? "Replace the stored credential." : unavailableMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!open && (
|
||||
{canReplace && !open && (
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
Replace key
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -89,14 +141,23 @@ export function ReconnectCard({
|
|||
connection,
|
||||
galleryEntry,
|
||||
onReconnected,
|
||||
canReconnect = true,
|
||||
reconnectUnavailableMessage,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppDefinition | null;
|
||||
onReconnected: () => void;
|
||||
canReconnect?: boolean;
|
||||
reconnectUnavailableMessage?: string;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const reconnectOAuth = useMutation({
|
||||
mutationFn: () => toolsApi.startOAuth(connection.id),
|
||||
// Reconnect is not a new identity choice. Personal-only connections must
|
||||
// put the replacement token back on the signed-in user's existing grant;
|
||||
// shared and legacy fallback connections keep using the organization slot.
|
||||
mutationFn: () => connection.credentialPolicy === "per_user"
|
||||
? toolsApi.startOAuth(connection.id, { asCurrentUser: true })
|
||||
: toolsApi.startOAuth(connection.id),
|
||||
onSuccess: ({ authorizationUrl }) => {
|
||||
// Reconnect navigates to the same discovered address a fresh connect does,
|
||||
// so it goes through the same gate (PAP-17099).
|
||||
|
|
@ -114,20 +175,60 @@ export function ReconnectCard({
|
|||
tone: "error",
|
||||
}),
|
||||
});
|
||||
const verifyVercel = useMutation({
|
||||
mutationFn: () => toolsApi.checkConnectionHealth(connection.id),
|
||||
onSuccess: () => {
|
||||
pushToast({
|
||||
title: "Vercel credential verified",
|
||||
body: `${humanizeConnectionDisplayName(connection)} is back online.`,
|
||||
tone: "success",
|
||||
});
|
||||
onReconnected();
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Credential still needs attention",
|
||||
body: error instanceof Error ? error.message : "Review the connector in Vercel Connect and try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
const oauth = connection.authKind === "oauth";
|
||||
const managedByVercel = connection.credentialSource === "vercel_connect";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/50 bg-amber-500/10 p-5">
|
||||
<h2 className="text-sm font-bold text-amber-900 dark:text-amber-100">
|
||||
{oauth ? "Reconnect required" : "This app needs reconnecting"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
{connection.healthMessage?.trim() || (oauth
|
||||
? "Authorization expired or was revoked. Sign in again to restore access."
|
||||
: "The key stopped working. Paste a new one to get it back online.")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
{oauth ? (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
{oauth ? "Reconnect required" : "This app needs reconnecting"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-amber-800 dark:text-amber-200">
|
||||
{connection.healthMessage?.trim() || (oauth
|
||||
? "Authorization expired or was revoked. Sign in again to restore access."
|
||||
: "The key stopped working. Paste a new one to get it back online.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{!canReconnect ? (
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
|
||||
</p>
|
||||
) : managedByVercel && !oauth ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" asChild>
|
||||
<a href="https://vercel.com/connect" target="_blank" rel="noreferrer">
|
||||
Manage in Vercel <ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={verifyVercel.isPending}
|
||||
onClick={() => verifyVercel.mutate()}
|
||||
>
|
||||
{verifyVercel.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Check again
|
||||
</Button>
|
||||
</div>
|
||||
) : oauth ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -209,6 +310,14 @@ function ReconnectForm({
|
|||
? fields.every((f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0)
|
||||
: single.trim().length > 0;
|
||||
|
||||
if (connection.credentialSource === "vercel_connect") {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Credentials for this connection are managed in Vercel Connect.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{usesGallery ? (
|
||||
|
|
@ -261,62 +370,214 @@ function ReconnectForm({
|
|||
}
|
||||
|
||||
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">Technical details</h2>
|
||||
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
<dd className="break-all font-mono text-foreground">{connectionAddress(connection)}</dd>
|
||||
<dt className="text-muted-foreground">Connection type</dt>
|
||||
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<Collapsible open={open} onOpenChange={setOpen} asChild>
|
||||
<section>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center gap-3 py-1 text-left">
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">Connection details</span>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<dl className="mt-4 grid gap-2 pb-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
<dd className="break-all font-mono text-foreground">{connectionAddress(connection)}</dd>
|
||||
<dt className="text-muted-foreground">Type</dt>
|
||||
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
|
||||
</dl>
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function DangerZone({
|
||||
appName,
|
||||
connection,
|
||||
galleryEntry = null,
|
||||
childConnectionCount = 0,
|
||||
removing,
|
||||
onRemove,
|
||||
onReplaced,
|
||||
canReplaceCredential = true,
|
||||
credentialUnavailableMessage = "You don't have permission to replace this identity's credential.",
|
||||
toggleDisabled = false,
|
||||
onToggleConnection,
|
||||
identityGrant = null,
|
||||
identityCurrentUserId = null,
|
||||
identityProviderName = appName,
|
||||
credentialPolicy,
|
||||
identityActionPending = false,
|
||||
onReconnectIdentity,
|
||||
onRevokeIdentity,
|
||||
}: {
|
||||
appName: string;
|
||||
connection?: ToolConnection;
|
||||
galleryEntry?: AppDefinition | null;
|
||||
childConnectionCount?: number;
|
||||
removing: boolean;
|
||||
onRemove: () => void;
|
||||
onReplaced?: () => void;
|
||||
canReplaceCredential?: boolean;
|
||||
credentialUnavailableMessage?: string;
|
||||
toggleDisabled?: boolean;
|
||||
onToggleConnection?: () => void;
|
||||
identityGrant?: ConnectionGrant | null;
|
||||
identityCurrentUserId?: string | null;
|
||||
identityProviderName?: string;
|
||||
credentialPolicy?: ToolConnectionCredentialPolicy;
|
||||
identityActionPending?: boolean;
|
||||
onReconnectIdentity?: () => void;
|
||||
onRevokeIdentity?: (grant: ConnectionGrant) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState<ConnectionGrant | null>(null);
|
||||
const paused = connection
|
||||
? connection.enabled === false || connection.status === "disabled"
|
||||
: false;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-bold text-destructive">
|
||||
Danger zone
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Remove this app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{childConnectionCount > 0
|
||||
? `Deletes the saved credentials for ${appName} and removes ${childConnectionCount} connected ${childConnectionCount === 1 ? "service" : "services"}. Agents lose access to all of them right away.`
|
||||
: `Deletes the saved credentials for ${appName} and takes agent access away right away. Connecting it again later needs a new sign-in or key.`}
|
||||
</p>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)} disabled={removing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onRemove} disabled={removing}>
|
||||
{removing && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Yes, remove it
|
||||
</Button>
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setConfirming(false);
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<section>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 py-1 text-left"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-destructive">Danger zone</span>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 divide-y divide-border border-t border-border">
|
||||
{connection && onToggleConnection ? (
|
||||
<div className="flex items-center justify-between gap-4 py-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Pause connection</h2>
|
||||
<ToggleSwitch
|
||||
aria-label="Pause connection"
|
||||
checked={paused}
|
||||
disabled={toggleDisabled}
|
||||
onCheckedChange={onToggleConnection}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connection && connection.authKind !== "oauth" ? (
|
||||
<div className="py-4">
|
||||
<KeySection
|
||||
connection={connection}
|
||||
galleryEntry={galleryEntry}
|
||||
onReplaced={onReplaced ?? (() => undefined)}
|
||||
canReplace={canReplaceCredential}
|
||||
unavailableMessage={credentialUnavailableMessage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Reconnect</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{canReplaceCredential
|
||||
? `Sign in to ${identityProviderName} again.`
|
||||
: credentialUnavailableMessage}
|
||||
</p>
|
||||
</div>
|
||||
{canReplaceCredential && onReconnectIdentity ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={identityActionPending}
|
||||
onClick={onReconnectIdentity}
|
||||
>
|
||||
{identityActionPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{identityGrant?.capabilities?.canRevoke
|
||||
&& identityGrant.status !== "revoked"
|
||||
&& onRevokeIdentity ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Revoke identity</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disconnect the identity currently used by this app.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setRevokeTarget(identityGrant)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Remove this app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{childConnectionCount > 0
|
||||
? `Deletes credentials for ${appName} and ${childConnectionCount} connected ${childConnectionCount === 1 ? "service" : "services"}.`
|
||||
: `Deletes credentials for ${appName} and removes agent access. Reconnecting requires a new sign-in or key.`}
|
||||
</p>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)} disabled={removing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onRemove} disabled={removing}>
|
||||
{removing && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Yes, remove it
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
|
||||
Remove app
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
|
||||
Remove app
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleContent>
|
||||
|
||||
{revokeTarget && credentialPolicy ? (
|
||||
<RevokeGrantDialog
|
||||
grant={revokeTarget}
|
||||
providerName={identityProviderName}
|
||||
pending={identityActionPending}
|
||||
credentialPolicy={credentialPolicy}
|
||||
isOwnIdentity={revokeTarget.kind === "user" && revokeTarget.subjectUserId === identityCurrentUserId}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onConfirm={() => {
|
||||
onRevokeIdentity?.(revokeTarget);
|
||||
setRevokeTarget(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { Building2, Loader2, UserRound } from "lucide-react";
|
||||
import type {
|
||||
ConnectionAudienceMember,
|
||||
ConnectionGrant,
|
||||
|
|
@ -7,10 +7,10 @@ import type {
|
|||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { MemberMultiSelect } from "@/components/MemberMultiSelect";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -33,14 +33,12 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import { brandChipBadge } from "@/lib/status-colors";
|
||||
import {
|
||||
audienceSummary,
|
||||
audienceUserIds,
|
||||
grantAccountLabel,
|
||||
grantStatusLabel,
|
||||
grantStatusTone,
|
||||
memberLabel,
|
||||
organizationGrant,
|
||||
otherPersonalGrants,
|
||||
personalGrantFor,
|
||||
type GrantStatusTone,
|
||||
} from "../connection-identity";
|
||||
|
|
@ -66,69 +64,43 @@ function StatusText({ status }: { status: ConnectionGrant["status"] | null }) {
|
|||
);
|
||||
}
|
||||
|
||||
function formatLastUsed(value: ConnectionGrant["lastUsedAt"]): string | null {
|
||||
if (!value) return null;
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return `Last used ${parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity rows for the connection Setup tab (PAP-17835 Surface B).
|
||||
* Fixed identity for the connection Setup tab.
|
||||
*
|
||||
* Rows are separated by space and a rule, not wrapped in one card each: "space
|
||||
* separates; lines contain". Every action here is rendered from a server
|
||||
* capability — a policy-forbidden action is absent rather than disabled, so a
|
||||
* viewer sees the same legible state with no controls at all.
|
||||
* The chosen personal/organization type comes from the connection policy and
|
||||
* the alternative is not rendered after setup. Every action is rendered from
|
||||
* a server capability — a policy-forbidden action is absent rather than
|
||||
* disabled, so a viewer sees the same legible state with no controls at all.
|
||||
*/
|
||||
export function IdentitiesSection({
|
||||
appName,
|
||||
providerName,
|
||||
credentialPolicy,
|
||||
ownerUserId,
|
||||
connectedUser,
|
||||
grantsQuery,
|
||||
agents,
|
||||
agentsLoading,
|
||||
agentsError,
|
||||
loading,
|
||||
error,
|
||||
onConnectAsMe,
|
||||
onConnectOrganization,
|
||||
onReconnectOrganization,
|
||||
onRevokeGrant,
|
||||
onReplaceAudience,
|
||||
connectPending,
|
||||
revokePending,
|
||||
delegationPending,
|
||||
audiencePending,
|
||||
audienceError,
|
||||
audienceGrantId,
|
||||
onOpenAudience,
|
||||
onCloseAudience,
|
||||
onReplaceDelegations,
|
||||
}: {
|
||||
appName: string;
|
||||
providerName: string;
|
||||
credentialPolicy: ToolConnectionCredentialPolicy;
|
||||
ownerUserId: string | null;
|
||||
connectedUser: { label: string; image: string | null } | null;
|
||||
grantsQuery: ConnectionGrantsResponse | undefined;
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
agentsLoading: boolean;
|
||||
agentsError: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onConnectAsMe: () => void;
|
||||
onConnectOrganization: () => void;
|
||||
onReconnectOrganization: () => void;
|
||||
onRevokeGrant: (grant: ConnectionGrant) => void;
|
||||
onReplaceAudience: (grant: ConnectionGrant, memberUserIds: string[]) => void;
|
||||
connectPending: boolean;
|
||||
revokePending: boolean;
|
||||
delegationPending: boolean;
|
||||
audiencePending: boolean;
|
||||
audienceError: string | null;
|
||||
/**
|
||||
|
|
@ -139,36 +111,43 @@ export function IdentitiesSection({
|
|||
audienceGrantId: string | null;
|
||||
onOpenAudience: (grantId: string) => void;
|
||||
onCloseAudience: () => void;
|
||||
onReplaceDelegations: (grant: ConnectionGrant, agentIds: string[]) => void;
|
||||
}) {
|
||||
const [revokeTarget, setRevokeTarget] = useState<ConnectionGrant | null>(null);
|
||||
const [othersExpanded, setOthersExpanded] = useState(false);
|
||||
|
||||
const grants = grantsQuery?.grants ?? [];
|
||||
const capabilities = grantsQuery?.capabilities;
|
||||
const currentUserId = grantsQuery?.currentUserId ?? null;
|
||||
const members = grantsQuery?.members ?? [];
|
||||
const orgGrant = useMemo(() => organizationGrant(grants), [grants]);
|
||||
const myGrant = useMemo(() => personalGrantFor(grants, currentUserId), [grants, currentUserId]);
|
||||
const others = useMemo(() => otherPersonalGrants(grants, currentUserId), [grants, currentUserId]);
|
||||
const myLabel = memberLabel(members, currentUserId);
|
||||
const personalGrant = useMemo(() => {
|
||||
const personalGrants = grants.filter((grant) => grant.kind === "user");
|
||||
return personalGrants.find((grant) => grant.subjectUserId === ownerUserId)
|
||||
?? myGrant
|
||||
?? personalGrants.find((grant) => grant.status === "active")
|
||||
?? personalGrants[0]
|
||||
?? null;
|
||||
}, [grants, myGrant, ownerUserId]);
|
||||
const personalSubjectLabel = memberLabel(
|
||||
members,
|
||||
personalGrant?.subjectUserId ?? ownerUserId ?? currentUserId,
|
||||
);
|
||||
const usesPersonalIdentity = credentialPolicy === "per_user"
|
||||
|| (credentialPolicy === "per_user_with_fallback" && Boolean(myGrant));
|
||||
const audienceGrant = audienceGrantId
|
||||
? grants.find((grant) => grant.id === audienceGrantId) ?? null
|
||||
: null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="space-y-3" aria-busy="true">
|
||||
<section className="space-y-5" aria-busy="true">
|
||||
<IdentitiesHeading />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<section className="space-y-5">
|
||||
<IdentitiesHeading />
|
||||
<InlineBanner tone="warning" compact>
|
||||
We couldn't load who this connection acts as. Reload the page to try again.
|
||||
|
|
@ -178,157 +157,55 @@ export function IdentitiesSection({
|
|||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<section className="space-y-5">
|
||||
<IdentitiesHeading />
|
||||
|
||||
{agentsError ? (
|
||||
<div className="mt-3">
|
||||
<InlineBanner tone="warning" compact>
|
||||
We couldn't load agents for autonomous access. Reload the page to try again.
|
||||
</InlineBanner>
|
||||
</div>
|
||||
) : null}
|
||||
<ConnectionAudienceCallout
|
||||
personal={usesPersonalIdentity}
|
||||
connectedName={usesPersonalIdentity ? personalSubjectLabel ?? connectedUser?.label ?? null : null}
|
||||
connectedImage={usesPersonalIdentity ? connectedUser?.image ?? null : null}
|
||||
status={(usesPersonalIdentity ? personalGrant : orgGrant)?.status ?? null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{/* Organization identity — always visible, including when missing, so the
|
||||
shared-vs-personal distinction never has to be inferred from absence. */}
|
||||
<IdentityRow
|
||||
title="Organization identity"
|
||||
secondary={grantAccountLabel(orgGrant)}
|
||||
status={orgGrant?.status ?? null}
|
||||
detail={orgGrant
|
||||
? audienceSummary(orgGrant)
|
||||
: "Used when the connection policy allows a shared identity."}
|
||||
actions={
|
||||
<>
|
||||
{orgGrant?.capabilities?.canEditAudience ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenAudience(orgGrant.id)}>
|
||||
Manage audience
|
||||
</Button>
|
||||
) : null}
|
||||
{orgGrant && capabilities?.canConfigure ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={connectPending}
|
||||
onClick={onReconnectOrganization}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
{orgGrant?.capabilities?.canRevoke && orgGrant.status !== "revoked" ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(orgGrant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{!orgGrant && capabilities?.canCreateOrganizationGrant ? (
|
||||
<Button size="sm" disabled={connectPending} onClick={onConnectOrganization}>
|
||||
Connect organization identity
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Your identity — the signed-in user only. Audience is never shown here:
|
||||
a personal identity is consent-bound to the person who granted it. */}
|
||||
{capabilities?.canConnectAsCurrentUser || myGrant ? (
|
||||
<IdentityRow
|
||||
id="personal-identity"
|
||||
title="Your identity"
|
||||
secondary={myGrant ? grantAccountLabel(myGrant, { subjectLabel: myLabel }) : null}
|
||||
status={myGrant?.status ?? null}
|
||||
detail={myGrant
|
||||
? formatLastUsed(myGrant.lastUsedAt) ?? "Only work running for you can use this identity."
|
||||
: "You have not connected your account."}
|
||||
actions={
|
||||
<>
|
||||
{myGrant && myGrant.status !== "revoked" ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={connectPending} onClick={onConnectAsMe}>
|
||||
Reconnect
|
||||
</Button>
|
||||
{myGrant.capabilities?.canRevoke ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(myGrant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{myGrant.status === "active" ? (
|
||||
<AgentMultiSelect
|
||||
agents={agents.filter((agent) => agent.status !== "terminated")}
|
||||
loading={agentsLoading}
|
||||
selectedAgentIds={new Set(
|
||||
(myGrant.delegations ?? []).map((delegation) => delegation.agentId),
|
||||
)}
|
||||
pending={delegationPending}
|
||||
triggerLabel={(myGrant.delegations?.length ?? 0) === 0
|
||||
? "Allow autonomous access"
|
||||
: `${myGrant.delegations?.length ?? 0} ${myGrant.delegations?.length === 1 ? "agent" : "agents"} allowed for autonomous runs`}
|
||||
triggerSize="sm"
|
||||
triggerFullWidth={false}
|
||||
showSelectionPreview={false}
|
||||
headerContent={(
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Select the named agents that may use your identity in autonomous runs.
|
||||
</p>
|
||||
)}
|
||||
onSave={(agentIds) => onReplaceDelegations(myGrant, [...agentIds])}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : capabilities?.canConnectAsCurrentUser ? (
|
||||
<div>
|
||||
{usesPersonalIdentity ? (
|
||||
personalGrant ? null : (
|
||||
<IdentityRow
|
||||
id="personal-identity"
|
||||
title="Personal account"
|
||||
status={null}
|
||||
detail="Personal identity"
|
||||
actions={capabilities?.canConnectAsCurrentUser ? (
|
||||
<Button size="sm" disabled={connectPending} onClick={onConnectAsMe}>
|
||||
{connectPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Connect as me
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Manager oversight. A regular member never sees this list — the server
|
||||
omits the grants entirely, so there is nothing to hide client-side. */}
|
||||
{capabilities?.canViewOtherPersonalIdentities && others.length > 0 ? (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 text-left text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={othersExpanded}
|
||||
onClick={() => setOthersExpanded((open) => !open)}
|
||||
>
|
||||
<span className="flex-1">Other personal identities · {others.length}</span>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 transition-transform", othersExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
{othersExpanded ? (
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{others.map((grant) => (
|
||||
<div key={grant.id} className="flex flex-wrap items-center gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-foreground">
|
||||
{grantAccountLabel(grant, {
|
||||
subjectLabel: memberLabel(members, grant.subjectUserId),
|
||||
})}
|
||||
</div>
|
||||
{formatLastUsed(grant.lastUsedAt) ? (
|
||||
<div className="text-xs text-muted-foreground">{formatLastUsed(grant.lastUsedAt)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusText status={grant.status} />
|
||||
{grant.capabilities?.canRevoke && grant.status !== "revoked" ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(grant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
)
|
||||
) : (
|
||||
orgGrant ? (
|
||||
orgGrant.capabilities?.canEditAudience ? (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenAudience(orgGrant.id)}>
|
||||
Manage access
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<IdentityRow
|
||||
title="Organization account"
|
||||
status={null}
|
||||
detail="Organization identity"
|
||||
actions={capabilities?.canCreateOrganizationGrant ? (
|
||||
<Button size="sm" disabled={connectPending} onClick={onConnectOrganization}>
|
||||
Connect organization identity
|
||||
</Button>
|
||||
) : null}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{audienceGrant ? (
|
||||
<AudienceDialog
|
||||
|
|
@ -342,31 +219,44 @@ export function IdentitiesSection({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{revokeTarget ? (
|
||||
<RevokeGrantDialog
|
||||
grant={revokeTarget}
|
||||
providerName={providerName}
|
||||
pending={revokePending}
|
||||
credentialPolicy={credentialPolicy}
|
||||
isOwnIdentity={revokeTarget.kind === "user" && revokeTarget.subjectUserId === currentUserId}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onConfirm={() => {
|
||||
onRevokeGrant(revokeTarget);
|
||||
setRevokeTarget(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentitiesHeading() {
|
||||
return <h2 className="text-sm font-semibold text-foreground">Account</h2>;
|
||||
}
|
||||
|
||||
function ConnectionAudienceCallout({
|
||||
personal,
|
||||
connectedName,
|
||||
connectedImage,
|
||||
status,
|
||||
}: {
|
||||
personal: boolean;
|
||||
connectedName: string | null;
|
||||
connectedImage: string | null;
|
||||
status: ConnectionGrant["status"] | null;
|
||||
}) {
|
||||
const Icon = personal ? UserRound : Building2;
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Identities</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Who agents act as when they use this connection.
|
||||
</p>
|
||||
<div className="flex items-start gap-4 rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{personal
|
||||
? "Only you can use this connection"
|
||||
: "Anyone in your company can use this connection"}
|
||||
</p>
|
||||
{connectedName && status !== null ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Identity name={connectedName} avatarUrl={connectedImage} />
|
||||
{status === "active" ? null : <StatusText status={status} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -374,26 +264,23 @@ function IdentitiesHeading() {
|
|||
function IdentityRow({
|
||||
id,
|
||||
title,
|
||||
secondary,
|
||||
status,
|
||||
detail,
|
||||
actions,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
secondary: string | null;
|
||||
status: ConnectionGrant["status"] | null;
|
||||
detail: string | null;
|
||||
actions: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div id={id} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div id={id} className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{title}</span>
|
||||
<StatusText status={status} />
|
||||
{status === "active" ? null : <StatusText status={status} />}
|
||||
</div>
|
||||
{secondary ? <div className="mt-0.5 truncate text-sm text-foreground">{secondary}</div> : null}
|
||||
{detail ? <div className="mt-0.5 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
|
|
|
|||
|
|
@ -9,27 +9,26 @@ import { RadioCardGroup } from "@/components/ui/radio-card";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { type InstallState } from "@/lib/tool-installs";
|
||||
import { QuarantinedActionsReview } from "./SetupPanel";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
import {
|
||||
formatActionPermissionSummary,
|
||||
summarizeActionPermissions,
|
||||
} from "./action-permission-summary";
|
||||
import type { AccessDraft, AppDetailSectionProps } from "./types";
|
||||
|
||||
type ActionPermission = "off" | "allowed" | "ask";
|
||||
|
||||
/**
|
||||
* Permissions tab.
|
||||
*
|
||||
* Agent availability is expressed **once** (PAP-17859). This panel used to
|
||||
* stack a legacy "Who can use it" editor on top of "Available to agents",
|
||||
* asking the reader to hold two overlapping models of the same fact and to
|
||||
* guess which one wins. The install model — *Agents I pick / Any agent* — is
|
||||
* now the single visible source of truth.
|
||||
*
|
||||
* The runtime distinction survives untouched: an install still authorizes its
|
||||
* target server-side (`putConnectionInstalls` extends the app profile's
|
||||
* bindings), so "installed ⊆ permitted" holds without a second editor. What is
|
||||
* gone is the *user-facing* duplicate, and with it the hidden path that could
|
||||
* widen access from a control the reader could not see.
|
||||
* Agent access and installs answer two different questions. Access decides who
|
||||
* may use the app when work needs it. Installs decide which agents load the app
|
||||
* on every run. Keeping the sections adjacent makes that distinction explicit
|
||||
* while preserving the server invariant that installed agents are permitted.
|
||||
*/
|
||||
export function PermissionsPanel({
|
||||
appName,
|
||||
agents,
|
||||
access,
|
||||
install,
|
||||
readOnly,
|
||||
canChange,
|
||||
|
|
@ -38,6 +37,7 @@ export function PermissionsPanel({
|
|||
askFirstIds,
|
||||
pending,
|
||||
installPending,
|
||||
onSaveAccess,
|
||||
onSaveInstall,
|
||||
onSetActionPermission,
|
||||
onReviewQuarantined,
|
||||
|
|
@ -46,10 +46,19 @@ export function PermissionsPanel({
|
|||
capabilities,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"agents" | "readOnly" | "canChange" | "quarantined" | "enabledIds" | "askFirstIds" | "pending"
|
||||
| "appName"
|
||||
| "agents"
|
||||
| "access"
|
||||
| "readOnly"
|
||||
| "canChange"
|
||||
| "quarantined"
|
||||
| "enabledIds"
|
||||
| "askFirstIds"
|
||||
| "pending"
|
||||
> & {
|
||||
install: InstallState;
|
||||
installPending: boolean;
|
||||
onSaveAccess: (next: AccessDraft) => void;
|
||||
onSaveInstall: (next: InstallState) => void;
|
||||
onSetActionPermission: (id: string, next: ActionPermission) => void;
|
||||
onReviewQuarantined: (enabledIds: string[]) => void;
|
||||
|
|
@ -63,14 +72,24 @@ export function PermissionsPanel({
|
|||
const [searchParams] = useSearchParams();
|
||||
const focusId = searchParams.get("focus");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AvailableToAgentsSection
|
||||
<div className="space-y-10">
|
||||
<AlwaysInstalledSection
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
install={install}
|
||||
capabilities={capabilities}
|
||||
disabled={installPending}
|
||||
onSave={onSaveInstall}
|
||||
/>
|
||||
<AgentAccessSection
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
capabilities={capabilities}
|
||||
disabled={pending}
|
||||
onSave={onSaveAccess}
|
||||
/>
|
||||
<ActionsSection
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
|
|
@ -89,55 +108,46 @@ export function PermissionsPanel({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Available to agents" (PAP-17835 Surface E).
|
||||
*
|
||||
* The old section exposed the runtime's own vocabulary — "permitted only",
|
||||
* "installed", an auto-extend warning — which asked the reader to hold two
|
||||
* overlapping concepts to answer one question. It is now the same two-choice
|
||||
* model the create flow uses: pick agents, or any agent. The runtime
|
||||
* distinction still exists in code; it just stopped being the user's problem.
|
||||
*
|
||||
* Every control is gated on a server capability. A viewer, or a member who may
|
||||
* not configure this connection, sees the summary and the agent list with no
|
||||
* controls at all rather than disabled ones.
|
||||
*/
|
||||
function AvailableToAgentsSection({
|
||||
function AgentAccessSection({
|
||||
appName,
|
||||
agents,
|
||||
access,
|
||||
install,
|
||||
capabilities,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: Agent[];
|
||||
access: AccessDraft;
|
||||
install: InstallState;
|
||||
capabilities: ToolConnectionCapabilities | undefined;
|
||||
disabled: boolean;
|
||||
onSave: (next: InstallState) => void;
|
||||
onSave: (next: AccessDraft) => void;
|
||||
}) {
|
||||
const liveAgents = agents.filter((a) => a.status !== "terminated");
|
||||
const canManage = capabilities?.canManageAgentInstalls ?? false;
|
||||
const canSetCompanyWide = capabilities?.canSetCompanyInstall ?? false;
|
||||
// "Agents I pick" is scoped to the agents this person may actually edit. The
|
||||
// server decides that set; the client never infers it from a role string.
|
||||
const canManage = capabilities?.canConfigure ?? false;
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
const selectableAgents = editableAgentIds
|
||||
? liveAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||||
: liveAgents;
|
||||
const mode: "all" | "specific" = install.onAll ? "all" : "specific";
|
||||
const selectedAgents = liveAgents.filter((agent) => install.agentIds.has(agent.id));
|
||||
const summary = install.onAll
|
||||
const selectedAgents = liveAgents.filter((agent) => access.agentIds.has(agent.id));
|
||||
const requiredAgentIds = install.agentIds;
|
||||
const summary = access.mode === "all"
|
||||
? "Any agent"
|
||||
: install.agentIds.size === 0
|
||||
? "No agents yet"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"}`;
|
||||
: access.agentIds.size === 0
|
||||
? "No agents"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"}`;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<section className="border-t border-border pt-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Available to agents</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
|
||||
<h2 className="text-lg font-semibold text-foreground">Agent access</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Agents that may use {appName} when work needs it.
|
||||
</p>
|
||||
</div>
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
</div>
|
||||
|
|
@ -146,6 +156,128 @@ function AvailableToAgentsSection({
|
|||
<div className="space-y-3 pt-4">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which agents can use this connection"
|
||||
value={access.mode}
|
||||
disabled={disabled}
|
||||
className="sm:grid-cols-2"
|
||||
onValueChange={(next) => {
|
||||
if (next === "all") onSave({ mode: "all", agentIds: new Set() });
|
||||
else onSave({
|
||||
mode: "specific",
|
||||
agentIds: new Set([...access.agentIds, ...requiredAgentIds]),
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "specific",
|
||||
title: "Agents I pick",
|
||||
description: install.onAll
|
||||
? "Unavailable while installed for every agent."
|
||||
: "Only selected agents.",
|
||||
disabled: install.onAll,
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
title: "Any agent",
|
||||
description: "Available across your company.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{access.mode === "specific" ? (
|
||||
<AgentMultiSelect
|
||||
agents={selectableAgents}
|
||||
selectedAgentIds={access.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={
|
||||
access.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} selected`
|
||||
}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
isAgentDisabled={(agent) => requiredAgentIds.has(agent.id)}
|
||||
getDescription={(agent) => requiredAgentIds.has(agent.id) ? "Always installed" : agent.title}
|
||||
headerContent={requiredAgentIds.size > 0 ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Always-installed agents keep access.
|
||||
</p>
|
||||
) : null}
|
||||
onChange={(agentIds) => onSave({
|
||||
mode: "specific",
|
||||
agentIds: new Set([...agentIds, ...requiredAgentIds]),
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
// Read-only: the state is still fully legible, just not editable.
|
||||
<div className="pt-3">
|
||||
{access.mode === "all" ? (
|
||||
<p className="text-sm text-muted-foreground">Every agent can use this connection.</p>
|
||||
) : selectedAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No agents can use this connection.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AlwaysInstalledSection({
|
||||
appName,
|
||||
agents,
|
||||
install,
|
||||
capabilities,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: Agent[];
|
||||
install: InstallState;
|
||||
capabilities: ToolConnectionCapabilities | undefined;
|
||||
disabled: boolean;
|
||||
onSave: (next: InstallState) => void;
|
||||
}) {
|
||||
const liveAgents = agents.filter((agent) => agent.status !== "terminated");
|
||||
const canManage = capabilities?.canManageAgentInstalls ?? false;
|
||||
const canSetCompanyWide = capabilities?.canSetCompanyInstall ?? false;
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
const selectableAgents = editableAgentIds
|
||||
? liveAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||||
: liveAgents;
|
||||
const selectedAgents = liveAgents.filter((agent) => install.agentIds.has(agent.id));
|
||||
const mode: "all" | "specific" = install.onAll ? "all" : "specific";
|
||||
const summary = install.onAll
|
||||
? "Every agent"
|
||||
: install.agentIds.size === 0
|
||||
? "No agents"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"}`;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Always installed</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Loads {appName} on every run. Agent access only makes it available when needed.
|
||||
</p>
|
||||
</div>
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="space-y-3 pt-4">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which agents always load this connection"
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
className="sm:grid-cols-2"
|
||||
|
|
@ -157,14 +289,14 @@ function AvailableToAgentsSection({
|
|||
{
|
||||
value: "specific",
|
||||
title: "Agents I pick",
|
||||
description: "Choose one or more agents you can edit.",
|
||||
description: "Always loaded for selected agents.",
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
title: "Any agent",
|
||||
title: "Every agent",
|
||||
description: canSetCompanyWide
|
||||
? "Make this connection available to every agent."
|
||||
: "Only someone who can configure this connection can choose this.",
|
||||
? "Always loaded for current and future agents."
|
||||
: "Only a connection manager can choose this.",
|
||||
},
|
||||
].filter((option) => option.value !== "all" || canSetCompanyWide || install.onAll)}
|
||||
/>
|
||||
|
|
@ -174,23 +306,20 @@ function AvailableToAgentsSection({
|
|||
agents={selectableAgents}
|
||||
selectedAgentIds={install.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={
|
||||
install.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} selected`
|
||||
}
|
||||
triggerLabel={install.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} selected`}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
// Read-only: the state is still fully legible, just not editable.
|
||||
<div className="pt-3">
|
||||
{install.onAll ? (
|
||||
<p className="text-sm text-muted-foreground">Every agent can use this connection.</p>
|
||||
<p className="text-sm text-muted-foreground">This connection is always loaded for every agent.</p>
|
||||
) : selectedAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No agents have this connection yet.</p>
|
||||
<p className="text-sm text-muted-foreground">This connection is not always loaded for any agent.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.map((agent) => (
|
||||
|
|
@ -236,14 +365,16 @@ function ActionsSection({
|
|||
onRefreshActions: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<section className="space-y-10 border-t border-border pt-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Action permissions</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{canConfigure
|
||||
? "Choose what agents can do and what needs a human first."
|
||||
: "What agents can do, and what needs a human first."}
|
||||
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatActionPermissionSummary(summarizeActionPermissions(
|
||||
[...readOnly, ...canChange],
|
||||
enabledIds,
|
||||
askFirstIds,
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
{/* Viewer rule D4: a forbidden action is omitted, not rendered disabled.
|
||||
|
|
@ -278,8 +409,8 @@ function ActionsSection({
|
|||
)}
|
||||
|
||||
<ActionGroup
|
||||
title="Read only"
|
||||
hint="Can look up context without changing anything."
|
||||
title={`Read (${readOnly.length})`}
|
||||
hint="Views data without changing it."
|
||||
actions={readOnly}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
|
|
@ -289,8 +420,8 @@ function ActionsSection({
|
|||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
<ActionGroup
|
||||
title="Can make changes"
|
||||
hint="Can change something in another app."
|
||||
title={`Write (${canChange.length})`}
|
||||
hint="Creates or changes data."
|
||||
actions={canChange}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
|
|
@ -339,9 +470,9 @@ function ActionGroup({
|
|||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-2 text-sm">
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
<span className="ml-2 text-muted-foreground">- {hint}</span>
|
||||
<div className="pb-4">
|
||||
<div className="text-lg font-semibold text-foreground">{title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{actions.map((action) => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export function ReviewPanel({
|
|||
const showsQuarantinedActions = quarantined.length > 0 && !!onReviewQuarantined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">Review</h2>
|
||||
{showsQuarantinedActions ? (
|
||||
<QuarantinedActionsReview
|
||||
entries={quarantined}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -10,33 +11,38 @@ import { googleSheetsConfigWithAllowlist, parseGoogleSheetIds } from "../google-
|
|||
export function SetupPanel({
|
||||
connection,
|
||||
galleryEntry,
|
||||
onToggleApp,
|
||||
appToggleDisabled,
|
||||
onUpdateConfig,
|
||||
configUpdateDisabled,
|
||||
identities,
|
||||
agentsSummary,
|
||||
permissionsSummary,
|
||||
permissionsLoading,
|
||||
onOpenPermissions,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"connection" | "galleryEntry"
|
||||
> & {
|
||||
onToggleApp: () => void;
|
||||
appToggleDisabled: boolean;
|
||||
onUpdateConfig: (config: Record<string, unknown>) => void;
|
||||
configUpdateDisabled: boolean;
|
||||
/**
|
||||
* The Identities section (PAP-17835). It replaces the old generic OAuth
|
||||
* "workspace authorization" block, because that block could only ever describe
|
||||
* one shared identity and this connection may act as each person instead.
|
||||
* The fixed Identity section. It replaces the old generic OAuth "workspace
|
||||
* authorization" block and shows the identity type chosen during setup.
|
||||
*/
|
||||
identities?: ReactNode;
|
||||
agentsSummary: string;
|
||||
permissionsSummary: string | null;
|
||||
permissionsLoading: boolean;
|
||||
onOpenPermissions: () => void;
|
||||
}) {
|
||||
const description = galleryEntry?.description ?? null;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{description && (
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<div className="space-y-10">
|
||||
{identities}
|
||||
<SetupLinkSection title="Agents" summary={agentsSummary} onClick={onOpenPermissions} />
|
||||
<SetupLinkSection
|
||||
title="Actions"
|
||||
summary={permissionsLoading ? "Loading permissions…" : permissionsSummary ?? "Manage permissions"}
|
||||
onClick={onOpenPermissions}
|
||||
/>
|
||||
{appDefinitionSlug(galleryEntry) === "google-sheets" && (
|
||||
<GoogleSheetsAllowlistSection
|
||||
connection={connection}
|
||||
|
|
@ -47,11 +53,34 @@ export function SetupPanel({
|
|||
{appDefinitionSlug(galleryEntry) === "posthog" && (
|
||||
<PostHogConfigurationSection connection={connection} />
|
||||
)}
|
||||
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupLinkSection({
|
||||
title,
|
||||
summary,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
summary: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="min-w-0 text-sm text-muted-foreground">{summary}</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider label used in identity and revoke copy. Falls back to the app's own
|
||||
* display name so a pasted server never reads as a generic "OAuth".
|
||||
|
|
@ -84,7 +113,7 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
|
|||
const tools = typeof config.tools === "string" && config.tools ? config.tools : "None";
|
||||
const rows = [
|
||||
["Connection method", method],
|
||||
["Project ID", typeof config.projectId === "string" ? config.projectId : "Not set"],
|
||||
["Project pin", typeof config.projectId === "string" ? config.projectId : "Use active project"],
|
||||
["Read-only mode", config.readOnly === true ? "On" : "Off"],
|
||||
["Feature groups", features],
|
||||
["Individual tools", tools],
|
||||
|
|
@ -94,7 +123,7 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
|
|||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">PostHog access scope</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
This connection is pinned to the project and analytics surface below.
|
||||
PostHog uses its normal account defaults unless you narrow the optional controls below.
|
||||
</p>
|
||||
<dl className="mt-4 divide-y divide-border">
|
||||
{rows.map(([label, value]) => (
|
||||
|
|
@ -215,41 +244,6 @@ function GoogleSheetsAllowlistSection({
|
|||
);
|
||||
}
|
||||
|
||||
export function AppLifecycleSection({
|
||||
connection,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const enabled = connection.enabled !== false && connection.status !== "disabled";
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
{enabled ? "Agents can use this app" : "This app is paused"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{enabled
|
||||
? "Pause it to stop every agent from using its actions."
|
||||
: "Resume it when agents should be able to use its actions again."}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
aria-label={enabled ? "Pause this app" : "Resume this app"}
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onToggle}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuarantinedActionsReview({
|
||||
entries,
|
||||
disabled,
|
||||
|
|
|
|||
|
|
@ -229,11 +229,12 @@ describe("TestPanel", () => {
|
|||
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders the Test-as header and grouped actions with access badges", async () => {
|
||||
it("renders the test hierarchy and grouped actions with access badges", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Test as");
|
||||
expect(container.textContent).toContain("Test an action");
|
||||
expect(container.textContent).toContain("Agent");
|
||||
expect(container.textContent).toContain("ClaudeCoder");
|
||||
expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
|
||||
expect(container.textContent).toContain("Read (1)");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
|
|
@ -41,6 +40,7 @@ import {
|
|||
} from "@/components/JsonSchemaForm";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { appTabHref } from "../app-tabs";
|
||||
import { formatActionPermissionSummary } from "./action-permission-summary";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small format helpers
|
||||
|
|
@ -99,19 +99,6 @@ function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** "Allowed for 1 action · Ask first for 2 · Off for 1" — singular gets " action". */
|
||||
function summaryCount(label: string, n: number): string {
|
||||
return `${label} ${n}${n === 1 ? " action" : ""}`;
|
||||
}
|
||||
|
||||
function accessSummaryLine(summary: ToolConnectionAccessSummary): string {
|
||||
return [
|
||||
summaryCount("Allowed for", summary.allowedCount),
|
||||
summaryCount("Ask first for", summary.askFirstCount),
|
||||
summaryCount("Off for", summary.offCount),
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -152,9 +139,6 @@ export function TestPanel({
|
|||
setAgentId(agents[0].id);
|
||||
}, [agents, agentId]);
|
||||
|
||||
// Switches the header from "TEST AS" card to the compact "Testing as …" line.
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
|
||||
const selectedAgent = agents.find((a) => a.id === agentId) ?? null;
|
||||
|
||||
// Per-action decision for the selected agent, keyed by both the upstream and
|
||||
|
|
@ -236,11 +220,10 @@ export function TestPanel({
|
|||
appName,
|
||||
allAgents: agents,
|
||||
onSelectAgent: setAgentId,
|
||||
onInteract: () => setHasInteracted(true),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-8">
|
||||
{selectedAgent && (
|
||||
<TestAsHeader
|
||||
appName={appName}
|
||||
|
|
@ -248,11 +231,11 @@ export function TestPanel({
|
|||
selectedAgent={selectedAgent}
|
||||
onSelect={setAgentId}
|
||||
connectionId={connectionId}
|
||||
compact={hasInteracted}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-(--sz-12rem) flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
|
|
@ -269,7 +252,7 @@ export function TestPanel({
|
|||
<FilterChip label={`Write ${writeActions.length}`} active={kindFilter === "write"} onClick={() => setKindFilter("write")} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{visibleCount} matches · sorted A–Z</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{visibleCount === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -339,38 +322,24 @@ function TestAsHeader({
|
|||
selectedAgent,
|
||||
onSelect,
|
||||
connectionId,
|
||||
compact,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: ToolConnectionTestAgent[];
|
||||
selectedAgent: ToolConnectionTestAgent;
|
||||
onSelect: (agentId: string) => void;
|
||||
connectionId: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Testing as{" "}
|
||||
<AgentPicker
|
||||
agents={agents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelect={onSelect}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
inline
|
||||
/>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{accessSummaryLine(selectedAgent.effectiveAccess)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Test an action</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Run a real action as an agent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Test as</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Agent</p>
|
||||
<AgentPicker
|
||||
agents={agents}
|
||||
selectedAgent={selectedAgent}
|
||||
|
|
@ -379,12 +348,14 @@ function TestAsHeader({
|
|||
appName={appName}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{accessSummaryLine(selectedAgent.effectiveAccess)}</p>
|
||||
<Link
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
to={appTabHref(connectionId, "permissions")}
|
||||
>
|
||||
{formatActionPermissionSummary(selectedAgent.effectiveAccess)}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Runs real actions in {appName}, exactly as this agent would.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -531,7 +502,6 @@ type RowSharedProps = {
|
|||
appName: string;
|
||||
allAgents: ToolConnectionTestAgent[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onInteract: () => void;
|
||||
};
|
||||
|
||||
function ActionGroup({
|
||||
|
|
@ -693,7 +663,6 @@ function ActionTester({
|
|||
agent,
|
||||
allAgents,
|
||||
onSelectAgent,
|
||||
onInteract,
|
||||
}: {
|
||||
entry: ToolCatalogEntry;
|
||||
decision: ToolConnectionTestDecision;
|
||||
|
|
@ -761,7 +730,6 @@ function ActionTester({
|
|||
const validationErrors = validateJsonSchemaForm(rawSchema, values);
|
||||
setErrors(validationErrors);
|
||||
if (Object.keys(validationErrors).length > 0) return;
|
||||
onInteract();
|
||||
cancelledRef.current = false;
|
||||
startedAtRef.current = Date.now();
|
||||
setElapsedMs(0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
|
||||
export type ActionPermissionSummary = {
|
||||
allowedCount: number;
|
||||
askFirstCount: number;
|
||||
offCount: number;
|
||||
};
|
||||
|
||||
export function summarizeActionPermissions(
|
||||
entries: ToolCatalogEntry[],
|
||||
enabledIds: Set<string>,
|
||||
askFirstIds: Set<string>,
|
||||
): ActionPermissionSummary {
|
||||
let allowedCount = 0;
|
||||
let askFirstCount = 0;
|
||||
let offCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!enabledIds.has(entry.id)) {
|
||||
offCount += 1;
|
||||
} else if (askFirstIds.has(entry.id)) {
|
||||
askFirstCount += 1;
|
||||
} else {
|
||||
allowedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { allowedCount, askFirstCount, offCount };
|
||||
}
|
||||
|
||||
function summaryCount(label: string, count: number): string {
|
||||
return `${label} ${count}${count === 1 ? " action" : ""}`;
|
||||
}
|
||||
|
||||
export function formatActionPermissionSummary(summary: ActionPermissionSummary): string {
|
||||
return [
|
||||
summaryCount("Allowed for", summary.allowedCount),
|
||||
summaryCount("Ask first for", summary.askFirstCount),
|
||||
summaryCount("Off for", summary.offCount),
|
||||
].join(" · ");
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ export const APP_TABS = [
|
|||
{ key: "setup", label: "Setup", icon: Settings2 },
|
||||
{ key: "test", label: "Test", icon: Beaker },
|
||||
{ key: "services", label: "Services", icon: Blocks },
|
||||
{ key: "review", label: "Review", icon: Inbox },
|
||||
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
|
||||
{ key: "review", label: "Review", icon: Inbox },
|
||||
{ key: "activity", label: "Activity", icon: Activity },
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
ConnectionGrantKind,
|
||||
ConnectionAudienceMember,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantStatus,
|
||||
|
|
@ -14,24 +15,34 @@ import type {
|
|||
* card cannot drift into three different names for the same thing.
|
||||
*/
|
||||
|
||||
export interface ActsAsSummary {
|
||||
title: string;
|
||||
detail: string;
|
||||
export type ConnectionTypeLabel = "Personal" | "Company";
|
||||
|
||||
/** The two connection types shown throughout the product. */
|
||||
export function connectionTypeLabel(
|
||||
credentialPolicy: ToolConnectionCredentialPolicy,
|
||||
): ConnectionTypeLabel {
|
||||
return credentialPolicy === "per_user" ? "Personal" : "Company";
|
||||
}
|
||||
|
||||
export function actsAsSummary(credentialPolicy: ToolConnectionCredentialPolicy): ActsAsSummary {
|
||||
switch (credentialPolicy) {
|
||||
case "per_user":
|
||||
return { title: "Acts as each person", detail: "Each person connects their own account." };
|
||||
case "per_user_with_fallback":
|
||||
return {
|
||||
title: "Uses a personal identity with organization fallback",
|
||||
detail: "Agents use your account when you have one, and the organization account otherwise.",
|
||||
};
|
||||
case "shared":
|
||||
default:
|
||||
return { title: "Acts as the organization", detail: "Agents share the organization identity." };
|
||||
const COMPANY_NAME_SUFFIX = " for the company";
|
||||
|
||||
/** Keep company-owned connections unmistakable anywhere their name appears. */
|
||||
export function connectionNameForGrantKind(name: string, grantKind: ConnectionGrantKind): string {
|
||||
const trimmed = name.trim();
|
||||
if (grantKind !== "organization" || trimmed.toLocaleLowerCase().endsWith(COMPANY_NAME_SUFFIX)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}${COMPANY_NAME_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function connectionNameForCredentialPolicy(
|
||||
name: string,
|
||||
credentialPolicy: ToolConnectionCredentialPolicy,
|
||||
): string {
|
||||
return connectionNameForGrantKind(
|
||||
name,
|
||||
connectionTypeLabel(credentialPolicy) === "Company" ? "organization" : "user",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
genericConnectGuidance,
|
||||
genericConnectPayload,
|
||||
newCustomHeaderRow,
|
||||
oauthCallbackUrlForBrowser,
|
||||
type GenericConnectDraft,
|
||||
} from "./generic-mcp-connect";
|
||||
|
||||
|
|
@ -36,6 +37,23 @@ describe("endpointHost", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("oauthCallbackUrlForBrowser", () => {
|
||||
it("uses localhost for local HTTP callbacks to match the API authorization request", () => {
|
||||
expect(oauthCallbackUrlForBrowser("http://127.0.0.1:3200")).toBe(
|
||||
"http://localhost:3200/api/tools/oauth/callback",
|
||||
);
|
||||
expect(oauthCallbackUrlForBrowser("http://[::1]:3200")).toBe(
|
||||
"http://localhost:3200/api/tools/oauth/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves public HTTPS origins", () => {
|
||||
expect(oauthCallbackUrlForBrowser("https://paperclip.example.test")).toBe(
|
||||
"https://paperclip.example.test/api/tools/oauth/callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultGenericMcpName", () => {
|
||||
it("keeps the port and path so endpoints on one host get distinct names", () => {
|
||||
expect(defaultGenericMcpName("http://127.0.0.1:47399/mcp"))
|
||||
|
|
|
|||
|
|
@ -35,6 +35,25 @@ export function endpointHost(url: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback URI shown to operators must exactly match the URI the API sends.
|
||||
*
|
||||
* Xero and a few other OAuth providers reject numeric loopback hosts even though
|
||||
* they point at the same machine. The API therefore uses `localhost` for local
|
||||
* HTTP OAuth, so the setup form must advertise that same canonical spelling.
|
||||
*/
|
||||
export function oauthCallbackUrlForBrowser(origin: string = window.location.origin): string {
|
||||
const callbackUrl = new URL("/api/tools/oauth/callback", origin);
|
||||
const hostname = callbackUrl.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (
|
||||
callbackUrl.protocol === "http:"
|
||||
&& (hostname === "127.0.0.1" || hostname === "::1")
|
||||
) {
|
||||
callbackUrl.hostname = "localhost";
|
||||
}
|
||||
return callbackUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A useful, non-secret default label for an arbitrary endpoint.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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", "github", "slack", "notion", "posthog", "linear"];
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
|
|||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "none",
|
||||
credentialPolicy: "shared",
|
||||
credentialSource: "paperclip_vault",
|
||||
credentialPolicy: "shared",
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
|
|
@ -124,7 +124,7 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
|
|||
}],
|
||||
canMakeChanges: [],
|
||||
},
|
||||
suggestedDefaults: { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] },
|
||||
suggestedDefaults: { access: "all_agents", askFirstRiskLevels: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { useNavigate } from "@/lib/router";
|
|||
import {
|
||||
OAuthConnectStateScreen,
|
||||
type OAuthConnectPhase,
|
||||
} from "@/pages/apps/AppsConnect";
|
||||
} from "@/features/connections/ConnectionSetupFlow";
|
||||
import { endpointHost } from "@/pages/apps/generic-mcp-connect";
|
||||
import { McpConfigHelpDialog } from "./McpConfigHelpDialog";
|
||||
import { ErrorState } from "./shared";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
type WorkTimelineResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard";
|
||||
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
|
||||
|
|
@ -39,12 +42,16 @@ import "./styles.css";
|
|||
const STORYBOOK_USER_AVATAR =
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=96&q=80";
|
||||
|
||||
function withStorybookTimelineDetails(data: WorkTimelineResult): WorkTimelineResult {
|
||||
function withStorybookTimelineDetails(
|
||||
data: WorkTimelineResult,
|
||||
): WorkTimelineResult {
|
||||
return {
|
||||
...data,
|
||||
actors: data.actors.map((actor) => (
|
||||
actor.type === "user" ? { ...actor, avatar: STORYBOOK_USER_AVATAR } : actor
|
||||
)),
|
||||
actors: data.actors.map((actor) =>
|
||||
actor.type === "user"
|
||||
? { ...actor, avatar: STORYBOOK_USER_AVATAR }
|
||||
: actor,
|
||||
),
|
||||
spans: data.spans.map((span, index) => {
|
||||
const inputTokens = 42_000 + index * 137;
|
||||
const cachedInputTokens = index % 3 === 0 ? 8_000 : 0;
|
||||
|
|
@ -62,7 +69,9 @@ function withStorybookTimelineDetails(data: WorkTimelineResult): WorkTimelineRes
|
|||
};
|
||||
}
|
||||
|
||||
const storybookTimelineSample = withStorybookTimelineDetails(timelineSample as WorkTimelineResult);
|
||||
const storybookTimelineSample = withStorybookTimelineDetails(
|
||||
timelineSample as WorkTimelineResult,
|
||||
);
|
||||
|
||||
// Install fetch monkeypatch eagerly so any module-load-time fetches (e.g. schema
|
||||
// caches in adapter config renderers) hit our fixtures before they reach the
|
||||
|
|
@ -148,6 +157,66 @@ function installStorybookApiFixtures() {
|
|||
return Response.json([]);
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname ===
|
||||
"/api/connection-intents/interaction-connection-intent-default/setup-options"
|
||||
) {
|
||||
return Response.json({
|
||||
version: 1,
|
||||
interaction: null,
|
||||
service: {
|
||||
service: "notion",
|
||||
name: "Notion",
|
||||
description: "Search and update a Notion workspace.",
|
||||
logoUrl: null,
|
||||
methods: [
|
||||
{ key: "mcp-oauth", label: "Sign in with Notion", auth: "oauth" },
|
||||
],
|
||||
state: "needs_user_action",
|
||||
connectionId: null,
|
||||
},
|
||||
requestedAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
existingConnections: [
|
||||
{
|
||||
id: "connection-storybook-notion",
|
||||
companyId: "company-storybook",
|
||||
applicationId: "application-storybook-notion",
|
||||
name: "Board Operator’s Notion",
|
||||
uid: "notion/storybook",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: { sourceTemplateKey: "notion" },
|
||||
credentialRefs: [],
|
||||
credentialSecretRefs: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/companies/company-storybook/tools/gallery") {
|
||||
return Response.json({
|
||||
apps: CONNECTABLE_APP_DEFINITIONS.filter(
|
||||
(app) => app.slug === "notion",
|
||||
),
|
||||
capabilities: {
|
||||
canSetCompanyInstall: true,
|
||||
companyInstallReason: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
url.pathname === "/api/companies/company-storybook/tools/applications"
|
||||
) {
|
||||
return Response.json({ applications: [] });
|
||||
}
|
||||
if (url.pathname === "/api/companies/company-storybook/tools/connections") {
|
||||
return Response.json({ connections: [] });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/adapters") {
|
||||
return Response.json([
|
||||
{
|
||||
|
|
@ -209,23 +278,33 @@ function installStorybookApiFixtures() {
|
|||
return Response.json([]);
|
||||
}
|
||||
|
||||
const adapterSchemaMatch = url.pathname.match(/^\/api\/adapters\/([^/]+)\/config-schema$/);
|
||||
const adapterSchemaMatch = url.pathname.match(
|
||||
/^\/api\/adapters\/([^/]+)\/config-schema$/,
|
||||
);
|
||||
if (adapterSchemaMatch) {
|
||||
const [, adapterType] = adapterSchemaMatch;
|
||||
const schemas = (window as typeof window & {
|
||||
__paperclipStorybookAdapterSchemas?: Record<string, unknown>;
|
||||
}).__paperclipStorybookAdapterSchemas;
|
||||
const schemas = (
|
||||
window as typeof window & {
|
||||
__paperclipStorybookAdapterSchemas?: Record<string, unknown>;
|
||||
}
|
||||
).__paperclipStorybookAdapterSchemas;
|
||||
const schema = schemas?.[adapterType];
|
||||
if (schema) return Response.json(schema);
|
||||
}
|
||||
|
||||
const secretsListMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secrets$/);
|
||||
const secretsListMatch = url.pathname.match(
|
||||
/^\/api\/companies\/([^/]+)\/secrets$/,
|
||||
);
|
||||
if (secretsListMatch) {
|
||||
const [, companyId] = secretsListMatch;
|
||||
return Response.json(companyId === "company-storybook" ? storybookSecrets : []);
|
||||
return Response.json(
|
||||
companyId === "company-storybook" ? storybookSecrets : [],
|
||||
);
|
||||
}
|
||||
|
||||
const secretProvidersMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secret-providers$/);
|
||||
const secretProvidersMatch = url.pathname.match(
|
||||
/^\/api\/companies\/([^/]+)\/secret-providers$/,
|
||||
);
|
||||
if (secretProvidersMatch) {
|
||||
return Response.json(storybookSecretProviders);
|
||||
}
|
||||
|
|
@ -247,36 +326,57 @@ function installStorybookApiFixtures() {
|
|||
const secretProviderConfigDiscoveryPreviewMatch = url.pathname.match(
|
||||
/^\/api\/companies\/([^/]+)\/secret-provider-configs\/discovery\/preview$/,
|
||||
);
|
||||
if (secretProviderConfigDiscoveryPreviewMatch && init?.method?.toUpperCase() === "POST") {
|
||||
if (
|
||||
secretProviderConfigDiscoveryPreviewMatch &&
|
||||
init?.method?.toUpperCase() === "POST"
|
||||
) {
|
||||
return Response.json(storybookSecretProviderDiscoveryPreview);
|
||||
}
|
||||
|
||||
const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
|
||||
const secretUsageMatch = url.pathname.match(
|
||||
/^\/api\/secrets\/([^/]+)\/usage$/,
|
||||
);
|
||||
if (secretUsageMatch) {
|
||||
const [, secretId] = secretUsageMatch;
|
||||
return Response.json({
|
||||
secretId,
|
||||
bindings: storybookSecretBindings.filter((binding) => binding.secretId === secretId),
|
||||
bindings: storybookSecretBindings.filter(
|
||||
(binding) => binding.secretId === secretId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const secretEventsMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/access-events$/);
|
||||
const secretEventsMatch = url.pathname.match(
|
||||
/^\/api\/secrets\/([^/]+)\/access-events$/,
|
||||
);
|
||||
if (secretEventsMatch) {
|
||||
const [, secretId] = secretEventsMatch;
|
||||
return Response.json(storybookSecretAccessEvents.filter((event) => event.secretId === secretId));
|
||||
return Response.json(
|
||||
storybookSecretAccessEvents.filter(
|
||||
(event) => event.secretId === secretId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
|
||||
const companyResourceMatch = url.pathname.match(
|
||||
/^\/api\/companies\/([^/]+)\/([^/]+)$/,
|
||||
);
|
||||
if (companyResourceMatch) {
|
||||
const [, companyId, resource] = companyResourceMatch;
|
||||
if (resource === "agents") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookAgents : []);
|
||||
return Response.json(
|
||||
companyId === "company-storybook" ? storybookAgents : [],
|
||||
);
|
||||
}
|
||||
if (resource === "projects") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookProjects : []);
|
||||
return Response.json(
|
||||
companyId === "company-storybook" ? storybookProjects : [],
|
||||
);
|
||||
}
|
||||
if (resource === "approvals") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookApprovals : []);
|
||||
return Response.json(
|
||||
companyId === "company-storybook" ? storybookApprovals : [],
|
||||
);
|
||||
}
|
||||
if (resource === "dashboard") {
|
||||
return Response.json({
|
||||
|
|
@ -293,9 +393,15 @@ function installStorybookApiFixtures() {
|
|||
spans: [],
|
||||
events: [],
|
||||
edges: [],
|
||||
pagination: { limit: 100, offset: 0, totalIssues: 0, hasMore: false },
|
||||
pagination: {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
totalIssues: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
window: {
|
||||
from: url.searchParams.get("from") ?? new Date(0).toISOString(),
|
||||
from:
|
||||
url.searchParams.get("from") ?? new Date(0).toISOString(),
|
||||
to: url.searchParams.get("to") ?? new Date(0).toISOString(),
|
||||
capped: false,
|
||||
},
|
||||
|
|
@ -306,7 +412,9 @@ function installStorybookApiFixtures() {
|
|||
return Response.json([]);
|
||||
}
|
||||
if (resource === "live-runs") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookLiveRuns : []);
|
||||
return Response.json(
|
||||
companyId === "company-storybook" ? storybookLiveRuns : [],
|
||||
);
|
||||
}
|
||||
if (resource === "inbox-dismissals") {
|
||||
return Response.json([]);
|
||||
|
|
@ -327,14 +435,19 @@ function installStorybookApiFixtures() {
|
|||
return Response.json(
|
||||
query
|
||||
? issues.filter((issue) =>
|
||||
`${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`.toLowerCase().includes(query),
|
||||
`${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
: issues,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/invites/") && url.pathname.endsWith("/logo")) {
|
||||
if (
|
||||
url.pathname.startsWith("/api/invites/") &&
|
||||
url.pathname.endsWith("/logo")
|
||||
) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -275,8 +275,8 @@ const CONNECTIONS: ToolConnection[] = [
|
|||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
credentialSource: "paperclip_vault",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
|
|
@ -311,8 +311,8 @@ const CONNECTIONS: ToolConnection[] = [
|
|||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
credentialSource: "paperclip_vault",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from "react";
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
acceptedRequestCheckboxConfirmationInteraction,
|
||||
|
|
@ -55,6 +61,13 @@ import {
|
|||
rejectedSuggestedTasksInteraction,
|
||||
staleTargetRequestCheckboxConfirmationInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
pendingConnectionIntentInteraction,
|
||||
authorizingConnectionIntentInteraction,
|
||||
retryConnectionIntentInteraction,
|
||||
connectedConnectionIntentInteraction,
|
||||
declinedConnectionIntentInteraction,
|
||||
supersededConnectionIntentInteraction,
|
||||
expiredConnectionIntentInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type {
|
||||
AskUserQuestionsAnswer,
|
||||
|
|
@ -123,7 +136,37 @@ function ScenarioCard({
|
|||
);
|
||||
}
|
||||
|
||||
function AudienceCard({ interaction }: { interaction: RequestConfirmationInteraction }) {
|
||||
function OpenConnectionIntentDialogStory() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const trigger = Array.from(
|
||||
hostRef.current?.querySelectorAll("button") ?? [],
|
||||
).find((candidate) =>
|
||||
candidate.textContent?.includes("Connect / Use existing"),
|
||||
);
|
||||
trigger?.click();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={hostRef}>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudienceCard({
|
||||
interaction,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
}) {
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
|
|
@ -152,13 +195,20 @@ function InteractiveSuggestedTasksCard() {
|
|||
...acceptedSuggestedTasksInteraction,
|
||||
result: {
|
||||
version: 1,
|
||||
createdTasks: (acceptedSuggestedTasksInteraction.result?.createdTasks ?? []).filter((task) =>
|
||||
selectedClientKeys?.includes(task.clientKey) ?? true),
|
||||
createdTasks: (
|
||||
acceptedSuggestedTasksInteraction.result?.createdTasks ?? []
|
||||
).filter(
|
||||
(task) => selectedClientKeys?.includes(task.clientKey) ?? true,
|
||||
),
|
||||
skippedClientKeys: pendingSuggestedTasksInteraction.payload.tasks
|
||||
.map((task) => task.clientKey)
|
||||
.filter((clientKey) => !(selectedClientKeys?.includes(clientKey) ?? true)),
|
||||
.filter(
|
||||
(clientKey) =>
|
||||
!(selectedClientKeys?.includes(clientKey) ?? true),
|
||||
),
|
||||
},
|
||||
})}
|
||||
})
|
||||
}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejectedSuggestedTasksInteraction,
|
||||
|
|
@ -166,11 +216,12 @@ function InteractiveSuggestedTasksCard() {
|
|||
version: 1,
|
||||
...(rejectedSuggestedTasksInteraction.result ?? {}),
|
||||
rejectionReason:
|
||||
reason
|
||||
|| rejectedSuggestedTasksInteraction.result?.rejectionReason
|
||||
|| null,
|
||||
reason ||
|
||||
rejectedSuggestedTasksInteraction.result?.rejectionReason ||
|
||||
null,
|
||||
},
|
||||
})}
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -178,13 +229,15 @@ function InteractiveSuggestedTasksCard() {
|
|||
function buildAnsweredInteraction(
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
): AskUserQuestionsInteraction {
|
||||
const labels = pendingAskUserQuestionsInteraction.payload.questions.flatMap((question) => {
|
||||
const answer = answers.find((entry) => entry.questionId === question.id);
|
||||
if (!answer) return [];
|
||||
return question.options
|
||||
.filter((option) => answer.optionIds.includes(option.id))
|
||||
.map((option) => option.label);
|
||||
});
|
||||
const labels = pendingAskUserQuestionsInteraction.payload.questions.flatMap(
|
||||
(question) => {
|
||||
const answer = answers.find((entry) => entry.questionId === question.id);
|
||||
if (!answer) return [];
|
||||
return question.options
|
||||
.filter((option) => answer.optionIds.includes(option.id))
|
||||
.map((option) => option.label);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...answeredAskUserQuestionsInteraction,
|
||||
|
|
@ -208,15 +261,17 @@ function InteractiveAskUserQuestionsCard() {
|
|||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onSubmitInteractionAnswers={(_interaction, answers) =>
|
||||
setInteraction(buildAnsweredInteraction(answers))}
|
||||
setInteraction(buildAnsweredInteraction(answers))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveRequestConfirmationCard() {
|
||||
const [interaction, setInteraction] = useState<RequestConfirmationInteraction>(
|
||||
pendingRequestConfirmationInteraction,
|
||||
);
|
||||
const [interaction, setInteraction] =
|
||||
useState<RequestConfirmationInteraction>(
|
||||
pendingRequestConfirmationInteraction,
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
|
|
@ -224,16 +279,22 @@ function InteractiveRequestConfirmationCard() {
|
|||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={() => setInteraction(acceptedRequestConfirmationInteraction)}
|
||||
onAcceptInteraction={() =>
|
||||
setInteraction(acceptedRequestConfirmationInteraction)
|
||||
}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejectedRequestConfirmationInteraction,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: reason || rejectedRequestConfirmationInteraction.result?.reason || null,
|
||||
reason:
|
||||
reason ||
|
||||
rejectedRequestConfirmationInteraction.result?.reason ||
|
||||
null,
|
||||
},
|
||||
})}
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -247,7 +308,8 @@ function InteractiveRequestCheckboxConfirmationCard({
|
|||
accepted: RequestCheckboxConfirmationInteraction;
|
||||
rejected: RequestCheckboxConfirmationInteraction;
|
||||
}) {
|
||||
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
|
||||
const [interaction, setInteraction] =
|
||||
useState<RequestCheckboxConfirmationInteraction>(pending);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
|
|
@ -255,7 +317,11 @@ function InteractiveRequestCheckboxConfirmationCard({
|
|||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
|
||||
onAcceptInteraction={(
|
||||
_interaction,
|
||||
_selectedClientKeys,
|
||||
selectedOptionIds,
|
||||
) =>
|
||||
setInteraction({
|
||||
...accepted,
|
||||
payload: pending.payload,
|
||||
|
|
@ -264,7 +330,8 @@ function InteractiveRequestCheckboxConfirmationCard({
|
|||
outcome: "accepted",
|
||||
selectedOptionIds: selectedOptionIds ?? [],
|
||||
},
|
||||
})}
|
||||
})
|
||||
}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejected,
|
||||
|
|
@ -274,7 +341,8 @@ function InteractiveRequestCheckboxConfirmationCard({
|
|||
outcome: "rejected",
|
||||
reason: reason || rejected.result?.reason || null,
|
||||
},
|
||||
})}
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -284,7 +352,8 @@ function InteractiveRequestItemVerdictsCard({
|
|||
}: {
|
||||
initial?: RequestItemVerdictsInteraction;
|
||||
}) {
|
||||
const [interaction, setInteraction] = useState<RequestItemVerdictsInteraction>(initial);
|
||||
const [interaction, setInteraction] =
|
||||
useState<RequestItemVerdictsInteraction>(initial);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
|
|
@ -304,7 +373,8 @@ function InteractiveRequestItemVerdictsCard({
|
|||
id: verdict.id,
|
||||
verdict: verdict.verdict as RequestItemVerdictValue,
|
||||
reason: verdict.reason ?? null,
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedByUserId:
|
||||
issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:20:00.000Z"),
|
||||
})),
|
||||
];
|
||||
|
|
@ -313,7 +383,9 @@ function InteractiveRequestItemVerdictsCard({
|
|||
...current,
|
||||
status: complete ? "answered" : "pending",
|
||||
resolvedAt: complete ? new Date("2026-04-20T15:20:00.000Z") : null,
|
||||
resolvedByUserId: complete ? issueThreadInteractionFixtureMeta.currentUserId : null,
|
||||
resolvedByUserId: complete
|
||||
? issueThreadInteractionFixtureMeta.currentUserId
|
||||
: null,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
|
|
@ -321,7 +393,8 @@ function InteractiveRequestItemVerdictsCard({
|
|||
items: merged,
|
||||
},
|
||||
};
|
||||
})}
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -334,8 +407,13 @@ function AutoOpenDeclineRequestConfirmationCard({
|
|||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const declineButton = Array.from(ref.current?.querySelectorAll("button") ?? [])
|
||||
.find((button) => button.textContent?.includes(interaction.payload.rejectLabel ?? "Decline"));
|
||||
const declineButton = Array.from(
|
||||
ref.current?.querySelectorAll("button") ?? [],
|
||||
).find((button) =>
|
||||
button.textContent?.includes(
|
||||
interaction.payload.rejectLabel ?? "Decline",
|
||||
),
|
||||
);
|
||||
declineButton?.click();
|
||||
}, [interaction]);
|
||||
|
||||
|
|
@ -675,7 +753,128 @@ export const RequestConfirmationFailed: Story = {
|
|||
};
|
||||
|
||||
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
|
||||
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
|
||||
export const RequestConfirmationRejected =
|
||||
RequestConfirmationDeclinedWithReason;
|
||||
|
||||
export const ConnectionIntentStates: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<Section eyebrow="Connection intent" title="Inline setup request states">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Addressed user"
|
||||
description="The responsible user can launch the shared connection setup flow or decline in place."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Other viewer"
|
||||
description="Other viewers see who Paperclip is waiting for and receive no connection controls."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId="user-product"
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Authorizing"
|
||||
description="The in-flight state prevents duplicate authorization or decline actions."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={authorizingConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Retry needed"
|
||||
description="A failed popup or provider callback keeps the intent pending with a safe retry path."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={retryConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Connected"
|
||||
description="The terminal card records that the requesting agent receives the connection on continuation."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={connectedConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Declined"
|
||||
description="Decline is terminal and wakes the requesting agent without exposing setup controls."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={declinedConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Superseded"
|
||||
description="A newer run owns the active request, so the stale card points at the latest one."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={supersededConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Expired"
|
||||
description="Closed tasks and expired requests have a quiet terminal state with no authorization controls."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={expiredConnectionIntentInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ConnectionIntentSetupDialog: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<Section eyebrow="Connection intent" title="Shared setup dialog">
|
||||
<OpenConnectionIntentDialogStory />
|
||||
</Section>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ConnectionIntentSetupDialogMobile: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<OpenConnectionIntentDialogStory />
|
||||
</StoryFrame>
|
||||
),
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile" },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP tool-approval card (PAP-13745). A `request_confirmation` carrying a
|
||||
|
|
@ -710,7 +909,10 @@ export const ToolActionPendingWrite: Story = {
|
|||
title="Pending · write"
|
||||
description="A write tool call awaits approval: identity header, WRITE risk badge, humanized preview, collapsible technical details, expiry countdown, and an Approve & run CTA."
|
||||
>
|
||||
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
|
||||
<ToolActionCard
|
||||
interaction={pendingToolActionWriteInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
|
|
@ -723,7 +925,10 @@ export const ToolActionPendingDestructive: Story = {
|
|||
title="Pending · destructive"
|
||||
description="A destructive call takes the red risk badge and a destructive primary button; the countdown sits inside the sub-5-minute urgent window."
|
||||
>
|
||||
<ToolActionCard interaction={pendingToolActionDestructiveInteraction} interactive />
|
||||
<ToolActionCard
|
||||
interaction={pendingToolActionDestructiveInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
|
|
@ -801,7 +1006,10 @@ export const ToolActionLegacyGeneric: Story = {
|
|||
title="Legacy · no toolAction"
|
||||
description="A confirmation without a toolAction payload keeps the existing generic rendering unchanged — the tool-approval surface is strictly additive."
|
||||
>
|
||||
<ToolActionCard interaction={genericPendingRequestConfirmationInteraction} interactive />
|
||||
<ToolActionCard
|
||||
interaction={genericPendingRequestConfirmationInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
|
|
@ -833,7 +1041,10 @@ export const SecretProposalPending: Story = {
|
|||
title="Pending secret binding"
|
||||
description="A human reviews safe binding metadata, the agent-authored reason, and expiry before approving the real write."
|
||||
>
|
||||
<SecretProposalCard interaction={pendingSecretProposalInteraction} interactive />
|
||||
<SecretProposalCard
|
||||
interaction={pendingSecretProposalInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
|
|
@ -896,20 +1107,44 @@ export const SecretProposalAllStates: Story = {
|
|||
<StoryFrame>
|
||||
<Section eyebrow="Secret binding proposal" title="All lifecycle states">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard title="1 · Pending" description="Safe metadata and approval actions.">
|
||||
<SecretProposalCard interaction={pendingSecretProposalInteraction} interactive />
|
||||
<ScenarioCard
|
||||
title="1 · Pending"
|
||||
description="Safe metadata and approval actions."
|
||||
>
|
||||
<SecretProposalCard
|
||||
interaction={pendingSecretProposalInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="2 · Executed" description="The binding was created.">
|
||||
<SecretProposalCard interaction={executedSecretProposalInteraction} />
|
||||
<ScenarioCard
|
||||
title="2 · Executed"
|
||||
description="The binding was created."
|
||||
>
|
||||
<SecretProposalCard
|
||||
interaction={executedSecretProposalInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="3 · FAILED" description="Accepted, then failed closed.">
|
||||
<ScenarioCard
|
||||
title="3 · FAILED"
|
||||
description="Accepted, then failed closed."
|
||||
>
|
||||
<SecretProposalCard interaction={failedSecretProposalInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="4 · Rejected" description="The binding was not created.">
|
||||
<SecretProposalCard interaction={rejectedSecretProposalInteraction} />
|
||||
<ScenarioCard
|
||||
title="4 · Rejected"
|
||||
description="The binding was not created."
|
||||
>
|
||||
<SecretProposalCard
|
||||
interaction={rejectedSecretProposalInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="5 · Expired" description="A fresh proposal is required.">
|
||||
<SecretProposalCard interaction={expiredSecretProposalInteraction} />
|
||||
<ScenarioCard
|
||||
title="5 · Expired"
|
||||
description="A fresh proposal is required."
|
||||
>
|
||||
<SecretProposalCard
|
||||
interaction={expiredSecretProposalInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -937,31 +1172,41 @@ export const ResolverAudienceStates: Story = {
|
|||
title="Anyone except creator"
|
||||
description="Requested on purpose when the answer has to be independent of the agent that asked."
|
||||
>
|
||||
<AudienceCard interaction={notCreatorRequestConfirmationInteraction} />
|
||||
<AudienceCard
|
||||
interaction={notCreatorRequestConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Human only"
|
||||
description="Reserved for a person: agents are turned away by the server, and the copy says so."
|
||||
>
|
||||
<AudienceCard interaction={humanOnlyRequestConfirmationInteraction} />
|
||||
<AudienceCard
|
||||
interaction={humanOnlyRequestConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Named addressee"
|
||||
description="One agent owns the response; the card stays out of the open attention feed."
|
||||
>
|
||||
<AudienceCard interaction={agentAddressedRequestConfirmationInteraction} />
|
||||
<AudienceCard
|
||||
interaction={agentAddressedRequestConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Narrowed by a company cap"
|
||||
description="The request asked for Anyone; company interaction governance capped the kind, and the card explains the narrowing."
|
||||
>
|
||||
<AudienceCard interaction={companyCappedRequestConfirmationInteraction} />
|
||||
<AudienceCard
|
||||
interaction={companyCappedRequestConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Legacy restricted card"
|
||||
description="Created before Anyone became the default. Migration keeps it restricted fail-closed and the card says a new card would be open."
|
||||
>
|
||||
<AudienceCard interaction={legacyRestrictedRequestConfirmationInteraction} />
|
||||
<AudienceCard
|
||||
interaction={legacyRestrictedRequestConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -972,31 +1217,67 @@ export const ResolverAudienceStates: Story = {
|
|||
export const ToolActionAllStates: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<Section eyebrow="MCP Tool Approval" title="All six lifecycle states (PAP-13745)">
|
||||
<Section
|
||||
eyebrow="MCP Tool Approval"
|
||||
title="All six lifecycle states (PAP-13745)"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard title="1 · Pending (write)" description="Awaiting approval — Approve & run.">
|
||||
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
|
||||
<ScenarioCard
|
||||
title="1 · Pending (write)"
|
||||
description="Awaiting approval — Approve & run."
|
||||
>
|
||||
<ToolActionCard
|
||||
interaction={pendingToolActionWriteInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="1b · Pending (destructive)" description="Red risk badge, urgent countdown.">
|
||||
<ToolActionCard interaction={pendingToolActionDestructiveInteraction} interactive />
|
||||
<ScenarioCard
|
||||
title="1b · Pending (destructive)"
|
||||
description="Red risk badge, urgent countdown."
|
||||
>
|
||||
<ToolActionCard
|
||||
interaction={pendingToolActionDestructiveInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="2 · Approved — running…" description="Transient, self-resolving spinner.">
|
||||
<ScenarioCard
|
||||
title="2 · Approved — running…"
|
||||
description="Transient, self-resolving spinner."
|
||||
>
|
||||
<ToolActionCard interaction={runningToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="3 · Executed" description="Green, with a result summary.">
|
||||
<ScenarioCard
|
||||
title="3 · Executed"
|
||||
description="Green, with a result summary."
|
||||
>
|
||||
<ToolActionCard interaction={executedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="4 · Failed" description="Ran, but the connector errored.">
|
||||
<ScenarioCard
|
||||
title="4 · Failed"
|
||||
description="Ran, but the connector errored."
|
||||
>
|
||||
<ToolActionCard interaction={failedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="5 · Declined" description="Rejected — nothing ran.">
|
||||
<ScenarioCard
|
||||
title="5 · Declined"
|
||||
description="Rejected — nothing ran."
|
||||
>
|
||||
<ToolActionCard interaction={declinedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="6 · Expired" description="No response in 60 min.">
|
||||
<ScenarioCard
|
||||
title="6 · Expired"
|
||||
description="No response in 60 min."
|
||||
>
|
||||
<ToolActionCard interaction={expiredToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="Legacy · no toolAction" description="Unchanged generic rendering.">
|
||||
<ToolActionCard interaction={genericPendingRequestConfirmationInteraction} interactive />
|
||||
<ScenarioCard
|
||||
title="Legacy · no toolAction"
|
||||
description="Unchanged generic rendering."
|
||||
>
|
||||
<ToolActionCard
|
||||
interaction={genericPendingRequestConfirmationInteraction}
|
||||
interactive
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -1012,7 +1293,10 @@ export const ToolActionMobile: Story = {
|
|||
description="Single column: risk badge wraps under the tool name, actions stack full-width, the technical drawer stays collapsed."
|
||||
>
|
||||
<div className="mx-auto max-w-[358px]">
|
||||
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
|
||||
<ToolActionCard
|
||||
interaction={pendingToolActionWriteInteraction}
|
||||
interactive
|
||||
/>
|
||||
</div>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
|
|
@ -1165,7 +1449,9 @@ export const ItemVerdictsPartial: Story = {
|
|||
title="S3 / S4 — partial progress"
|
||||
description="Two items already applied (one approved, one rejected with its reason echoed); three remain actionable. The card stays alive and shows 2 of 5 decided."
|
||||
>
|
||||
<InteractiveRequestItemVerdictsCard initial={partialRequestItemVerdictsInteraction} />
|
||||
<InteractiveRequestItemVerdictsCard
|
||||
initial={partialRequestItemVerdictsInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
|
|
@ -1214,7 +1500,9 @@ export const ItemVerdictsManyItems: Story = {
|
|||
title="S7 — long list"
|
||||
description="24 items decided in passes; the expanded list scrolls in a bounded region and reuses the 200-item cap."
|
||||
>
|
||||
<InteractiveRequestItemVerdictsCard initial={manyItemsRequestItemVerdictsInteraction} />
|
||||
<InteractiveRequestItemVerdictsCard
|
||||
initial={manyItemsRequestItemVerdictsInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
|
|
@ -1226,14 +1514,17 @@ export const ReviewSurface: Story = {
|
|||
<section className="paperclip-story__frame p-6">
|
||||
<div className="paperclip-story__label">Thread interactions</div>
|
||||
<div className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
This review surface pressure-tests the thread interaction kinds directly inside the issue
|
||||
chat surface. The card language leans closer to
|
||||
annotated review sheets than generic admin widgets so the objects feel like first-class work
|
||||
artifacts in the thread.
|
||||
This review surface pressure-tests the thread interaction kinds
|
||||
directly inside the issue chat surface. The card language leans closer
|
||||
to annotated review sheets than generic admin widgets so the objects
|
||||
feel like first-class work artifacts in the thread.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section eyebrow="Suggested Tasks" title="Pending, accepted, and rejected task-tree cards">
|
||||
<Section
|
||||
eyebrow="Suggested Tasks"
|
||||
title="Pending, accepted, and rejected task-tree cards"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<ScenarioCard
|
||||
title="Pending"
|
||||
|
|
@ -1266,7 +1557,10 @@ export const ReviewSurface: Story = {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Ask User Questions" title="Pending multi-question form and answered summary">
|
||||
<Section
|
||||
eyebrow="Ask User Questions"
|
||||
title="Pending multi-question form and answered summary"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Pending"
|
||||
|
|
@ -1288,7 +1582,10 @@ export const ReviewSurface: Story = {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Request Confirmation" title="Plan approval and compact resolution states">
|
||||
<Section
|
||||
eyebrow="Request Confirmation"
|
||||
title="Plan approval and compact resolution states"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Plan approval"
|
||||
|
|
@ -1340,7 +1637,10 @@ export const ReviewSurface: Story = {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Mixed Feed" title="Interaction cards in the real issue thread">
|
||||
<Section
|
||||
eyebrow="Mixed Feed"
|
||||
title="Interaction cards in the real issue thread"
|
||||
>
|
||||
<ScenarioCard
|
||||
title="IssueChatThread composition"
|
||||
description="Comments, timeline events, accepted task suggestions, a pending confirmation, a pending question form, and an active run share the same feed."
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
type AppDefinition,
|
||||
type ConnectionGrant,
|
||||
type ConnectionGrantsResponse,
|
||||
type ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -14,20 +16,75 @@ import {
|
|||
type OAuthConnectPhase,
|
||||
} from "@/pages/apps/AppsConnect";
|
||||
import { SetupPanel } from "@/pages/apps/app-detail/SetupPanel";
|
||||
import { ReconnectCard } from "@/pages/apps/app-detail/AdvancedPanel";
|
||||
import {
|
||||
AdvancedPanel,
|
||||
ReconnectCard,
|
||||
} from "@/pages/apps/app-detail/AdvancedPanel";
|
||||
import { IdentitiesSection } from "@/pages/apps/app-detail/IdentitiesSection";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion") as AppDefinition;
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier") as AppDefinition;
|
||||
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github") as AppDefinition;
|
||||
const NOTION = CONNECTABLE_APP_DEFINITIONS.find(
|
||||
(app) => app.slug === "notion",
|
||||
) as AppDefinition;
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find(
|
||||
(app) => app.slug === "zapier",
|
||||
) as AppDefinition;
|
||||
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find(
|
||||
(app) => app.slug === "github",
|
||||
) as AppDefinition;
|
||||
|
||||
function seededClient() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryData(queryKeys.apps.gallery(COMPANY_ID), { apps: [NOTION, ZAPIER, GITHUB] });
|
||||
client.setQueryData(queryKeys.apps.gallery(COMPANY_ID), {
|
||||
apps: [NOTION, ZAPIER, GITHUB],
|
||||
credentialSources: {
|
||||
vercelConnect: {
|
||||
available: true,
|
||||
enabled: true,
|
||||
authentication: "access_token",
|
||||
manageUrl: "https://vercel.com/connect",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.applications(COMPANY_ID), {
|
||||
applications: [
|
||||
{
|
||||
id: "application-notion",
|
||||
companyId: COMPANY_ID,
|
||||
name: "Notion",
|
||||
applicationKey: "notion",
|
||||
status: "active",
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.connections(COMPANY_ID), {
|
||||
connections: [notionConnection()],
|
||||
});
|
||||
client.setQueryData(queryKeys.access.companyUserDirectory(COMPANY_ID), {
|
||||
users: [
|
||||
{
|
||||
principalId: "board-user",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "board-user",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +99,13 @@ function BrowseHost() {
|
|||
);
|
||||
}
|
||||
|
||||
function OAuthStateHost({ phase, error }: { phase: OAuthConnectPhase; error?: string }) {
|
||||
function OAuthStateHost({
|
||||
phase,
|
||||
error,
|
||||
}: {
|
||||
phase: OAuthConnectPhase;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<OAuthConnectStateScreen
|
||||
|
|
@ -56,7 +119,9 @@ function OAuthStateHost({ phase, error }: { phase: OAuthConnectPhase; error?: st
|
|||
);
|
||||
}
|
||||
|
||||
function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnection {
|
||||
function notionConnection(
|
||||
overrides: Partial<ToolConnection> = {},
|
||||
): ToolConnection {
|
||||
return {
|
||||
id: "connection-notion",
|
||||
companyId: COMPANY_ID,
|
||||
|
|
@ -67,8 +132,8 @@ function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnecti
|
|||
ownership: "dcr",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
credentialSource: "paperclip_vault",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
transportConfig: { url: "https://mcp.notion.com/mcp" },
|
||||
config: {
|
||||
|
|
@ -91,25 +156,131 @@ function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnecti
|
|||
};
|
||||
}
|
||||
|
||||
function personalGrant(): ConnectionGrant {
|
||||
return {
|
||||
id: "grant-notion-personal",
|
||||
companyId: COMPANY_ID,
|
||||
connectionId: "connection-notion",
|
||||
kind: "user",
|
||||
subjectUserId: "board-user",
|
||||
providerTenant: { name: "Dotta" },
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
revokedAt: null,
|
||||
revokedByAgentId: null,
|
||||
revokedByUserId: null,
|
||||
lastUsedAt: new Date("2026-08-06T19:00:00.000Z"),
|
||||
createdAt: new Date("2026-08-06T18:55:00.000Z"),
|
||||
updatedAt: new Date("2026-08-06T19:00:00.000Z"),
|
||||
delegations: [
|
||||
{
|
||||
id: "delegation-1",
|
||||
companyId: COMPANY_ID,
|
||||
grantId: "grant-notion-personal",
|
||||
agentId: "agent-1",
|
||||
createdByUserId: "board-user",
|
||||
createdAt: new Date("2026-08-06T19:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
capabilities: { canRevoke: true, canEditAudience: false },
|
||||
};
|
||||
}
|
||||
|
||||
function personalGrantsResponse(
|
||||
grant: ConnectionGrant,
|
||||
): ConnectionGrantsResponse {
|
||||
return {
|
||||
connection: { id: "connection-notion", uid: "notion-storybook" },
|
||||
grants: [grant],
|
||||
capabilities: {
|
||||
canConfigure: true,
|
||||
canCreateOrganizationGrant: false,
|
||||
canSetCompanyInstall: true,
|
||||
canConnectAsCurrentUser: true,
|
||||
canManageAgentInstalls: true,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: ["agent-1"],
|
||||
},
|
||||
currentUserId: "board-user",
|
||||
members: [
|
||||
{ userId: "board-user", name: "Dotta", email: "dotta@example.com" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function ConnectedHost() {
|
||||
const client = useMemo(() => seededClient(), []);
|
||||
const connection = notionConnection();
|
||||
const grant = personalGrant();
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<header className="mb-6 flex items-center gap-3">
|
||||
<AppLogo name={NOTION.name} logoUrl={NOTION.branding.logoUrl} size={44} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Connected app setup</p>
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto w-screen max-w-3xl p-6">
|
||||
<header className="mb-6 flex items-center gap-3">
|
||||
<AppLogo
|
||||
name={NOTION.name}
|
||||
logoUrl={NOTION.branding.logoUrl}
|
||||
size={44}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connected app setup
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-8">
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={NOTION}
|
||||
onUpdateConfig={() => undefined}
|
||||
configUpdateDisabled={false}
|
||||
agentsSummary="1 agent"
|
||||
permissionsSummary="Allowed for 28 · Ask first for 0 · Off for 0"
|
||||
permissionsLoading={false}
|
||||
onOpenPermissions={() => undefined}
|
||||
identities={
|
||||
<IdentitiesSection
|
||||
appName="Notion"
|
||||
credentialPolicy="per_user"
|
||||
ownerUserId="board-user"
|
||||
connectedUser={{ label: "Dotta", image: null }}
|
||||
grantsQuery={personalGrantsResponse(grant)}
|
||||
loading={false}
|
||||
error={false}
|
||||
onConnectAsMe={() => undefined}
|
||||
onConnectOrganization={() => undefined}
|
||||
onReplaceAudience={() => undefined}
|
||||
connectPending={false}
|
||||
audiencePending={false}
|
||||
audienceError={null}
|
||||
audienceGrantId={null}
|
||||
onOpenAudience={() => undefined}
|
||||
onCloseAudience={() => undefined}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName="Notion"
|
||||
galleryEntry={NOTION}
|
||||
removing={false}
|
||||
onRemove={() => undefined}
|
||||
onReplaced={() => undefined}
|
||||
appToggleDisabled={false}
|
||||
onToggleApp={() => undefined}
|
||||
identityGrant={grant}
|
||||
identityCurrentUserId="board-user"
|
||||
identityProviderName="Notion"
|
||||
credentialPolicy="per_user"
|
||||
onReconnectIdentity={() => undefined}
|
||||
onRevokeIdentity={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<SetupPanel
|
||||
connection={notionConnection()}
|
||||
galleryEntry={NOTION}
|
||||
onToggleApp={() => undefined}
|
||||
appToggleDisabled={false}
|
||||
onUpdateConfig={() => undefined}
|
||||
configUpdateDisabled={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -118,12 +289,15 @@ function ReconnectRequiredHost() {
|
|||
<div className="mx-auto max-w-3xl p-6">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Connection needs attention</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connection needs attention
|
||||
</p>
|
||||
</header>
|
||||
<ReconnectCard
|
||||
connection={notionConnection({
|
||||
healthStatus: "failed",
|
||||
healthMessage: "Notion authorization expired or was revoked (invalid_grant).",
|
||||
healthMessage:
|
||||
"Notion authorization expired or was revoked (invalid_grant).",
|
||||
lastError: "invalid_grant",
|
||||
})}
|
||||
galleryEntry={NOTION}
|
||||
|
|
@ -133,6 +307,33 @@ function ReconnectRequiredHost() {
|
|||
);
|
||||
}
|
||||
|
||||
function VercelConnectProvenanceHost() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<ReconnectCard
|
||||
connection={notionConnection({
|
||||
credentialSource: "vercel_connect",
|
||||
externalCredential: {
|
||||
provider: "vercel_connect",
|
||||
connectorId: "scl_storybook",
|
||||
connectorUid: "notion-paperclip",
|
||||
service: "notion",
|
||||
connectorType: "oauth",
|
||||
principalMode: "user",
|
||||
headerName: "Authorization",
|
||||
headerPrefix: "Bearer ",
|
||||
scopes: ["*"],
|
||||
},
|
||||
healthStatus: "failed",
|
||||
healthMessage: "This Vercel Connect identity needs authorization.",
|
||||
})}
|
||||
galleryEntry={NOTION}
|
||||
onReconnected={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/Notion MCP connect flow (PAP-16650)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
|
|
@ -175,3 +376,8 @@ export const ReconnectRequired: Story = {
|
|||
name: "6 — Reconnect required",
|
||||
render: () => <ReconnectRequiredHost />,
|
||||
};
|
||||
|
||||
export const VercelConnectReconnect: Story = {
|
||||
name: "7 — Vercel Connect reconnect",
|
||||
render: () => <VercelConnectProvenanceHost />,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interact
|
|||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
||||
import { PermissionsPanel } from "@/pages/apps/app-detail/PermissionsPanel";
|
||||
import type { AccessDraft } from "@/pages/apps/app-detail/types";
|
||||
import { AccessStep } from "@/pages/apps/AppsConnect";
|
||||
import type { InstallState } from "@/lib/tool-installs";
|
||||
|
||||
|
|
@ -137,11 +138,14 @@ function PanelHarness({
|
|||
capabilities?: ToolConnectionCapabilities;
|
||||
}) {
|
||||
const [state, setState] = useState(install);
|
||||
const [access, setAccess] = useState<AccessDraft>({ mode: "all", agentIds: new Set() });
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<PermissionsPanel
|
||||
capabilities={capabilities}
|
||||
appName="Gmail"
|
||||
agents={AGENTS}
|
||||
access={access}
|
||||
install={state}
|
||||
readOnly={GMAIL_TOOLS.filter((t) => t.isReadOnly)}
|
||||
canChange={GMAIL_TOOLS.filter((t) => !t.isReadOnly)}
|
||||
|
|
@ -151,6 +155,7 @@ function PanelHarness({
|
|||
pending={false}
|
||||
installPending={false}
|
||||
refreshPending={false}
|
||||
onSaveAccess={setAccess}
|
||||
onSaveInstall={setState}
|
||||
onSetActionPermission={() => {}}
|
||||
onReviewQuarantined={() => {}}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type {
|
|||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { IdentitiesSection } from "@/pages/apps/app-detail/IdentitiesSection";
|
||||
import { actsAsSummary } from "@/pages/apps/connection-identity";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PAP-17835 — personal connection identity UX review harness.
|
||||
|
|
@ -100,11 +99,7 @@ function personalGrant(overrides: Partial<ConnectionGrant> = {}): ConnectionGran
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the identity surface the way the Setup tab does, including the header
|
||||
* "acts as" sentence — that sentence is the at-a-glance answer the design
|
||||
* requires, so a screenshot of the rows alone would not show the whole state.
|
||||
*/
|
||||
/** Renders the fixed account surface used on the Setup tab. */
|
||||
function IdentitiesHarness({
|
||||
credentialPolicy = "per_user",
|
||||
grants,
|
||||
|
|
@ -130,30 +125,17 @@ function IdentitiesHarness({
|
|||
currentUserId: CURRENT_USER,
|
||||
members: MEMBERS,
|
||||
};
|
||||
const actsAs = actsAsSummary(credentialPolicy);
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Gmail</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{actsAs.title}</span>
|
||||
{" · "}
|
||||
{actsAs.detail}
|
||||
</p>
|
||||
</header>
|
||||
<div className="mx-auto max-w-4xl bg-background p-8">
|
||||
<IdentitiesSection
|
||||
appName="Gmail"
|
||||
providerName="Gmail"
|
||||
credentialPolicy={credentialPolicy}
|
||||
ownerUserId={CURRENT_USER}
|
||||
connectedUser={{ label: "Carol", image: null }}
|
||||
grantsQuery={loading || error ? undefined : response}
|
||||
agents={[{ id: "agent-1", name: "Outreach agent", title: "Growth", status: "active" }]}
|
||||
agentsLoading={false}
|
||||
agentsError={false}
|
||||
loading={loading}
|
||||
error={error}
|
||||
connectPending={false}
|
||||
revokePending={false}
|
||||
delegationPending={false}
|
||||
audiencePending={false}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={openAudience}
|
||||
|
|
@ -161,9 +143,6 @@ function IdentitiesHarness({
|
|||
onCloseAudience={() => setOpenAudience(null)}
|
||||
onConnectAsMe={() => {}}
|
||||
onConnectOrganization={() => {}}
|
||||
onReconnectOrganization={() => {}}
|
||||
onRevokeGrant={() => {}}
|
||||
onReplaceDelegations={() => {}}
|
||||
onReplaceAudience={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -177,10 +156,10 @@ const meta: Meta = {
|
|||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
// --- Gate 3: personal connected, organization missing ----------------------
|
||||
// --- Gate 3: fixed personal identity ---------------------------------------
|
||||
|
||||
export const SetupPersonalConnectedOrganizationMissing: Story = {
|
||||
name: "3 · Setup — your identity connected, organization missing",
|
||||
export const SetupPersonalConnectedOrganizationHidden: Story = {
|
||||
name: "3 · Setup — your identity connected, organization hidden",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
grants={[personalGrant({ providerTenant: { name: "carol@example.com" } })]}
|
||||
|
|
@ -193,10 +172,10 @@ export const SetupPersonalNotConnected: Story = {
|
|||
render: () => <IdentitiesHarness grants={[]} />,
|
||||
};
|
||||
|
||||
// --- Gate 4: organization identity with a selected audience, manager view ---
|
||||
// --- Gate 4: legacy alternates stay hidden after setup ---------------------
|
||||
|
||||
export const SetupManagerOversight: Story = {
|
||||
name: "4 · Setup — selected audience + manager oversight",
|
||||
export const SetupFixedPersonalIdentity: Story = {
|
||||
name: "4 · Setup — fixed personal identity",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
credentialPolicy="per_user_with_fallback"
|
||||
|
|
@ -273,6 +252,7 @@ export const SetupViewerReadOnly: Story = {
|
|||
name: "7 · Setup — viewer read-only",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
credentialPolicy="shared"
|
||||
capabilities={VIEWER_CAPABILITIES}
|
||||
grants={[
|
||||
grant({
|
||||
|
|
|
|||
Loading…
Reference in New Issue