Merge PR fast-path activation from master
* commit '89bf6a33c23d12c592c2c45b07ac36a362d418e6': ci: activate Node-first pnpm setup for PRs (#12810) feat(apps): unify permissions and action testing (#12802)
This commit is contained in:
commit
49910c1c24
|
|
@ -10,4 +10,4 @@ permissions:
|
|||
|
||||
jobs:
|
||||
ci:
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@f038633bf5b04163ff985ef0542876bd9f455379
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@a0a78ee60946a5f79f85b2bd0584fc766fae43bb
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import path from "node:path";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const ordinaryPrTrustedWorkflowRevision =
|
||||
"a0a78ee60946a5f79f85b2bd0584fc766fae43bb";
|
||||
const fullStackTestNeeds =
|
||||
/needs:\s*\[\s*authorize,\s*target_lock,\s*catalog,\s*daytona_image,\s*build_runner_artifacts,\s*build_remote_provider_pack,?\s*\]/u;
|
||||
const buildRunnerNeeds =
|
||||
|
|
@ -11,6 +13,26 @@ const buildRemoteProviderPackNeeds =
|
|||
/needs:\s*\[\s*authorize,\s*target_lock,\s*catalog,\s*daytona_image,\s*build_runner_artifacts,?\s*\]/u;
|
||||
|
||||
describe("public repository paid workflow security", () => {
|
||||
it("pins ordinary PR CI to the trusted Node-before-pnpm workflow", async () => {
|
||||
const ordinaryPrWorkflow = await readFile(
|
||||
path.join(repositoryRoot, ".github/workflows/pr.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const trustedWorkflowCalls = [
|
||||
...ordinaryPrWorkflow.matchAll(
|
||||
/^\s+uses:\s+(paperclipai\/paperclip\/\.github\/workflows\/pr-trusted\.yml)@([0-9a-f]{40})$/gmu,
|
||||
),
|
||||
];
|
||||
|
||||
expect(trustedWorkflowCalls).toHaveLength(1);
|
||||
expect(trustedWorkflowCalls[0]?.[1]).toBe(
|
||||
"paperclipai/paperclip/.github/workflows/pr-trusted.yml",
|
||||
);
|
||||
expect(trustedWorkflowCalls[0]?.[2]).toBe(
|
||||
ordinaryPrTrustedWorkflowRevision,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pnpm bootstrap registry telemetry out of trusted workflow setup", async () => {
|
||||
for (const workflowName of [
|
||||
"runner-full-stack-e2e.yml",
|
||||
|
|
|
|||
|
|
@ -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