feat(apps): unify permissions and action testing (#12802)
## Thinking Path > - Paperclip is the control plane for companies that use AI agents. > - Apps give humans and agents controlled access to external services. > - The existing app detail flow split permissions, tests, setup, and activity across separate pages. > - The split made access rules harder to understand and made reconnect work hard to find. > - New write actions also defaulted to Ask first, which did not match the intended connection policy. > - This pull request combines permission control and action testing, removes the setup page, and moves connection activity into Audit. > - The benefit is one clear place to configure, test, reconnect, and review each app. ## Linked Issues or Issue Description **What existing behavior does this improve?** The installed app Permissions, Test, Setup, and Activity views. **Subsystem affected** Cross-cutting. This change updates the React UI, shared app defaults, server permission behavior, tests, smoke scripts, and connection documentation. **Current behavior** App access and action testing use separate pages. The app detail view also links to a setup page after installation. Connection activity uses a separate tab. New write actions default to Ask first. **Proposed behavior** Permissions uses the connection access language from the initial flow. It includes searchable Read and Write sections, a three-state permission control, and a Test dialog for each action. Reconnect appears below a Needs attention header on Permissions and Review. Old Setup and Test links redirect to Permissions. Old Activity links redirect to the filtered company Audit feed. New write actions default to Allowed. **Reason and benefit** A person can understand and test app access without moving between several pages. Reconnect work stays visible where the person reviews the connection. Audit events use one consistent feed and filter model. New connections have the intended default policy. **Breaking changes** The Setup, Test, and app Activity tabs are removed. Existing deep links redirect to their replacement pages. Existing saved action permissions do not change. Only defaults for new write actions change. **Additional context** This builds on the managed app connection work in #12728. A search found no duplicate open pull request or issue. ## What Changed - Combined action testing with Permissions. - Added searchable Read and Write action groups. - Added Off, Ask first, and Allowed controls with tooltips. - Added an action Test dialog with agent selection, arguments, and formatted results. - Removed the installed-app Setup and Activity tabs. - Added reconnect guidance to Permissions and Review when a connection needs attention. - Routed connection activity into the company Audit feed and preserved the Apps & tools filter in streamlined Audit. - Moved connection removal to the Connectors-page management menu. - Made new write actions default to Allowed across connection creation paths. - Updated regression tests, browser suites, smoke scripts, and connection documentation. ## Verification - `pnpm check:token-gates` - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/generic-mcp-connection.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/components/AppConnectionSidebar.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/AppNotConnected.test.tsx ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx ui/src/pages/apps/Connections.test.tsx ui/src/pages/apps/composio-services.test.ts ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/tools/PasteConfigTab.test.tsx` (517 tests passed) - `pnpm exec vitest run ui/src/pages/apps/app-detail/TestPanel.test.tsx ui/src/pages/audit/AuditHub.test.tsx ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/Browse.test.tsx` (96 tests passed) - Targeted Playwright verification for connection removal, rename on Permissions, inline action testing, and Smoke Lab Audit evidence (5 flows passed) - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,755 passing tests and 20 unrelated macOS harness failures. The failures use `/tmp` versus `/private/tmp`, invalid ports above 65535, and workspace fixtures outside this change. ## Risks - Low migration risk. This change has no database migration. - Old app-detail URLs depend on redirect compatibility. - New connections grant write actions by default. Finalization remains configure-authorized and audited, Ask first and Off remain available per action, and existing connections keep their saved policy. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, exact model ID `gpt-5`. The client does not expose the context-window size. The model used reasoning, repository tools, code execution, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
a0a78ee609
commit
f449b05bc5
|
|
@ -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=<slug>&resume=<connection-id>` without creating another draft.
|
||||
7. Complete setup. Confirm the connection is active/healthy and opens
|
||||
`/<company-prefix>/apps/<connection-id>/test`.
|
||||
`/<company-prefix>/apps/<connection-id>/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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")!;
|
||||
|
|
|
|||
|
|
@ -238,12 +238,15 @@ export function resolveConnectionMethodServerUrl(
|
|||
}
|
||||
|
||||
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolConnection>(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<ToolConnection>(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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
|
|||
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
|
||||
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
|
||||
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
|
||||
<Route path="apps/:connectionId" element={<Navigate to="permissions" replace />} />
|
||||
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
|
||||
<Route path="company/settings/instance" element={<Navigate to="/company/settings" replace />} />
|
||||
<Route element={<HiddenSettingsPageGate pageKey="instance.profile" />}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
|||
access: 1,
|
||||
key: 2,
|
||||
};
|
||||
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||||
const SELECTED_APP_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||||
access: 0,
|
||||
key: 1,
|
||||
};
|
||||
|
|
@ -441,7 +441,9 @@ export function ConnectionSetupFlow({
|
|||
});
|
||||
|
||||
const [step, setStep] = useState<Step>(
|
||||
requestedAppKey ? "key" : prefill.link || zapierSource ? "access" : "gallery",
|
||||
requestedAppKey
|
||||
? resumeConnectionId ? "key" : "access"
|
||||
: prefill.link || zapierSource ? "access" : "gallery",
|
||||
);
|
||||
const [entry, setEntry] = useState<AppDefinition | null>(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];
|
||||
|
|
|
|||
|
|
@ -98,6 +98,23 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|||
"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<string, string> = {
|
||||
|
|
@ -414,6 +431,23 @@ export function formatActivityVerb(
|
|||
details?: Record<string, unknown> | 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;
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(
|
||||
'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 <provider>" block is gone: the connection's
|
||||
// fixed identity type is explicit even before that identity is connected.
|
||||
expect(container.textContent).toContain("Account");
|
||||
expect(container.textContent).toContain("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<HTMLInputElement>(
|
||||
'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<HTMLSelectElement>('select[aria-label="Read repo permission"]');
|
||||
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
|
||||
expect(readSelect?.value).toBe("allowed");
|
||||
expect(writeSelect?.value).toBe("ask");
|
||||
expect(container.querySelector<HTMLInputElement>('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<HTMLSelectElement>('select[aria-label="Read repo permission"]');
|
||||
expect(readSelect).toBeTruthy();
|
||||
const askFirst = container.querySelector<HTMLButtonElement>('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<HTMLSelectElement>('select[aria-label="Write issue permission"]');
|
||||
expect(writeSelect).toBeTruthy();
|
||||
const off = container.querySelector<HTMLButtonElement>('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<HTMLElement>('[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"),
|
||||
|
|
|
|||
|
|
@ -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 ("<User> tested as <Agent>") 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<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "setup" || searchParams.get("oauth") !== "choose-access") return;
|
||||
// Older OAuth states may still return to the retired post-authorization
|
||||
// identity screen. Identity is now selected before consent, so normalize
|
||||
// the stale URL without asking a contradictory second question.
|
||||
navigate(appTabHref(connectionId, "setup"), { replace: true });
|
||||
}, [activeTab, connectionId, navigate, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "test"
|
||||
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<string, unknown>) => 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<string | null>(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 <Navigate replace to={`${appTabHref(connectionId, "permissions")}${query ? `?${query}` : ""}`} />;
|
||||
}
|
||||
if (tab === "activity") {
|
||||
return <Navigate replace to="/activity?action=tool_" />;
|
||||
}
|
||||
|
||||
if (!connectionId || !activeTab) {
|
||||
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps"} />;
|
||||
return <Navigate replace to={connectionId ? appTabHref(connectionId, "permissions") : "/apps"} />;
|
||||
}
|
||||
|
||||
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" && (
|
||||
<div className="space-y-12">
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
agentsSummary={setupAgentsSummary}
|
||||
permissionsSummary={setupPermissionsSummary}
|
||||
permissionsLoading={setupPermissionsLoading}
|
||||
onOpenPermissions={() => navigate(appTabHref(connectionId, "permissions"))}
|
||||
identities={
|
||||
<IdentitiesSection
|
||||
appName={appName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
ownerUserId={connection.createdByUserId}
|
||||
connectedUser={owner}
|
||||
grantsQuery={grantsQuery.data}
|
||||
loading={grantsQuery.isLoading}
|
||||
error={grantsQuery.isError}
|
||||
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
|
||||
audiencePending={replaceAudience.isPending}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={audienceOpenGrantId}
|
||||
onOpenAudience={(grantId) => {
|
||||
setAudienceError(null);
|
||||
setAudienceOpenGrantId(grantId);
|
||||
}}
|
||||
onCloseAudience={() => {
|
||||
setAudienceOpenGrantId(null);
|
||||
setAudienceError(null);
|
||||
}}
|
||||
onConnectAsMe={() => startPersonalAuth.mutate()}
|
||||
// The organization identity is a shared credential, so it goes
|
||||
// through the connection-level OAuth start, not a personal one.
|
||||
onConnectOrganization={() => startOAuth.mutate()}
|
||||
onReplaceAudience={(grant, memberUserIds) =>
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName={appName}
|
||||
galleryEntry={logoEntry}
|
||||
childConnectionCount={composioChildConnectionCount}
|
||||
removing={removeApp.isPending}
|
||||
onRemove={() => removeApp.mutate()}
|
||||
canReplaceCredential={canReconnect}
|
||||
credentialUnavailableMessage={reconnectUnavailableMessage}
|
||||
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
|
||||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
identityGrant={managedIdentityGrant}
|
||||
identityCurrentUserId={grantsQuery.data?.currentUserId ?? null}
|
||||
identityProviderName={baseAppName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
identityActionPending={
|
||||
startPersonalAuth.isPending || startOAuth.isPending || revokeGrant.isPending
|
||||
}
|
||||
onReconnectIdentity={managedIdentityGrant ? () => {
|
||||
if (managedIdentityGrant.kind === "user") startPersonalAuth.mutate();
|
||||
else startOAuth.mutate();
|
||||
} : undefined}
|
||||
onRevokeIdentity={(grant) => revokeGrant.mutate(grant.id)}
|
||||
onReplaced={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "services" && (
|
||||
<ServicesPanel connectionId={connectionId} appName={appName} />
|
||||
)}
|
||||
|
|
@ -763,46 +531,52 @@ export function AppDetail() {
|
|||
}} />
|
||||
: permissionsLoading
|
||||
? <ToolsLoading />
|
||||
: <PermissionsPanel
|
||||
capabilities={grantsQuery.data?.capabilities}
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
installPending={persistInstall.isPending}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: accessIncludingInstalls(next, install) })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "test" && (
|
||||
catalogQuery.isError
|
||||
? <ToolsLoadError onRetry={() => { void catalogQuery.refetch(); }} />
|
||||
: catalogQuery.isLoading
|
||||
? <ToolsLoading mcpActions />
|
||||
: <TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
<ActivityPanel
|
||||
events={activityQuery.data?.events ?? []}
|
||||
lifecycleEvents={activityQuery.data?.lifecycleEvents ?? []}
|
||||
issues={activityQuery.data?.issues ?? {}}
|
||||
actionRequests={activityQuery.data?.actionRequests ?? {}}
|
||||
loading={activityQuery.isLoading}
|
||||
agents={agents}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
userLabelById={userLabelById}
|
||||
/>
|
||||
: <div className="space-y-10">
|
||||
<IdentitiesSection
|
||||
appName={appName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
ownerUserId={connection.createdByUserId}
|
||||
connectedUser={owner}
|
||||
grantsQuery={grantsQuery.data}
|
||||
loading={grantsQuery.isLoading}
|
||||
error={grantsQuery.isError}
|
||||
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
|
||||
audiencePending={replaceAudience.isPending}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={audienceOpenGrantId}
|
||||
onOpenAudience={(grantId) => {
|
||||
setAudienceError(null);
|
||||
setAudienceOpenGrantId(grantId);
|
||||
}}
|
||||
onCloseAudience={() => {
|
||||
setAudienceOpenGrantId(null);
|
||||
setAudienceError(null);
|
||||
}}
|
||||
onConnectAsMe={() => startPersonalAuth.mutate()}
|
||||
onConnectOrganization={() => startOAuth.mutate()}
|
||||
onReplaceAudience={(grant, memberUserIds) =>
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
<PermissionsPanel
|
||||
connectionId={connectionId}
|
||||
capabilities={grantsQuery.data?.capabilities}
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: accessIncludingInstalls(next, install) })}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 <Navigate to="/activity?action=tool_" replace />;
|
||||
}
|
||||
if (tab === "setup" || tab === "test") {
|
||||
return <Navigate to={appApplicationTabHref(applicationId, "permissions")} replace />;
|
||||
}
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
|
||||
}
|
||||
if (!applicationId || !activeTab) {
|
||||
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps"} replace />;
|
||||
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "permissions") : "/apps"} replace />;
|
||||
}
|
||||
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
|
||||
return (
|
||||
|
|
@ -164,9 +114,12 @@ export function AppNotConnected() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
if (activeConnection && activeTab !== "setup") {
|
||||
if (activeConnection) {
|
||||
return <Navigate to={appTabHref(activeConnection.id, activeTab)} replace />;
|
||||
}
|
||||
if (activeTab === "services") {
|
||||
return <Navigate to={appApplicationTabHref(applicationId, "permissions")} replace />;
|
||||
}
|
||||
|
||||
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
|
||||
const logoEntry = (appSourceSlug
|
||||
|
|
@ -223,26 +176,13 @@ export function AppNotConnected() {
|
|||
connectedCount={activeConnections.length}
|
||||
/>
|
||||
|
||||
{activeTab === "setup" && (
|
||||
<div className="space-y-8">
|
||||
<SetupTab
|
||||
applicationName={application.name}
|
||||
activeConnections={activeConnections}
|
||||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
userProfileById={userProfileById}
|
||||
canReconnect={canReconnect}
|
||||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||||
onConnect={() => navigate(connectHref)}
|
||||
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
|
||||
/>
|
||||
<DangerZone
|
||||
appName={application.name}
|
||||
removing={remove.isPending}
|
||||
onRemove={() => remove.mutate()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionCallout
|
||||
applicationName={application.name}
|
||||
previousConnection={previousConnection}
|
||||
canReconnect={canReconnect}
|
||||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||||
onConnect={() => navigate(connectHref)}
|
||||
/>
|
||||
{activeTab === "review" && (
|
||||
previousConnection ? (
|
||||
<ReviewPanel connectionId={previousConnection.id} />
|
||||
|
|
@ -256,37 +196,6 @@ export function AppNotConnected() {
|
|||
{activeTab === "permissions" && (
|
||||
<PermissionsTab previousConnection={previousConnection} />
|
||||
)}
|
||||
{activeTab === "test" && (
|
||||
<EmptyTab
|
||||
title="Reconnect to test this app."
|
||||
body="Testing becomes available after this app is connected again."
|
||||
/>
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
previousConnection ? (
|
||||
<ActivityPanel
|
||||
events={activityQuery.data?.events ?? []}
|
||||
lifecycleEvents={activityQuery.data?.lifecycleEvents ?? []}
|
||||
issues={activityQuery.data?.issues ?? {}}
|
||||
actionRequests={activityQuery.data?.actionRequests ?? {}}
|
||||
loading={activityQuery.isLoading}
|
||||
agents={agentsQuery.data ?? []}
|
||||
connectionId={previousConnection.id}
|
||||
appName={appName}
|
||||
/>
|
||||
) : (
|
||||
<ActivityPanel
|
||||
events={[]}
|
||||
lifecycleEvents={[]}
|
||||
issues={{}}
|
||||
actionRequests={{}}
|
||||
loading={false}
|
||||
agents={[]}
|
||||
connectionId=""
|
||||
appName={appName}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, CompanyUserProfile>;
|
||||
canReconnect: boolean;
|
||||
reconnectUnavailableMessage: string;
|
||||
onConnect: () => void;
|
||||
onEdit: (connectionId: string) => void;
|
||||
}) {
|
||||
if (activeConnections.length > 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Already connected to {applicationName}</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Edit an existing connection, or deliberately add another account below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{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 (
|
||||
<button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
onClick={() => onEdit(connection.id)}
|
||||
className="flex w-full items-center gap-3 py-3 text-left transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{connectionDisplayNameForOwner(connection, applicationName, owner)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{secondary}</div>
|
||||
</div>
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
<span className="text-xs text-muted-foreground">{status}</span>
|
||||
<span className="text-xs font-semibold text-primary">Edit →</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Connect another</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Add another {applicationName} account without changing the connections above.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onConnect}>Connect another</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
{previousConnection ? "Reconnect this app" : "Connect this app"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
{previousConnection && !canReconnect ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{reconnectUnavailableMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{!previousConnection || canReconnect ? (
|
||||
<Button onClick={onConnect}>
|
||||
{previousConnection ? "Reconnect" : "Connect"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{previousConnection && (
|
||||
<PreviousSetup
|
||||
connection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
owner={connectionOwnerProfile(previousConnection, userProfileById)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviousSetup({
|
||||
connection,
|
||||
previousAddress,
|
||||
owner,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
previousAddress: string | null;
|
||||
owner: CompanyUserProfile | null;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">Previous setup</h2>
|
||||
{owner && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Connected by</span>
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
)}
|
||||
{connection.healthMessage && (
|
||||
<p className="mt-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Last error: {connection.healthMessage}
|
||||
<section className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{previousConnection ? "Needs attention" : "Not connected"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{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.`}
|
||||
</p>
|
||||
)}
|
||||
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
<dd className="break-all font-mono text-foreground">{previousAddress}</dd>
|
||||
<dt className="text-muted-foreground">Connection type</dt>
|
||||
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
|
||||
<dt className="text-muted-foreground">Last used</dt>
|
||||
<dd className="text-foreground">
|
||||
{connection.lastUsedAt ? timeAgo(connection.lastUsedAt) : "Never"}
|
||||
</dd>
|
||||
</dl>
|
||||
{previousConnection && !canReconnect ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{reconnectUnavailableMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{!previousConnection || canReconnect ? (
|
||||
<Button onClick={onConnect}>{previousConnection ? "Reconnect" : "Connect"}</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -279,11 +279,11 @@ describe("Connectors landing page", () => {
|
|||
await act(async () => {
|
||||
notion
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'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 () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<button
|
||||
type="button"
|
||||
className="block max-w-full cursor-pointer truncate text-left text-sm font-medium text-foreground hover:underline focus-visible:underline"
|
||||
aria-label={`Open ${accountName} connection settings`}
|
||||
onClick={() => onNavigate(`/apps/${connection.id}/setup`)}
|
||||
aria-label={`Open ${accountName} permissions`}
|
||||
onClick={() => onNavigate(`/apps/${connection.id}/permissions`)}
|
||||
>
|
||||
{accountName}
|
||||
</button>
|
||||
|
|
@ -663,8 +663,8 @@ function ConnectionAccountRow({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onNavigate(`/apps/${connection.id}/setup`)}>
|
||||
Edit connection
|
||||
<DropdownMenuItem onSelect={() => onNavigate(`/apps/${connection.id}/permissions`)}>
|
||||
Permissions
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={onRemove}>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<tr
|
||||
key={connection?.id ?? application.id}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type TimelineRow = {
|
|||
dotClass: string;
|
||||
/** Secondary "while working on PAP-…" issue link, tool-call rows only. */
|
||||
issue?: { identifier: string } | null;
|
||||
/** Deep-link rendered after the timestamp ("View in Setup"), lifecycle rows only. */
|
||||
/** Deep-link rendered after the timestamp, lifecycle rows only. */
|
||||
link?: { to: string; label: string } | null;
|
||||
};
|
||||
|
||||
|
|
@ -59,13 +59,13 @@ function RecentActivity({
|
|||
};
|
||||
});
|
||||
|
||||
const setupHref = appTabHref(connectionId, "setup");
|
||||
const permissionsHref = appTabHref(connectionId, "permissions");
|
||||
const lifecycleRows: TimelineRow[] = lifecycleEvents.map((event) => ({
|
||||
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<string, unknown> |
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -160,11 +160,19 @@ export function IdentitiesSection({
|
|||
<section className="space-y-5">
|
||||
<IdentitiesHeading />
|
||||
|
||||
<ConnectionAudienceCallout
|
||||
<HumanAccessCards
|
||||
personal={usesPersonalIdentity}
|
||||
restricted={!usesPersonalIdentity && Boolean(orgGrant?.members?.length)}
|
||||
connectedName={usesPersonalIdentity ? personalSubjectLabel ?? connectedUser?.label ?? null : null}
|
||||
connectedImage={usesPersonalIdentity ? connectedUser?.image ?? null : null}
|
||||
status={(usesPersonalIdentity ? personalGrant : orgGrant)?.status ?? null}
|
||||
canEditAudience={orgGrant?.capabilities?.canEditAudience ?? false}
|
||||
onChooseAll={() => {
|
||||
if (orgGrant) onReplaceAudience(orgGrant, []);
|
||||
}}
|
||||
onChooseSelected={() => {
|
||||
if (orgGrant) onOpenAudience(orgGrant.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
|
@ -224,39 +232,69 @@ export function IdentitiesSection({
|
|||
}
|
||||
|
||||
function IdentitiesHeading() {
|
||||
return <h2 className="text-sm font-semibold text-foreground">Account</h2>;
|
||||
return <h2 className="text-sm font-semibold text-foreground">Which humans can use this credential?</h2>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-start gap-4 rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{personal
|
||||
? "Only you can use this connection"
|
||||
: "Anyone in your company can use this connection"}
|
||||
</p>
|
||||
{connectedName && status !== null ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Identity name={connectedName} avatarUrl={connectedImage} />
|
||||
{status === "active" ? null : <StatusText status={status} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which humans can use this credential"
|
||||
value={personal ? "personal" : restricted ? "selected" : "company"}
|
||||
className="sm:grid-cols-2"
|
||||
onValueChange={(next) => {
|
||||
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: <UserRound className="h-4 w-4" />,
|
||||
},
|
||||
] : [
|
||||
{
|
||||
value: "selected",
|
||||
title: "Humans I pick",
|
||||
description: "Only selected people in your company.",
|
||||
icon: <UserRound className="h-4 w-4" />,
|
||||
disabled: !canEditAudience,
|
||||
},
|
||||
{
|
||||
value: "company",
|
||||
title: "Any human in the company",
|
||||
description: "Anyone in your company can use this connection.",
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
disabled: !canEditAudience,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{connectedName && status !== null ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Identity name={connectedName} avatarUrl={connectedImage} />
|
||||
{status === "active" ? null : <StatusText status={status} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-10">
|
||||
<AlwaysInstalledSection
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
install={install}
|
||||
capabilities={capabilities}
|
||||
disabled={installPending}
|
||||
onSave={onSaveInstall}
|
||||
/>
|
||||
<AgentAccessSection
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
|
|
@ -91,6 +68,8 @@ export function PermissionsPanel({
|
|||
onSave={onSaveAccess}
|
||||
/>
|
||||
<ActionsSection
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
|
|
@ -98,7 +77,7 @@ export function PermissionsPanel({
|
|||
askFirstIds={askFirstIds}
|
||||
disabled={pending}
|
||||
refreshPending={refreshPending}
|
||||
focusId={focusId}
|
||||
focusId={searchParams.get("focus")}
|
||||
canConfigure={capabilities?.canConfigure ?? false}
|
||||
onSetPermission={onSetActionPermission}
|
||||
onReviewQuarantined={onReviewQuarantined}
|
||||
|
|
@ -109,7 +88,6 @@ export function PermissionsPanel({
|
|||
}
|
||||
|
||||
function AgentAccessSection({
|
||||
appName,
|
||||
agents,
|
||||
access,
|
||||
install,
|
||||
|
|
@ -117,7 +95,6 @@ function AgentAccessSection({
|
|||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: Agent[];
|
||||
access: AccessDraft;
|
||||
install: InstallState;
|
||||
|
|
@ -125,7 +102,7 @@ function AgentAccessSection({
|
|||
disabled: boolean;
|
||||
onSave: (next: AccessDraft) => 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 (
|
||||
<section className="border-t border-border pt-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Agent access</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Agents that may use {appName} when work needs it.
|
||||
</p>
|
||||
</div>
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Which agents can use this connection?</h2>
|
||||
{disabled ? <span className="text-xs text-muted-foreground">Saving…</span> : null}
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="space-y-3">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which agents can use this connection"
|
||||
value={access.mode}
|
||||
|
|
@ -169,10 +135,10 @@ function AgentAccessSection({
|
|||
options={[
|
||||
{
|
||||
value: "specific",
|
||||
title: "Agents I pick",
|
||||
title: "Just agents I pick",
|
||||
description: install.onAll
|
||||
? "Unavailable while installed for every agent."
|
||||
: "Only selected agents.",
|
||||
? "Unavailable while this connection is installed for every agent."
|
||||
: "Available only to selected agents.",
|
||||
disabled: install.onAll,
|
||||
},
|
||||
{
|
||||
|
|
@ -188,19 +154,12 @@ function AgentAccessSection({
|
|||
agents={selectableAgents}
|
||||
selectedAgentIds={access.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={
|
||||
access.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} selected`
|
||||
}
|
||||
triggerLabel={access.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} selected`}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
isAgentDisabled={(agent) => requiredAgentIds.has(agent.id)}
|
||||
getDescription={(agent) => requiredAgentIds.has(agent.id) ? "Always installed" : agent.title}
|
||||
headerContent={requiredAgentIds.size > 0 ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Always-installed agents keep access.
|
||||
</p>
|
||||
) : null}
|
||||
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}
|
||||
</div>
|
||||
) : access.mode === "all" ? (
|
||||
<p className="text-sm text-muted-foreground">Any agent can use this connection.</p>
|
||||
) : selectedAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No agents can use this connection.</p>
|
||||
) : (
|
||||
// Read-only: the state is still fully legible, just not editable.
|
||||
<div className="pt-3">
|
||||
{access.mode === "all" ? (
|
||||
<p className="text-sm text-muted-foreground">Every agent can use this connection.</p>
|
||||
) : selectedAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No agents can use this connection.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AlwaysInstalledSection({
|
||||
appName,
|
||||
agents,
|
||||
install,
|
||||
capabilities,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: Agent[];
|
||||
install: InstallState;
|
||||
capabilities: ToolConnectionCapabilities | undefined;
|
||||
disabled: boolean;
|
||||
onSave: (next: InstallState) => void;
|
||||
}) {
|
||||
const liveAgents = agents.filter((agent) => agent.status !== "terminated");
|
||||
const canManage = capabilities?.canManageAgentInstalls ?? false;
|
||||
const canSetCompanyWide = capabilities?.canSetCompanyInstall ?? false;
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
const selectableAgents = editableAgentIds
|
||||
? liveAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||||
: liveAgents;
|
||||
const selectedAgents = liveAgents.filter((agent) => install.agentIds.has(agent.id));
|
||||
const mode: "all" | "specific" = install.onAll ? "all" : "specific";
|
||||
const summary = install.onAll
|
||||
? "Every agent"
|
||||
: install.agentIds.size === 0
|
||||
? "No agents"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"}`;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Always installed</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Loads {appName} on every run. Agent access only makes it available when needed.
|
||||
</p>
|
||||
</div>
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="space-y-3 pt-4">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which agents always load this connection"
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
className="sm:grid-cols-2"
|
||||
onValueChange={(next) => {
|
||||
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" ? (
|
||||
<AgentMultiSelect
|
||||
agents={selectableAgents}
|
||||
selectedAgentIds={install.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={install.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} selected`}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-3">
|
||||
{install.onAll ? (
|
||||
<p className="text-sm text-muted-foreground">This connection is always loaded for every agent.</p>
|
||||
) : selectedAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">This connection is not always loaded for any agent.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -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<ActionKindFilter>("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 (
|
||||
<section className="space-y-10 border-t border-border pt-8">
|
||||
<section className="space-y-6 border-t border-border pt-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatActionPermissionSummary(summarizeActionPermissions(
|
||||
[...readOnly, ...canChange],
|
||||
enabledIds,
|
||||
askFirstIds,
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
{/* Viewer rule D4: a forbidden action is omitted, not rendered disabled.
|
||||
Refreshing the catalog mutates the connection, so a caller who may
|
||||
not configure it never sees the control. */}
|
||||
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
|
||||
{canConfigure ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving...</span>}
|
||||
{disabled ? <span className="text-xs text-muted-foreground">Saving…</span> : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -400,50 +256,92 @@ function ActionsSection({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{canConfigure && quarantined.length > 0 && (
|
||||
{canConfigure && quarantined.length > 0 ? (
|
||||
<QuarantinedActionsReview
|
||||
entries={quarantined}
|
||||
disabled={disabled}
|
||||
onSubmit={onReviewQuarantined}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<ActionGroup
|
||||
title={`Read (${readOnly.length})`}
|
||||
hint="Views data without changing it."
|
||||
actions={readOnly}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
<ActionGroup
|
||||
title={`Write (${canChange.length})`}
|
||||
hint="Creates or changes data."
|
||||
actions={canChange}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-(--sz-12rem) flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Find an action"
|
||||
placeholder="Find an action…"
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FilterChip label={`All ${readOnly.length + canChange.length}`} active={kindFilter === "all"} onClick={() => setKindFilter("all")} />
|
||||
<FilterChip label={`Read ${readOnly.length}`} active={kindFilter === "read"} onClick={() => setKindFilter("read")} />
|
||||
<FilterChip label={`Write ${canChange.length}`} active={kindFilter === "write"} onClick={() => setKindFilter("write")} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{visibleCount} matches · sorted A–Z</p>
|
||||
</div>
|
||||
|
||||
{visibleCount === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No actions match “{query}”. Clear the search to see them all.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ActionGroup
|
||||
title={`Read (${visibleRead.length})`}
|
||||
actions={visibleRead}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
<ActionGroup
|
||||
title={`Write (${visibleWrite.length})`}
|
||||
actions={visibleWrite}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_PERMISSION_LABELS: Record<ActionPermission, string> = {
|
||||
off: "Off",
|
||||
allowed: "Allowed",
|
||||
ask: "Ask a human first",
|
||||
};
|
||||
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>;
|
||||
askFirstIds: Set<string>;
|
||||
disabled: boolean;
|
||||
|
|
@ -461,69 +360,144 @@ function ActionGroup({
|
|||
canConfigure: boolean;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
}) {
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (focusId && focusRef.current) {
|
||||
focusRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [focusId]);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-4">
|
||||
<div className="text-lg font-semibold text-foreground">{title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
<div className="divide-y divide-border">
|
||||
{actions.map((action) => {
|
||||
const value = actionPermission(action.id, enabledIds, askFirstIds);
|
||||
const focused = focusId === action.id;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
ref={focused ? focusRef : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-4 py-3",
|
||||
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
|
||||
)}
|
||||
data-action-id={action.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{action.title ?? action.toolName}</div>
|
||||
{action.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{canConfigure ? (
|
||||
<select
|
||||
aria-label={`${action.title ?? action.toolName} permission`}
|
||||
className={cn(
|
||||
"h-9 w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onSetPermission(action.id, event.currentTarget.value as ActionPermission)}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="allowed">Allowed</option>
|
||||
<option value="ask">Ask a human first</option>
|
||||
</select>
|
||||
) : (
|
||||
// Read-only: the same fact, stated rather than offered.
|
||||
<span className="w-44 shrink-0 text-sm text-muted-foreground">
|
||||
{ACTION_PERMISSION_LABELS[value]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{actions.map((action) => (
|
||||
<ActionRow
|
||||
key={action.id}
|
||||
action={action}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
value={actionPermission(action.id, enabledIds, askFirstIds)}
|
||||
disabled={disabled}
|
||||
focused={focusId === action.id}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>(null);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const title = action.title ?? action.toolName;
|
||||
|
||||
useEffect(() => {
|
||||
if (focused) rowRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [focused]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
"flex flex-col gap-3 py-3 sm:flex-row sm:items-center",
|
||||
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
|
||||
)}
|
||||
data-action-id={action.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
{action.description ? (
|
||||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canConfigure ? (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={`${title} permission`}
|
||||
className="inline-flex rounded-md border border-border bg-muted/40 p-0.5"
|
||||
>
|
||||
{PERMISSION_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={`${title}: ${option.label}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onSetPermission(action.id, option.value)}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors",
|
||||
"hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
selected && "bg-background text-foreground shadow-xs",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<span className="font-medium">{option.label}</span> — {option.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{PERMISSION_OPTIONS.find((option) => option.value === value)?.label}
|
||||
</span>
|
||||
)}
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setTestOpen(true)}>
|
||||
<FlaskConical className="mr-1.5 h-3.5 w-3.5" />
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ActionTestDialog
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
entry={action}
|
||||
open={testOpen}
|
||||
onOpenChange={setTestOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function actionPermission(
|
||||
id: string,
|
||||
enabledIds: Set<string>,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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<TestAgentWithAccess | null>(() => (
|
||||
selectedAgentBase && accessQuery.data
|
||||
? { ...selectedAgentBase, effectiveAccess: accessQuery.data.access }
|
||||
: null
|
||||
), [accessQuery.data, selectedAgentBase]);
|
||||
const decision = useMemo<ToolConnectionTestDecision>(() => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test {title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run a real action with the same permissions and credentials an agent would use.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{testAgentsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agents…
|
||||
</div>
|
||||
) : testAgentsQuery.isError ? (
|
||||
<TestLoadError
|
||||
message="We couldn't load the agents available for testing."
|
||||
onRetry={() => { void testAgentsQuery.refetch(); }}
|
||||
/>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="py-6 text-sm text-muted-foreground">No agents are available to test as.</p>
|
||||
) : accessQuery.isError && !accessQuery.data ? (
|
||||
<TestLoadError
|
||||
message={`We couldn't load ${selectedAgentBase?.name ?? "this agent"}'s permissions.`}
|
||||
onRetry={() => { void accessQuery.refetch(); }}
|
||||
/>
|
||||
) : !selectedAgent ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agent permissions…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">Act as</p>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<AgentPicker
|
||||
agents={agents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelect={setRequestedAgentId}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
inline
|
||||
/>
|
||||
<DecisionBadge decision={decision} />
|
||||
</div>
|
||||
</div>
|
||||
<ActionTester
|
||||
key={`${entry.id}:${selectedAgent.id}`}
|
||||
entry={entry}
|
||||
decision={decision}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
agent={selectedAgent}
|
||||
allAgents={agents}
|
||||
onSelectAgent={setRequestedAgentId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DECISION_META: Record<ToolConnectionTestDecision, DecisionMeta> = {
|
||||
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.
|
||||
</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link to={appTabHref(connectionId, "setup")}>Go to Setup</Link>
|
||||
<Link to={appTabHref(connectionId, "permissions")}>Go to Permissions</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1046,8 +1173,8 @@ function AllowedResult({
|
|||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This call is in the{" "}
|
||||
<Link className="text-primary hover:underline" to={appTabHref(connectionId, "activity")}>
|
||||
Activity tab
|
||||
<Link className="text-primary hover:underline" to="/activity?mode=agents&action=tool_">
|
||||
Audit log
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
@ -1177,8 +1304,8 @@ function ErrorResult({
|
|||
<p className="mt-3 text-xs text-muted-foreground">Adjust the input above and try again.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Also visible in the{" "}
|
||||
<Link className="text-primary hover:underline" to={appTabHref(connectionId, "activity")}>
|
||||
Activity tab
|
||||
<Link className="text-primary hover:underline" to="/activity?mode=agents&action=tool_">
|
||||
Audit log
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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<AppTabKey> = new Set<AppTabKey>([
|
||||
"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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export function AppsToolsPanel({
|
|||
<tbody>
|
||||
{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 (
|
||||
<tr key={app.application.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<li className="flex items-center justify-between gap-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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 <span className="font-medium text-foreground">{document.key}</span>;
|
||||
}
|
||||
const connectionId = record.entityType === "tool_connection"
|
||||
? record.entityId
|
||||
: typeof record.details?.connectionId === "string"
|
||||
? record.details.connectionId
|
||||
: null;
|
||||
if (connectionId) {
|
||||
return (
|
||||
<Link to={`/apps/${connectionId}/permissions`} className="font-medium text-primary hover:underline">
|
||||
the connection
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// Non-linkable entities (company, agent, goal, …) — show a plain descriptor.
|
||||
return <span className="text-muted-foreground">{record.entityType}</span>;
|
||||
}
|
||||
|
|
@ -271,11 +287,18 @@ export function AuditFeed({
|
|||
hideHeader,
|
||||
mode,
|
||||
onModeChange,
|
||||
actionDomain: controlledActionDomain,
|
||||
onActionDomainChange,
|
||||
}: AuditFeedProps) {
|
||||
const { pushToast } = useToastActions();
|
||||
const [agent, setAgent] = useState<string>(ALL);
|
||||
const [responsibleUser, setResponsibleUser] = useState<string>(ALL);
|
||||
const [actionDomain, setActionDomain] = useState<string>(ALL);
|
||||
const [localActionDomain, setLocalActionDomain] = useState<string>(ALL);
|
||||
const actionDomain = controlledActionDomain ?? localActionDomain;
|
||||
const setActionDomain = (next: string) => {
|
||||
setLocalActionDomain(next);
|
||||
onActionDomainChange?.(next);
|
||||
};
|
||||
const [entityType, setEntityType] = useState<string>(ALL);
|
||||
const [dateFrom, setDateFrom] = useState<string>("");
|
||||
const [dateTo, setDateTo] = useState<string>("");
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <EmptyState icon={History} message="Select an organization to view Audit." />;
|
||||
}
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 <AuditHub section="activity" />;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={History} message="Select an organization to view activity." />;
|
||||
}
|
||||
|
||||
return <AuditFeed companyId={selectedCompanyId} mode={mode} onModeChange={handleModeChange} />;
|
||||
return (
|
||||
<AuditFeed
|
||||
companyId={selectedCompanyId}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
actionDomain={actionDomain}
|
||||
onActionDomainChange={handleActionDomainChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ function missingCredentialFields(draft: McpJsonImportDraft, values: Record<strin
|
|||
|
||||
function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
|
||||
const raw = (result.suggestedDefaults as { askFirstRiskLevels?: unknown })?.askFirstRiskLevels;
|
||||
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
|
||||
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<string, boolean> = {};
|
||||
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}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={onFinish} disabled={finishing || enabledCount === 0 || Boolean(activatedName)}>
|
||||
|
|
|
|||
|
|
@ -137,26 +137,24 @@ function PanelHarness({
|
|||
install: InstallState;
|
||||
capabilities?: ToolConnectionCapabilities;
|
||||
}) {
|
||||
const [state, setState] = useState(install);
|
||||
const [access, setAccess] = useState<AccessDraft>({ mode: "all", agentIds: new Set() });
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<PermissionsPanel
|
||||
connectionId="connection-gmail"
|
||||
capabilities={capabilities}
|
||||
appName="Gmail"
|
||||
agents={AGENTS}
|
||||
access={access}
|
||||
install={state}
|
||||
install={install}
|
||||
readOnly={GMAIL_TOOLS.filter((t) => t.isReadOnly)}
|
||||
canChange={GMAIL_TOOLS.filter((t) => !t.isReadOnly)}
|
||||
quarantined={[]}
|
||||
enabledIds={new Set(["g-list", "g-read"])}
|
||||
askFirstIds={new Set(["g-send"])}
|
||||
pending={false}
|
||||
installPending={false}
|
||||
refreshPending={false}
|
||||
onSaveAccess={setAccess}
|
||||
onSaveInstall={setState}
|
||||
onSetActionPermission={() => {}}
|
||||
onReviewQuarantined={() => {}}
|
||||
onRefreshActions={() => {}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue