diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 9cbbb88db3..a8d8223de0 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -274,17 +274,23 @@ The current product behavior is encoded by `recommendedDefaultsForApp` in `packages/shared/src/app-definitions.ts`: - Every discovered action is enabled during successful setup. -- S1-S3 methods default their actions to **Allowed**, including writes. -- S4 methods default `write` and `destructive` actions to **Ask first**. +- Every active action defaults to **Allowed**, including `write` and + `destructive` actions, for every connection method. - Permanently blocked provider actions stay disabled. - Provider/schema-specific changed-tool quarantine remains a separate catalog concern; do not turn writes Off as a substitute for correct risk classification. -If a destructive provider cannot be safe with those defaults, classify the -method S4 or add a narrowly reviewed provider policy with tests. Do not hide a -dangerous tool by misclassifying it as read, and do not silently change global -defaults in a provider PR. +This is an opt-in restriction model. Finishing a connection is still limited to +a board user with connection-configuration access, commits the selected action +IDs to an auditable profile, and leaves **Ask first** available for any action. +The open default changes the initial policy; it does not create a route around a +policy the operator has applied. + +If a destructive provider cannot be safe with those defaults, add a narrowly +reviewed provider policy with tests. Do not hide a dangerous tool by +misclassifying it as read, and do not silently change global defaults in a +provider PR. ## Golden-Path Agent Tutorial @@ -742,7 +748,8 @@ Walk the user path: it returns to `?source=&resume=` without creating another draft. 7. Complete setup. Confirm the connection is active/healthy and opens - `//apps//test`. + `//apps//permissions`, then use the action's + **Test** button. For OAuth, the instance callback must be browser-reachable and must match the provider registration. Loopback HTTP is acceptable only when provider and @@ -956,7 +963,7 @@ Suggested PR verification block: | `consoleLinks` | Official registration, key, settings, and docs destinations. | | `warnings` | Plan, preview, admin, financial, production-data, or destructive-action caveats. | | `variants` | Legacy/simple variant metadata. Prefer explicit methods plus `capabilityProfile` for materially different endpoints/auth. | -| `riskTier` | S1-S4 provider/method sensitivity. Drives recommended policy defaults. | +| `riskTier` | S1-S4 provider/method sensitivity used for review and validation. | | `requiredResourceFilters` | Reviewed resource boundaries. Must be backed by enforcement, not only copy. | | `credentialSources.vercelConnect` | Reviewed services, principal modes, scopes, and header projection for the Vercel exception. | @@ -1149,7 +1156,7 @@ Capture: `requiredResourceFilters` only when their documented semantics apply. - `setupPrerequisite`, `warnings`, `guidanceMd`, and `consoleLinks`: everything the operator must know before credentials or consent. -- `riskTier`: the method-level S1-S4 tier that drives central access defaults. +- `riskTier`: the method-level S1-S4 tier used for review and validation. - `availability`: whether the connection is usable on this instance and the precise reason when it is not. @@ -1190,8 +1197,8 @@ Risk classes: | Risk | Examples | Default | | --- | --- | --- | | `read` | Search, list, fetch metadata/content inside allowed resources. | Active when profile includes the app or read risk level. | -| `write` | Create issue, add comment, update status, append block, trigger redeploy. | Allowed for S1-S3 under the current product default; ask-first for S4. | -| `destructive` | Delete, refund, cancel production deployment, send external message, broad tenant mutation. | Allowed for S1-S3 and ask-first for S4 under the current default. A provider with meaningful destructive capability should normally be S4 or receive a reviewed explicit policy. | +| `write` | Create issue, add comment, update status, append block, trigger redeploy. | Allowed under the current new-connection default. Operators may narrow individual actions. | +| `destructive` | Delete, refund, cancel production deployment, send external message, broad tenant mutation. | Allowed under the current new-connection default. A provider with meaningful destructive capability should receive an explicit security review and may receive a narrower provider policy. | Changed-action quarantine is available when a connection sets `quarantineNewEntries: true`. Use it for providers whose catalog can change @@ -1239,8 +1246,8 @@ Recommended defaults for a new catalog entry: - Use the central `recommendedDefaultsForApp` policy. Do not invent a provider default in UI code. -- S1-S3 actions default Allowed. S4 writes and destructive actions default Ask - first. +- All active actions default Allowed for every method tier. Operators can move + individual actions to Ask first or Off after setup. - Classify a method S4 when its normal catalog includes payments, external sends, refunds, production deployment, deletion, tenant-wide administration, or comparable high-impact mutations. @@ -1257,7 +1264,8 @@ Recommended defaults for a new catalog entry: - Catalog discovery produces the expected actions and the declared changed-tool behavior. - An allowed read call succeeds through the gateway. -- A write call matches the method tier: Allowed for S1-S3, Ask first for S4. +- A write call is Allowed by the new-connection default unless an explicit + provider or operator policy narrows it. - A blocked/quarantined action, when declared, cannot be listed or invoked by an agent. - Revocation removes tools and blocks execution immediately. @@ -1511,7 +1519,7 @@ Copy this section into a connector proposal or implementation issue. - Connect evidence: - Catalog evidence: - Allowed read: -- Governed write (Allowed for S1-S3, Ask first for S4): +- Governed write (Allowed by default; operator policy may narrow it): - Denied/quarantined case: - Revoke: - Audit: diff --git a/doc/connections/POSTHOG.md b/doc/connections/POSTHOG.md index e88676cc5f..9c054875d3 100644 --- a/doc/connections/POSTHOG.md +++ b/doc/connections/POSTHOG.md @@ -84,9 +84,9 @@ values for either. 6. For OAuth, continue through browser consent. For API-key setup, create a personal API key using PostHog's **MCP Server** preset and paste it into Paperclip. Never put the key in connection configuration or a URL. -7. Review discovered actions. Known writes and destructive actions default to - **Ask first**, and unknown PostHog tools default to write risk so they inherit - that approval gate unless the operator changes the selection. +7. Review discovered actions. Every discovered action starts **Allowed**, + including writes and destructive actions. Unknown PostHog tools are still + classified as write risk so operators can identify and narrow them when needed. When configured, Paperclip sends the optional project pin as the `x-posthog-project-id` managed header. Without it, PostHog keeps an active diff --git a/doc/connections/SECURITY-THREAT-MODEL.md b/doc/connections/SECURITY-THREAT-MODEL.md index 3b64a4f59b..5b1a062e34 100644 --- a/doc/connections/SECURITY-THREAT-MODEL.md +++ b/doc/connections/SECURITY-THREAT-MODEL.md @@ -44,8 +44,10 @@ context and server-side ownership checks. Slack needs channel/workspace bounds, Google Drive/Docs needs drive/folder/doc bounds, and equivalent broad providers need provider-specific bounds before agent grants are usable. -6. **Write/admin actions are explicit opt-ins.** Read access does not imply write - access. Destructive or newly changed write actions default to review. +6. **Write/admin actions stay explicit and visible.** Completing connection + setup is the operator's opt-in to the selected active catalog. New + connections default those active actions to Allowed; newly discovered or + changed write actions still enter quarantine for review. 7. **Revocation is immediate and failure-closed.** Revoked secrets, disabled connections, expired policies, missing secret refs, or failed health checks block new execution and queued mutation work. @@ -307,5 +309,6 @@ Redaction and agent safety: board-supervised rollout. - Provider OAuth/app-installation scopes may be broader than Paperclip resource filters. Paperclip must enforce the narrower internal filter. -- High-risk writes still need good UX. Default them to ask-first, dry-run, or - draft semantics until product copy and review flows are proven. +- High-risk writes still need good UX. Prefer provider-side dry-run or draft + semantics, clear action names, and narrow explicit provider policy where an + Allowed new-connection default would be unsafe. diff --git a/doc/connections/SMOKE-LAB-TUTORIAL.md b/doc/connections/SMOKE-LAB-TUTORIAL.md index 0299fbda05..e081e0a148 100644 --- a/doc/connections/SMOKE-LAB-TUTORIAL.md +++ b/doc/connections/SMOKE-LAB-TUTORIAL.md @@ -87,10 +87,9 @@ the URL bar, e.g. `PAP`). Replace it in the example paths. anything. > **Which fixture am I in?** The Connections list shows both, and the stdio one -> may be listed first. If you open a fixture's **Setup** tab and there is no -> **Connect with Smoke OAuth** card — only the "Agents can use this app" toggle — -> you're in the **stdio** fixture. Go back and open **Smoke Lab HTTP MCP -> fixture** for the OAuth steps. +> may be listed first. Open **Permissions** and check the action names: the HTTP +> fixture includes **List synthetic todos**, while the stdio fixture includes +> **Deterministic time**. Use **Smoke Lab HTTP MCP fixture** for the OAuth steps. > If **Start services** errors with a `403`, re-check §0 — you're on a `public` > (internet-facing) instance. Any private instance works, including the everyday @@ -101,8 +100,8 @@ the URL bar, e.g. `PAP`). Replace it in the example paths. ## 3. The lifecycle you'll exercise on every path Each path P1–P7 walks the same governed lifecycle. You drive it from a fixture -connection's pages — a small left-hand menu inside the app with **Setup**, -**Review**, **Permissions**, **Activity**, **Test**, and **Advanced** +connection's pages — a small left-hand menu inside the app with +**Permissions** and **Review** (plus **Services** for broker connections) (`/{PREFIX}/apps/{connectionId}/{tab}`). Two things to know before you start: @@ -110,23 +109,23 @@ Two things to know before you start: - **Actions are listed by their display title**, with the raw tool name behind them — e.g. `todo.list` renders as **List synthetic todos**. The table below gives both. -- **"Policies" are the per-action dropdowns on the Permissions tab.** Each action - is **Off**, **Allowed**, or **Ask a human first**. When a step below says "with - a require-approval policy in force", that means: set that action's dropdown to - **Ask a human first**. "Block policy" means set it to **Off**. Fresh installs - start conservative, so check the dropdown before running a step. +- **"Policies" are the three-way per-action toggles on the Permissions tab.** + Each action is **Off**, **Ask first**, or **Allowed**. When a step below says + "with a require-approval policy in force", set that action to **Ask first**. + "Block policy" means set it to **Off**. New connections start Allowed; narrow + an action before testing when the scenario requires another decision. | Step | What you do | What you should see | |---|---|---| | **connect** | Open the fixture connection (for P1, complete the fake OAuth consent). | Connection shows **Connected**, with the action count. | | **discover-catalog** | Open **Permissions**. | The action list includes the path's tools (e.g. **List synthetic todos**). | -| **allowed-read** | Set the read action to **Allowed**, then run it from the **Test** tab. | Decision badge **Allowed**; the call returns without error. | -| **ask-first-write** | Set the write action to **Ask a human first**, then run it from **Test**. | Decision **Ask first**; a pending request appears in **Review**. | +| **allowed-read** | Set the read action to **Allowed**, then use its **Test** button on **Permissions**. | Decision badge **Allowed**; the call returns without error. | +| **ask-first-write** | Set the write action to **Ask first**, then use its **Test** button. | Decision **Ask first**; a pending request appears in **Review**. | | **approve** | **Review** tab → approve the pending write. | The request clears; the call completes. | -| **denied-call** | Set the blocked action to **Off**, then run it from **Test**. | Decision **Off**; the call is refused with a reason. | +| **denied-call** | Set the blocked action to **Off**, then use its **Test** button on **Permissions**. | Decision **Off**; the call is refused with a reason. | | **schema-change / quarantine** | Trigger the fixture schema flip (HTTP paths), then **Refresh actions** on Permissions. | A **quarantine** pill with the changed entries held back. | -| **revoke** | **Setup** → turn off the **"Agents can use this app"** toggle (or revoke the gateway session for P6). | The connection is paused; a revoked token is cut off (401). | -| **audit-evidence** | **Activity** tab. | Audit rows for the allowed, approved, denied, quarantine, and revoke decisions. | +| **revoke** | From **Connectors**, choose **Remove connection** from the connection's management menu. In the classic table, use the trash button labeled **Delete _app_ connection**. (For P6, revoke the gateway session instead.) | Agent access is removed immediately; a revoked token is cut off (401). | +| **audit-evidence** | Open company **Audit** and choose **Apps & tools** in the Action filter. | Audit rows for the allowed, approved, denied, quarantine, and revoke decisions. | (The results matrix in §6 folds **approve** into its *Ask-first write* column, so the matrix shows 8 columns for these 9 steps.) @@ -146,45 +145,48 @@ This is the richest path — do it by hand once and the rest are variations. 1. **Connect via the fake OAuth provider.** - From **Apps → Connections** (`/{PREFIX}/apps`), open **Smoke Lab HTTP MCP - fixture** (not the stdio one — see the callout in §2), then choose **Setup**. - - **You should see:** a **Connect with Smoke OAuth** card ("Open the provider's - consent page to finish connecting this app.") with a **Connect with Smoke - OAuth** button. If someone already connected it, the card reads **Connected - with Smoke OAuth** with a **Reconnect** button instead — Reconnect walks the - same flow. + fixture** (not the stdio one — see the callout in §2). If its header says + **Needs attention**, use the **Reconnect** action directly below the header. + - **You should see:** the reconnect card explains that the saved connection + needs authorization and offers **Connect with Smoke OAuth**. If the fixture + is already healthy, no reconnect card is shown. - Click it. The fake provider's **real consent page** opens: a brown banner *"SMOKE TEST - not a real provider"*, headed *"Paperclip Smoke OAuth login + consent"*. - The **email is pre-filled** (`smoke@paperclip.test`). Type the password `smoke-password` and click **Authorize smoke test app**. - **You should see:** the provider accepts the credentials and returns you to - this connection's **Setup** tab with the card now reading **Connected with - Smoke OAuth**. Wrong credentials are rejected with a `403`. + this connection's **Permissions** page with a **Connected** status. Wrong + credentials are rejected with a `403`. 2. **Discover the catalog.** Open **Permissions** and confirm **List synthetic todos** (`todo.list`) and **Add synthetic todo** (`todo.add`) appear under - *Action permissions*. + *Actions*. 3. **Allowed read.** Make sure **List synthetic todos** is set to **Allowed** in - Permissions. Then on the **Test** tab, pick an agent in the **Test as** picker - and run **List synthetic todos**. **You should see:** an **Allowed** badge and - a result with no error. + Permissions. Click its **Test** button, pick an agent in the **Act as** picker, + and run it. **You should see:** an **Allowed** badge and a result with no error. 4. **Ask-first write → approve.** In Permissions, set **Add synthetic todo** to - **Ask a human first**. Run it from the **Test** tab. **You should see:** an + **Ask first**. Click its **Test** button and run it. **You should see:** an **Ask first** badge and a **pending** request. Switch to the **Review** tab (its idle state says "Nothing is waiting for your OK right now") and **approve** it. **You should see:** the request clears and the write completes. 5. **Denied call.** In Permissions, set **Send outbox email** (`email.send`) to - **Off**, then run it from **Test**. **You should see:** an **Off** badge and a - refusal carrying a reason code. + **Off**, then click its **Test** button. **You should see:** an **Off** badge + and a refusal carrying a reason code. 6. **Schema change → quarantine.** Run **Fixture schema mutation** (`fixture.schemaFlip`) — it changes a tool's schema — then click **Refresh actions** on the **Permissions** tab. **You should see:** a **quarantine** pill (on Review and Permissions) — the changed entries are held back until you explicitly turn them on. -7. **Revoke.** On **Setup**, turn off the **"Agents can use this app"** toggle. - **You should see:** the app is paused for every agent. (Turn it back on to - continue.) -8. **Audit evidence.** **Activity** tab. **You should see:** rows for each decision - above (allowed, approved, denied, quarantine, revoke). +7. **Revoke.** Return to **Apps → Connections** and choose **Remove connection** + from the connection's management menu. In the classic table, use the trash + button labeled **Delete _app_ connection**. **You should see:** a confirmation + explaining that saved credentials are deleted and agent access ends + immediately. Reinstall the fixture apps before continuing with another path + that uses this connection. +8. **Audit evidence.** Open company **Audit** and choose **Apps & tools** in the + Action filter. + **You should see:** rows for each decision above (allowed, approved, denied, + quarantine, revoke). > Prefer not to click all seven by hand? Use the automated browser smoke — §7 — > which performs exactly these steps and leaves you screenshots to read, including @@ -204,23 +206,23 @@ tools change. - **P3 — Local stdio MCP template.** Uses the **Smoke Lab stdio MCP fixture** connection and its tools (see the stdio row in §3's table). The read is **Deterministic time** (`time.now`); the "denied" tool **Crashing stdio - fixture** (`crash.now`) is blocked by policy. Its **Setup** tab has no OAuth - card — just the "Agents can use this app" toggle. Quarantine evidence is - recorded via fixture metadata rather than an HTTP schema flip. + fixture** (`crash.now`) is blocked by policy. It does not require OAuth. + Quarantine evidence is recorded via fixture metadata rather than an HTTP + schema flip. - **P4 — Plugin-provided integration.** Exercises the catalog-backed **app install** path a plugin would use, over the stdio fixture. Same stdio tools as P3. - **You should see:** Activity rows record the install + lifecycle decisions. + **You should see:** Audit rows record the install + lifecycle decisions. - **P5 — Paste-a-config / run-your-own import.** Entry via the **Developer** section of Apps; import the HTTP fixture through the advanced configuration surface, then run the same HTTP lifecycle. **You should see:** advanced - Activity rows show the import and the governed calls. + Audit rows show the import and the governed calls. - **P6 — Token broker / gateway session.** Create a **run-scoped gateway session** for the smoke agent, list tools through the session token, then **revoke** the session. **You should see:** the token lists tools before revoke and is **cut - off (401)** after. Entry/evidence via **Activity**. + off (401)** after. Entry/evidence via **Audit**. - **P7 — Governance surfaces.** Entry via **Review**. This path is about the governance surfaces themselves — profiles, ask-first policies, block policies, - and quarantine. **You should see:** Review and Activity expose the ask-first, + and quarantine. **You should see:** Review and Audit expose the ask-first, block, quarantine, and revoke evidence together. --- diff --git a/doc/connections/VERCEL-CONNECT.md b/doc/connections/VERCEL-CONNECT.md index cb0ab1a339..1b35cc45a6 100644 --- a/doc/connections/VERCEL-CONNECT.md +++ b/doc/connections/VERCEL-CONNECT.md @@ -149,7 +149,7 @@ Before enabling another app method, run the real-provider smoke matrix: - create/attach the connector and validate its UID; - discover the MCP catalog; - run an allowed read; -- confirm a write stops at ask-first and runs only after approval; +- set a write to ask-first, then confirm it stops for approval and runs only after approval; - revoke in Vercel and confirm the one retry fails closed; - confirm the grant becomes `needs_reauthorization` and the audit trail contains no bearer, claims, bootstrap authority, or upstream response body; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index e22d2e8f8d..bef9cc3801 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -114,7 +114,7 @@ describe("AppDefinition catalog",()=>{ }); it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"])); it("requests only Hugging Face's MCP read scope",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="hugging-face")?.methods[0]?.defaults?.scopesHint).toEqual(["read-mcp"])); - it("defaults S2-S4 write and destructive actions to ask-first",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods)expect(recommendedDefaultsForApp(app,method.key)).toEqual({access:"all_agents",askFirstRiskLevels:method.riskTier==="S1"?[]:["write","destructive"]})}); + it("defaults every new connection action to allowed",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods)expect(recommendedDefaultsForApp(app,method.key)).toEqual({access:"all_agents",askFirstRiskLevels:[]})}); it("defaults explicit read/write capability groups to their write-capable method",()=>{ const drive=APP_DEFINITIONS.find((app)=>app.slug==="google-drive")!; const gmail=APP_DEFINITIONS.find((app)=>app.slug==="gmail")!; diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 2f3df52718..19d43cd93c 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -238,12 +238,15 @@ export function resolveConnectionMethodServerUrl( } export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record { - const normalizedMethodKey = app.slug === "gmail" && methodKey === "paperclip-id-oauth" ? "paperclip-draft" : methodKey; - const method = normalizedMethodKey - ? app.methods.find((candidate) => candidate.key === normalizedMethodKey) ?? null - : getAvailableConnectionMethod(app, null); + // Keep the parameters in the public contract: callers resolve defaults for a + // concrete app/method even though the initial policy is now uniform. This is + // an open default, not an approval bypass: connection finalization remains a + // configure-authorized, audited operation, and Ask first stays available as + // an operator-selected policy for any action after the connection is made. + void app; + void methodKey; return { access: "all_agents", - askFirstRiskLevels: method && method.riskTier !== "S1" ? ["write", "destructive"] : [], + askFirstRiskLevels: [], }; } diff --git a/scripts/smoke/notion-generic-live.mjs b/scripts/smoke/notion-generic-live.mjs index 0e0927185b..eac8c791b8 100644 --- a/scripts/smoke/notion-generic-live.mjs +++ b/scripts/smoke/notion-generic-live.mjs @@ -264,7 +264,7 @@ async function fetchNotionVerificationCodeFromAgentMail({ notBefore }) { async function completeNotionAuthorization(page, config, credential, connectionId) { const paperclipOrigin = new URL(config.baseUrl).origin; - const setupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`; + const permissionsPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`; const deadline = Date.now() + 6 * 60_000; const verificationNotBefore = new Date(); let providerSeen = false; @@ -277,7 +277,7 @@ async function completeNotionAuthorization(page, config, credential, connectionI fail("C.oauth-callback", "invalid_navigation_url"); } if (current.origin === paperclipOrigin) { - if (providerSeen && current.pathname === setupPath) return; + if (providerSeen && current.pathname === permissionsPath) return; await page.waitForTimeout(300); continue; } @@ -778,8 +778,8 @@ async function runSmoke({ config, chromium }) { activeCheckpoint = "C.notion-login"; await completeNotionAuthorization(page, config, credential, connectionId); activeCheckpoint = "C.oauth-callback"; - const cleanSetupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`; - await page.goto(new URL(cleanSetupPath, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); + const permissionsPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`; + await page.goto(new URL(permissionsPath, config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); await expectVisible(page.getByText("OAuth connected", { exact: true }), "C.oauth-callback", "connected_state_missing", 45_000); await expectVisible(page.getByText("Unverified server", { exact: true }), "C.oauth-callback", "unverified_badge_missing"); diff --git a/scripts/smoke/posthog-live.mjs b/scripts/smoke/posthog-live.mjs index 21885d0f15..406722ff23 100644 --- a/scripts/smoke/posthog-live.mjs +++ b/scripts/smoke/posthog-live.mjs @@ -674,10 +674,10 @@ async function runSmoke({ config, chromium }) { activeCheckpoint = "B.oauth-callback"; await completePosthogAuthorization(page, config); - const cleanSetupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`; + const permissionsPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`; await gotoPaperclipPage( page, - new URL(cleanSetupPath, config.baseUrl).toString(), + new URL(permissionsPath, config.baseUrl).toString(), page.getByText("PostHog connected", { exact: true }), "B.oauth-callback", "connected_state_missing", diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 0da6aad5ec..d16e564be0 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -1552,7 +1552,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { await expect(db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state))).resolves.toHaveLength(0); }); - it("returns browser denials to setup without reflecting provider-authored details", async () => { + it("returns browser denials to Permissions without reflecting provider-authored details", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", PUBLIC_BASE_URL); installMcpOAuthFixture({ auth: "oauth" }); const company = await createCompany(db); @@ -1580,7 +1580,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { expect(res.status).toBe(303); const location = new URL(res.headers.location, PUBLIC_BASE_URL); - expect(location.pathname).toBe(`/${company.issuePrefix}/apps/${connected.connectionId}/setup`); + expect(location.pathname).toBe(`/${company.issuePrefix}/apps/${connected.connectionId}/permissions`); expect(location.searchParams.get("oauth")).toBe("denied"); expect(location.searchParams.get("code")).toBe("oauth_authorization_denied"); expect(res.headers.location).not.toContain(PROVIDER_CANARY); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 3eda01db8d..fc29b79dea 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5284,7 +5284,7 @@ describeEmbeddedPostgres("tool access service", () => { } }); - it("activates allowed Drive write actions with recommended approval defaults after a managed callback", async () => { + it("activates allowed Drive write actions without approval defaults after a managed callback", async () => { const company = await createCompany(db); const userId = `drive-write-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); @@ -5348,14 +5348,10 @@ describeEmbeddedPostgres("tool access service", () => { ); const searchEntry = callback.body.catalog.find((entry: { toolName: string }) => entry.toolName === "search_files"); const createEntry = callback.body.catalog.find((entry: { toolName: string }) => entry.toolName === "create_file"); - const [approvalPolicy] = await db.select().from(toolPolicies).where(and( + await expect(db.select().from(toolPolicies).where(and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), - )); - expect(approvalPolicy).toMatchObject({ - policyType: "require_approval", - selectors: expect.objectContaining({ catalogEntryId: createEntry.id }), - }); + ))).resolves.toEqual([]); await expect(db.select().from(toolConnectionInstalls).where(and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "company"), @@ -5374,7 +5370,6 @@ describeEmbeddedPostgres("tool access service", () => { targetId: agent.id, }); await db.update(toolProfiles).set({ status: "archived" }).where(eq(toolProfiles.id, profileRow!.id)); - await db.update(toolPolicies).set({ enabled: false }).where(eq(toolPolicies.id, approvalPolicy!.id)); mockToolsList([ { name: "search_files", description: "Search files with a changed contract.", annotations: { readOnlyHint: true } }, @@ -5414,14 +5409,14 @@ describeEmbeddedPostgres("tool access service", () => { ))).resolves.toEqual([ expect.objectContaining({ targetType: "agent", targetId: agent.id }), ]); - await expect(db.select().from(toolPolicies).where(eq(toolPolicies.id, approvalPolicy!.id))) - .resolves.toEqual([expect.objectContaining({ enabled: false })]); + await expect(db.select().from(toolPolicies).where(eq(toolPolicies.companyId, company.id))) + .resolves.toEqual([]); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } }); - it("keeps a managed draft retryable when recommended-default finalization fails", async () => { + it("keeps a managed draft retryable when default finalization fails", async () => { const company = await createCompany(db); const userId = `drive-finalize-failure-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); @@ -5525,16 +5520,10 @@ describeEmbeddedPostgres("tool access service", () => { ))).resolves.toEqual([ expect.objectContaining({ targetType: "company", targetId: company.id }), ]); - const createEntry = completed.catalog.find((entry) => entry.toolName === "create_file")!; await expect(db.select().from(toolPolicies).where(and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), - ))).resolves.toEqual([ - expect.objectContaining({ - policyType: "require_approval", - selectors: expect.objectContaining({ catalogEntryId: createEntry.id }), - }), - ]); + ))).resolves.toEqual([]); await expect(db.select().from(toolConnectionInstalls).where(and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "company"), @@ -5659,16 +5648,10 @@ describeEmbeddedPostgres("tool access service", () => { ))).resolves.toEqual([ expect.objectContaining({ targetType: "company", targetId: company.id }), ]); - const createEntry = completed.catalog.find((entry) => entry.toolName === "create_file")!; await expect(db.select().from(toolPolicies).where(and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), - ))).resolves.toEqual([ - expect.objectContaining({ - policyType: "require_approval", - selectors: expect.objectContaining({ catalogEntryId: createEntry.id }), - }), - ]); + ))).resolves.toEqual([]); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } @@ -6176,12 +6159,7 @@ describeEmbeddedPostgres("tool access service", () => { await expect(db.select().from(toolPolicies).where(and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), - ))).resolves.toEqual([ - expect.objectContaining({ - policyType: "require_approval", - selectors: expect.objectContaining({ catalogEntryId: sendMessageEntry.id }), - }), - ]); + ))).resolves.toEqual([]); const callbackPolicy = toolAccessPolicyService(db); const decide = (entry: (typeof completed.catalog)[number]) => callbackPolicy.decide({ companyId: company.id, @@ -6198,8 +6176,8 @@ describeEmbeddedPostgres("tool access service", () => { reasonCode: "allow_profile", }); await expect(decide(sendMessageEntry)).resolves.toMatchObject({ - decision: "require_approval", - reasonCode: "requires_approval_policy", + decision: "allow", + reasonCode: "allow_profile", }); const [personalGrant] = await db.select().from(connectionGrants).where(and( eq(connectionGrants.connectionId, connected.connectionId), @@ -6445,7 +6423,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(versions.filter((version) => version.status === "current")).toHaveLength(2); }); - it("returns a pre-scoped personal Notion callback directly to Test", async () => { + it("returns a pre-scoped personal Notion callback directly to Permissions", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", ""); @@ -6517,7 +6495,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(callbackRes.status).toBe(303); expect(callbackRes.headers.location).toBe( - `/${company.issuePrefix}/apps/${connectRes.body.connectionId}/test?success=1`, + `/${company.issuePrefix}/apps/${connectRes.body.connectionId}/permissions?success=1`, ); const [activeConnection] = await db.select().from(toolConnections).where(eq( toolConnections.id, @@ -6701,7 +6679,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(redirectCallbackRes.status).toBe(303); expect(redirectCallbackRes.headers.location).toBe( - `/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/test?success=1`, + `/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/permissions?success=1`, ); expect(fetchMock).toHaveBeenCalledTimes(6); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0); @@ -7265,15 +7243,7 @@ describeEmbeddedPostgres("tool access service", () => { expect.objectContaining({ toolName: "list_tables", riskLevel: "read" }), ]); await expect(db.select().from(toolPolicies).where(eq(toolPolicies.companyId, company.id))) - .resolves.toEqual([ - expect.objectContaining({ - policyType: "require_approval", - enabled: true, - selectors: expect.objectContaining({ - catalogEntryId: completed.actions.canMakeChanges[0]!.catalogEntryId, - }), - }), - ]); + .resolves.toEqual([]); const [connection] = await db.select().from(toolConnections).where(eq( toolConnections.id, connected.connectionId, @@ -8257,8 +8227,8 @@ describeEmbeddedPostgres("tool access service", () => { status: 502, details: expect.objectContaining({ code: "oauth_refresh_missing", - setupUrl: `/apps/${connect.connectionId}/setup`, - reconnectUrl: `/apps/${connect.connectionId}/advanced`, + setupUrl: `/apps/${connect.connectionId}/permissions`, + reconnectUrl: `/apps/${connect.connectionId}/permissions`, connection: expect.objectContaining({ healthStatus: "failed" }), }), }); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index ea16fe6d3e..8c0edba231 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -429,7 +429,6 @@ export function toolAccessRoutes( async function oauthAppPath( companyId: string, connectionId: string, - tab: "setup" | "test", ) { const [company] = await db .select({ issuePrefix: companies.issuePrefix }) @@ -437,8 +436,8 @@ export function toolAccessRoutes( .where(eq(companies.id, companyId)) .limit(1); if (!company) throw new Error("OAuth callback connection belongs to a missing company"); - return `/${company.issuePrefix}/apps/${connectionId}/${tab}`; -} + return `/${company.issuePrefix}/apps/${connectionId}/permissions`; + } function connectorEnrollmentPrincipal(req: Request): string { return req.actor.userId ? `user:${req.actor.userId}` : `source:${req.actor.source ?? "board"}`; @@ -455,17 +454,17 @@ function connectorEnrollmentPrincipal(req: Request): string { outcome: "failed" | "denied", code?: string | null, ) { - const detailSetupPath = await oauthAppPath(connection.companyId, connection.id, "setup"); + const detailPermissionsPath = await oauthAppPath(connection.companyId, connection.id); const params = new URLSearchParams({ oauth: outcome }); if (code) params.set("code", code); const source = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey; if (connection.status !== "draft" || typeof source !== "string" || !source.trim()) { - return `${detailSetupPath}?${params.toString()}`; + return `${detailPermissionsPath}?${params.toString()}`; } - const appsSegment = detailSetupPath.indexOf("/apps/"); - const companyPrefix = appsSegment >= 0 ? detailSetupPath.slice(0, appsSegment) : ""; + const appsSegment = detailPermissionsPath.indexOf("/apps/"); + const companyPrefix = appsSegment >= 0 ? detailPermissionsPath.slice(0, appsSegment) : ""; const setupParams = new URLSearchParams({ source, resume: connection.id, @@ -1107,8 +1106,8 @@ function connectorEnrollmentPrincipal(req: Request): string { return; } if (acceptsHtml) { - const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test"); - res.redirect(303, `${testPath}?success=1`); + const permissionsPath = await oauthAppPath(result.connection.companyId, result.connection.id); + res.redirect(303, `${permissionsPath}?success=1`); return; } res.json(result); @@ -1198,8 +1197,8 @@ function connectorEnrollmentPrincipal(req: Request): string { return; } if (acceptsHtml) { - const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test"); - res.redirect(303, `${testPath}?success=1`); + const permissionsPath = await oauthAppPath(result.connection.companyId, result.connection.id); + res.redirect(303, `${permissionsPath}?success=1`); return; } res.json(result); @@ -1360,8 +1359,8 @@ function connectorEnrollmentPrincipal(req: Request): string { return; } if (acceptsHtml) { - const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test"); - res.redirect(303, `${testPath}?success=1`); + const permissionsPath = await oauthAppPath(result.connection.companyId, result.connection.id); + res.redirect(303, `${permissionsPath}?success=1`); return; } res.json(result); @@ -2139,7 +2138,20 @@ function connectorEnrollmentPrincipal(req: Request): string { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; await assertToolConnectionConfigureAccess(req, existing); - res.json(await svc.refreshCatalog(existing.id, getActorInfo(req))); + const result = await svc.refreshCatalog(existing.id, getActorInfo(req)); + await logActivity(db, { + companyId: existing.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "tool_connection.catalog_refresh", + entityType: "tool_connection", + entityId: existing.id, + details: { + discoveredCount: result.discoveredCount, + quarantinedCount: result.quarantinedCount, + }, + }); + res.json(result); }); router.get("/tool-connections/:connectionId/catalog", async (req, res) => { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index eed7097dae..31bb5785cf 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2820,7 +2820,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} key: `connection:${input.connection.uid}:delegation:${input.ownerUserId}:${input.agentId}`, revisionId: input.connection.updatedAt.toISOString(), label: `Delegate ${input.connection.name}`, - href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup#personal-identity`, + href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/permissions#personal-identity`, }, }; const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and( @@ -6275,11 +6275,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } function connectionSetupUrl(connection: typeof toolConnections.$inferSelect) { - return `/apps/${connection.id}/setup`; + return `/apps/${connection.id}/permissions`; } function connectionReconnectUrl(connection: typeof toolConnections.$inferSelect) { - return `/apps/${connection.id}/advanced`; + return `/apps/${connection.id}/permissions`; } function credentialScope(connection: typeof toolConnections.$inferSelect, actor?: ActorInfo) { @@ -8594,7 +8594,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connectionMethodKey: method?.key, methodConfig: normalizedMethodConfig?.values ?? {}, // Grant-backed setup keeps the full discovered catalog selectable; - // the wizard projects the app's ask-first defaults into policies at + // the wizard projects the app's action defaults into policies at // finish time instead of using catalog quarantine as access state. quarantineNewEntries: false, ...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}), diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 92ce8e3465..3b2d4b1a64 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -3026,7 +3026,7 @@ export function createToolGatewayService( if (!session.issueId || !session.agentId || !session.runId) return; const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies) .where(eq(companies.id, session.companyId)).limit(1); - const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/setup`; + const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/permissions`; const idempotencyKey = `connection-authorization:${connection.id}:${userId}`; const payload = { version: 1 as const, @@ -3097,7 +3097,7 @@ export function createToolGatewayService( if (!session.issueId || !session.agentId || !session.runId) return; const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies) .where(eq(companies.id, session.companyId)).limit(1); - const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/setup`; + const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/permissions`; const idempotencyKey = `connection-delegation:${connection.id}:${userId}:${session.agentId}`; const payload = { version: 1 as const, diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts index 71a93dbf8d..e8ceef241b 100644 --- a/tests/e2e/app-not-connected.spec.ts +++ b/tests/e2e/app-not-connected.spec.ts @@ -2,10 +2,8 @@ import { expect, test, type APIRequestContext } from "@playwright/test"; import { createServer, type Server } from "node:http"; import { listenOnFetchAllowedPort } from "./fetch-allowed-port"; -// Apps navigation wave 6 — not-connected apps get a real app page. -// A row with no live connection must open /apps/app/:applicationId/setup (previous -// setup + advanced danger zone + reconnect prefill), not the generic connect wizard, -// and reconnecting must revive the same application/connection, not duplicate. +// Not-connected apps keep their identity on the Permissions page. Reconnecting +// must revive the same application/connection, not duplicate it. const SCREENSHOT_DIR = "test-results"; @@ -109,10 +107,10 @@ test.describe.serial("not-connected app page", () => { await expect(connectButton).toBeVisible(); await connectButton.click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/permissions$`), { timeout: 20_000 }); await expect(page.getByRole("heading", { name: "Bla" })).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Previous setup" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Reconnect this app" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Needs attention" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-01-app-not-connected.png`, fullPage: true }); }); @@ -148,7 +146,7 @@ test.describe.serial("not-connected app page", () => { expect(appConns[0].status).not.toBe("archived"); }); - test("archived app connection returns to provider setup", async ({ page, request }) => { + test("archived app connection returns to Permissions with reconnect", async ({ page, request }) => { const archive = await request.delete(`/api/tool-connections/${connectionId}`); expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true); const revive = await request.patch(`/api/tool-applications/${applicationId}`, { data: { status: "active" } }); @@ -156,11 +154,12 @@ test.describe.serial("not-connected app page", () => { await page.goto(`/${seed.prefix}/apps/app/${applicationId}`); await expect(page).toHaveURL( - new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`), + new RegExp(`/${seed.prefix}/apps/app/${applicationId}/permissions$`), { timeout: 20_000 }, ); await expect(page.getByText("Not connected", { exact: true })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Needs attention" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible(); await page.goto(`/${seed.prefix}/apps/connections`); const row = page @@ -172,34 +171,4 @@ test.describe.serial("not-connected app page", () => { await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true }); }); - test("danger zone on the app page removes the app", async ({ page, request }) => { - // Build a second not-connected app to remove from its app page. - const second = await request.post(`/api/companies/${seed.companyId}/tools/apps/connect`, { - data: { - link: mock.url.replace("127.0.0.1", "localhost"), - name: "Doomed app", - credentialValues: { "credentials.authorization": "qa-token" }, - }, - }); - expect(second.ok(), `second connect failed ${second.status()}: ${await second.text()}`).toBe(true); - const secondBody = await second.json(); - await request.delete(`/api/tool-connections/${secondBody.connectionId}`); - await request.patch(`/api/tool-applications/${secondBody.application.id}`, { data: { status: "active" } }); - - 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(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); - await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await expect( - page - .getByRole("list", { name: "Connector list" }) - .getByRole("listitem") - .filter({ has: page.getByRole("heading", { name: "Doomed app", exact: true }) }), - ).toHaveCount(0); - }); }); diff --git a/tests/e2e/application-delete-screenshot.spec.ts b/tests/e2e/application-delete-screenshot.spec.ts index 960373877a..966037fc77 100644 --- a/tests/e2e/application-delete-screenshot.spec.ts +++ b/tests/e2e/application-delete-screenshot.spec.ts @@ -1,8 +1,7 @@ import { expect, test } from "@playwright/test"; -// One-off visual capture for PAP-10817. The retired Tools -> Applications -// table now redirects into Apps, so capture the current app removal -// confirmation on the app Advanced tab instead. +// One-off visual capture for PAP-10817. Connection removal now lives on the +// Connectors page, rather than behind a per-connection setup surface. test("captures the current app removal confirmations", async ({ page }) => { const companyRes = await page.request.post("/api/companies", { data: { name: `PAP-10817 remove app ${Date.now()}` }, @@ -12,19 +11,6 @@ test("captures the current app removal confirmations", async ({ page }) => { const companyId: string = company.id; const prefix: string = company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E"; - const created = await page.request.post(`/api/companies/${companyId}/tools/applications`, { - data: { name: "Demo Notes", description: "Sample MCP application", type: "mcp_http" }, - }); - expect(created.ok(), `create failed ${created.status()}: ${await created.text()}`).toBe(true); - const application = await created.json(); - - await page.goto(`/${prefix}/apps/app/${application.id}/advanced`); - await expect(page.getByRole("heading", { name: "Demo Notes" })).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.png", fullPage: true }); - const conn = await page.request.post(`/api/companies/${companyId}/tools/connections`, { data: { applicationName: "Guarded MCP", @@ -34,13 +20,13 @@ test("captures the current app removal confirmations", async ({ page }) => { }, }); expect(conn.ok(), `connection create failed ${conn.status()}: ${await conn.text()}`).toBe(true); - const connection = await conn.json(); + await conn.json(); - 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.goto(`/${prefix}/apps`); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 15_000 }); + await page.getByRole("button", { name: "Manage Primary connection connection" }).click(); + await page.getByRole("menuitem", { name: "Remove connection" }).click(); + await expect(page.getByRole("button", { name: "Remove connection" })).toBeVisible(); await page.screenshot({ path: "test-results/pap-10817-delete-dialog-guarded.png", fullPage: true }); await page.request.delete(`/api/companies/${companyId}`).catch(() => undefined); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index 9e9a64f6af..a244964986 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type APIRequestContext, type Page } from "@playwright/tes // Current Apps lifecycle coverage. The legacy Tools -> Applications CRUD table // was retired; old links now redirect to /apps. Keep this harness focused on -// the user-visible Connections list plus app detail setup/advanced flows. +// the user-visible Connections list plus app Permissions flows. type SeedResult = { companyId: string; @@ -86,7 +86,7 @@ test.describe.serial("applications lifecycle", () => { // background health sweep then probes the connection endpoint. The test // endpoint is an unreachable loopback URL, so the probe fails and the pill // becomes "Needs attention" and adds a "Reconnect" action. Both are - // connected states that navigate to the same provider setup page. This test + // connected states that navigate to the same Permissions page. This test // proves the connected-vs-not-connected split, not the transient health // label, so accept either connected state instead of the racy exact label. // The pill is derived from two react-query fetches (applications + @@ -97,7 +97,7 @@ test.describe.serial("applications lifecycle", () => { .filter({ has: page.getByRole("heading", { name: connectedName, exact: true }) }); await expect(connectedRow).toBeVisible(); await expect(connectedRow.getByText(/^(Connected|Needs attention)$/)).toBeVisible({ timeout: 30_000 }); - const openConnection = connectedRow.getByRole("button", { name: /^Open .* connection settings$/ }); + const openConnection = connectedRow.getByRole("button", { name: /^Open .* permissions$/ }); await expect(openConnection).toBeVisible(); // The not-connected app has no connection, so the health sweep never touches @@ -112,19 +112,19 @@ test.describe.serial("applications lifecycle", () => { await openConnection.click(); await expect(page).toHaveURL( - new RegExp(`/${seed.prefix}/apps/${connected.id}/setup$`), + new RegExp(`/${seed.prefix}/apps/${connected.id}/permissions$`), { timeout: 20_000 }, ); await gotoApps(page, seed.prefix); await notConnectedRow.getByRole("button", { name: `Connect ${notConnectedName}` }).click(); await expect(page).toHaveURL( - new RegExp(`/${seed.prefix}/apps/app/${notConnected.id}/setup$`), + new RegExp(`/${seed.prefix}/apps/app/${notConnected.id}/permissions$`), { timeout: 20_000 }, ); }); - test("connected app detail supports pause, rename, and removal", async ({ page, request }) => { + test("connected app detail supports rename on Permissions", async ({ page, request }) => { const appName = `${APP_PREFIX}-detail-app`; const renamed = `${APP_PREFIX}-renamed-app`; const connection = await createConnection(request, seed.companyId, { @@ -132,59 +132,13 @@ test.describe.serial("applications lifecycle", () => { name: appName, }); - await page.goto(`/${seed.prefix}/apps/${connection.id}/setup`); + await page.goto(`/${seed.prefix}/apps/${connection.id}/permissions`); await expect(page.getByRole("heading", { name: appName })).toBeVisible({ timeout: 30_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); await page.getByRole("button", { name: "Save", exact: true }).click(); 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.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 }); - await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); - await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await expect( - page - .getByRole("list", { name: "Connector list" }) - .getByRole("listitem") - .filter({ has: page.getByRole("heading", { name: renamed, exact: true }) }), - ).toHaveCount(0); - }); - - test("not-connected app advanced page removes the application", async ({ page, request }) => { - const cleanAppName = `${APP_PREFIX}-clean-remove-app`; - const cleanApp = await createApplication(request, seed.companyId, { name: cleanAppName }); - - await page.goto(`/${seed.prefix}/apps/app/${cleanApp.id}/advanced`); - await expect(page.getByRole("heading", { name: cleanAppName })).toBeVisible({ timeout: 30_000 }); - 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(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); - await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await expect( - page - .getByRole("list", { name: "Connector list" }) - .getByRole("listitem") - .filter({ has: page.getByRole("heading", { name: cleanAppName, exact: true }) }), - ).toHaveCount(0); }); }); diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index 4090a0a2a7..99544e4a62 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -168,22 +168,24 @@ test.describe.serial("dark-mode Apps surfaces", () => { await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-05-developer-overview-dark.png`, fullPage: true }); }); - test("app detail rename and danger zone removal", async ({ page }) => { + test("app detail rename and connector-list removal", async ({ page }) => { await forceDark(page); - await page.goto(`/${seed.prefix}/apps/${brokenId}/advanced`); - await expect(page.getByText("Danger zone")).toBeVisible({ timeout: 30_000 }); + await page.goto(`/${seed.prefix}/apps/${brokenId}/permissions`); + await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 30_000 }); // Rename from the header pencil. await page.getByRole("button", { name: "Rename app" }).click(); 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.goto(`/${seed.prefix}/apps`); + await page.getByRole("button", { name: "Manage QA Renamed App connection" }).click(); + await page.getByRole("menuitem", { name: "Remove connection" }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-06-danger-zone-dark.png`, fullPage: true }); - await page.getByRole("button", { name: "Yes, remove it" }).click(); + await page.getByRole("button", { name: "Remove connection" }).click(); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); - await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("Connection removed").first()).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-07-after-remove-dark.png`, fullPage: true }); }); diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts index c2d180e9c9..c17cf1916c 100644 --- a/tests/e2e/connection-intents.spec.ts +++ b/tests/e2e/connection-intents.spec.ts @@ -250,11 +250,12 @@ test("store setup and task connection intent share one fake provider through con }), ); - await page.goto(`/${seed.prefix}/apps/${connectionId}/test`); + await page.goto(`/${seed.prefix}/apps/${connectionId}/permissions`); + const actionRow = page.locator("[data-action-id]").filter({ hasText: "List fixture pages" }); + await actionRow.getByRole("button", { name: "Test", exact: true }).click(); await expect( - page.getByRole("heading", { name: "Test an action" }), + page.getByRole("heading", { name: "Test List fixture pages" }), ).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, diff --git a/tests/e2e/mcp-user-stories.spec.ts b/tests/e2e/mcp-user-stories.spec.ts index 60e6ee565d..93acd041bb 100644 --- a/tests/e2e/mcp-user-stories.spec.ts +++ b/tests/e2e/mcp-user-stories.spec.ts @@ -303,7 +303,7 @@ test.describe.serial("MCP prod Phase 5a user-story harness", () => { await page.goto(`/${seed.prefix}/apps/${connectionId}`); await expect(page.getByRole("heading", { name: /Sheets Fixture us1/i })).toBeVisible({ timeout: 30_000 }); await screenshot(page, "US-1", "01-connected-app"); - await page.goto(`/${seed.prefix}/apps/${connectionId}/activity`); + await page.goto(`/${seed.prefix}/activity?action=tool_`); await screenshot(page, "US-1", "02-activity"); } finally { await mock.close(); diff --git a/tests/e2e/smoke-lab-browser-runner.mts b/tests/e2e/smoke-lab-browser-runner.mts index 9ffe4cb5ea..94838edef3 100644 --- a/tests/e2e/smoke-lab-browser-runner.mts +++ b/tests/e2e/smoke-lab-browser-runner.mts @@ -147,7 +147,7 @@ async function main() { const p = scenario.uiEntryPath; if (p === "advanced") await page.goto(`${BASE}/${prefix}/apps/advanced`, { waitUntil: "networkidle" }); else if (p === "review") await page.goto(`${BASE}/${prefix}/apps/${connId}/review`, { waitUntil: "networkidle" }); - else if (p === "activity") await page.goto(`${BASE}/${prefix}/apps/${connId}/activity`, { waitUntil: "networkidle" }); + else if (p === "activity") await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" }); else if (p === "attention") await page.goto(`${BASE}/${prefix}/apps/attention`, { waitUntil: "networkidle" }); else await page.goto(`${BASE}/${prefix}/apps/${connId}`, { waitUntil: "networkidle" }); }; @@ -216,7 +216,7 @@ async function main() { assert(read.decision === "allowed", `allowed-read decision=${read.decision}`); assert(!read.error, "allowed-read no error"); await auditHit(conn.id, scenario.lifecycle.allowedRead.name); - await page.goto(`${BASE}/${prefix}/apps/${conn.id}/activity`, { waitUntil: "networkidle" }); + await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" }); return `Allowed read ${scenario.lifecycle.allowedRead.name}`; }); @@ -264,7 +264,7 @@ async function main() { // schema-change-quarantine await doStep(scenario, "schema-change-quarantine", async () => { if (conn.transport !== "mcp_remote") { - await page.goto(`${BASE}/${prefix}/apps/${conn.id}/activity`, { waitUntil: "networkidle" }); + await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" }); return "Non-HTTP path records governance/quarantine evidence through fixture metadata."; } await api("PATCH", `/api/tool-connections/${conn.id}`, { config: { ...(conn.config ?? {}), quarantineNewEntries: true } }); @@ -292,7 +292,7 @@ async function main() { await api("POST", `/api/tool-gateway/sessions/${session.sessionId}/revoke`, { companyId }); const after = await fetch(`${BASE}${new URL(session.toolsUrl, BASE).pathname}`, { headers: { "x-paperclip-tool-gateway-token": session.token } }); assert(after.status === 401, `revoked token cut off (got ${after.status})`); - await page.goto(`${BASE}/${prefix}/apps/${conn.id}/activity`, { waitUntil: "networkidle" }); + await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" }); return scenario.lifecycle.revoke; } const disabled = await api("PATCH", `/api/tool-connections/${conn.id}`, { enabled: false }); @@ -305,7 +305,7 @@ async function main() { // audit-evidence await doStep(scenario, "audit-evidence", async () => { await auditHit(conn.id, scenario.lifecycle.allowedRead.name); - await page.goto(`${BASE}/${prefix}/apps/${conn.id}/activity`, { waitUntil: "networkidle" }); + await page.goto(`${BASE}/${prefix}/activity?action=tool_`, { waitUntil: "networkidle" }); return scenario.lifecycle.auditEvidence; }); } diff --git a/tests/e2e/smoke-lab.shared.ts b/tests/e2e/smoke-lab.shared.ts index 4e91bbee60..489e5ff1e8 100644 --- a/tests/e2e/smoke-lab.shared.ts +++ b/tests/e2e/smoke-lab.shared.ts @@ -159,8 +159,9 @@ async function navigateForEvidence(page: Page, seed: Seed, connectionId: string, return; } if (scenario.uiEntryPath === "activity") { - await page.goto(`/${seed.prefix}/apps/${connectionId}/activity`); - await expect(page.getByRole("heading", { name: "Recent activity" })).toBeVisible({ timeout: 20_000 }); + await page.goto(`/${seed.prefix}/activity?action=tool_`); + await expect(page.locator("#main-content").getByRole("heading", { name: "Audit" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("combobox").filter({ hasText: "Apps & tools" })).toBeVisible(); return; } if (scenario.uiEntryPath === "attention") { @@ -347,7 +348,7 @@ export const defineSmokeLabSuite = (label: string, scenarios: SmokeLabScenario[] agentId: scout.id, search: scenario.lifecycle.allowedRead.name, }); - await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`); + await page.goto(`/${seed.prefix}/activity?action=tool_`); return `Allowed read ${scenario.lifecycle.allowedRead.name}`; }); @@ -383,7 +384,7 @@ export const defineSmokeLabSuite = (label: string, scenarios: SmokeLabScenario[] await runRecordedStep(page, request, seed, smokeRun.id, scenario, "schema-change-quarantine", async () => { if (connection.transport !== "mcp_remote") { - await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`); + await page.goto(`/${seed.prefix}/activity?action=tool_`); return "Non-HTTP path records governance/quarantine evidence through fixture metadata."; } await json(await request.patch(`/api/tool-connections/${connection.id}`, { @@ -415,7 +416,7 @@ export const defineSmokeLabSuite = (label: string, scenarios: SmokeLabScenario[] data: { companyId: seed.companyId }, })); await expectError(await gatewayFetch(request, session.toolsUrl, session.token), 401); - await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`); + await page.goto(`/${seed.prefix}/activity?action=tool_`); return scenario.lifecycle.revoke; } const disabled = await json(await request.patch(`/api/tool-connections/${connection.id}`, { @@ -435,7 +436,7 @@ export const defineSmokeLabSuite = (label: string, scenarios: SmokeLabScenario[] agentId: scout.id, search: scenario.lifecycle.allowedRead.name, }); - await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`); + await page.goto(`/${seed.prefix}/activity?action=tool_`); return scenario.lifecycle.auditEvidence; }); } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d047e0d3c4..e5ab3a3062 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -216,7 +216,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) { } /> } /> } /> - } /> + } /> } /> } /> }> diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx index 12bac9e761..5cbc65cdf8 100644 --- a/ui/src/components/AppConnectionSidebar.test.tsx +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -186,25 +186,25 @@ describe("AppConnectionSidebar", () => { await flushReact(); } - it("renders a back link and the connected app tabs with Test after Setup", async () => { + it("renders the consolidated connected app tabs", async () => { await renderSidebar(); expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); expect(container.textContent).toContain("GitHub"); - expect(container.querySelectorAll("[data-to]").length).toBe(5); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true })); + expect(container.querySelectorAll("[data-to]").length).toBe(2); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 3, badgeTone: "danger" })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/test", label: "Test", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/activity", label: "Activity", end: true })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Test" })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Activity" })); expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Setup" })); }); it("marks the current tab active through the nav item target", async () => { await renderSidebar(); expect(container.querySelector('[data-to="/apps/conn-1/permissions"]')?.getAttribute("data-active")).toBe("true"); - expect(container.querySelector('[data-to="/apps/conn-1/setup"]')?.getAttribute("data-active")).toBe("false"); + expect(container.querySelector('[data-to="/apps/conn-1/setup"]')).toBeNull(); }); it("uses the application key for a customized connection display name", async () => { @@ -272,15 +272,15 @@ describe("AppConnectionSidebar", () => { expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); expect(container.textContent).toContain("GitHub"); expect(mockToolsApi.getConnection).not.toHaveBeenCalled(); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/review", label: "Review", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/permissions", label: "Permissions", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/activity", label: "Activity", end: true })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Activity" })); expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Setup" })); expect(container.querySelector('[data-to="/apps/app/app-1/review"]')?.getAttribute("data-active")).toBe("true"); - // The Test tab needs a live connection, so it is hidden in application mode. + // Testing is part of Permissions and Activity lives in the company Audit feed. expect(container.querySelector('[data-to="/apps/app/app-1/test"]')).toBeNull(); - expect(container.querySelectorAll("[data-to]").length).toBe(4); + expect(container.querySelectorAll("[data-to]").length).toBe(2); }); it("keeps rendering a connection sidebar when its connection is unavailable", async () => { @@ -292,7 +292,7 @@ describe("AppConnectionSidebar", () => { expect(container.textContent).toContain("App"); expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); - expect(container.querySelectorAll("[data-to]").length).toBe(5); + expect(container.querySelectorAll("[data-to]").length).toBe(2); }); it("keeps rendering an application sidebar when its application is unavailable", async () => { @@ -305,6 +305,6 @@ describe("AppConnectionSidebar", () => { expect(container.textContent).toContain("App"); expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); - expect(container.querySelectorAll("[data-to]").length).toBe(4); + expect(container.querySelectorAll("[data-to]").length).toBe(2); }); }); diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 98b1c12994..eca956c894 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -211,7 +211,7 @@ const STEP_INDEX: Record, number> = { access: 1, key: 2, }; -const ZAPIER_STEP_INDEX: Record, number> = { +const SELECTED_APP_STEP_INDEX: Record, number> = { access: 0, key: 1, }; @@ -441,7 +441,9 @@ export function ConnectionSetupFlow({ }); const [step, setStep] = useState( - requestedAppKey ? "key" : prefill.link || zapierSource ? "access" : "gallery", + requestedAppKey + ? resumeConnectionId ? "key" : "access" + : prefill.link || zapierSource ? "access" : "gallery", ); const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); @@ -1633,11 +1635,13 @@ export function ConnectionSetupFlow({ const stepLabels = zapierSource ? ZAPIER_STEP_LABELS : entry && credentialSourceMethods.length > 1 - ? ["Pick app", "Access", "Choose connection"] + ? ["Access", "Choose connection"] : entry && credentialSourceMethods[0]?.auth === "oauth" - ? ["Pick app", "Access", "Sign in"] + ? ["Access", "Sign in"] : isGoogleSheetsRobotMethod(entry, connectionMethodKey) - ? ["Pick app", "Access", "Share sheet"] + ? ["Access", "Share sheet"] + : entry + ? ["Access", "Add your key"] : STEP_LABELS; // The Access step's identity question only makes sense when there *is* a // credential, so it reads the selected method's auth kind. @@ -1662,8 +1666,8 @@ export function ConnectionSetupFlow({ ? `Continue to ${entry?.name ?? "sign-in"}` : "Save and continue"; - const stepIndex = zapierSource && step !== "gallery" && step !== "success" - ? ZAPIER_STEP_INDEX[step] + const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success" + ? SELECTED_APP_STEP_INDEX[step] : step === "success" ? stepLabels.length : STEP_INDEX[step]; diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index f1dc710c3c..387ab35def 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -98,6 +98,23 @@ const ACTIVITY_ROW_VERBS: Record = { "company.reactivated": "reactivated", "company.budget_updated": "updated budget for", "audit.exported": "exported the agent audit log for", + "tool_app.connected": "connected", + "tool_app.oauth_connected": "connected credentials for", + "tool_app.oauth_failed": "failed to connect credentials for", + "tool_app.oauth_access_finalized": "finished credential access for", + "tool_app.finished": "finished setup for", + "tool_app.reconnected": "reconnected", + "tool_connection.created": "created", + "tool_connection.updated": "updated", + "tool_connection.archived": "removed", + "tool_connection.catalog_refresh": "refreshed actions for", + "tool_connection.installs_synced": "changed agent installs for", + "tool_connection.install_access_extended": "extended agent access for", + "tool_connection.grant_audience_replaced": "changed human access for", + "tool_connection.grant_added": "added credentials to", + "tool_connection.grant_revoked": "revoked credentials from", + "tool_connection.grant_delegated": "delegated credentials for", + "tool_connection.grant_delegation_revoked": "revoked credential delegation for", }; const ISSUE_ACTIVITY_LABELS: Record = { @@ -414,6 +431,23 @@ export function formatActivityVerb( details?: Record | null, options: ActivityFormatOptions = {}, ): string { + if (action.startsWith("tool_gateway.")) { + const rawTool = typeof details?.tool === "string" + ? details.tool + : typeof details?.upstreamToolName === "string" + ? details.upstreamToolName + : "an app action"; + const tool = rawTool.replace(/[._-]+/g, " "); + const isTest = details?.source === "test"; + if (action === "tool_gateway.call_completed") return `${isTest ? "tested" : "used"} ${tool} on`; + if (action === "tool_gateway.call_allowed") return `${isTest ? "started a test of" : "was allowed to use"} ${tool} on`; + if (action === "tool_gateway.call_denied") return `was blocked from using ${tool} on`; + if (action === "tool_gateway.approval_requested") return `asked to use ${tool} on`; + if (action === "tool_gateway.session_created") return "opened an app session for"; + if (action === "tool_gateway.session_rejected") return "was blocked from opening an app session for"; + if (action === "tool_gateway.discovery") return "discovered app actions for"; + } + if (action === "issue.updated") { const issueUpdatedVerb = formatIssueUpdatedVerb(details); if (issueUpdatedVerb) return issueUpdatedVerb; diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 7d73333df5..5c44bd4fdc 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -35,7 +35,7 @@ const startPersonalAuthorizationMock = vi.hoisted(() => vi.fn()); const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const getSessionMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); -const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "setup" as string | undefined })); +const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "permissions" as string | undefined })); const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() })); const navigateComponentMock = vi.hoisted(() => vi.fn()); const navigateTopLevelMock = vi.hoisted(() => vi.fn()); @@ -296,7 +296,7 @@ describe("AppDetail", () => { container = document.createElement("div"); document.body.appendChild(container); mockParams.connectionId = "conn-1"; - mockParams.tab = "setup"; + mockParams.tab = "permissions"; mockSearchParams.value = new URLSearchParams(); getConnectionMock.mockResolvedValue(connection()); getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); @@ -418,44 +418,14 @@ describe("AppDetail", () => { await flushReact(); } - it("places Test immediately below Setup", () => { + it("uses Permissions as the primary connection page and has no Setup tab", () => { expect(APP_TABS.map((tab) => tab.key)).toEqual([ - "setup", - "test", - // Services (PAP-17865) sits below Test rather than above it, so the - // Setup→Test adjacency this test exists to protect still holds. - "services", "permissions", + "services", "review", - "activity", ]); }); - 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( - '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 })); - }); - await flushReact(); - - expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", { enabled: false }); - }); - it("allows the gallery logo fallback after application identity lookup fails", async () => { listApplicationsMock.mockRejectedValueOnce(new Error("Application lookup unavailable")); @@ -499,27 +469,26 @@ describe("AppDetail", () => { expect(container.textContent).toContain("127.0.0.1:8848"); }); - it("redirects a missing tab to setup", async () => { + it("redirects a missing tab to Permissions", async () => { mockParams.tab = undefined; await renderAppDetail(); - expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/setup", replace: true }); + expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true }); }); it.each([ - ["setup", "Danger zone", false], - ["review", "Review 1 new action", true], - ["permissions", "Agent access", true], - ["activity", "No activity yet.", false], - ])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => { + ["review", "Review 1 new action"], + ["permissions", "Which agents can use this connection?"], + ])("renders the %s tab panel", async (tab, expectedText) => { mockParams.tab = tab; await renderAppDetail(); expect(container.textContent).toContain("GitHub"); - expect(container.textContent?.includes("2 actions available")).toBe(showsActionCount); + expect(container.textContent).toContain("2 actions available"); expect(container.textContent).toContain(expectedText); + expect(container.textContent).not.toContain("Setup"); expect(container.querySelector("section.bg-card")).toBeNull(); }); @@ -540,44 +509,17 @@ describe("AppDetail", () => { expect(container.textContent).not.toContain("2 actions available"); }); - it("redirects the legacy Advanced route to Setup", async () => { - mockParams.tab = "advanced"; + it.each(["setup", "advanced"])("redirects the retired %s route to Permissions", async (tab) => { + mockParams.tab = tab; await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ - to: "/apps/conn-1/setup", + to: "/apps/conn-1/permissions", replace: true, }); }); - it("renders setup while its permission summary loads", async () => { - listCatalogMock.mockImplementation(() => new Promise(() => undefined)); - - await renderAppDetail(); - - expect(container.textContent).toContain("Danger zone"); - expect(container.textContent).toContain("Loading permissions…"); - expect(container.textContent).not.toContain("Loading tools"); - 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 () => { mockParams.tab = "permissions"; listCatalogMock.mockImplementation(() => new Promise(() => undefined)); @@ -589,31 +531,29 @@ describe("AppDetail", () => { expect(container.textContent).not.toContain("Action permissions"); }); - it("explains that MCP actions can take a minute while Test loads", async () => { + it("redirects the retired Test tab into Permissions", async () => { mockParams.tab = "test"; - listCatalogMock.mockImplementation(() => new Promise(() => undefined)); await renderAppDetail(); - expect(container.textContent).toContain("Loading MCP actions, this may take a minute."); - expect(container.querySelector(".animate-spin")).toBeTruthy(); + expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true }); }); - it("confirms a successful connection on Test and clears the one-time URL flag", async () => { - mockParams.tab = "test"; + it("confirms a successful connection on Permissions and clears the one-time URL flag", async () => { + mockParams.tab = "permissions"; mockSearchParams.value = new URLSearchParams("success=1"); await renderAppDetail(); expect(pushToastMock).toHaveBeenCalledWith({ title: "GitHub connected", - body: "The connection is ready. You can test an action below.", + body: "The connection is ready. Review permissions or test an action below.", tone: "success", }); - expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/test", { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions", { replace: true }); }); - it("normalizes the retired post-OAuth identity choice back to fixed Setup", async () => { + it("normalizes the retired post-OAuth Setup URL into Permissions", async () => { mockParams.tab = "setup"; mockSearchParams.value = new URLSearchParams("oauth=choose-access"); getConnectionMock.mockResolvedValue(connection({ @@ -625,34 +565,11 @@ describe("AppDetail", () => { 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( - connection({ - config: { - url: "https://mcp.zapier.com/api/v1/connect?token=zapier-secret®ion=us", - }, - }), - ); - - await renderAppDetail(); - await act(async () => { - Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Connection details")) - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/apps/conn-1/permissions?oauth=choose-access", + replace: true, }); - await flushReact(); - - expect(container.textContent).toContain( - "https://mcp.zapier.com/api/v1/connect?token=REDACTED®ion=us", - ); - expect(container.textContent).not.toContain("zapier-secret"); + expect(finalizeOAuthAccessMock).not.toHaveBeenCalled(); }); it("reviews quarantined actions as one toggle list and saves allowed and blocked choices together", async () => { @@ -723,43 +640,6 @@ describe("AppDetail", () => { expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block"); }); - it("keeps setup focused with secondary details folded away", async () => { - mockParams.tab = "setup"; - - await renderAppDetail(); - - 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(); - }); - it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => { getConnectionMock.mockResolvedValue(connection({ name: "Smoke Lab HTTP MCP fixture", @@ -777,7 +657,7 @@ describe("AppDetail", () => { // The old generic "Connect with " 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("Which humans can use this credential?"); expect(container.textContent).toContain("Anyone in your company can use this connection"); expect(container.textContent).toContain("Organization identity"); expect(container.textContent).toContain("Not connected"); @@ -788,7 +668,7 @@ describe("AppDetail", () => { ).toBe(true); }); - it("matches connected Notion guidance to the reconnect action", async () => { + it("keeps reconnect off a healthy Notion permissions page", async () => { getConnectionMock.mockResolvedValue(connection({ name: "Notion", createdByUserId: "user-1", @@ -841,77 +721,10 @@ describe("AppDetail", () => { 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", - ), - ).toBe(true); + expect(findButton("Danger zone")).toBeUndefined(); }); - it("lets Google Sheets connections add spreadsheet links from setup", async () => { - mockParams.tab = "setup"; - getConnectionMock.mockResolvedValue(connection({ - name: "Google Sheets", - transport: "local_stdio", - config: { - templateId: "paperclip.google-sheets", - sourceTemplateKey: "google-sheets", - allowedSpreadsheetIds: ["sheet_existing"], - env: { GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "sheet_existing" }, - }, - })); - listGalleryMock.mockResolvedValue({ - apps: [ - { - key: "google-sheets", - name: "Google Sheets", - logoUrl: "https://example.com/sheets.png", - tagline: "Read and update selected spreadsheets.", - description: "Share each sheet with the robot email, then paste the sheet links here.", - authKind: "none", - transportTemplate: { transport: "local_stdio", templateKey: "paperclip.google-sheets" }, - credentialFields: [], - recommendedDefaults: {}, - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, - ], - }); - - await renderAppDetail(); - - expect(container.textContent).toContain("Sheets agents can use"); - expect(container.textContent).toContain("https://docs.google.com/spreadsheets/d/sheet_existing/edit"); - expect(container.textContent).toContain("sheet_existing"); - const input = container.querySelector( - 'input[placeholder="https://docs.google.com/spreadsheets/d/..."]', - ); - expect(input).toBeTruthy(); - await act(async () => setInputValue(input!, "https://docs.google.com/spreadsheets/d/sheet_new/edit")); - await flushReact(); - await act(async () => { - Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.trim() === "Add sheet") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", { - config: expect.objectContaining({ - allowedSpreadsheetIds: ["sheet_existing", "sheet_new"], - env: expect.objectContaining({ GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "sheet_existing,sheet_new" }), - }), - transportConfig: { url: "https://github.example/mcp" }, - }); - }); - - it("renders unified action permission dropdowns in the permissions tab", async () => { + it("renders searchable action groups with three-way permission toggles", async () => { mockParams.tab = "permissions"; await renderAppDetail(); @@ -921,23 +734,76 @@ describe("AppDetail", () => { expect(container.textContent).toContain("Read repo"); expect(container.textContent).toContain("Write issue"); expect(container.textContent).toContain("Review 1 new action"); - const readSelect = container.querySelector('select[aria-label="Read repo permission"]'); - const writeSelect = container.querySelector('select[aria-label="Write issue permission"]'); - expect(readSelect?.value).toBe("allowed"); - expect(writeSelect?.value).toBe("ask"); + expect(container.querySelector('input[aria-label="Find an action"]')).toBeTruthy(); + expect(container.querySelector('button[aria-label="Read repo: Allowed"]')?.getAttribute("aria-checked")).toBe("true"); + expect(container.querySelector('button[aria-label="Write issue: Ask first"]')?.getAttribute("aria-checked")).toBe("true"); + expect(Array.from(container.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "Test")).toHaveLength(2); + expect(container.textContent).not.toContain("Views data without changing it."); + expect(container.textContent).not.toContain("Creates or changes data."); expect(container.querySelector("section.bg-card")).toBeNull(); }); - it("persists ask-first for read-only actions from the unified dropdown", async () => { + it("opens an action test modal with agent selection, inputs, and result-ready chrome", async () => { + mockParams.tab = "permissions"; + listTestAgentsMock.mockResolvedValue({ + agents: [{ + id: "agent-1", + name: "Coder", + role: "engineer", + title: "Engineer", + status: "active", + orgDepth: 1, + }], + }); + getTestAgentAccessMock.mockResolvedValue({ + access: { + connectionId: "conn-1", + toolCount: 2, + allowedCount: 1, + askFirstCount: 1, + offCount: 0, + lastChangedAt: null, + lastChangedByAgentId: null, + lastChangedByName: null, + tools: [ + { + toolName: "read_repo", + gatewayToolName: "github__read_repo", + displayName: "Read repo", + risk: "read", + decision: "allowed", + reasonCode: null, + matchedPolicyIds: [], + }, + ], + }, + }); + + await renderAppDetail(); + const testButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Test"); + await act(async () => { + testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Test Read repo"); + expect(dialog?.textContent).toContain("Act as"); + expect(dialog?.textContent).toContain("Coder"); + expect(dialog?.textContent).toContain("This action takes no inputs."); + expect(dialog?.textContent).toContain("Run"); + }); + + it("persists ask-first for read-only actions from the three-way toggle", async () => { mockParams.tab = "permissions"; await renderAppDetail(); - const readSelect = container.querySelector('select[aria-label="Read repo permission"]'); - expect(readSelect).toBeTruthy(); + const askFirst = container.querySelector('button[aria-label="Read repo: Ask first"]'); + expect(askFirst).toBeTruthy(); await act(async () => { - readSelect!.value = "ask"; - readSelect!.dispatchEvent(new Event("change", { bubbles: true })); + askFirst!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); @@ -953,11 +819,10 @@ describe("AppDetail", () => { await renderAppDetail(); - const writeSelect = container.querySelector('select[aria-label="Write issue permission"]'); - expect(writeSelect).toBeTruthy(); + const off = container.querySelector('button[aria-label="Write issue: Off"]'); + expect(off).toBeTruthy(); await act(async () => { - writeSelect!.value = "off"; - writeSelect!.dispatchEvent(new Event("change", { bubbles: true })); + off!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); @@ -968,29 +833,14 @@ describe("AppDetail", () => { }); }); - it("persists installed agents from the permissions tab", async () => { + it("removes the separate always-installed controls from Permissions", async () => { mockParams.tab = "permissions"; await renderAppDetail(); - expect(container.textContent).toContain("Agent access"); - await act(async () => { - Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Choose agents")) - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - const coderCheckbox = document.body.querySelector('[aria-label="Allow Coder"]'); - expect(coderCheckbox).toBeTruthy(); - await act(async () => { - coderCheckbox!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [ - { targetType: "agent", targetId: "agent-1" }, - ]); + expect(container.textContent).toContain("Which agents can use this connection?"); + expect(container.textContent).not.toContain("Always installed"); + expect(putConnectionInstallsMock).not.toHaveBeenCalled(); }); it("persists agent access independently from always-installed agents", async () => { @@ -1046,7 +896,7 @@ describe("AppDetail", () => { 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")); + .find((radio) => radio.textContent?.includes("Just agents I pick")); await act(async () => { pickedAgents?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -1059,7 +909,7 @@ describe("AppDetail", () => { }); }); - it("separates agent access from agents that always install the app", async () => { + it("uses the setup-style agent access question without install terminology", async () => { mockParams.tab = "permissions"; listProfilesMock.mockResolvedValue({ profiles: [{ @@ -1074,22 +924,11 @@ describe("AppDetail", () => { 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).toContain("Which agents can use this connection?"); + expect(container.textContent).toContain("Just agents I pick"); + expect(container.textContent).toContain("Any agent"); + expect(container.textContent).not.toContain("Always installed"); expect(container.querySelector('button[aria-label="Remove Coder access"]')).toBeNull(); - expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(2); expect( Array.from(container.querySelectorAll("button")).filter( (button) => button.textContent?.trim() === "Change", @@ -1121,12 +960,13 @@ describe("AppDetail", () => { await renderAppDetail(); // State is still legible. - expect(container.textContent).toContain("Agent access"); + expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.textContent).toContain("Actions"); expect(container.textContent).toContain("Read repo"); // Nothing to mutate: no radios, no permission selects, no refresh, no save. - expect(container.querySelector('[role="radiogroup"]')).toBeNull(); + expect(container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]')).toBeNull(); + expect(container.querySelector('[role="radiogroup"][aria-label="Read repo permission"]')).toBeNull(); expect(container.querySelectorAll("select").length).toBe(0); const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim()); for (const forbidden of ["Change", "Save", "Refresh actions", "Choose agents"]) { @@ -1134,7 +974,7 @@ describe("AppDetail", () => { } }); - it("renders activity attribution with issue context and human resolver names", async () => { + it("redirects legacy connection activity to the filtered company Audit feed", async () => { mockParams.tab = "activity"; listConnectionActivityMock.mockResolvedValue({ events: [ @@ -1174,14 +1014,13 @@ describe("AppDetail", () => { await renderAppDetail(); - expect(container.textContent).toContain("Coder used Get value"); - expect(container.textContent).toContain("while working on PAP-10912"); - expect(container.textContent).toContain("Dotta approved Mark done"); - expect(container.querySelector('a[href="/issues/PAP-10912"]')).toBeTruthy(); - expect(container.textContent).not.toContain("You reviewed"); + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/activity?action=tool_", + replace: true, + }); }); - it("humanizes the raw gateway-prefixed tool name in activity", async () => { + it("removes the per-connection activity surface", async () => { mockParams.tab = "activity"; listConnectionActivityMock.mockResolvedValue({ events: [ @@ -1202,11 +1041,14 @@ describe("AppDetail", () => { await renderAppDetail(); - expect(container.textContent).toContain("Coder used Kv Set"); - expect(container.textContent).not.toContain("mcp.app-gallery-link"); + expect(container.textContent).toBe(""); + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/activity?action=tool_", + replace: true, + }); }); - it("renders connection lifecycle events humanized on the timeline", async () => { + it("redirects lifecycle history to the same Audit destination", async () => { mockParams.tab = "activity"; listConnectionActivityMock.mockResolvedValue({ events: [ @@ -1273,22 +1115,10 @@ describe("AppDetail", () => { await renderAppDetail(); - expect(container.textContent).toContain("Dotta connected GitHub"); - expect(container.textContent).toContain("Dotta paused this app"); - expect(container.textContent).toContain("Dotta added 1 sheet to the allowlist"); - expect(container.textContent).toContain("2 new actions need review"); - // Lifecycle rows deep-link to the Setup tab; quarantine uses the review label. - expect(container.querySelector('a[href="/apps/conn-1/setup"]')).toBeTruthy(); - expect(container.textContent).toContain("Review in Setup"); - - // Merged timeline: the newest event (the pause at 11:00) renders before the - // tool call at 10:30, which renders before the connect at 09:00. - const pausedAt = container.textContent?.indexOf("Dotta paused this app") ?? -1; - const usedAt = container.textContent?.indexOf("Coder used Get value") ?? -1; - const connectedAt = container.textContent?.indexOf("Dotta connected GitHub") ?? -1; - expect(pausedAt).toBeGreaterThanOrEqual(0); - expect(pausedAt).toBeLessThan(usedAt); - expect(usedAt).toBeLessThan(connectedAt); + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/activity?action=tool_", + replace: true, + }); }); it("keeps the header and reconnect banner across tabs", async () => { @@ -1304,7 +1134,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("Agent access"); + expect(container.textContent).toContain("Which agents can use this connection?"); }); it("shows terminal OAuth failures as reconnect-required sign-in", async () => { @@ -1380,7 +1210,7 @@ describe("AppDetail", () => { }); it("does not offer personal key replacement to someone other than its fixed owner", async () => { - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ authKind: "api_key", credentialPolicy: "per_user", @@ -1397,12 +1227,8 @@ describe("AppDetail", () => { }); 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("This app needs reconnecting"); 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", @@ -1477,14 +1303,14 @@ describe("AppDetail", () => { }); it("lets a regular member connect their own identity and never someone else's", async () => { - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection()); startPersonalAuthorizationMock.mockResolvedValue({ url: "https://accounts.example.test/authorize" }); await renderAppDetail(); // Missing personal identity is explicit, never a silent fallback. - expect(container.textContent).toContain("Account"); + expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Only you can use this connection"); expect(container.textContent).toContain("Personal account"); expect(container.textContent).toContain("Not connected"); @@ -1500,7 +1326,7 @@ describe("AppDetail", () => { // consent on a coworker's behalf. expect(startPersonalAuthorizationMock).toHaveBeenCalledWith("company-1", "conn-1", { subjectUserId: "user-1", - returnTo: "/apps/conn-1/setup", + returnTo: "/apps/conn-1/permissions", }); expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize"); }); @@ -1510,7 +1336,7 @@ describe("AppDetail", () => { const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ authorizationUrl: "https://provider.example.test/authorize?state=personal", })); - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection()); startPersonalAuthorizationMock.mockResolvedValue({ url: "https://my.paperclip.app/connections/confirm?session=legacy", @@ -1535,30 +1361,8 @@ describe("AppDetail", () => { expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm")); }); - 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: [{ id: "delegation-1", agentId: "agent-1" }] })], - capabilities: fullCapabilities(), - currentUserId: "user-1", - members: [{ userId: "user-1", name: "Dotta", email: "dotta@example.com" }], - }); - - await renderAppDetail(); - - expect(findButton("Every agent")).toBeTruthy(); - expect(findButton("No agents")).toBeUndefined(); - await act(async () => { - findButton("Every agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions"); - }); - it("keeps a viewer read-only across identities and installs", async () => { - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "shared" })); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, @@ -1591,7 +1395,7 @@ describe("AppDetail", () => { }); it("shows one fixed personal identity without an organization switch", async () => { - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection({ createdByUserId: "user-2" })); revokeConnectionGrantMock.mockResolvedValue({ id: "grant-other", kind: "user" }); listConnectionGrantsMock.mockResolvedValue({ @@ -1617,36 +1421,12 @@ describe("AppDetail", () => { 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 () => { - findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - await act(async () => { - findButton("Revoke")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - // Revoke is a confirmation, not a one-click action, and it never offers to - // reconnect on the other person's behalf. - const dialogText = document.body.textContent ?? ""; - expect(dialogText).toContain("Revoke this"); - expect(dialogText).toContain("They can connect again themselves"); - - await act(async () => { - Array.from(document.body.querySelectorAll("button")) - .find((button) => button.textContent?.trim() === "Revoke identity") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - expect(revokeConnectionGrantMock).toHaveBeenCalledWith("conn-1", "grant-other"); + expect(findButton("Reconnect")).toBeUndefined(); + expect(findButton("Revoke")).toBeUndefined(); }); it("persists an empty audience as all organization members", async () => { - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" })); replaceConnectionGrantMembersMock.mockResolvedValue(organizationGrant({ members: [] })); listConnectionGrantsMock.mockResolvedValue({ @@ -1690,7 +1470,7 @@ describe("AppDetail", () => { }); it("persists a selected audience and keeps the dialog open when the server refuses", async () => { - mockParams.tab = "setup"; + mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" })); replaceConnectionGrantMembersMock.mockRejectedValue( new Error("Every audience member must be an active company member"), diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 5408a01d8e..20e457ece8 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -20,9 +20,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; import { agentsApi } from "@/api/agents"; import { accessApi } from "@/api/access"; -import { authApi } from "@/api/auth"; -import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "@/lib/company-members"; -import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs"; +import { buildCompanyUserProfileMap } from "@/lib/company-members"; +import { installStateFrom, type InstallState } from "@/lib/tool-installs"; import { navigateTopLevel } from "@/lib/browserNavigation"; import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { Button } from "@/components/ui/button"; @@ -41,22 +40,13 @@ import { type AppGalleryDisplayEntry, } from "./app-definition-display"; import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs"; -import { SetupPanel } from "./app-detail/SetupPanel"; import { ServicesPanel } from "./app-detail/ServicesPanel"; import { ConnectionProvenanceChip } from "./ComposioProvenanceChip"; import { IdentitiesSection } from "./app-detail/IdentitiesSection"; 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 { - AdvancedPanel, ReconnectCard, - DangerZone, connectionAddress, connectionTransportLabel, } from "./app-detail/AdvancedPanel"; @@ -66,7 +56,7 @@ import { connectionOwnerProfile, } from "./connection-owner"; -export { DangerZone, connectionAddress, connectionTransportLabel }; +export { connectionAddress, connectionTransportLabel }; export function AppDetail() { const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>(); @@ -78,18 +68,13 @@ export function AppDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null; - const needsCatalog = activeTab === "setup" || activeTab === "review" || activeTab === "permissions" || activeTab === "test"; + const needsCatalog = activeTab === "review" || activeTab === "permissions"; const connectionQuery = useQuery({ queryKey: queryKeys.tools.connection(connectionId), queryFn: () => toolsApi.getConnection(connectionId), enabled: !!connectionId && !!activeTab, }); - const connectionsQuery = useQuery({ - queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), - queryFn: () => toolsApi.listConnections(selectedCompanyId!), - enabled: !!selectedCompanyId && activeTab === "setup", - }); const applicationsQuery = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listApplications(selectedCompanyId!), @@ -113,43 +98,26 @@ export function AppDetail() { const profilesQuery = useQuery({ queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listProfiles(selectedCompanyId!), - enabled: !!selectedCompanyId && ( - activeTab === "setup" || activeTab === "review" || activeTab === "permissions" - ), + enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"), }); const policiesQuery = useQuery({ queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listPolicies(selectedCompanyId!), - enabled: !!selectedCompanyId && ( - activeTab === "setup" || activeTab === "review" || activeTab === "permissions" - ), + enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"), }); const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"), queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && ( - activeTab === "setup" || activeTab === "permissions" || activeTab === "activity" - ), + enabled: !!selectedCompanyId && activeTab === "permissions", }); - const activityQuery = useQuery({ - queryKey: queryKeys.tools.connectionActivity(connectionId), - queryFn: () => toolsApi.listConnectionActivity(connectionId, 20), - enabled: !!connectionId && activeTab === "activity", - }); - // Resolve who ran Test-tab calls (" tested as ") in the Activity feed (PAP-11415). const userDirectoryQuery = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId && !!activeTab, }); - const sessionQuery = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - enabled: activeTab === "activity", - }); // 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. + // 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), @@ -198,11 +166,6 @@ export function AppDetail() { && 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" - && candidate.config?.parentConnectionId === connectionId, - ).length; const logoEntry = useMemo( () => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection, application), [galleryQuery.data, connection, application], @@ -223,17 +186,9 @@ export function AppDetail() { : "App"; const successNoticeShownFor = useRef(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" + activeTab !== "permissions" || searchParams.get("success") !== "1" || !connection || successNoticeShownFor.current === connection.id @@ -241,17 +196,17 @@ export function AppDetail() { successNoticeShownFor.current = connection.id; pushToast({ title: `${appName} connected`, - body: "The connection is ready. You can test an action below.", + body: "The connection is ready. Review permissions or test an action below.", tone: "success", }); - navigate(appTabHref(connection.id, "test"), { replace: true }); + navigate(appTabHref(connection.id, "permissions"), { replace: true }); }, [activeTab, appName, connection, navigate, pushToast, searchParams]); useEffect(() => { if (!activeTab) return; setBreadcrumbs([ { label: "Connectors", href: "/apps" }, - { label: appName, href: appTabHref(connectionId, "setup") }, + { label: appName, href: appTabHref(connectionId, "permissions") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); @@ -273,15 +228,6 @@ export function AppDetail() { ); const access = useMemo(() => accessFrom(profile, install), [profile, install]); const agents = agentsQuery.data ?? []; - const userLabelById = useMemo(() => { - const labels = buildCompanyUserLabelMap(userDirectoryQuery.data?.users); - const session = sessionQuery.data; - // Prefer the viewer's own profile name for their own test runs ("Dotta", not a fallback). - if (session?.user?.id && session.user.name?.trim()) { - labels.set(session.user.id, session.user.name.trim()); - } - return labels; - }, [userDirectoryQuery.data, sessionQuery.data]); const [pending, setPending] = useState(false); const persist = useMutation({ mutationFn: (next: { @@ -315,25 +261,6 @@ export function AppDetail() { onSettled: () => setPending(false), }); - const persistInstall = useMutation({ - mutationFn: (next: InstallState) => - toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, next)), - onSuccess: (snapshot) => { - queryClient.setQueryData(queryKeys.tools.connectionInstalls(connectionId), snapshot); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); - }, - onError: (error) => - pushToast({ - title: "Couldn't save installs", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }), - }); - const [renaming, setRenaming] = useState(false); const [nameDraft, setNameDraft] = useState(""); const rename = useMutation({ @@ -352,24 +279,6 @@ export function AppDetail() { }), }); - const updateConfig = useMutation({ - mutationFn: (config: Record) => toolsApi.updateConnection(connectionId, { - config, - transportConfig: connection?.transportConfig ?? {}, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); - }, - onError: (error) => - pushToast({ - title: "Couldn't save that", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }), - }); - const startOAuth = useMutation({ mutationFn: () => toolsApi.startOAuth(connectionId), onSuccess: async (start) => { @@ -411,7 +320,7 @@ export function AppDetail() { if (!subjectUserId) throw new Error("Sign in again to connect your own account."); return toolsApi.startPersonalAuthorization(selectedCompanyId!, connectionId, { subjectUserId, - returnTo: appTabHref(connectionId, "setup"), + returnTo: appTabHref(connectionId, "permissions"), }); }, onSuccess: async ({ url, handoff }) => { @@ -437,26 +346,6 @@ export function AppDetail() { }), }); - const revokeGrant = useMutation({ - mutationFn: (grantId: string) => toolsApi.revokeConnectionGrant(connectionId, grantId), - onSuccess: (grant) => { - invalidateGrants(); - pushToast({ - title: grant.kind === "user" ? "Identity revoked" : "Organization identity revoked", - body: grant.kind === "user" - ? "Agents will stop acting as this person." - : "Installed agents no longer have the shared identity.", - tone: "success", - }); - }, - onError: (error) => - pushToast({ - title: "Couldn't revoke that identity", - 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(null); @@ -480,52 +369,6 @@ export function AppDetail() { setAudienceError(error instanceof Error ? error.message : "We couldn't save that audience."), }); - const removeApp = useMutation({ - mutationFn: () => toolsApi.archiveConnection(connectionId, { - confirmComposioChildren: composioChildConnectionCount > 0, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); - pushToast({ - title: "App removed", - body: `${appName} no longer has access and its credentials are deleted. Connecting it again needs a new sign-in or key.`, - tone: "success", - }); - navigate("/apps"); - }, - onError: (error) => - pushToast({ - title: "Couldn't remove the app", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }), - }); - - const toggleEnabled = useMutation({ - mutationFn: () => toolsApi.updateConnection(connectionId, { enabled: !connection?.enabled }), - onSuccess: (updated) => { - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); - pushToast({ - title: updated.enabled ? "App resumed" : "App paused", - body: updated.enabled - ? `${humanizeConnectionDisplayName(updated)} is available to agents again.` - : `${humanizeConnectionDisplayName(updated)} is paused for agents.`, - tone: "success", - }); - }, - onError: (error) => - pushToast({ - title: "Couldn't update the app", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }), - }); - const refreshTools = useMutation({ mutationFn: () => toolsApi.refreshCatalog(connectionId), onSuccess: (result) => { @@ -570,8 +413,18 @@ export function AppDetail() { apply({ enabled: nextEnabled, reviewed: quarantinedIds }); }; + // Keep old bookmarks and OAuth return URLs working after Setup and Test were + // consolidated into Permissions, and Activity moved to the company feed. + if (connectionId && (tab === "setup" || tab === "test")) { + const query = searchParams.toString(); + return ; + } + if (tab === "activity") { + return ; + } + if (!connectionId || !activeTab) { - return ; + return ; } if (!selectedCompanyId) { @@ -604,19 +457,6 @@ export function AppDetail() { 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; @@ -631,7 +471,7 @@ export function AppDetail() { brandKey={brandKey} allowRemoteLogo={!applicationsQuery.isPending} status={status} - actionCount={activeTab === "setup" ? null : actionCount} + actionCount={actionCount} renaming={renaming} nameDraft={nameDraft} renamePending={rename.isPending} @@ -661,78 +501,6 @@ export function AppDetail() { /> )} - {activeTab === "setup" && ( -
- updateConfig.mutate(config)} - agentsSummary={setupAgentsSummary} - permissionsSummary={setupPermissionsSummary} - permissionsLoading={setupPermissionsLoading} - onOpenPermissions={() => navigate(appTabHref(connectionId, "permissions"))} - identities={ - { - 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 })} - /> - } - /> - 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) }); - }} - /> -
- )} {activeTab === "services" && ( )} @@ -763,46 +531,52 @@ export function AppDetail() { }} /> : permissionsLoading ? - : apply({ access: accessIncludingInstalls(next, install) })} - onSaveInstall={(next) => persistInstall.mutate(next)} - onRefreshActions={() => refreshTools.mutate()} - onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))} - onReviewQuarantined={reviewQuarantined} - /> - )} - {activeTab === "test" && ( - catalogQuery.isError - ? { void catalogQuery.refetch(); }} /> - : catalogQuery.isLoading - ? - : - )} - {activeTab === "activity" && ( - + :
+ { + setAudienceError(null); + setAudienceOpenGrantId(grantId); + }} + onCloseAudience={() => { + setAudienceOpenGrantId(null); + setAudienceError(null); + }} + onConnectAsMe={() => startPersonalAuth.mutate()} + onConnectOrganization={() => startOAuth.mutate()} + onReplaceAudience={(grant, memberUserIds) => + replaceAudience.mutate({ grantId: grant.id, memberUserIds })} + /> + apply({ access: accessIncludingInstalls(next, install) })} + onRefreshActions={() => refreshTools.mutate()} + onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))} + onReviewQuarantined={reviewQuarantined} + /> +
)} ); diff --git a/ui/src/pages/apps/AppNotConnected.test.tsx b/ui/src/pages/apps/AppNotConnected.test.tsx index 90957a8b88..a4953ac633 100644 --- a/ui/src/pages/apps/AppNotConnected.test.tsx +++ b/ui/src/pages/apps/AppNotConnected.test.tsx @@ -17,7 +17,7 @@ const updateApplicationMock = vi.hoisted(() => vi.fn()); const mockAgentsList = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const navigateComponentMock = vi.hoisted(() => vi.fn()); -const mockParams = vi.hoisted(() => ({ applicationId: "app-1", tab: "setup" as string | undefined })); +const mockParams = vi.hoisted(() => ({ applicationId: "app-1", tab: "permissions" as string | undefined })); vi.mock("@/api/tools", () => ({ toolsApi: { @@ -153,7 +153,7 @@ describe("AppNotConnected", () => { container = document.createElement("div"); document.body.appendChild(container); mockParams.applicationId = "app-1"; - mockParams.tab = "setup"; + mockParams.tab = "permissions"; listApplicationsMock.mockResolvedValue({ applications: [application()] }); listConnectionsMock.mockResolvedValue({ connections: [connection()] }); listGalleryMock.mockResolvedValue({ @@ -201,12 +201,12 @@ describe("AppNotConnected", () => { await flushReact(); } - it("redirects the application root route to setup", async () => { + it("redirects the application root route to Permissions", async () => { mockParams.tab = undefined; await renderPage(); - expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/app/app-1/setup", replace: true }); + expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/app/app-1/permissions", replace: true }); expect(listApplicationsMock).not.toHaveBeenCalled(); }); @@ -221,7 +221,7 @@ describe("AppNotConnected", () => { expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-live/permissions", replace: true }); }); - it("shows all existing provider connections before connecting another", async () => { + it("redirects a provider application to its live connection Permissions page", async () => { listApplicationsMock.mockResolvedValue({ applications: [ application({ @@ -265,31 +265,10 @@ describe("AppNotConnected", () => { await renderPage(); - expect(navigateComponentMock).not.toHaveBeenCalled(); - expect(container.textContent).toContain("2 connected"); - expect(container.textContent).toContain("Already connected to Notion"); - expect(container.textContent).toContain("Dotta’s Notion"); - expect(container.textContent).toContain("Notion team"); - expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy(); - expect(container.textContent).toContain("Connect another"); - - const editRows = Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.includes("Edit"), - ); - await act(async () => { - editRows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/apps/conn-one/permissions", + replace: true, }); - expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-two/setup"); - - const connectAnother = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Connect another", - ); - await act(async () => { - connectAnother?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(mockNavigate).toHaveBeenCalledWith( - "/apps/connect?applicationId=app-1&name=Notion&new=1&source=notion", - ); }); it("does not group unrelated generic link applications", async () => { @@ -319,16 +298,13 @@ describe("AppNotConnected", () => { await renderPage(); expect(container.textContent).toContain("Not connected"); - expect(container.textContent).toContain("Reconnect this app"); - expect(container.textContent).not.toContain("Already connected to First server"); + expect(container.textContent).toContain("Needs attention"); + expect(container.textContent).toContain("Reconnect"); }); it.each([ - ["setup", "Reconnect this app"], ["review", "Nothing is waiting for your OK right now."], ["permissions", "Permissions paused"], - ["test", "Reconnect to test this app."], - ["activity", "No activity yet."], ])("renders the %s tab with persistent app identity", async (tab, expectedText) => { mockParams.tab = tab; @@ -339,26 +315,27 @@ describe("AppNotConnected", () => { expect(container.textContent).toContain(expectedText); }); - it("redirects the legacy Advanced route to Setup", async () => { - mockParams.tab = "advanced"; - + it.each([ + ["setup", "/apps/app/app-1/permissions"], + ["test", "/apps/app/app-1/permissions"], + ["advanced", "/apps/app/app-1/permissions"], + ["activity", "/activity?action=tool_"], + ])("redirects the retired %s tab", async (tab, to) => { + mockParams.tab = tab; await renderPage(); - - expect(navigateComponentMock).toHaveBeenCalledWith({ - to: "/apps/app/app-1/setup", - replace: true, - }); + expect(navigateComponentMock).toHaveBeenCalledWith({ to, replace: true }); }); - it("keeps previous setup context on reconnect tabs", async () => { - mockParams.tab = "setup"; + it.each(["permissions", "review"])("shows reconnect directly below the header on %s", async (tab) => { + mockParams.tab = tab; await renderPage(); - expect(container.textContent).toContain("Previous setup"); - expect(container.textContent).toContain("Last error: Token expired."); - expect(container.textContent).toContain("https://github.example/mcp"); - expect(container.textContent).toContain("Danger zone"); + expect(container.textContent).toContain("Needs attention"); + expect(container.textContent).toContain("Add a working GitHub key to restore access."); + expect(Array.from(container.querySelectorAll("button")).some( + (button) => button.textContent?.trim() === "Reconnect", + )).toBe(true); }); it("carries the retained identity into the reconnect flow", async () => { diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx index ba0a5c8565..444128f758 100644 --- a/ui/src/pages/apps/AppNotConnected.tsx +++ b/ui/src/pages/apps/AppNotConnected.tsx @@ -1,23 +1,14 @@ import { useEffect, useMemo } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import type { ToolConnection } from "@paperclipai/shared"; -import { - connectionDisplaySecondaryHint, - isConnectableAppSlug, - isToolConnectionAttentionHealth, -} from "@paperclipai/shared"; +import { isConnectableAppSlug } from "@paperclipai/shared"; import { Navigate, useNavigate, useParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; -import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; -import { timeAgo } from "@/lib/timeAgo"; import { toolsApi } from "@/api/tools"; -import { agentsApi } from "@/api/agents"; -import { accessApi } from "@/api/access"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/company-members"; import { AppLogo } from "./AppLogo"; import { appApplicationSourceSlug, @@ -27,21 +18,13 @@ import { appDefinitionSlug, type AppGalleryDisplayEntry, } from "./app-definition-display"; -import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail"; -import { ActivityPanel } from "./app-detail/ActivityPanel"; +import { connectionAddress } from "./AppDetail"; import { ReviewPanel } from "./app-detail/ReviewPanel"; import { appApplicationTabHref, appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs"; -import { - ConnectionOwnerIdentity, - connectionDisplayNameForOwner, - connectionOwnerProfile, -} from "./connection-owner"; export function AppNotConnected() { const { applicationId = "", tab } = useParams<{ applicationId: string; tab?: string }>(); const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { pushToast } = useToast(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null; @@ -61,11 +44,6 @@ export function AppNotConnected() { queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId && !!activeTab, }); - const userDirectoryQuery = useQuery({ - queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), - queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), - enabled: !!selectedCompanyId && !!activeTab, - }); const application = useMemo( () => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId), @@ -91,24 +69,10 @@ export function AppNotConnected() { ); const activeConnection = activeConnections[0] ?? null; const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]); - const userProfileById = useMemo( - () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), - [userDirectoryQuery.data], - ); - const activityQuery = useQuery({ - queryKey: queryKeys.tools.connectionActivity(previousConnection?.id ?? "__none__"), - 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!), - enabled: !!selectedCompanyId && activeTab === "activity", + enabled: !!previousConnection && !!activeTab, }); const appName = application?.name ?? "App"; @@ -116,37 +80,23 @@ export function AppNotConnected() { if (!activeTab) return; setBreadcrumbs([ { label: "Connectors", href: "/apps" }, - { label: appName, href: appApplicationTabHref(applicationId, "setup") }, + { label: appName, href: appApplicationTabHref(applicationId, "permissions") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); }, [setBreadcrumbs, appName, applicationId, activeTab]); - const remove = useMutation({ - mutationFn: () => toolsApi.updateApplication(applicationId, { status: "archived" }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__") }); - pushToast({ - title: "App removed", - body: `${appName} no longer shows in your apps. You can connect it again any time.`, - tone: "success", - }); - navigate("/apps"); - }, - onError: (error) => { - pushToast({ - title: "Couldn’t remove the app", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }); - }, - }); - + if (tab === "activity") { + return ; + } + if (tab === "setup" || tab === "test") { + return ; + } if (!selectedCompanyId) { return
Select an organization to manage apps.
; } if (!applicationId || !activeTab) { - return ; + return ; } if (applicationsQuery.isLoading || connectionsQuery.isLoading) { return ( @@ -164,9 +114,12 @@ export function AppNotConnected() { ); } - if (activeConnection && activeTab !== "setup") { + if (activeConnection) { return ; } + if (activeTab === "services") { + return ; + } const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]; const logoEntry = (appSourceSlug @@ -223,26 +176,13 @@ export function AppNotConnected() { connectedCount={activeConnections.length} /> - {activeTab === "setup" && ( -
- navigate(connectHref)} - onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))} - /> - remove.mutate()} - /> -
- )} + navigate(connectHref)} + /> {activeTab === "review" && ( previousConnection ? ( @@ -256,37 +196,6 @@ export function AppNotConnected() { {activeTab === "permissions" && ( )} - {activeTab === "test" && ( - - )} - {activeTab === "activity" && ( - previousConnection ? ( - - ) : ( - - ) - )} ); } @@ -322,155 +231,39 @@ function ApplicationHeader({ ); } -function SetupTab({ +function ConnectionCallout({ applicationName, - activeConnections, previousConnection, - previousAddress, - userProfileById, canReconnect, reconnectUnavailableMessage, onConnect, - onEdit, }: { applicationName: string; - activeConnections: ToolConnection[]; previousConnection: ToolConnection | null; - previousAddress: string | null; - userProfileById: ReadonlyMap; canReconnect: boolean; reconnectUnavailableMessage: string; onConnect: () => void; - onEdit: (connectionId: string) => void; -}) { - if (activeConnections.length > 0) { - return ( -
-
-
-

Already connected to {applicationName}

-

- Edit an existing connection, or deliberately add another account below. -

-
-
- {activeConnections.map((connection) => { - const owner = connectionOwnerProfile(connection, userProfileById); - const secondary = connectionDisplaySecondaryHint(connection) ?? - (connection.lastUsedAt ? `Last used ${timeAgo(connection.lastUsedAt)}` : "Not used yet"); - const status = connection.enabled === false || connection.status === "disabled" - ? "Paused" - : isToolConnectionAttentionHealth(connection.healthStatus) - ? "Needs attention" - : "Connected"; - return ( - - ); - })} -
-
- -
-
-
-

Connect another

-

- Add another {applicationName} account without changing the connections above. -

-
- -
-
-
- ); - } - - return ( -
-
-
-
-

- {previousConnection ? "Reconnect this app" : "Connect this app"} -

-

- {previousConnection - ? 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."} -

- {previousConnection && !canReconnect ? ( -

{reconnectUnavailableMessage}

- ) : null} -
- {!previousConnection || canReconnect ? ( - - ) : null} -
-
- - {previousConnection && ( - - )} -
- ); -} - -function PreviousSetup({ - connection, - previousAddress, - owner, -}: { - connection: ToolConnection; - previousAddress: string | null; - owner: CompanyUserProfile | null; }) { return ( -
-

Previous setup

- {owner && ( -
- Connected by - -
- )} - {connection.healthMessage && ( -

- Last error: {connection.healthMessage} +

+
+

+ {previousConnection ? "Needs attention" : "Not connected"} +

+

+ {previousConnection + ? previousConnection.authKind === "oauth" + ? `Sign in to ${applicationName} again to restore access.` + : `Add a working ${applicationName} key to restore access.` + : `Connect ${applicationName} so agents can use it.`}

- )} -
-
Address
-
{previousAddress}
-
Connection type
-
{connectionTransportLabel(connection.transport)}
-
Last used
-
- {connection.lastUsedAt ? timeAgo(connection.lastUsedAt) : "Never"} -
-
+ {previousConnection && !canReconnect ? ( +

{reconnectUnavailableMessage}

+ ) : null} +
+ {!previousConnection || canReconnect ? ( + + ) : null}
); } diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index a219ab77df..e53ab35a12 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -647,6 +647,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); }); + it("starts a selected app deep link at step one of its two-step setup", async () => { + mockSearch.value = "source=gmail"; + listGalleryMock.mockResolvedValue({ apps: [GMAIL] }); + + await render(); + + expect(document.body.textContent).toContain("Step 1 of 2"); + expect(document.body.textContent).toContain("Access · Choose connection"); + expect(document.body.textContent).not.toContain("Pick app ·"); + }); + it("opens a brokered Gmail deep link at the access step", async () => { mockParams.appKey = "gmail"; mockSearch.value = "byo=1&appKey=gmail&stage=access"; @@ -654,6 +665,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await render(); + expect(document.body.textContent).toContain("Step 1 of 2"); + expect(document.body.textContent).toContain("Access · Choose connection"); + expect(document.body.textContent).not.toContain("Pick app ·"); expect(document.body.textContent).toContain("Which humans can use this credential?"); expect(document.body.textContent).toContain("Just me"); expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index bae2665207..6de671ee07 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -279,11 +279,11 @@ describe("Connectors landing page", () => { await act(async () => { notion .querySelector( - 'button[aria-label="Open devinfoley@gmail.com connection settings"]', + 'button[aria-label="Open devinfoley@gmail.com permissions"]', ) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(navigateMock).toHaveBeenCalledWith("/apps/conn-notion/setup"); + expect(navigateMock).toHaveBeenCalledWith("/apps/conn-notion/permissions"); await act(async () => { notion @@ -300,7 +300,7 @@ describe("Connectors landing page", () => { await act(async () => { reconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(navigateMock).toHaveBeenCalledWith("/apps/conn-expired/setup"); + expect(navigateMock).toHaveBeenCalledWith("/apps/conn-expired/permissions"); }); it("removes a connection from the overflow menu only after destructive confirmation", async () => { diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 6becb659a6..ac8317e455 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -169,7 +169,7 @@ function connectorAction(row: ConnectorRowModel): { } return { label: "Add account", - href: applicationId ? `/apps/app/${applicationId}/setup` : null, + href: applicationId ? `/apps/app/${applicationId}/permissions` : null, }; } @@ -183,7 +183,7 @@ function connectorAction(row: ConnectorRowModel): { if (row.entry) return { label: "Connect", href: connectHrefFor(row.entry) }; return { label: "Connect", - href: applicationId ? `/apps/app/${applicationId}/setup` : null, + href: applicationId ? `/apps/app/${applicationId}/permissions` : null, }; } @@ -191,7 +191,7 @@ function accountActionHref(row: ConnectorRowModel, connection: ToolConnection): if (connection.status === "draft" && row.entry) { return appSourceResumeHref(row.slug, connection.id); } - return `/apps/${connection.id}/setup`; + return `/apps/${connection.id}/permissions`; } /** @@ -617,8 +617,8 @@ function ConnectionAccountRow({ @@ -663,8 +663,8 @@ function ConnectionAccountRow({ - onNavigate(`/apps/${connection.id}/setup`)}> - Edit connection + onNavigate(`/apps/${connection.id}/permissions`)}> + Permissions diff --git a/ui/src/pages/apps/Connections.test.tsx b/ui/src/pages/apps/Connections.test.tsx index 9810f32351..c01c6e05d1 100644 --- a/ui/src/pages/apps/Connections.test.tsx +++ b/ui/src/pages/apps/Connections.test.tsx @@ -218,14 +218,14 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { tr.textContent?.includes("GitHub"), ); row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/setup"); + expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/permissions"); mockNavigate.mockClear(); const connectButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Connect") && !button.textContent.includes("Connect an app"), ); connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/setup"); + expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/permissions"); }); it("renders every account with its owner, status, actions, and direct edit navigation", async () => { @@ -323,15 +323,15 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { tr.textContent?.includes("Slack"), ); slackRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockNavigate).toHaveBeenCalledWith("/apps/c-attention/setup"); + expect(mockNavigate).toHaveBeenCalledWith("/apps/c-attention/permissions"); // 7. Button labels are honest: broken health says Reconnect, healthy/paused say Edit. const rowButtonLabel = (name: string, exact = false) => Array.from(container.querySelectorAll("tbody tr")) .find((tr) => exact ? tr.textContent?.includes(name) && !tr.textContent?.includes("Slack Team") : tr.textContent?.includes(name)) ?.querySelector("td:last-child button")?.textContent; - expect(rowButtonLabel("GitHub")).toBe("Edit"); + expect(rowButtonLabel("GitHub")).toBe("Permissions"); expect(rowButtonLabel("Slack", true)).toBe("Reconnect"); - expect(rowButtonLabel("Notion")).toBe("Edit"); + expect(rowButtonLabel("Notion")).toBe("Permissions"); // 8. Generic connection names inherit the originating user's first name. expect(text).toContain("Dotta’s GitHub"); expect(text).toContain("Slack for the company"); @@ -376,9 +376,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { ); expect(row?.className).not.toContain("amber"); const button = row?.querySelector("td:last-child button"); - expect(button?.textContent).toBe("Edit"); + expect(button?.textContent).toBe("Permissions"); button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy/setup"); + expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy/permissions"); }); it("deletes a connection only after trash-can confirmation", async () => { diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx index 69c584adbb..57ed1b93ce 100644 --- a/ui/src/pages/apps/Connections.tsx +++ b/ui/src/pages/apps/Connections.tsx @@ -425,13 +425,13 @@ export function Connections() { ? application.name : null; const appHref = connection - ? `/apps/${connection.id}/setup` - : `/apps/app/${application.id}/setup`; + ? `/apps/${connection.id}/permissions` + : `/apps/app/${application.id}/permissions`; const actionLabel = !connection ? "Connect" : status.tone === "attention" ? "Reconnect" - : "Edit"; + : "Permissions"; return ( ({ key: `lifecycle:${event.id}`, createdAt: event.createdAt, primary: humanizeLifecycleEvent(event, appName, nameById.get(event.agentId ?? "") ?? null), dotClass: lifecycleDotColor(event), - link: { to: setupHref, label: lifecycleLinkLabel(event) }, + link: { to: permissionsHref, label: lifecycleLinkLabel(event) }, })); return [...callRows, ...lifecycleRows].sort( @@ -250,7 +250,7 @@ function humanizeAllowlistChange(who: string, details: Record | } function lifecycleLinkLabel(event: ToolConnectionLifecycleEvent): string { - return event.type === "actions_quarantined" ? "Review in Setup" : "View in Setup"; + return event.type === "actions_quarantined" ? "Review permissions" : "View permissions"; } function numberFrom(value: unknown): number { diff --git a/ui/src/pages/apps/app-detail/IdentitiesSection.tsx b/ui/src/pages/apps/app-detail/IdentitiesSection.tsx index ea31e022ce..94970e5497 100644 --- a/ui/src/pages/apps/app-detail/IdentitiesSection.tsx +++ b/ui/src/pages/apps/app-detail/IdentitiesSection.tsx @@ -160,11 +160,19 @@ export function IdentitiesSection({
- { + if (orgGrant) onReplaceAudience(orgGrant, []); + }} + onChooseSelected={() => { + if (orgGrant) onOpenAudience(orgGrant.id); + }} />
@@ -224,39 +232,69 @@ export function IdentitiesSection({ } function IdentitiesHeading() { - return

Account

; + return

Which humans can use this credential?

; } -function ConnectionAudienceCallout({ +function HumanAccessCards({ personal, + restricted, connectedName, connectedImage, status, + canEditAudience, + onChooseAll, + onChooseSelected, }: { personal: boolean; + restricted: boolean; connectedName: string | null; connectedImage: string | null; status: ConnectionGrant["status"] | null; + canEditAudience: boolean; + onChooseAll: () => void; + onChooseSelected: () => void; }) { - const Icon = personal ? UserRound : Building2; return ( -
-
- -
-
-

- {personal - ? "Only you can use this connection" - : "Anyone in your company can use this connection"} -

- {connectedName && status !== null ? ( -
- - {status === "active" ? null : } -
- ) : null} -
+
+ { + if (!canEditAudience || personal) return; + if (next === "company") onChooseAll(); + if (next === "selected") onChooseSelected(); + }} + options={personal ? [ + { + value: "personal", + title: "Just me", + description: "Only you can use this connection.", + icon: , + }, + ] : [ + { + value: "selected", + title: "Humans I pick", + description: "Only selected people in your company.", + icon: , + disabled: !canEditAudience, + }, + { + value: "company", + title: "Any human in the company", + description: "Anyone in your company can use this connection.", + icon: , + disabled: !canEditAudience, + }, + ]} + /> + {connectedName && status !== null ? ( +
+ + {status === "active" ? null : } +
+ ) : null}
); } diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx index f53982d19f..a9ee49bd1d 100644 --- a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx +++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx @@ -1,31 +1,24 @@ -import { useEffect, useRef } from "react"; -import { Loader2, RefreshCw } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Ban, Check, FlaskConical, Loader2, RefreshCw, Search, ShieldQuestion } from "lucide-react"; import type { Agent, ToolCatalogEntry, ToolConnectionCapabilities } from "@paperclipai/shared"; import { useSearchParams } from "@/lib/router"; import { AgentIcon } from "@/components/AgentIconPicker"; -import { Button } from "@/components/ui/button"; import { AgentMultiSelect } from "@/components/AgentMultiSelect"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { RadioCardGroup } from "@/components/ui/radio-card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { type InstallState } from "@/lib/tool-installs"; import { QuarantinedActionsReview } from "./SetupPanel"; -import { - formatActionPermissionSummary, - summarizeActionPermissions, -} from "./action-permission-summary"; +import { ActionTestDialog } from "./TestPanel"; import type { AccessDraft, AppDetailSectionProps } from "./types"; -type ActionPermission = "off" | "allowed" | "ask"; +type ActionPermission = "off" | "ask" | "allowed"; +type ActionKindFilter = "all" | "read" | "write"; -/** - * Permissions tab. - * - * 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({ + connectionId, appName, agents, access, @@ -36,9 +29,7 @@ export function PermissionsPanel({ enabledIds, askFirstIds, pending, - installPending, onSaveAccess, - onSaveInstall, onSetActionPermission, onReviewQuarantined, onRefreshActions, @@ -56,33 +47,19 @@ export function PermissionsPanel({ | "askFirstIds" | "pending" > & { + connectionId: string; install: InstallState; - installPending: boolean; onSaveAccess: (next: AccessDraft) => void; - onSaveInstall: (next: InstallState) => void; onSetActionPermission: (id: string, next: ActionPermission) => void; onReviewQuarantined: (enabledIds: string[]) => void; onRefreshActions: () => void; refreshPending: boolean; - /** Server verdict on what this caller may change here (PAP-17835). */ capabilities: ToolConnectionCapabilities | undefined; }) { - // Deep-link from the Test tab's "off" panel: ?focus={catalogEntryId} scrolls - // to and highlights that action row. const [searchParams] = useSearchParams(); - const focusId = searchParams.get("focus"); return (
- void; }) { - const liveAgents = agents.filter((a) => a.status !== "terminated"); + const liveAgents = agents.filter((agent) => agent.status !== "terminated"); const canManage = capabilities?.canConfigure ?? false; const editableAgentIds = capabilities?.editableAgentIds; const selectableAgents = editableAgentIds @@ -133,27 +110,16 @@ function AgentAccessSection({ : liveAgents; const selectedAgents = liveAgents.filter((agent) => access.agentIds.has(agent.id)); const requiredAgentIds = install.agentIds; - const summary = access.mode === "all" - ? "Any agent" - : access.agentIds.size === 0 - ? "No agents" - : `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"}`; return ( -
-
-
-

Agent access

-

{summary}

-

- Agents that may use {appName} when work needs it. -

-
- {disabled && Saving…} +
+
+

Which agents can use this connection?

+ {disabled ? Saving… : null}
{canManage ? ( -
+
requiredAgentIds.has(agent.id)} - getDescription={(agent) => requiredAgentIds.has(agent.id) ? "Always installed" : agent.title} - headerContent={requiredAgentIds.size > 0 ? ( -

- Always-installed agents keep access. -

- ) : null} + getDescription={(agent) => requiredAgentIds.has(agent.id) ? "Required by this connection's install setting" : agent.title} onChange={(agentIds) => onSave({ mode: "specific", agentIds: new Set([...agentIds, ...requiredAgentIds]), @@ -208,128 +167,18 @@ function AgentAccessSection({ /> ) : null}
+ ) : access.mode === "all" ? ( +

Any agent can use this connection.

+ ) : selectedAgents.length === 0 ? ( +

No agents can use this connection.

) : ( - // Read-only: the state is still fully legible, just not editable. -
- {access.mode === "all" ? ( -

Every agent can use this connection.

- ) : selectedAgents.length === 0 ? ( -

No agents can use this connection.

- ) : ( -
- {selectedAgents.map((agent) => ( -
- - {agent.name} -
- ))} +
+ {selectedAgents.map((agent) => ( +
+ + {agent.name}
- )} -
- )} -
- ); -} - -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 ( -
-
-
-

Always installed

-

{summary}

-

- Loads {appName} on every run. Agent access only makes it available when needed. -

-
- {disabled && Saving…} -
- - {canManage ? ( -
- { - if (next === "all") onSave({ onAll: true, agentIds: new Set() }); - else onSave({ onAll: false, agentIds: new Set(install.agentIds) }); - }} - options={[ - { - value: "specific", - title: "Agents I pick", - description: "Always loaded for selected agents.", - }, - { - value: "all", - title: "Every agent", - description: canSetCompanyWide - ? "Always loaded for current and future agents." - : "Only a connection manager can choose this.", - }, - ].filter((option) => option.value !== "all" || canSetCompanyWide || install.onAll)} - /> - - {mode === "specific" ? ( - onSave({ onAll: false, agentIds })} - /> - ) : null} -
- ) : ( -
- {install.onAll ? ( -

This connection is always loaded for every agent.

- ) : selectedAgents.length === 0 ? ( -

This connection is not always loaded for any agent.

- ) : ( -
- {selectedAgents.map((agent) => ( -
- - {agent.name} -
- ))} -
- )} + ))}
)}
@@ -337,6 +186,8 @@ function AlwaysInstalledSection({ } function ActionsSection({ + connectionId, + appName, readOnly, canChange, quarantined, @@ -350,6 +201,8 @@ function ActionsSection({ onReviewQuarantined, onRefreshActions, }: { + connectionId: string; + appName: string; readOnly: ToolCatalogEntry[]; canChange: ToolCatalogEntry[]; quarantined: ToolCatalogEntry[]; @@ -358,31 +211,34 @@ function ActionsSection({ disabled: boolean; refreshPending: boolean; focusId?: string | null; - /** Server verdict: may this caller change this connection's configuration? */ canConfigure: boolean; onSetPermission: (id: string, next: ActionPermission) => void; onReviewQuarantined: (enabledIds: string[]) => void; onRefreshActions: () => void; }) { + const [query, setQuery] = useState(""); + const [kindFilter, setKindFilter] = useState("all"); + const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) => + (a.title ?? a.toolName).localeCompare(b.title ?? b.toolName); + const sortedRead = useMemo(() => [...readOnly].sort(byName), [readOnly]); + const sortedWrite = useMemo(() => [...canChange].sort(byName), [canChange]); + const matches = (entry: ToolCatalogEntry) => { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return (entry.title ?? entry.toolName).toLowerCase().includes(needle) + || (entry.description ?? "").toLowerCase().includes(needle); + }; + const visibleRead = kindFilter === "write" ? [] : sortedRead.filter(matches); + const visibleWrite = kindFilter === "read" ? [] : sortedWrite.filter(matches); + const visibleCount = visibleRead.length + visibleWrite.length; + return ( -
+
-
-

Actions

-

- {formatActionPermissionSummary(summarizeActionPermissions( - [...readOnly, ...canChange], - enabledIds, - askFirstIds, - ))} -

-
- {/* Viewer rule D4: a forbidden action is omitted, not rendered disabled. - Refreshing the catalog mutates the connection, so a caller who may - not configure it never sees the control. */} +

Actions

{canConfigure ? (
- {disabled && Saving...} + {disabled ? Saving… : null}
); } -const ACTION_PERMISSION_LABELS: Record = { - off: "Off", - allowed: "Allowed", - ask: "Ask a human first", -}; +function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} function ActionGroup({ title, - hint, actions, + connectionId, + appName, enabledIds, askFirstIds, disabled, @@ -452,8 +350,9 @@ function ActionGroup({ onSetPermission, }: { title: string; - hint: string; actions: ToolCatalogEntry[]; + connectionId: string; + appName: string; enabledIds: Set; askFirstIds: Set; disabled: boolean; @@ -461,69 +360,144 @@ function ActionGroup({ canConfigure: boolean; onSetPermission: (id: string, next: ActionPermission) => void; }) { - const focusRef = useRef(null); - useEffect(() => { - if (focusId && focusRef.current) { - focusRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, [focusId]); if (actions.length === 0) return null; return (
-
-
{title}
-
{hint}
-
+

{title}

- {actions.map((action) => { - const value = actionPermission(action.id, enabledIds, askFirstIds); - const focused = focusId === action.id; - return ( -
-
-
{action.title ?? action.toolName}
- {action.description && ( -
{action.description}
- )} -
- {canConfigure ? ( - - ) : ( - // Read-only: the same fact, stated rather than offered. - - {ACTION_PERMISSION_LABELS[value]} - - )} -
- ); - })} + {actions.map((action) => ( + + ))}
); } +const PERMISSION_OPTIONS: Array<{ + value: ActionPermission; + label: string; + description: string; + icon: typeof Ban; +}> = [ + { value: "off", label: "Off", description: "Agents cannot run this action.", icon: Ban }, + { value: "ask", label: "Ask first", description: "A human must approve each call.", icon: ShieldQuestion }, + { value: "allowed", label: "Allowed", description: "Runs without approval.", icon: Check }, +]; + +function ActionRow({ + action, + connectionId, + appName, + value, + disabled, + focused, + canConfigure, + onSetPermission, +}: { + action: ToolCatalogEntry; + connectionId: string; + appName: string; + value: ActionPermission; + disabled: boolean; + focused: boolean; + canConfigure: boolean; + onSetPermission: (id: string, next: ActionPermission) => void; +}) { + const rowRef = useRef(null); + const [testOpen, setTestOpen] = useState(false); + const title = action.title ?? action.toolName; + + useEffect(() => { + if (focused) rowRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, [focused]); + + return ( + <> +
+
+
{title}
+ {action.description ? ( +
{action.description}
+ ) : null} +
+
+ {canConfigure ? ( + +
+ {PERMISSION_OPTIONS.map((option) => { + const Icon = option.icon; + const selected = option.value === value; + return ( + + + + + + {option.label} — {option.description} + + + ); + })} +
+
+ ) : ( + + {PERMISSION_OPTIONS.find((option) => option.value === value)?.label} + + )} + +
+
+ + + ); +} + function actionPermission( id: string, enabledIds: Set, diff --git a/ui/src/pages/apps/app-detail/TestPanel.test.tsx b/ui/src/pages/apps/app-detail/TestPanel.test.tsx index 1537ecee0b..99f6e0d896 100644 --- a/ui/src/pages/apps/app-detail/TestPanel.test.tsx +++ b/ui/src/pages/apps/app-detail/TestPanel.test.tsx @@ -288,7 +288,7 @@ describe("TestPanel", () => { await act(async () => renderPanel([])); await flushReact(); expect(container.textContent).toContain("Nothing to test yet"); - expect(container.textContent).toContain("Go to Setup"); + expect(container.textContent).toContain("Go to Permissions"); }); it("renders an allowed result panel with a row-count headline after a successful run", async () => { diff --git a/ui/src/pages/apps/app-detail/TestPanel.tsx b/ui/src/pages/apps/app-detail/TestPanel.tsx index 4c1666b308..34e4db180c 100644 --- a/ui/src/pages/apps/app-detail/TestPanel.tsx +++ b/ui/src/pages/apps/app-detail/TestPanel.tsx @@ -33,6 +33,13 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { JsonSchemaForm, getDefaultValues, @@ -78,6 +85,126 @@ type TestAgentWithAccess = ToolConnectionTestAgent & { const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000; const TEST_ACCESS_GC_TIME_MS = 30 * 60_000; +/** + * Focused action tester used by the combined Permissions page. The modal keeps + * the existing schema form and result renderer, but scopes agent selection and + * test state to the action the user opened. + */ +export function ActionTestDialog({ + connectionId, + appName, + entry, + open, + onOpenChange, +}: { + connectionId: string; + appName: string; + entry: ToolCatalogEntry; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const testAgentsQuery = useQuery({ + queryKey: queryKeys.tools.testAgents(connectionId), + queryFn: () => toolsApi.listTestAgents(connectionId), + enabled: open && !!connectionId, + }); + const agents = useMemo( + () => [...(testAgentsQuery.data?.agents ?? [])].sort( + (a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name), + ), + [testAgentsQuery.data], + ); + const [requestedAgentId, setRequestedAgentId] = useState(null); + const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId) + ? requestedAgentId + : agents[0]?.id ?? null; + const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null; + const accessQuery = useQuery({ + queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"), + queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!), + enabled: open && !!connectionId && !!agentId, + staleTime: TEST_ACCESS_STALE_TIME_MS, + gcTime: TEST_ACCESS_GC_TIME_MS, + refetchOnWindowFocus: false, + }); + const selectedAgent = useMemo(() => ( + selectedAgentBase && accessQuery.data + ? { ...selectedAgentBase, effectiveAccess: accessQuery.data.access } + : null + ), [accessQuery.data, selectedAgentBase]); + const decision = useMemo(() => { + const tool = selectedAgent?.effectiveAccess.tools.find((candidate) => ( + candidate.toolName === entry.toolName || candidate.gatewayToolName === entry.toolName + )); + return tool?.decision ?? "off"; + }, [entry.toolName, selectedAgent]); + const title = entry.title ?? entry.toolName; + + return ( + + + + Test {title} + + Run a real action with the same permissions and credentials an agent would use. + + + + {testAgentsQuery.isLoading ? ( +
+ + Loading agents… +
+ ) : testAgentsQuery.isError ? ( + { void testAgentsQuery.refetch(); }} + /> + ) : agents.length === 0 ? ( +

No agents are available to test as.

+ ) : accessQuery.isError && !accessQuery.data ? ( + { void accessQuery.refetch(); }} + /> + ) : !selectedAgent ? ( +
+ + Loading agent permissions… +
+ ) : ( +
+
+

Act as

+
+ + +
+
+ +
+ )} +
+
+ ); +} + const DECISION_META: Record = { allowed: { label: "Allowed", @@ -357,7 +484,7 @@ function EmptyState({ connectionId, appName }: { connectionId: string; appName: Once {appName} is connected, the actions it offers will show up here so you can try them out.

); @@ -1046,8 +1173,8 @@ function AllowedResult({

This call is in the{" "} - - Activity tab + + Audit log .

@@ -1177,8 +1304,8 @@ function ErrorResult({

Adjust the input above and try again.

Also visible in the{" "} - - Activity tab + + Audit log .

diff --git a/ui/src/pages/apps/app-tabs.ts b/ui/src/pages/apps/app-tabs.ts index da7f992e52..cf5d8a45dc 100644 --- a/ui/src/pages/apps/app-tabs.ts +++ b/ui/src/pages/apps/app-tabs.ts @@ -1,24 +1,19 @@ -import { Activity, Beaker, Blocks, Inbox, Settings2, ShieldCheck } from "lucide-react"; +import { Blocks, Inbox, ShieldCheck } from "lucide-react"; export const APP_TABS = [ - { key: "setup", label: "Setup", icon: Settings2 }, - { key: "test", label: "Test", icon: Beaker }, - { key: "services", label: "Services", icon: Blocks }, { key: "permissions", label: "Permissions", icon: ShieldCheck }, + { key: "services", label: "Services", icon: Blocks }, { key: "review", label: "Review", icon: Inbox }, - { key: "activity", label: "Activity", icon: Activity }, ] as const; export type AppTabKey = (typeof APP_TABS)[number]["key"]; /** * Tabs hidden for an application that has no live connection (the - * `AppNotConnected` shell). The Test tab runs real calls against a connected - * app, so it only appears once the app is connected. Services lists the toolkits - * behind a broker's API key, which there is likewise nothing to read without one. + * `AppNotConnected` shell). Services lists the toolkits behind a broker's API + * key, which there is nothing to read without a live connection. */ export const CONNECTED_ONLY_APP_TABS: ReadonlySet = new Set([ - "test", "services", ]); @@ -47,5 +42,5 @@ export function isAppTabKey(value: string | undefined): value is AppTabKey { } export function appTabLabel(tabKey: AppTabKey): string { - return APP_TABS.find((tab) => tab.key === tabKey)?.label ?? "Setup"; + return APP_TABS.find((tab) => tab.key === tabKey)?.label ?? "Permissions"; } diff --git a/ui/src/pages/apps/composio-services.test.ts b/ui/src/pages/apps/composio-services.test.ts index 671aac64ef..07259bbcd5 100644 --- a/ui/src/pages/apps/composio-services.test.ts +++ b/ui/src/pages/apps/composio-services.test.ts @@ -230,7 +230,7 @@ describe("Services tab visibility", () => { }); it("does not gate the ordinary tabs behind broker-only", () => { - for (const tab of ["setup", "test", "review", "permissions", "activity"] as const) { + for (const tab of ["review", "permissions"] as const) { expect(BROKER_ONLY_APP_TABS.has(tab)).toBe(false); } }); diff --git a/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx b/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx index d64f1c240e..af4b8d9e45 100644 --- a/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx @@ -41,8 +41,8 @@ export function AppsToolsPanel({ {apps.map((app) => { const href = app.connection - ? `/apps/${app.connection.id}/setup` - : `/apps/app/${app.application.id}/setup`; + ? `/apps/${app.connection.id}/permissions` + : `/apps/app/${app.application.id}/permissions`; return ( diff --git a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx index a1ef5cc37f..4b8fbfe3fd 100644 --- a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx @@ -147,7 +147,9 @@ function Chip({ children }: { children: React.ReactNode }) { } function AppRow({ app }: { app: GatewayAppRow }) { - const href = app.connection ? `/apps/${app.connection.id}/setup` : `/apps/app/${app.application.id}/setup`; + const href = app.connection + ? `/apps/${app.connection.id}/permissions` + : `/apps/app/${app.application.id}/permissions`; return (
  • diff --git a/ui/src/pages/audit/AuditFeed.test.tsx b/ui/src/pages/audit/AuditFeed.test.tsx index 70210eb3d1..52ca3ef1fb 100644 --- a/ui/src/pages/audit/AuditFeed.test.tsx +++ b/ui/src/pages/audit/AuditFeed.test.tsx @@ -113,6 +113,8 @@ describe("AuditFeed", () => { lockedEntity?: { type: string; id: string; label?: string }; mode?: "all" | "agents"; onModeChange?: (mode: "all" | "agents") => void; + actionDomain?: string; + onActionDomainChange?: (actionDomain: string) => void; } = {}, ) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -127,6 +129,8 @@ describe("AuditFeed", () => { lockedEntity={props.lockedEntity} mode={props.mode} onModeChange={props.onModeChange} + actionDomain={props.actionDomain} + onActionDomainChange={props.onActionDomainChange} /> , ); @@ -202,6 +206,35 @@ describe("AuditFeed", () => { expect(container.textContent).toContain("Recorded by Paperclip"); }); + it("filters and renders connection tests in the same audit row shape", async () => { + listAgentActionsMock.mockResolvedValue({ + items: [record({ + actorType: "user", + actorId: "user-1", + action: "tool_gateway.call_completed", + entityType: "agent", + entityId: "agent-1", + agentId: "agent-1", + runId: null, + responsibleUserId: null, + details: { source: "test", tool: "get_repository", connectionId: "conn-1" }, + entity: { issue: null, comment: null, document: null }, + })], + nextCursor: null, + accessTier: "full", + }); + + await render({ actionDomain: "tool_" }); + + expect(listAgentActionsMock).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ action: "tool_" }), + ); + expect(container.textContent).toContain("tested get repository on"); + expect(container.querySelector('a[href="/apps/conn-1/permissions"]')).toBeTruthy(); + expect(container.textContent).toContain("tool_gateway.call_completed"); + }); + it("shows the permission-denied upsell when the feed 403s", async () => { listAgentActionsMock.mockRejectedValue( new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }), diff --git a/ui/src/pages/audit/AuditFeed.tsx b/ui/src/pages/audit/AuditFeed.tsx index cdbf86d008..2637f161d4 100644 --- a/ui/src/pages/audit/AuditFeed.tsx +++ b/ui/src/pages/audit/AuditFeed.tsx @@ -38,7 +38,7 @@ const ACTION_DOMAINS: { value: string; label: string }[] = [ { value: "approval.", label: "Approvals" }, { value: "project.", label: "Projects" }, { value: "goal.", label: "Goals" }, - { value: "tool_gateway.", label: "Tools" }, + { value: "tool_", label: "Apps & tools" }, { value: "cost.", label: "Costs" }, { value: "company.", label: "Organization" }, ]; @@ -53,6 +53,7 @@ const ENTITY_TYPES: { value: string; label: string }[] = [ { value: "project", label: "Project" }, { value: "goal", label: "Goal" }, { value: "company", label: "Organization" }, + { value: "tool_connection", label: "Connection" }, ]; /** @@ -82,6 +83,9 @@ export interface AuditFeedProps { */ mode?: AuditFeedMode; onModeChange?: (mode: AuditFeedMode) => void; + /** Optional controlled action prefix, used by links from connection testing. */ + actionDomain?: string; + onActionDomainChange?: (actionDomain: string) => void; } function toStartIso(value: string): string | undefined { @@ -169,6 +173,18 @@ function AuditEntityNode({ record }: { record: AuditActionRecord }) { if (document) { return {document.key}; } + const connectionId = record.entityType === "tool_connection" + ? record.entityId + : typeof record.details?.connectionId === "string" + ? record.details.connectionId + : null; + if (connectionId) { + return ( + + the connection + + ); + } // Non-linkable entities (company, agent, goal, …) — show a plain descriptor. return {record.entityType}; } @@ -271,11 +287,18 @@ export function AuditFeed({ hideHeader, mode, onModeChange, + actionDomain: controlledActionDomain, + onActionDomainChange, }: AuditFeedProps) { const { pushToast } = useToastActions(); const [agent, setAgent] = useState(ALL); const [responsibleUser, setResponsibleUser] = useState(ALL); - const [actionDomain, setActionDomain] = useState(ALL); + const [localActionDomain, setLocalActionDomain] = useState(ALL); + const actionDomain = controlledActionDomain ?? localActionDomain; + const setActionDomain = (next: string) => { + setLocalActionDomain(next); + onActionDomainChange?.(next); + }; const [entityType, setEntityType] = useState(ALL); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); diff --git a/ui/src/pages/audit/AuditHub.test.tsx b/ui/src/pages/audit/AuditHub.test.tsx index d1a52650d6..74634a87c9 100644 --- a/ui/src/pages/audit/AuditHub.test.tsx +++ b/ui/src/pages/audit/AuditHub.test.tsx @@ -35,6 +35,7 @@ vi.mock("./AuditFeed", () => ({ data-agent={props.lockedAgentId} data-run={props.lockedRunId} data-entity={JSON.stringify(props.lockedEntity ?? null)} + data-action={props.actionDomain} /> ), })); @@ -93,7 +94,7 @@ describe("AuditHub", () => { } it("uses one clear section model and passes deep-link scopes to Activity", () => { - currentSearch = "mode=agents&agentId=agent-1&runId=run-1"; + currentSearch = "mode=agents&agentId=agent-1&runId=run-1&action=tool_"; render("activity"); expect(container.querySelectorAll('[role="tab"]')).toHaveLength(5); @@ -107,6 +108,7 @@ describe("AuditHub", () => { expect(feed?.dataset.agent).toBe("agent-1"); expect(feed?.dataset.run).toBe("run-1"); expect(feed?.dataset.entity).toBe(JSON.stringify(null)); + expect(feed?.dataset.action).toBe("tool_"); expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Audit" }]); }); diff --git a/ui/src/pages/audit/AuditHub.tsx b/ui/src/pages/audit/AuditHub.tsx index fda7237d4f..bbd242df66 100644 --- a/ui/src/pages/audit/AuditHub.tsx +++ b/ui/src/pages/audit/AuditHub.tsx @@ -26,6 +26,18 @@ export function AuditHub({ section }: { section: AuditSection }) { const scope = auditScopeFromSearchParams(searchParams); const mode: AuditFeedMode = scope.mode === "agents" ? "agents" : "all"; const routineId = scope.entityType === "routine" ? scope.entityId ?? undefined : undefined; + const actionParam = searchParams.get("action"); + const actionDomain = [ + "issue.", + "agent.", + "heartbeat.", + "approval.", + "project.", + "goal.", + "tool_", + "cost.", + "company.", + ].includes(actionParam ?? "") ? actionParam! : "__all"; useEffect(() => { const current = AUDIT_SECTIONS.find((candidate) => candidate.value === section); @@ -50,6 +62,21 @@ export function AuditHub({ section }: { section: AuditSection }) { [setSearchParams], ); + const handleActionDomainChange = useCallback( + (next: string) => { + setSearchParams( + (current) => { + const params = new URLSearchParams(current); + if (next === "__all") params.delete("action"); + else params.set("action", next); + return params; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + if (!selectedCompanyId) { return ; } @@ -82,6 +109,8 @@ export function AuditHub({ section }: { section: AuditSection }) { hideHeader mode={mode} onModeChange={handleModeChange} + actionDomain={actionDomain} + onActionDomainChange={handleActionDomainChange} lockedAgentId={scope.agentId ?? undefined} lockedRunId={scope.runId ?? undefined} lockedEntity={ diff --git a/ui/src/pages/audit/CompanyActivity.tsx b/ui/src/pages/audit/CompanyActivity.tsx index 7e9d1f8a27..d67d64ea2a 100644 --- a/ui/src/pages/audit/CompanyActivity.tsx +++ b/ui/src/pages/audit/CompanyActivity.tsx @@ -20,6 +20,18 @@ export function CompanyActivity() { const { setBreadcrumbs } = useBreadcrumbs(); const [searchParams, setSearchParams] = useSearchParams(); const mode: AuditFeedMode = searchParams.get("mode") === "agents" ? "agents" : "all"; + const actionParam = searchParams.get("action"); + const actionDomain = [ + "issue.", + "agent.", + "heartbeat.", + "approval.", + "project.", + "goal.", + "tool_", + "cost.", + "company.", + ].includes(actionParam ?? "") ? actionParam! : "__all"; useEffect(() => { if (!streamlinedUiEnabled) setBreadcrumbs([{ label: "Activity" }]); @@ -40,11 +52,31 @@ export function CompanyActivity() { [setSearchParams], ); + const handleActionDomainChange = useCallback( + (next: string) => { + setSearchParams((current) => { + const params = new URLSearchParams(current); + if (next === "__all") params.delete("action"); + else params.set("action", next); + return params; + }, { replace: true }); + }, + [setSearchParams], + ); + if (streamlinedUiEnabled) return ; if (!selectedCompanyId) { return ; } - return ; + return ( + + ); } diff --git a/ui/src/pages/tools/PasteConfigTab.test.tsx b/ui/src/pages/tools/PasteConfigTab.test.tsx index 2289ebdcf8..9d57816129 100644 --- a/ui/src/pages/tools/PasteConfigTab.test.tsx +++ b/ui/src/pages/tools/PasteConfigTab.test.tsx @@ -294,6 +294,42 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { expect(container.textContent).not.toContain("Next, you'll add the keys"); }); + it("activates imported write actions as allowed by default", async () => { + await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG); + const result = connectResult(); + result.actions.canMakeChanges = [{ + catalogEntryId: "cat-write", + toolName: "create_page", + title: "Create page", + description: "Create a page.", + riskLevel: "write", + isReadOnly: false, + isWrite: true, + isDestructive: false, + status: "active", + }]; + toolsApiMock.connectApp.mockResolvedValue(result); + toolsApiMock.finishApp.mockResolvedValue({ connection: result.connection }); + + await act(async () => { + buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const activateButton = buttonStartingWith("Activate 2 of 2"); + expect(activateButton).toBeTruthy(); + await act(async () => { + activateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(toolsApiMock.finishApp).toHaveBeenCalledWith("company-1", "conn-1", { + enabledCatalogEntryIds: ["cat-read", "cat-write"], + askFirstCatalogEntryIds: [], + access: "all_agents", + }); + }); + it("collects imported headers as secret replacement fields before checking actions", async () => { await pasteAndCheck( { @@ -453,7 +489,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { buttonStartingWith("Back")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup"); + expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions"); expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1); expect(toolsApiMock.startOAuth).not.toHaveBeenCalled(); }); diff --git a/ui/src/pages/tools/PasteConfigTab.tsx b/ui/src/pages/tools/PasteConfigTab.tsx index 9663116f36..7da5d4d5ad 100644 --- a/ui/src/pages/tools/PasteConfigTab.tsx +++ b/ui/src/pages/tools/PasteConfigTab.tsx @@ -89,7 +89,7 @@ function missingCredentialFields(draft: McpJsonImportDraft, values: Record typeof x === "string") : ["write", "destructive"]; + return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : []; } /** @@ -188,7 +188,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { } const defaults: Record = {}; for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true; - for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = false; + for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = true; setEnabled(defaults); setActivatedName(null); }, @@ -245,7 +245,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { setOAuthPhase("starting"); oauthStartMutation.mutate(connectResult.connectionId); }} - onBack={() => navigate(`/apps/${connectResult.connectionId}/setup`)} + onBack={() => navigate(`/apps/${connectResult.connectionId}/permissions`)} onCancel={() => navigate("/apps")} /> ); @@ -493,7 +493,7 @@ function CatalogReview({ Review actions for {result.application.name}

    - Health and catalog checks passed. Read-only actions start on; actions that can change data start off. + Health and catalog checks passed. Every discovered action starts allowed; you can narrow access after activation.