Simplify app connections and enable managed Google access (#12728)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps subsystem gives humans and agents governed access to external tools. > - The current connection flow hides Apps behind an experimental gate and repeats setup text. > - Google sharing choices and generic MCP permissions do not use one consistent opening model. > - Self-hosted installs also need a safe default origin for managed OAuth without a manual config file. > - This pull request makes Apps available, simplifies connection setup, and applies one governed permissions model. > - The benefit is a shorter connection flow that works on a clean self-hosted install. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the Apps connection setup flow, managed Google connection flow, generic MCP connection flow, navigation, and runtime origin discovery. **Subsystem affected** Cross-cutting. This changes `ui/`, `server/`, `packages/shared/`, connector documentation, and browser tests. **Current behavior** Apps require an experimental switch. Setup pages repeat titles and explanatory copy. Connection names require manual input. Google credential sharing does not always offer both personal and organization access. Generic MCP providers do not start with the same permission choices. Managed OAuth needs a public URL setting even when the request already has a safe HTTPS origin. **Proposed behavior** Apps are available by default. Setup asks only for required permissions and sharing choices. Paperclip creates conflict-free connection names. Google apps and generic MCP providers use the same human and agent access model. Managed OAuth derives a validated same-origin HTTPS URL when no explicit public URL is set. **Reason and benefit** A clean self-hosted install can connect a managed Google app without hidden setup. Humans can share a service account with their organization. The shorter flow reduces duplicated choices and setup errors. **Breaking changes** The Apps experimental switch is removed. Existing connection APIs remain compatible. New connections can receive a numeric suffix when a name already exists. No duplicate or related public issue was found. ## What Changed - Removed the Apps experimental gate and the breadcrumb that leaves the Apps section. - Simplified all connection setup pages and moved optional provider requirements into one small link. - Added consistent human and agent access choices for Google apps, Zapier, and generic MCP connections. - Added organization sharing to Google Workspace credentials while keeping personal access available. - Generated connection names automatically and resolved name conflicts with numeric suffixes. - Derived a validated public HTTPS origin from the request for config-free managed OAuth. - Updated connector contracts, tests, browser coverage, and authoring documentation. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` - `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts server/src/__tests__/generic-mcp-connection.test.ts` (273 passed) - Targeted UI/service regression suite (308 passed) - Six targeted Playwright connection journeys on a fresh onboarding instance (6 passed) - Fresh-install browser proof through Tailscale HTTPS: enrolled with Paperclip Cloud, connected managed Google Drive, and completed a real read operation. - [Exact-head CI run](https://github.com/paperclipai/paperclip/actions/runs/33669760711): all 23 matrix jobs passed, including build, typecheck, server, serialized, canary, and all browser shards. - Greptile 5/5 on `0ae2a859f269984ee950d0af231a5b09a06f3dfd`, with no unresolved review threads. ## Risks Apps are now visible to all operators. The removed experimental flag no longer hides unfinished app definitions. Managed Google availability still depends on the Cloud profile rollout and active instance enrollment. Automatic conflict handling changes only the display name of a newly conflicting connection. > I checked [`ROADMAP.md`](ROADMAP.md). MCP Tool Gateway and Apps are shipped. Connected Apps is planned, and this change improves the existing shipped connection flow. ## Model Used OpenAI Codex, GPT-5, with reasoning, browser control, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5716fe907e
commit
8c3b8c432a
|
|
@ -746,9 +746,12 @@ Walk the user path:
|
|||
|
||||
For OAuth, the instance callback must be browser-reachable and must match the
|
||||
provider registration. Loopback HTTP is acceptable only when provider and
|
||||
Paperclip redirect policies permit it. A worktree exposed through HTTPS needs a
|
||||
unique, correct `PAPERCLIP_PUBLIC_URL`; internal service hostnames are not valid
|
||||
browser callback origins.
|
||||
Paperclip redirect policies permit it. Browser-started setup on an authenticated
|
||||
private instance automatically uses the same-origin HTTPS address that served
|
||||
the setup page, including a Tailscale Serve address; the request must pass the
|
||||
hostname and board-mutation guards. An explicit `PAPERCLIP_PUBLIC_URL` remains
|
||||
available for non-browser starts and unusual proxy topologies. Internal service
|
||||
hostnames are not valid browser callback origins.
|
||||
|
||||
Use the browser signed-in session only for an explicitly authorized live proof.
|
||||
Do not inspect cookies, storage, saved passwords, or unrelated account data.
|
||||
|
|
@ -1782,8 +1785,8 @@ plain-HTTP non-loopback origins.
|
|||
loopback HTTP (Notion's redirect-URI rule). A plain-HTTP non-loopback origin
|
||||
gets "This provider requires an HTTPS or loopback origin. Configure TLS
|
||||
before connecting." — add TLS first (e.g. a tailscale cert, as
|
||||
paperclip-dev did). The `enableApps` experimental setting must be on for
|
||||
`/apps/*` routes. The connecting user must be allowed to install
|
||||
paperclip-dev did). Apps is a standard product surface and `/apps/*` routes
|
||||
are always available. The connecting user must be allowed to install
|
||||
integrations in their Notion workspace.
|
||||
- How to verify: visit `/PAP/apps/connect?source=notion`, complete the Notion
|
||||
consent flow, and land on the wizard's actions step listing `notion-*`
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ sequenceDiagram
|
|||
U->>P: Return to exact enrolled instance URL
|
||||
P->>C: Signed one-time claim
|
||||
C-->>P: Instance-encrypted token response
|
||||
P->>V: Encrypt tokens and bind them to the user's grant
|
||||
P->>V: Encrypt tokens and bind them to the chosen user or organization grant
|
||||
```
|
||||
|
||||
Before an instance can create a session:
|
||||
|
|
@ -232,8 +232,11 @@ Before an instance can create a session:
|
|||
initiating administrator must complete the return callback.
|
||||
3. Paperclip Cloud binds the account, opaque instance id, both public keys,
|
||||
deployment environment, and exact allowed browser return origins.
|
||||
4. Tailscale HTTPS origins are allowed only when explicitly enrolled. Loopback
|
||||
HTTP is development-only. Other plaintext origins are rejected.
|
||||
4. On authenticated private instances, the setup request supplies its verified
|
||||
same-origin HTTPS address and enrollment binds it automatically. This makes a
|
||||
Tailscale HTTPS setup config-free while still rejecting a bare or mismatched
|
||||
`Host` header. Loopback HTTP is development-only; other plaintext origins are
|
||||
rejected.
|
||||
5. Create, claim, refresh, and supported revoke requests are signed, audience-bound,
|
||||
timestamped, and protected by a one-time `jti` replay cache.
|
||||
|
||||
|
|
@ -296,9 +299,11 @@ keys before they deploy a binary that enables the Cloud connector.
|
|||
|
||||
## Paperclip access defaults
|
||||
|
||||
The first Gmail release is personal-only:
|
||||
Gmail uses the same credential ownership choice as the rest of the Apps setup:
|
||||
|
||||
- **Just me** is the only credential ownership choice.
|
||||
- **Just me** stores the Gmail credential on the connecting user's grant.
|
||||
- **Any human in the company** stores it on the default organization grant so a
|
||||
deliberately shared mailbox or Workspace account can back company-wide use.
|
||||
- The disclosure states that Gmail access can search/read mail and create
|
||||
drafts. Sending mail is not enabled.
|
||||
- A user grant does not automatically authorize an agent. The user must also
|
||||
|
|
|
|||
|
|
@ -73,9 +73,12 @@ methods available for that capability:
|
|||
- **Use the Paperclip robot account** remains an additional Google Sheets-only
|
||||
option for explicitly shared spreadsheets.
|
||||
|
||||
OAuth grants begin as personal connections. Existing promotion controls may
|
||||
later make an eligible connection available to the company without silently
|
||||
changing the underlying Google principal.
|
||||
Before Google consent, the setup flow asks whether the credential is for just
|
||||
the connecting user or for any human in the company. A personal choice stores
|
||||
the tokens only on that user's grant. A company choice stores them on the
|
||||
default organization grant, while still recording which signed-in Google
|
||||
principal completed consent so refresh and reconnect stay bound to that
|
||||
principal.
|
||||
|
||||
## Broker profiles
|
||||
|
||||
|
|
@ -121,7 +124,10 @@ path.
|
|||
Cloud-hosted stacks receive these values through the existing per-stack secret
|
||||
delivery path. A self-hosted instance creates its keys during enrollment and
|
||||
stores them with owner-only permissions in the instance's ignored secret
|
||||
directory. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible
|
||||
directory. The setup page supplies its authenticated same-origin HTTPS address
|
||||
to enrollment, so a normal Tailscale-hosted self-hoster does not need to edit
|
||||
`config.json` or set `PAPERCLIP_PUBLIC_URL`; the enrolled origin becomes the
|
||||
durable callback binding. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible
|
||||
Paperclip ID protocol and are not read aliases. Enroll with Paperclip Cloud and
|
||||
reconnect legacy grants before their old access tokens expire.
|
||||
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ describe("AppDefinition catalog",()=>{
|
|||
oauthStrategy:"paperclip_cloud_connector",
|
||||
connectorProfile:expected.profile,
|
||||
capabilityProfile:{key:expected.capability},
|
||||
grantKinds:["user"],
|
||||
grantKinds:["user","organization"],
|
||||
ownershipModes:["platform_shared"],
|
||||
defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes},
|
||||
riskTier:expected.riskTier,
|
||||
|
|
@ -215,7 +215,7 @@ describe("AppDefinition catalog",()=>{
|
|||
&&method.capabilityProfile?.key===expected.capability
|
||||
);
|
||||
expect(customer,`${expected.profile}:customer-fallback`).toMatchObject({
|
||||
grantKinds:["user"],
|
||||
grantKinds:["user","organization"],
|
||||
ownershipModes:["customer"],
|
||||
defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes},
|
||||
riskTier:expected.riskTier,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"description": "Search and read messages, threads, drafts, and labels."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -70,7 +71,8 @@
|
|||
"description": "Search and read messages, threads, drafts, and labels."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -112,7 +114,8 @@
|
|||
"description": "Read Gmail and create drafts for review in Gmail."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -143,7 +146,8 @@
|
|||
"description": "Read Gmail and create drafts for review in Gmail."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"description": "Read calendars, events, and availability."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -72,7 +73,8 @@
|
|||
"description": "Read calendars, events, and availability."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -116,7 +118,8 @@
|
|||
"description": "Create, update, respond to, and delete events."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -148,7 +151,8 @@
|
|||
"description": "Create, update, respond to, and delete events."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
"description": "Search conversations and read messages."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -74,7 +75,8 @@
|
|||
"description": "Search conversations and read messages."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -120,7 +122,8 @@
|
|||
"description": "Read Chat and send messages."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -154,7 +157,8 @@
|
|||
"description": "Read Chat and send messages."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
"description": "Read document text and structure."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -72,7 +73,8 @@
|
|||
"description": "Read document text and structure."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -115,7 +117,8 @@
|
|||
"description": "Read and update documents."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -147,7 +150,8 @@
|
|||
"description": "Read and update documents."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
"description": "Search and read files and metadata."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -71,7 +72,8 @@
|
|||
"description": "Search and read files and metadata."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -113,7 +115,8 @@
|
|||
"description": "Read files and create or copy files."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -144,7 +147,8 @@
|
|||
"description": "Read files and create or copy files."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"description": "Search contacts, directory people, and your profile."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -72,7 +73,8 @@
|
|||
"description": "Search contacts, directory people, and your profile."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@
|
|||
"description": "Read spreadsheet values and structure."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -73,7 +74,8 @@
|
|||
"description": "Read spreadsheet values and structure."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -116,7 +118,8 @@
|
|||
"description": "Read and update spreadsheet values, formulas, and dimensions."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -148,7 +151,8 @@
|
|||
"description": "Read and update spreadsheet values, formulas, and dimensions."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
"description": "Read presentation slides and content."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -72,7 +73,8 @@
|
|||
"description": "Read presentation slides and content."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
@ -115,7 +117,8 @@
|
|||
"description": "Read and update presentations."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -147,7 +150,8 @@
|
|||
"description": "Read and update presentations."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"description": "Search Gmail, Drive, Calendar, and Chat without write access."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
|
|
@ -74,7 +75,8 @@
|
|||
"description": "Search Gmail, Drive, Calendar, and Chat without write access."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user"
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
|
|
|
|||
|
|
@ -82,12 +82,12 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
selfHostedDefault: true,
|
||||
},
|
||||
enableApps: {
|
||||
title: "Apps",
|
||||
title: "Apps (compatibility)",
|
||||
description:
|
||||
"Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.",
|
||||
"Deprecated compatibility key. Apps is always enabled; stored and managed values are ignored.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
cloudDefault: true,
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enablePipelines: {
|
||||
title: "Pipelines",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableManagedSandboxOnly: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
/** @deprecated Compatibility key only. Apps is always enabled. */
|
||||
enableApps: boolean;
|
||||
enablePipelines: boolean;
|
||||
enableCases: boolean;
|
||||
|
|
|
|||
|
|
@ -263,6 +263,8 @@ export interface ToolConnectionCapabilities {
|
|||
* authorization from membership roles or wait for a connection id.
|
||||
*/
|
||||
export interface ToolConnectionCreateCapabilities {
|
||||
canCreateOrganizationGrant: boolean;
|
||||
organizationGrantReason: string | null;
|
||||
canSetCompanyInstall: boolean;
|
||||
companyInstallReason: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,10 +96,10 @@ describe("instance experimental settings validators", () => {
|
|||
expect(settings.enableBetaSkills).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults apps off", () => {
|
||||
it("defaults the retired Apps compatibility key on", () => {
|
||||
const settings = instanceExperimentalSettingsSchema.parse({});
|
||||
|
||||
expect(settings.enableApps).toBe(false);
|
||||
expect(settings.enableApps).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts worktree run execution patches", () => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableManagedSandboxOnly: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(true),
|
||||
enableApps: z.boolean().default(false),
|
||||
// Deprecated compatibility key. Apps is a standard product surface and is
|
||||
// always enabled; this remains accepted so older stored rows and managed
|
||||
// configs continue to load during upgrades.
|
||||
enableApps: z.boolean().default(true),
|
||||
enablePipelines: z.boolean().default(false),
|
||||
enableCases: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -548,7 +548,8 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits a stable code when an application name is already used", async () => {
|
||||
it("automatically gives a new connection a distinct name when its default is already used", async () => {
|
||||
installMcpOAuthFixture({ auth: "public" });
|
||||
const company = await createCompany(db);
|
||||
await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
|
|
@ -565,12 +566,19 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
|
||||
const response = await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.send({ link: "http://127.0.0.1:8848/mcp", name: "Taken fixture name" })
|
||||
.expect(409);
|
||||
.send({ link: MCP_URL, name: "Taken fixture name" })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
details: { code: "tool_access_name_conflict" },
|
||||
});
|
||||
expect(response.body.application.name).toBe("Taken fixture name (2)");
|
||||
expect(response.body.connection.name).toBe("Taken fixture name (2)");
|
||||
await expect(
|
||||
db.select({ name: toolApplications.name })
|
||||
.from(toolApplications)
|
||||
.where(eq(toolApplications.companyId, company.id)),
|
||||
).resolves.toEqual(expect.arrayContaining([
|
||||
{ name: "Taken fixture name" },
|
||||
{ name: "Taken fixture name (2)" },
|
||||
]));
|
||||
});
|
||||
|
||||
it("emits deployment guidance without exposing server env-var names", async () => {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ const MANAGED_RAW = JSON.stringify({
|
|||
v: 1,
|
||||
mode: "cloud",
|
||||
catalogVersion: "2026.720.0",
|
||||
// enableApps stored true in the DB gets forced off; enablePipelines has no
|
||||
// stored value, so the overlay wins over the schema default (false).
|
||||
// enableApps is retained only for compatibility and must be ignored;
|
||||
// enablePipelines remains a live managed feature.
|
||||
features: { enableApps: false, enablePipelines: true },
|
||||
plugins: { autoInstall: [] },
|
||||
});
|
||||
|
|
@ -63,19 +63,17 @@ describe("applyManagedExperimentalOverlay", () => {
|
|||
expect(result.managedKeys).toEqual({});
|
||||
});
|
||||
|
||||
it("overlays managed values over stored values and records metadata", () => {
|
||||
it("ignores the retired Apps flag while overlaying live managed values", () => {
|
||||
const config = parseManagedConfigEnv(managedEnv())!;
|
||||
const stored = normalizeExperimentalSettings({ enableApps: true });
|
||||
const { experimental, managedKeys } = applyManagedExperimentalOverlay(stored, config);
|
||||
|
||||
// managed overlay > tenant DB value
|
||||
expect(experimental.enableApps).toBe(false);
|
||||
expect(experimental.enableApps).toBe(true);
|
||||
// managed overlay > schema default
|
||||
expect(experimental.enablePipelines).toBe(true);
|
||||
// unmanaged keys keep their stored/default values
|
||||
expect(experimental.enableCases).toBe(false);
|
||||
expect(managedKeys).toEqual({
|
||||
enableApps: { managed: true, managedBy: "paperclip-cloud" },
|
||||
enablePipelines: { managed: true, managedBy: "paperclip-cloud" },
|
||||
});
|
||||
// input is not mutated
|
||||
|
|
@ -96,10 +94,9 @@ describe("instanceSettingsService managed overlay", () => {
|
|||
const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() });
|
||||
|
||||
const experimental = await svc.getExperimental();
|
||||
expect(experimental.enableApps).toBe(false);
|
||||
expect(experimental.enableApps).toBe(true);
|
||||
expect(experimental.enablePipelines).toBe(true);
|
||||
expect(experimental.managedKeys).toEqual({
|
||||
enableApps: { managed: true, managedBy: "paperclip-cloud" },
|
||||
enablePipelines: { managed: true, managedBy: "paperclip-cloud" },
|
||||
});
|
||||
});
|
||||
|
|
@ -109,11 +106,8 @@ describe("instanceSettingsService managed overlay", () => {
|
|||
const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() });
|
||||
|
||||
const settings = await svc.get();
|
||||
expect(settings.experimental.enableApps).toBe(false);
|
||||
expect(settings.experimental.managedKeys?.enableApps).toEqual({
|
||||
managed: true,
|
||||
managedBy: "paperclip-cloud",
|
||||
});
|
||||
expect(settings.experimental.enableApps).toBe(true);
|
||||
expect(settings.experimental.managedKeys?.enableApps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the self-hosted read path unchanged (no managedKeys field)", async () => {
|
||||
|
|
@ -137,8 +131,8 @@ describe("instanceSettingsService managed overlay", () => {
|
|||
|
||||
expect(persistedSets).toHaveLength(1);
|
||||
const persisted = persistedSets[0]!.experimental as Record<string, unknown>;
|
||||
// The tenant's stored value survives in the DB even though the overlay
|
||||
// masks it at read time — a later un-managing restores tenant intent.
|
||||
// The retired compatibility key normalizes on, independent of the
|
||||
// managed document's historical value.
|
||||
expect(persisted.enableApps).toBe(true);
|
||||
// The overlay-added value is not written.
|
||||
expect(persisted.enablePipelines).toBe(false);
|
||||
|
|
@ -146,12 +140,9 @@ describe("instanceSettingsService managed overlay", () => {
|
|||
expect(persisted).not.toHaveProperty("managedKeys");
|
||||
|
||||
// The response still reflects the overlay.
|
||||
expect(updated.experimental.enableApps).toBe(false);
|
||||
expect(updated.experimental.enableApps).toBe(true);
|
||||
expect(updated.experimental.enablePipelines).toBe(true);
|
||||
expect(updated.experimental.managedKeys?.enableApps).toEqual({
|
||||
managed: true,
|
||||
managedBy: "paperclip-cloud",
|
||||
});
|
||||
expect(updated.experimental.managedKeys?.enableApps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not let managed metadata leak into self-hosted writes", async () => {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe("instance settings service", () => {
|
|||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
enableApps: true,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableExternalObjects: false,
|
||||
|
|
@ -59,10 +59,11 @@ describe("instance settings service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("defaults enableApps to false for empty and legacy stored settings", () => {
|
||||
expect(normalizeExperimentalSettings(undefined).enableApps).toBe(false);
|
||||
expect(normalizeExperimentalSettings({}).enableApps).toBe(false);
|
||||
expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(false);
|
||||
it("keeps Apps on for empty, legacy, and explicitly disabled stored settings", () => {
|
||||
expect(normalizeExperimentalSettings(undefined).enableApps).toBe(true);
|
||||
expect(normalizeExperimentalSettings({}).enableApps).toBe(true);
|
||||
expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(true);
|
||||
expect(normalizeExperimentalSettings({ enableApps: false }).enableApps).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the deprecated ingress key for stored-settings compatibility", () => {
|
||||
|
|
|
|||
|
|
@ -3670,6 +3670,8 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.capabilities).toEqual({
|
||||
canCreateOrganizationGrant: true,
|
||||
organizationGrantReason: null,
|
||||
canSetCompanyInstall: true,
|
||||
companyInstallReason: null,
|
||||
});
|
||||
|
|
@ -3903,11 +3905,59 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.capabilities).toEqual({
|
||||
canCreateOrganizationGrant: false,
|
||||
organizationGrantReason: "Only a company owner, administrator, or connection manager can share this credential with the organization.",
|
||||
canSetCompanyInstall: false,
|
||||
companyInstallReason: "Only someone who can configure this connection can choose this.",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires connection-manager authority to create an organization credential", async () => {
|
||||
const company = await createCompany(db);
|
||||
const app = createRouteApp(db, boardSessionActor(company.id, "member"));
|
||||
|
||||
await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.send({ galleryKey: "notion", grantKind: "organization" })
|
||||
.expect(403);
|
||||
// Omitted grantKind retains the legacy organization default, so it must
|
||||
// pass through the same authorization check.
|
||||
await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.send({ galleryKey: "notion" })
|
||||
.expect(403);
|
||||
|
||||
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("requires connection-manager authority to resume an organization credential", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const shared = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "notion",
|
||||
grantKind: "organization",
|
||||
}, { actorType: "user", actorId: "member" });
|
||||
const app = createRouteApp(db, boardSessionActor(company.id, "member", "member"));
|
||||
|
||||
// The retained shared identity wins over a contradictory personal value.
|
||||
await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.send({
|
||||
galleryKey: "notion",
|
||||
resumeConnectionId: shared.connectionId,
|
||||
grantKind: "user",
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
// Omitting the explicit id must not bypass the same check through the
|
||||
// service's retained draft lookup by application name and source.
|
||||
await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.send({ galleryKey: "notion", grantKind: "user" })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("previews remote mcp.json headers as secret replacement fields without echoing values", async () => {
|
||||
const company = await createCompany(db);
|
||||
const app = createRouteApp(db);
|
||||
|
|
@ -5127,6 +5177,101 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("routes a managed Drive callback into the shared organization vault when selected", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `shared-drive-member-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const profile = "drive.read" as const;
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile);
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const driveDefinition = getConnectableAppDefinition("google-drive")!;
|
||||
const previousOwnershipAvailability = driveDefinition.ownershipAvailability;
|
||||
driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true };
|
||||
mockToolsList([{ name: "search_files", annotations: { readOnlyHint: true } }]);
|
||||
|
||||
try {
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-drive",
|
||||
connectionMethodKey: "paperclip-read",
|
||||
grantKind: "organization",
|
||||
name: "Shared Drive managed read",
|
||||
}, actor);
|
||||
expect(connected.connection).toMatchObject({ credentialPolicy: "shared" });
|
||||
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
||||
actor,
|
||||
});
|
||||
const completed = await service.completePaperclipCloudConnectorCallback({
|
||||
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
||||
claimId: "shared-drive-claim",
|
||||
actor,
|
||||
});
|
||||
|
||||
expect(completed.connection).toMatchObject({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credentialPolicy: "shared",
|
||||
config: {
|
||||
oauth: {
|
||||
strategy: "paperclip_cloud_connector",
|
||||
connectorSubjectUserId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(completed.connection.credentialRefs).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "oauth.access_token",
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
}),
|
||||
]);
|
||||
expect(completed.connection.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual([
|
||||
"oauth.access_token",
|
||||
"oauth.refresh_token",
|
||||
]);
|
||||
|
||||
const grants = await db.select().from(connectionGrants).where(eq(
|
||||
connectionGrants.connectionId,
|
||||
connected.connectionId,
|
||||
));
|
||||
expect(grants).toHaveLength(1);
|
||||
expect(grants[0]).toMatchObject({
|
||||
kind: "organization",
|
||||
subjectUserId: null,
|
||||
isDefault: true,
|
||||
status: "active",
|
||||
providerTenant: {
|
||||
externalId: userId,
|
||||
oauth: { strategy: "paperclip_cloud_connector" },
|
||||
},
|
||||
});
|
||||
expect(grants[0]!.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual(
|
||||
completed.connection.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
||||
);
|
||||
|
||||
const secrets = await db.select().from(companySecrets).where(inArray(
|
||||
companySecrets.id,
|
||||
completed.connection.credentialSecretRefs.map((ref) => ref.secretId),
|
||||
));
|
||||
expect(secrets).toHaveLength(2);
|
||||
expect(secrets.every((secret) => secret.scope === "company" && secret.ownerUserId === null)).toBe(true);
|
||||
const bindings = await db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, connected.connectionId),
|
||||
));
|
||||
expect(bindings.map((binding) => binding.configPath).sort()).toEqual([
|
||||
"credentials.oauth.access_token",
|
||||
"oauth.access_token",
|
||||
"oauth.refresh_token",
|
||||
]);
|
||||
} finally {
|
||||
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
||||
}
|
||||
});
|
||||
|
||||
it("activates allowed Drive write actions with recommended approval defaults after a managed callback", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `drive-write-member-${randomUUID()}`;
|
||||
|
|
@ -5671,7 +5816,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
const userId = `oauth-owner-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
||||
await grantBoardUser(db, company.id, userId, ["tools:use", "tools:manage_connections"]);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
|
|
@ -5780,7 +5925,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "workspace-owner", []);
|
||||
await grantBoardUser(db, company.id, "workspace-owner", ["tools:manage_connections"]);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
|
@ -6439,7 +6584,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip-public.example");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board-user", []);
|
||||
await grantBoardUser(db, company.id, "board-user", ["tools:manage_connections"]);
|
||||
const app = createRouteApp(db);
|
||||
|
||||
const connectRes = await request(app)
|
||||
|
|
@ -6555,6 +6700,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
|
||||
it("normalizes a direct numeric loopback origin for OAuth when no public URL is configured", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
|
|
@ -6573,6 +6719,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
|
||||
it("does not derive an OAuth callback origin from a non-loopback request host", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
|
|
@ -6591,6 +6738,48 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("uses an authenticated same-origin HTTPS browser request without public URL config", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
const app = createRouteApp(db, boardSessionActor(company.id, "owner"), undefined, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
});
|
||||
|
||||
const connectRes = await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.set("Host", "paperclip.tail123.ts.net")
|
||||
.set("Origin", "https://paperclip.tail123.ts.net")
|
||||
.send({ galleryKey: "slack", name: "Tailscale Slack workspace" });
|
||||
|
||||
expect(connectRes.status).toBe(201);
|
||||
expect(new URL(connectRes.body.auth.startUrl).searchParams.get("redirect_uri")).toBe(
|
||||
"https://paperclip.tail123.ts.net/api/tools/oauth/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a browser HTTPS origin that does not match the routed request host", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
const app = createRouteApp(db, boardSessionActor(company.id, "owner"), undefined, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
});
|
||||
|
||||
const connectRes = await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.set("Host", "paperclip.tail123.ts.net")
|
||||
.set("Origin", "https://evil.example")
|
||||
.send({ galleryKey: "slack", name: "Mismatched Slack workspace" });
|
||||
|
||||
expect(connectRes.status).toBe(422);
|
||||
expect(connectRes.body).toMatchObject({ code: "oauth_redirect_origin_unsupported" });
|
||||
});
|
||||
|
||||
it("requires non-viewer board access to start OAuth for active app connections", async () => {
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
||||
|
|
@ -6729,7 +6918,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "oauth-operator", []);
|
||||
await grantBoardUser(db, company.id, "oauth-operator", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator");
|
||||
const connect = await service.connectGalleryApp(
|
||||
|
|
@ -6776,6 +6965,25 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
.query({ state, code: "oauth-code" })
|
||||
.expect(403);
|
||||
|
||||
await db.delete(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, company.id),
|
||||
eq(principalPermissionGrants.principalType, "user"),
|
||||
eq(principalPermissionGrants.principalId, "oauth-operator"),
|
||||
eq(principalPermissionGrants.permissionKey, "tools:manage_connections"),
|
||||
));
|
||||
await request(initiatingApp)
|
||||
.get("/api/tools/oauth/callback")
|
||||
.query({ state, code: "oauth-code" })
|
||||
.expect(403);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: "oauth-operator",
|
||||
permissionKey: "tools:manage_connections",
|
||||
scope: null,
|
||||
grantedByUserId: "owner",
|
||||
});
|
||||
|
||||
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
|
|
@ -6954,7 +7162,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "supabase",
|
||||
|
|
@ -7080,7 +7288,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "miro",
|
||||
|
|
@ -7587,7 +7795,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const concurrentService = createTestToolAccessService(db);
|
||||
|
||||
|
|
@ -7693,7 +7901,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack invalid grant" });
|
||||
const start = await service.startOAuth(company.id, connect.connectionId, {
|
||||
|
|
@ -7784,7 +7992,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connect = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "slack",
|
||||
|
|
@ -7980,7 +8188,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack no refresh" });
|
||||
const start = await service.startOAuth(company.id, connect.connectionId, {
|
||||
|
|
@ -8696,7 +8904,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows multiple same-named connections on one application", async () => {
|
||||
it("automatically gives same-named connections distinct names", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
mockToolsList([
|
||||
|
|
@ -8722,10 +8930,41 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(second.connectionId).not.toBe(first.connectionId);
|
||||
const rows = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, first.application.id));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((row) => row.name)).toEqual(["Notion", "Notion"]);
|
||||
expect(rows.map((row) => row.name).sort()).toEqual(["Notion", "Notion (2)"]);
|
||||
expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2);
|
||||
});
|
||||
|
||||
it("automatically resolves same-name application races", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
mockToolsList([
|
||||
{
|
||||
name: "read_items",
|
||||
description: "Read items.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await Promise.all([
|
||||
service.connectGalleryApp(company.id, {
|
||||
link: "https://parallel-one.example.test/actions",
|
||||
name: "Parallel app",
|
||||
}, { actorType: "user", actorId: "board" }),
|
||||
service.connectGalleryApp(company.id, {
|
||||
link: "https://parallel-two.example.test/actions",
|
||||
name: "Parallel app",
|
||||
}, { actorType: "user", actorId: "board" }),
|
||||
]);
|
||||
|
||||
expect(new Set(results.map((result) => result.application.id))).toHaveProperty("size", 2);
|
||||
const applications = await db
|
||||
.select({ name: toolApplications.name })
|
||||
.from(toolApplications)
|
||||
.where(eq(toolApplications.companyId, company.id));
|
||||
expect(applications.map((row) => row.name).sort()).toEqual(["Parallel app", "Parallel app (2)"]);
|
||||
});
|
||||
|
||||
it("does not delete a reused application when the connect rolls back", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
|
@ -8874,7 +9113,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_SECRET", "generic-client-secret");
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board-user", []);
|
||||
await grantBoardUser(db, company.id, "board-user", ["tools:manage_connections"]);
|
||||
const app = createRouteApp(db);
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
|
|
@ -9057,7 +9296,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
|
||||
it("starts OAuth only for the marked Smoke Lab HTTP fixture", async () => {
|
||||
const company = await createCompany(db);
|
||||
await grantBoardUser(db, company.id, "board", []);
|
||||
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
||||
const service = createTestToolAccessService(db);
|
||||
const [application] = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
|
|
|
|||
|
|
@ -33,15 +33,21 @@ function trustedOriginsForRequest(req: Request) {
|
|||
return origins;
|
||||
}
|
||||
|
||||
function isTrustedBoardMutationRequest(req: Request) {
|
||||
/**
|
||||
* Return the browser origin only when it is the same origin Paperclip's CSRF
|
||||
* guard accepts for this request. Callers may use this as browser-reachability
|
||||
* evidence, but must still apply any protocol-specific constraints (for
|
||||
* example OAuth requiring HTTPS outside loopback).
|
||||
*/
|
||||
export function trustedBoardMutationOrigin(req: Request): string | null {
|
||||
const allowedOrigins = trustedOriginsForRequest(req);
|
||||
const origin = parseOrigin(req.header("origin"));
|
||||
if (origin && allowedOrigins.has(origin)) return true;
|
||||
if (origin && allowedOrigins.has(origin)) return origin;
|
||||
|
||||
const refererOrigin = parseOrigin(req.header("referer"));
|
||||
if (refererOrigin && allowedOrigins.has(refererOrigin)) return true;
|
||||
if (refererOrigin && allowedOrigins.has(refererOrigin)) return refererOrigin;
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function boardMutationGuard(): RequestHandler {
|
||||
|
|
@ -68,7 +74,7 @@ export function boardMutationGuard(): RequestHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isTrustedBoardMutationRequest(req)) {
|
||||
if (!trustedBoardMutationOrigin(req)) {
|
||||
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@ describe("Cloud connector enrollment return path", () => {
|
|||
"/QA%20%2F%20Apps/apps/connections?cloud_connector=enrolled",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the connector setup that started enrollment", () => {
|
||||
expect(cloudConnectorEnrollmentReturnPath(
|
||||
"APP",
|
||||
"/apps/connect?source=google-drive&stage=setup",
|
||||
)).toBe(
|
||||
"/APP/apps/connect?source=google-drive&stage=setup&cloud_connector=enrolled",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects external and unrelated enrollment return paths", () => {
|
||||
expect(cloudConnectorEnrollmentReturnPath("APP", "https://evil.example/apps/connect")).toBe(
|
||||
"/APP/apps/connections?cloud_connector=enrolled",
|
||||
);
|
||||
expect(cloudConnectorEnrollmentReturnPath("APP", "/settings")).toBe(
|
||||
"/APP/apps/connections?cloud_connector=enrolled",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection intent OAuth callback document", () => {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import {
|
|||
oauthClientIdMetadataDocument,
|
||||
} from "../services/tool-access.js";
|
||||
import { isLoopbackHost } from "../url-utils.js";
|
||||
import { trustedBoardMutationOrigin } from "../middleware/board-mutation-guard.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import { redactRemoteUrlCredential } from "../services/remote-url-credentials.js";
|
||||
import { wakeConnectionIntentAfterResolution } from "./connection-intents.js";
|
||||
|
|
@ -78,6 +79,8 @@ import type { heartbeatService } from "../services/heartbeat.js";
|
|||
|
||||
const COMPANY_INSTALL_DENIAL_REASON =
|
||||
"Only someone who can configure this connection can choose this.";
|
||||
const ORGANIZATION_GRANT_DENIAL_REASON =
|
||||
"Only a company owner, administrator, or connection manager can share this credential with the organization.";
|
||||
type Heartbeat = ReturnType<typeof heartbeatService>;
|
||||
|
||||
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
|
||||
|
|
@ -180,8 +183,31 @@ export function connectionIntentOAuthOutcomeHtml(input: {
|
|||
return `<!doctype html><html><head><meta charset="utf-8"><title>Connection authorization</title></head><body><p>Returning to Paperclip…</p><script>const message=${message};const targetOrigin=${targetOrigin}||window.location.origin;if(window.opener&&window.opener!==window){window.opener.postMessage(message,targetOrigin);window.close();}else{window.location.replace(${fallback});}</script></body></html>`;
|
||||
}
|
||||
|
||||
export function cloudConnectorEnrollmentReturnPath(issuePrefix: string): string {
|
||||
return `/${encodeURIComponent(issuePrefix)}/apps/connections?cloud_connector=enrolled`;
|
||||
function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): string | null {
|
||||
if (!returnTo || returnTo.length > 2_048) return null;
|
||||
try {
|
||||
const parsed = new URL(returnTo, "http://paperclip.local");
|
||||
if (
|
||||
parsed.origin !== "http://paperclip.local"
|
||||
|| parsed.pathname !== "/apps/connect"
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
) return null;
|
||||
return `${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cloudConnectorEnrollmentReturnPath(issuePrefix: string, returnTo?: string | null): string {
|
||||
const companyRoot = `/${encodeURIComponent(issuePrefix)}`;
|
||||
const normalizedReturnTo = normalizeCloudConnectorEnrollmentReturnTo(returnTo);
|
||||
if (normalizedReturnTo) {
|
||||
const parsed = new URL(normalizedReturnTo, "http://paperclip.local");
|
||||
parsed.searchParams.set("cloud_connector", "enrolled");
|
||||
return `${companyRoot}${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
return `${companyRoot}/apps/connections?cloud_connector=enrolled`;
|
||||
}
|
||||
|
||||
export function toolAccessRoutes(
|
||||
|
|
@ -319,8 +345,62 @@ export function toolAccessRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
function trustedBrowserBaseUrl(req: Request) {
|
||||
const origin = trustedBoardMutationOrigin(req);
|
||||
if (!origin) return null;
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const routedHost = forwardedHost || req.header("host")?.trim();
|
||||
const normalizedRoutedHost = routedHost
|
||||
? new URL(`${parsed.protocol}//${routedHost}`).host.toLowerCase()
|
||||
: null;
|
||||
if (
|
||||
parsed.host.toLowerCase() === normalizedRoutedHost
|
||||
&& (parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)))
|
||||
) {
|
||||
return parsed.origin;
|
||||
}
|
||||
} catch {
|
||||
// The shared origin parser already validates this. Fail closed if its
|
||||
// contract ever changes.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function enrolledConnectorBaseUrl(req: Request) {
|
||||
// Browser-initiated mutations must prove their own same-origin HTTPS
|
||||
// request. The durable binding is only a callback/metadata fallback for
|
||||
// provider GETs, which do not carry the initiating browser's Origin.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return null;
|
||||
const identity = loadPaperclipCloudConnectorIdentity();
|
||||
if (identity?.status !== "active") return null;
|
||||
const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const requestHost = (forwardedHost || req.header("host")?.trim())?.toLowerCase();
|
||||
if (!requestHost) return null;
|
||||
for (const origin of identity.origins) {
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
if (
|
||||
parsed.protocol === "https:"
|
||||
&& !parsed.username
|
||||
&& !parsed.password
|
||||
&& parsed.host.toLowerCase() === requestHost
|
||||
) {
|
||||
return parsed.origin;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed legacy identity origins.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function oauthRedirectUri(req: Request) {
|
||||
const baseUrl = configuredPublicBaseUrl() ?? requestLoopbackBaseUrl(req);
|
||||
const baseUrl = configuredPublicBaseUrl()
|
||||
?? trustedBrowserBaseUrl(req)
|
||||
?? enrolledConnectorBaseUrl(req)
|
||||
?? requestLoopbackBaseUrl(req);
|
||||
if (!baseUrl) {
|
||||
throw unprocessable(
|
||||
"This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.",
|
||||
|
|
@ -331,6 +411,8 @@ export function toolAccessRoutes(
|
|||
}
|
||||
|
||||
function oauthBrowserOrigin(req: Request) {
|
||||
const trustedBrowserOrigin = trustedBrowserBaseUrl(req);
|
||||
if (trustedBrowserOrigin) return trustedBrowserOrigin;
|
||||
const host = req.get("host")?.trim();
|
||||
if (!host) return null;
|
||||
try {
|
||||
|
|
@ -513,10 +595,12 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
req: Request,
|
||||
companyId: string,
|
||||
): Promise<ToolConnectionCreateCapabilities> {
|
||||
const canSetCompanyInstall = await isToolConnectionManagerQuiet(req, companyId);
|
||||
const canManageConnections = await isToolConnectionManagerQuiet(req, companyId);
|
||||
return {
|
||||
canSetCompanyInstall,
|
||||
companyInstallReason: canSetCompanyInstall ? null : COMPANY_INSTALL_DENIAL_REASON,
|
||||
canCreateOrganizationGrant: canManageConnections,
|
||||
organizationGrantReason: canManageConnections ? null : ORGANIZATION_GRANT_DENIAL_REASON,
|
||||
canSetCompanyInstall: canManageConnections,
|
||||
companyInstallReason: canManageConnections ? null : COMPANY_INSTALL_DENIAL_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -749,6 +833,23 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
// An omitted grant kind is the backward-compatible organization default.
|
||||
// On resume, the persisted connection identity is authoritative: accepting
|
||||
// a contradictory `grantKind: "user"` here could otherwise let a creator
|
||||
// replace the credential behind an existing organization grant.
|
||||
const resumedConnection = req.body.resumeConnectionId
|
||||
? await svc.getConnection(req.body.resumeConnectionId, companyId)
|
||||
: null;
|
||||
const effectiveGrantKind = resumedConnection
|
||||
? resumedConnection.credentialPolicy === "per_user" ? "user" : "organization"
|
||||
: req.body.grantKind ?? "organization";
|
||||
// Personal connection creation remains available to ordinary active
|
||||
// members, but sharing a credential with every human is a manager
|
||||
// operation and must be enforced here, not inferred by the client.
|
||||
const createsOrganizationGrant = effectiveGrantKind === "organization";
|
||||
if (createsOrganizationGrant && !await isToolConnectionManagerQuiet(req, companyId)) {
|
||||
throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON);
|
||||
}
|
||||
try {
|
||||
const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
|
||||
if (result.auth?.kind === "oauth") {
|
||||
|
|
@ -863,6 +964,9 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
if (!companyId) throw badRequest("Paperclip Cloud enrollment requires a company");
|
||||
assertCompanyAccess(req, companyId);
|
||||
const origin = new URL(oauthRedirectUri(req)).origin;
|
||||
const returnTo = normalizeCloudConnectorEnrollmentReturnTo(
|
||||
typeof req.body?.returnTo === "string" ? req.body.returnTo : undefined,
|
||||
) ?? undefined;
|
||||
let status;
|
||||
try {
|
||||
status = await startPaperclipCloudConnectorEnrollment({
|
||||
|
|
@ -870,6 +974,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
companyId,
|
||||
initiatedBy: connectorEnrollmentPrincipal(req),
|
||||
label: typeof req.body?.label === "string" ? req.body.label : undefined,
|
||||
returnTo,
|
||||
});
|
||||
} catch {
|
||||
throw unprocessable("Paperclip Cloud enrollment could not be started", {
|
||||
|
|
@ -926,7 +1031,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
details: { environment: status.environment, status: status.status },
|
||||
});
|
||||
}
|
||||
res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix));
|
||||
res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, pending?.returnTo));
|
||||
});
|
||||
|
||||
const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => {
|
||||
|
|
@ -1145,7 +1250,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
|||
}
|
||||
const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId);
|
||||
const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId);
|
||||
if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) {
|
||||
if (!pendingState.subjectUserId) {
|
||||
if (!await isToolConnectionManagerQuiet(req, pendingConnection.companyId)) {
|
||||
throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON);
|
||||
}
|
||||
} else if (pendingState.subjectUserId === req.actor.userId) {
|
||||
await assertToolConnectionAccess(req, pendingConnection);
|
||||
} else {
|
||||
await assertToolConnectionConfigureAccess(req, pendingConnection);
|
||||
|
|
|
|||
|
|
@ -224,7 +224,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
||||
enableApps: parsed.data.enableApps ?? false,
|
||||
// Apps graduated from Experimental. Ignore historical off values while
|
||||
// continuing to accept the compatibility key in stored settings.
|
||||
enableApps: true,
|
||||
enablePipelines: parsed.data.enablePipelines ?? false,
|
||||
enableCases: parsed.data.enableCases ?? false,
|
||||
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
|
||||
|
|
@ -260,7 +262,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
enableApps: true,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableConferenceRoomChat: false,
|
||||
|
|
@ -314,6 +316,9 @@ export function applyManagedExperimentalOverlay(
|
|||
for (const [key, value] of Object.entries(managedConfig.features) as Array<
|
||||
[ManagedExperimentalFeatureKey, boolean]
|
||||
>) {
|
||||
// Existing Cloud stack configs may still carry enableApps. Accept the
|
||||
// document during rollout, but never let the retired flag disable Apps.
|
||||
if (key === "enableApps") continue;
|
||||
next[key] = value;
|
||||
managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type PendingEnrollment = {
|
|||
expiresAt: string;
|
||||
companyId?: string;
|
||||
initiatedBy?: string;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
export type PaperclipCloudConnectorIdentity = {
|
||||
|
|
@ -132,6 +133,7 @@ export async function startPaperclipCloudConnectorEnrollment(input: {
|
|||
label?: string;
|
||||
companyId?: string;
|
||||
initiatedBy?: string;
|
||||
returnTo?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
request?: typeof fetch;
|
||||
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
|
||||
|
|
@ -143,6 +145,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: {
|
|||
label?: string;
|
||||
companyId?: string;
|
||||
initiatedBy?: string;
|
||||
returnTo?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
request?: typeof fetch;
|
||||
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
|
||||
|
|
@ -217,6 +220,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: {
|
|||
expiresAt: body.expiresAt,
|
||||
...(input.companyId ? { companyId: input.companyId } : {}),
|
||||
...(input.initiatedBy ? { initiatedBy: input.initiatedBy } : {}),
|
||||
...(input.returnTo ? { returnTo: input.returnTo } : {}),
|
||||
},
|
||||
};
|
||||
saveIdentity(identity);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
companyMemberships,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
principalPermissionGrants,
|
||||
userSecretDefinitions,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
|
|
@ -137,6 +138,7 @@ import {
|
|||
type OAuthEndpointUrlRejection,
|
||||
} from "@paperclipai/shared";
|
||||
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { isUniqueViolation } from "../db-errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import {
|
||||
|
|
@ -189,6 +191,7 @@ type ActorInfo = {
|
|||
actorType?: "agent" | "user" | "system" | "plugin";
|
||||
actorId?: string | null;
|
||||
sessionId?: string | null;
|
||||
actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant";
|
||||
};
|
||||
|
||||
const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]);
|
||||
|
|
@ -8299,6 +8302,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
return base.length <= 160 ? base : base.slice(0, 160);
|
||||
}
|
||||
|
||||
function nextAvailableConnectionName(requestedName: string, existingNames: readonly string[]): string {
|
||||
const base = requestedName.trim() || "Custom app";
|
||||
const used = new Set(existingNames.map((candidate) => candidate.trim().toLocaleLowerCase()));
|
||||
const unsuffixed = base.slice(0, 160);
|
||||
if (!used.has(unsuffixed.toLocaleLowerCase())) return unsuffixed;
|
||||
for (let index = 2; index < 10_000; index += 1) {
|
||||
const suffix = ` (${index})`;
|
||||
const candidate = `${base.slice(0, 160 - suffix.length).trimEnd()}${suffix}`;
|
||||
if (!used.has(candidate.toLocaleLowerCase())) return candidate;
|
||||
}
|
||||
return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`;
|
||||
}
|
||||
|
||||
async function connectGalleryApp(
|
||||
companyId: string,
|
||||
input: ConnectToolApp,
|
||||
|
|
@ -8369,7 +8385,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
}
|
||||
}
|
||||
|
||||
const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? "");
|
||||
const requestedName = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? "");
|
||||
// Compatibility for the original Sheets robot flow, whose clients predate
|
||||
// method selection and identify the method by its spreadsheet allowlist.
|
||||
const inferredMethodKey = !input.connectionMethodKey
|
||||
|
|
@ -8429,11 +8445,64 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
.limit(1)
|
||||
: [undefined];
|
||||
const retainedConnection = requestedResumeConnection ?? recoveredConnection;
|
||||
let applicationName = existingApplication?.name ?? requestedName;
|
||||
let name = retainedConnection?.name ?? requestedName;
|
||||
if (!existingApplication) {
|
||||
const applicationNames = await db
|
||||
.select({ name: toolApplications.name })
|
||||
.from(toolApplications)
|
||||
.where(eq(toolApplications.companyId, companyId));
|
||||
applicationName = nextAvailableConnectionName(requestedName, applicationNames.map((row) => row.name));
|
||||
name = applicationName;
|
||||
} else if (!retainedConnection) {
|
||||
const connectionNames = await db
|
||||
.select({ name: toolConnections.name })
|
||||
.from(toolConnections)
|
||||
.where(and(
|
||||
eq(toolConnections.companyId, companyId),
|
||||
eq(toolConnections.applicationId, existingApplication.id),
|
||||
));
|
||||
name = nextAvailableConnectionName(requestedName, connectionNames.map((row) => row.name));
|
||||
}
|
||||
const retainedGrantKind: ConnectionGrantKind | null = retainedConnection
|
||||
? retainedConnection.credentialPolicy === "per_user"
|
||||
? "user"
|
||||
: "organization"
|
||||
: null;
|
||||
// The route can authorize an explicit resume before entering the service,
|
||||
// but name/source recovery happens here. Do not let a caller submit a
|
||||
// personal grant choice to pass the route and then inherit an implicitly
|
||||
// recovered organization identity. Local implicit mode is already the
|
||||
// unrestricted instance operator; every authenticated user must still hold
|
||||
// current connection-manager authority before this retained row is touched.
|
||||
if (
|
||||
retainedGrantKind === "organization"
|
||||
&& input.grantKind === "user"
|
||||
&& actor?.actorType === "user"
|
||||
&& actor.actorSource !== "local_implicit"
|
||||
) {
|
||||
const actorUserId = actor.actorId;
|
||||
const [membership] = actorUserId ? await db.select({
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
}).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, actorUserId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
)).limit(1) : [];
|
||||
const roleCanManage = membership?.membershipRole === "owner" || membership?.membershipRole === "admin";
|
||||
const [explicitManagerGrant] = roleCanManage || !actorUserId ? [] : await db.select({
|
||||
id: principalPermissionGrants.id,
|
||||
}).from(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, "user"),
|
||||
eq(principalPermissionGrants.principalId, actorUserId),
|
||||
eq(principalPermissionGrants.permissionKey, "tools:manage_connections"),
|
||||
)).limit(1);
|
||||
if (!roleCanManage && !explicitManagerGrant) {
|
||||
throw forbidden("Only a company owner, admin, or member with connection-manager permission can share credentials with the organization.");
|
||||
}
|
||||
}
|
||||
const requestedGrantKind = retainedGrantKind ?? input.grantKind ?? "organization";
|
||||
if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) {
|
||||
throw badRequest(`${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`);
|
||||
|
|
@ -8770,15 +8839,40 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
applicationRow = existingApplication;
|
||||
}
|
||||
} else {
|
||||
[applicationRow] = await db.insert(toolApplications).values({
|
||||
companyId,
|
||||
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
||||
name,
|
||||
description: safeApplicationDescription,
|
||||
type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "draft",
|
||||
metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" },
|
||||
}).returning();
|
||||
// Name selection is optimistic because multiple setup requests can
|
||||
// legitimately begin at the same time. The company/name unique index
|
||||
// is the authority: if another request wins after our read, refresh the
|
||||
// names and retry with the next suffix instead of surfacing a conflict
|
||||
// the user never asked to resolve.
|
||||
for (let attempt = 0; attempt < 10 && !applicationRow; attempt += 1) {
|
||||
try {
|
||||
[applicationRow] = await db.insert(toolApplications).values({
|
||||
companyId,
|
||||
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
||||
name: applicationName,
|
||||
description: safeApplicationDescription,
|
||||
type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "draft",
|
||||
metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" },
|
||||
}).returning();
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error, "tool_applications_company_name_uq")) throw error;
|
||||
const applicationNames = await db
|
||||
.select({ name: toolApplications.name })
|
||||
.from(toolApplications)
|
||||
.where(eq(toolApplications.companyId, companyId));
|
||||
applicationName = nextAvailableConnectionName(
|
||||
requestedName,
|
||||
applicationNames.map((row) => row.name),
|
||||
);
|
||||
name = applicationName;
|
||||
}
|
||||
}
|
||||
if (!applicationRow) {
|
||||
throw conflict("Paperclip could not allocate a unique connection name", {
|
||||
code: "tool_access_name_allocation_exhausted",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await assertSecretRefs(companyId, [...credentialRefs, ...credentialSecretRefs]);
|
||||
|
|
@ -10181,13 +10275,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
if (!consumedState) throw badRequest("OAuth state was not found, expired, or has already been used");
|
||||
const txSecrets = secretService(tx);
|
||||
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
||||
const [existingUserGrant] = await tx.select().from(connectionGrants).where(and(
|
||||
const personalCredential = connection.credentialPolicy === "per_user";
|
||||
const [existingCredentialGrant] = await tx.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, subjectUserId),
|
||||
eq(connectionGrants.kind, personalCredential ? "user" : "organization"),
|
||||
personalCredential
|
||||
? eq(connectionGrants.subjectUserId, subjectUserId)
|
||||
: eq(connectionGrants.isDefault, true),
|
||||
)).limit(1);
|
||||
const existingRefs = existingUserGrant?.credentialSecretRefs ?? [];
|
||||
const existingRefs = existingCredentialGrant?.credentialSecretRefs
|
||||
?? (personalCredential ? [] : connection.credentialSecretRefs);
|
||||
const accessRef = await createOrRotateOAuthSecret({
|
||||
companyId: connection.companyId,
|
||||
connection,
|
||||
|
|
@ -10196,7 +10294,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
value: credentials.accessToken,
|
||||
actor: input.actor,
|
||||
existingRefs,
|
||||
ownerUserId: subjectUserId,
|
||||
ownerUserId: personalCredential ? subjectUserId : undefined,
|
||||
}, txSecretContext);
|
||||
const refreshRef = await createOrRotateOAuthSecret({
|
||||
companyId: connection.companyId,
|
||||
|
|
@ -10206,7 +10304,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
value: refreshToken,
|
||||
actor: input.actor,
|
||||
existingRefs,
|
||||
ownerUserId: subjectUserId,
|
||||
ownerUserId: personalCredential ? subjectUserId : undefined,
|
||||
}, txSecretContext);
|
||||
const credentialSecretRefs = [
|
||||
...existingRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"),
|
||||
|
|
@ -10231,16 +10329,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
revokedByUserId: null,
|
||||
updatedAt: now(),
|
||||
};
|
||||
if (existingUserGrant) {
|
||||
await tx.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingUserGrant.id));
|
||||
if (existingCredentialGrant) {
|
||||
await tx.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingCredentialGrant.id));
|
||||
} else {
|
||||
await tx.insert(connectionGrants).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId,
|
||||
kind: personalCredential ? "user" : "organization",
|
||||
subjectUserId: personalCredential ? subjectUserId : null,
|
||||
...grantValues,
|
||||
isDefault: false,
|
||||
isDefault: !personalCredential,
|
||||
createdByUserId: subjectUserId,
|
||||
});
|
||||
}
|
||||
|
|
@ -10262,13 +10360,31 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
authKind: "oauth",
|
||||
config: nextConfig,
|
||||
transportConfig: nextConfig,
|
||||
credentialRefs: personalCredential
|
||||
? connection.credentialRefs.filter((ref) => ref.name !== "oauth.access_token")
|
||||
: [
|
||||
...connection.credentialRefs.filter((ref) => ref.name !== "oauth.access_token"),
|
||||
{
|
||||
name: "oauth.access_token",
|
||||
secretId: accessRef.secretId,
|
||||
version: "latest" as const,
|
||||
placement: "header" as const,
|
||||
key: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
},
|
||||
],
|
||||
credentialSecretRefs: personalCredential
|
||||
? connection.credentialSecretRefs.filter(
|
||||
(ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token",
|
||||
)
|
||||
: credentialSecretRefs,
|
||||
updatedAt: now(),
|
||||
}).where(eq(toolConnections.id, connection.id)).returning();
|
||||
await tx.update(toolApplications).set({
|
||||
status: shouldFinalizeManagedDefaults ? "draft" : "active",
|
||||
updatedAt: now(),
|
||||
}).where(eq(toolApplications.id, connection.applicationId));
|
||||
await syncCredentialBindings(connection, credentialSecretRefs, tx);
|
||||
await syncCredentialBindings(connection, personalCredential ? credentialSecretRefs : [], tx);
|
||||
const linkedInteractionKind = stateRow.interactionId
|
||||
? await tx
|
||||
.select({ kind: issueThreadInteractions.kind })
|
||||
|
|
@ -10737,7 +10853,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
// Keep callback persistence serialized with membership suspension, role
|
||||
// downgrade, and removal. Once this row is locked, authority cannot be
|
||||
// revoked between the live check and the shared credential/grant writes.
|
||||
const [membership] = await tx.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
||||
const [membership] = await tx.select({
|
||||
id: companyMemberships.id,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
}).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, connection.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, organizationActorUserId),
|
||||
|
|
@ -10747,6 +10866,18 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
if (!membership) {
|
||||
throw forbidden("Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.");
|
||||
}
|
||||
const roleCanManage = membership.membershipRole === "owner" || membership.membershipRole === "admin";
|
||||
const [explicitManagerGrant] = roleCanManage ? [] : await tx.select({
|
||||
id: principalPermissionGrants.id,
|
||||
}).from(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, connection.companyId),
|
||||
eq(principalPermissionGrants.principalType, "user"),
|
||||
eq(principalPermissionGrants.principalId, organizationActorUserId),
|
||||
eq(principalPermissionGrants.permissionKey, "tools:manage_connections"),
|
||||
)).limit(1).for("update");
|
||||
if (!roleCanManage && !explicitManagerGrant) {
|
||||
throw forbidden("Only a company owner, admin, or member with connection-manager permission can share credentials with the organization.");
|
||||
}
|
||||
const txSecrets = secretService(tx);
|
||||
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
|
|||
const res = await request.post("/api/companies", { data: { name: `Apps navigation ${label} ${Date.now()}` } });
|
||||
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
|
||||
const company = await res.json();
|
||||
const flags = await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } });
|
||||
expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true);
|
||||
return {
|
||||
companyId: company.id,
|
||||
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
|
||||
|
|
@ -123,6 +121,7 @@ test.describe.serial("not-connected app page", () => {
|
|||
await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 });
|
||||
await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
await expect(page.getByText(mock.url)).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import { expect, test } from "@playwright/test";
|
|||
// table now redirects into Apps, so capture the current app removal
|
||||
// confirmation on the app Advanced tab instead.
|
||||
test("captures the current app removal confirmations", async ({ page }) => {
|
||||
const flags = await page.request.patch("/api/instance/settings/experimental", { data: { enableApps: true } });
|
||||
expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true);
|
||||
|
||||
const companyRes = await page.request.post("/api/companies", {
|
||||
data: { name: `PAP-10817 remove app ${Date.now()}` },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ async function discoverCompany(request: APIRequestContext): Promise<SeedResult>
|
|||
});
|
||||
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
|
||||
const company = await res.json();
|
||||
const flags = await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } });
|
||||
expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true);
|
||||
return {
|
||||
companyId: company.id,
|
||||
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
|
|||
const res = await request.post("/api/companies", { data: { name: `Apps navigation ${label} ${Date.now()}` } });
|
||||
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
|
||||
const company = await res.json();
|
||||
const flags = await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } });
|
||||
expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true);
|
||||
return {
|
||||
companyId: company.id,
|
||||
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
|
|||
const res = await request.post("/api/companies", { data: { name: `prosumer MCP flow ${label} ${Date.now()}` } });
|
||||
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
|
||||
const company = await res.json();
|
||||
const flags = await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } });
|
||||
expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true);
|
||||
return {
|
||||
companyId: company.id,
|
||||
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
|
||||
|
|
@ -163,6 +161,11 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
await linkInput.fill(mock.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Access is chosen before credentials so the user knows who and which
|
||||
// agents will receive the connection before Paperclip contacts it.
|
||||
await expect(page.getByText("Which humans can use this credential?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
|
||||
// LinkKey step keeps the BYO connection heading. Mock doesn't
|
||||
// require a key — leave the default "No" answer.
|
||||
await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 });
|
||||
|
|
|
|||
|
|
@ -22,11 +22,6 @@ async function newCompany(request: APIRequestContext): Promise<Seed> {
|
|||
data: { name: `Connection intent E2E ${Date.now()}` },
|
||||
}),
|
||||
);
|
||||
await json(
|
||||
await request.patch("/api/instance/settings/experimental", {
|
||||
data: { enableApps: true },
|
||||
}),
|
||||
);
|
||||
return { companyId: company.id, prefix: company.issuePrefix };
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +216,7 @@ test("store setup and task connection intent share one fake provider through con
|
|||
.getByPlaceholder("https://example.com/actions")
|
||||
.fill(provider.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
// A no-auth read-only provider can complete the access/install defaults in
|
||||
// one commit. Other methods exercise the same intermediate steps in the
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
|
|||
const body = await json<{ id: string; issuePrefix?: string; prefix?: string; urlKey?: string }>(
|
||||
await request.post("/api/companies", { data: { name: `MCP US ${label} ${Date.now()}` } }),
|
||||
);
|
||||
await json(await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } }));
|
||||
return { companyId: body.id, prefix: body.issuePrefix ?? body.prefix ?? body.urlKey ?? "E2E" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ async function createScout(request: APIRequestContext, companyId: string): Promi
|
|||
}
|
||||
|
||||
async function enableSmokeLab(request: APIRequestContext) {
|
||||
await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true, enableApps: true } }));
|
||||
await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true } }));
|
||||
}
|
||||
|
||||
async function createSmokeRun(request: APIRequestContext, companyId: string, scenarioCount: number) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { TaskChatLab } from "./pages/TaskChatLab";
|
|||
import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate";
|
||||
import { CasesExperimentalGate } from "./components/CasesExperimentalGate";
|
||||
import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate";
|
||||
import { AppsExperimentalGate } from "./components/AppsExperimentalGate";
|
||||
import { CloudManagedPageGate } from "./components/CloudManagedPageGate";
|
||||
import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate";
|
||||
import { IsolatedWorkspacesRouteGate } from "./components/IsolatedWorkspacesRouteGate";
|
||||
|
|
@ -156,37 +155,35 @@ function boardRoutes() {
|
|||
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
|
||||
<Route path="tools" element={<LegacyToolsRedirect />} />
|
||||
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
|
||||
<Route element={<AppsExperimentalGate />}>
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
|
||||
<Route
|
||||
path="apps/vercel-connect"
|
||||
element={<AppsConnectEntryRoute credentialSource="vercel_connect" />}
|
||||
/>
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/review" element={<AppsReview />} />
|
||||
{/* Connector health is inline on the Apps landing page; keep legacy links working. */}
|
||||
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
|
||||
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
|
||||
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/advanced/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
|
||||
<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
|
||||
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
|
||||
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
|
||||
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
|
||||
</Route>
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
|
||||
<Route
|
||||
path="apps/vercel-connect"
|
||||
element={<AppsConnectEntryRoute credentialSource="vercel_connect" />}
|
||||
/>
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/review" element={<AppsReview />} />
|
||||
{/* Connector health is inline on the Apps landing page; keep legacy links working. */}
|
||||
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
|
||||
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
|
||||
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/advanced/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
|
||||
<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
|
||||
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
|
||||
<Route path="apps/:connectionId" element={<Navigate to="setup" 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" />}>
|
||||
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
|
||||
|
|
|
|||
|
|
@ -289,8 +289,8 @@ export type ToolPolicyTestResponse = {
|
|||
export const toolsApi = {
|
||||
getCloudConnectorEnrollment: () =>
|
||||
api.get<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment"),
|
||||
startCloudConnectorEnrollment: (companyId: string, label?: string) =>
|
||||
api.post<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment", { companyId, label }),
|
||||
startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) =>
|
||||
api.post<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment", { companyId, label, returnTo }),
|
||||
// --- Applications ---
|
||||
listGallery: (companyId: string) =>
|
||||
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppsExperimentalGate } from "./AppsExperimentalGate";
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => (
|
||||
<div data-testid="navigate" data-to={to} data-replace={String(replace)} />
|
||||
),
|
||||
Outlet: () => <div data-testid="apps-content">Apps content</div>,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("AppsExperimentalGate", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderGate() {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppsExperimentalGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("redirects to the dashboard when apps are disabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
await renderGate();
|
||||
|
||||
expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe("/dashboard");
|
||||
expect(container.querySelector('[data-testid="apps-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders apps routes when apps are enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
|
||||
await renderGate();
|
||||
|
||||
expect(container.querySelector('[data-testid="apps-content"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing while the flag is loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
await renderGate();
|
||||
|
||||
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="apps-content"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Navigate, Outlet } from "@/lib/router";
|
||||
import { useAppsEnabled } from "@/hooks/useAppsEnabled";
|
||||
|
||||
export function AppsExperimentalGate() {
|
||||
const { enabled, loaded } = useAppsEnabled();
|
||||
|
||||
if (!loaded) return null;
|
||||
if (!enabled) return <Navigate to="/dashboard" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -565,7 +565,7 @@ describe("Layout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not mount the Apps secondary sidebar while experimental apps are disabled", async () => {
|
||||
it("mounts the Apps secondary sidebar regardless of the retired experimental flag", async () => {
|
||||
currentPathname = "/PAP/apps";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
const root = createRoot(container);
|
||||
|
|
@ -583,9 +583,9 @@ describe("Layout", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import { usePanel } from "../context/PanelContext";
|
|||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useAppsEnabled } from "../hooks/useAppsEnabled";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -113,7 +112,6 @@ export function Layout() {
|
|||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const { enabled: appsEnabled } = useAppsEnabled();
|
||||
const isCompanySettingsRoute = [
|
||||
"/company/settings",
|
||||
"/company/export",
|
||||
|
|
@ -176,11 +174,11 @@ export function Layout() {
|
|||
// both desktop (SecondarySidebar) and mobile (off-canvas drawer).
|
||||
const secondarySidebar = isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : appsEnabled && appDetailConnectionId ? (
|
||||
) : appDetailConnectionId ? (
|
||||
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
|
||||
) : appsEnabled && appDetailApplicationId ? (
|
||||
) : appDetailApplicationId ? (
|
||||
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
|
||||
) : appsEnabled && (isAppsRoute || isToolsRoute) ? (
|
||||
) : isAppsRoute || isToolsRoute ? (
|
||||
<AppsSidebar />
|
||||
) : routeSidebarSlot ? (
|
||||
<PluginSlotMount
|
||||
|
|
|
|||
|
|
@ -479,24 +479,22 @@ describe("Sidebar", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hides the Apps nav item unless experimental apps are enabled", async () => {
|
||||
it("always shows Apps at the bottom of the Work section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
const disabledRoot = await renderSidebar();
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect([...container.querySelectorAll("a")].some((anchor) => anchor.textContent === "Apps")).toBe(false);
|
||||
|
||||
flushSync(() => {
|
||||
disabledRoot.unmount();
|
||||
});
|
||||
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
|
||||
const enabledRoot = await renderSidebar();
|
||||
|
||||
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Apps");
|
||||
const links = [...container.querySelectorAll("a")];
|
||||
const link = links.find((anchor) => anchor.textContent === "Apps");
|
||||
expect(link?.getAttribute("href")).toBe("/apps");
|
||||
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan(
|
||||
links.findIndex((anchor) => anchor.textContent === "Projects"),
|
||||
);
|
||||
expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan(
|
||||
links.findIndex((anchor) => anchor.textContent === "Org"),
|
||||
);
|
||||
|
||||
flushSync(() => {
|
||||
enabledRoot.unmount();
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ export function Sidebar() {
|
|||
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const showApps = experimentalSettings?.enableApps === true;
|
||||
const showPipelines = experimentalSettings?.enablePipelines === true;
|
||||
const showStatusCards = experimentalSettings?.enableStatusCards === true;
|
||||
const goalsLinkPending = experimentalSettings === undefined;
|
||||
|
|
@ -266,6 +265,7 @@ export function Sidebar() {
|
|||
className="flex flex-col gap-0.5"
|
||||
itemClassName="text-(length:--text-compact) font-medium"
|
||||
/>
|
||||
<SidebarNavItem to="/apps" label="Apps" icon={AppWindow} />
|
||||
</SidebarSection>
|
||||
|
||||
{/* Classic mode restores the per-project collapsible below Work. */}
|
||||
|
|
@ -275,7 +275,6 @@ export function Sidebar() {
|
|||
|
||||
<SidebarSection label="Organization" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
|
||||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Copy,
|
||||
Link2,
|
||||
Loader2,
|
||||
|
|
@ -209,10 +210,10 @@ const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
|||
key: 2,
|
||||
};
|
||||
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||||
key: 0,
|
||||
access: 1,
|
||||
access: 0,
|
||||
key: 1,
|
||||
};
|
||||
const ZAPIER_STEP_LABELS = ["Add MCP URL"];
|
||||
const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"];
|
||||
|
||||
/**
|
||||
* Which identity a fresh connection should default to (PAP-17835).
|
||||
|
|
@ -437,7 +438,9 @@ export function ConnectionSetupFlow({
|
|||
};
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<Step>(requestedAppKey || prefill.link || zapierSource ? "key" : "gallery");
|
||||
const [step, setStep] = useState<Step>(
|
||||
requestedAppKey ? "key" : prefill.link || zapierSource ? "access" : "gallery",
|
||||
);
|
||||
const [entry, setEntry] = useState<AppDefinition | null>(null);
|
||||
const [galleryName, setGalleryName] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState(prefill.link);
|
||||
|
|
@ -577,7 +580,7 @@ export function ConnectionSetupFlow({
|
|||
resetGenericAuthState();
|
||||
setCredentials({});
|
||||
setConnectResult(null);
|
||||
setStep("key");
|
||||
setStep("access");
|
||||
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
|
||||
return;
|
||||
}
|
||||
|
|
@ -629,18 +632,75 @@ export function ConnectionSetupFlow({
|
|||
useEffect(() => {
|
||||
if (host !== "page") return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: vercelConnectMode ? "Vercel Connect" : byoOnly ? "Connect your own tool" : "Connect an app" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [byoOnly, host, setBreadcrumbs, selectedCompany?.name, vercelConnectMode]);
|
||||
}, [byoOnly, host, setBreadcrumbs, vercelConnectMode]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const fullRequestedDefinition = requestedAppKey
|
||||
? getConnectableAppDefinition(requestedAppKey)
|
||||
: null;
|
||||
const requestedDefinitionUsesManagedConnector = Boolean(
|
||||
fullRequestedDefinition?.methods.some((candidate) =>
|
||||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||||
),
|
||||
);
|
||||
const entryAdvertisesManagedConnector = Boolean(
|
||||
entry?.methods.some((candidate) =>
|
||||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||||
),
|
||||
);
|
||||
const connectorEnrollmentQuery = useQuery({
|
||||
queryKey: ["cloud-connector", "enrollment"],
|
||||
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
|
||||
enabled: Boolean(
|
||||
selectedCompanyId
|
||||
&& requestedDefinitionUsesManagedConnector
|
||||
&& !entryAdvertisesManagedConnector
|
||||
),
|
||||
});
|
||||
const [connectorEnrollmentError, setConnectorEnrollmentError] = useState<string | null>(null);
|
||||
const openConnectorEnrollment = useCallback((verificationUrl: string) => {
|
||||
const target = resolveAuthorizationTarget(verificationUrl);
|
||||
if (!target.ok) {
|
||||
setConnectorEnrollmentError(target.message);
|
||||
return;
|
||||
}
|
||||
navigateTopLevel(target.url);
|
||||
}, []);
|
||||
const startConnectorEnrollment = useMutation({
|
||||
mutationFn: () => toolsApi.startCloudConnectorEnrollment(
|
||||
selectedCompanyId!,
|
||||
selectedCompany?.name,
|
||||
requestedAppKey
|
||||
? appConnectHref(requestedAppKey, "key", credentialSource, {
|
||||
resumeConnectionId,
|
||||
reconnectConnectionId,
|
||||
interactionId: connectionIntentId,
|
||||
})
|
||||
: undefined,
|
||||
),
|
||||
onSuccess: (status) => {
|
||||
if (!status.verificationUrl) {
|
||||
setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again.");
|
||||
return;
|
||||
}
|
||||
openConnectorEnrollment(status.verificationUrl);
|
||||
},
|
||||
onError: (error) => {
|
||||
setConnectorEnrollmentError(
|
||||
error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.",
|
||||
);
|
||||
},
|
||||
});
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||||
|
|
@ -1414,6 +1474,18 @@ export function ConnectionSetupFlow({
|
|||
&& (directOAuthEntry || oauthPhase !== "entry"),
|
||||
);
|
||||
|
||||
const showConnectorEnrollmentStep = Boolean(
|
||||
step === "key"
|
||||
&& entry
|
||||
&& requestedDefinitionUsesManagedConnector
|
||||
&& !entryAdvertisesManagedConnector
|
||||
&& (
|
||||
connectorEnrollmentQuery.isLoading
|
||||
|| connectorEnrollmentQuery.isError
|
||||
|| connectorEnrollmentQuery.data?.configured !== true
|
||||
)
|
||||
);
|
||||
|
||||
if (showCuratedOAuthState && automaticOAuthEntry) {
|
||||
return (
|
||||
<OAuthConnectStateScreen
|
||||
|
|
@ -1625,16 +1697,54 @@ export function ConnectionSetupFlow({
|
|||
setInstallAgentIds(new Set());
|
||||
setInstallChoice("all");
|
||||
setGrantKind(reconnectGrantKind ?? "organization");
|
||||
setStep("key");
|
||||
setStep("access");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "key" && entry && (
|
||||
{step === "key" && entry && showConnectorEnrollmentStep ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
|
||||
<Cloud className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Connect with Paperclip
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
|
||||
<InlineBanner tone="danger" className="mt-4">
|
||||
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||||
onClick={() => {
|
||||
setConnectorEnrollmentError(null);
|
||||
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
|
||||
if (verificationUrl) openConnectorEnrollment(verificationUrl);
|
||||
else startConnectorEnrollment.mutate();
|
||||
}}
|
||||
>
|
||||
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{connectorEnrollmentQuery.data?.status === "pending"
|
||||
? "Continue"
|
||||
: "Connect with Paperclip"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : step === "key" && entry ? (
|
||||
<KeyStep
|
||||
entry={entry}
|
||||
name={galleryName}
|
||||
onNameChange={setGalleryName}
|
||||
values={credentials}
|
||||
onChange={setCredentials}
|
||||
oauthClientId={curatedOAuthClientId}
|
||||
|
|
@ -1708,13 +1818,12 @@ export function ConnectionSetupFlow({
|
|||
connectApp();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{step === "key" && !entry && linkUrl && !zapierSource && (
|
||||
<LinkConnectStep
|
||||
link={linkUrl}
|
||||
name={linkName}
|
||||
onNameChange={setLinkName}
|
||||
needsKey={linkNeedsKey}
|
||||
onNeedsKeyChange={(next) => {
|
||||
setLinkNeedsKey(next);
|
||||
|
|
@ -1746,7 +1855,7 @@ export function ConnectionSetupFlow({
|
|||
matchedEntry={linkMatchedEntry}
|
||||
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
|
||||
submitting={connectMutation.isPending || genericOAuthPending}
|
||||
onBack={backToGallery}
|
||||
onBack={() => setStep("access")}
|
||||
onConnect={() => {
|
||||
setLinkGuidance(null);
|
||||
connectMutation.mutate(undefined);
|
||||
|
|
@ -1759,7 +1868,7 @@ export function ConnectionSetupFlow({
|
|||
link={linkUrl}
|
||||
onLinkChange={setLinkUrl}
|
||||
submitting={connectMutation.isPending}
|
||||
onBack={backToGallery}
|
||||
onBack={() => setStep("access")}
|
||||
onConnect={() => connectMutation.mutate(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1848,7 +1957,7 @@ function StepHeader({
|
|||
) : null}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{appIdentity ? `Connect ${appIdentity.name}` : "Connect an app"}
|
||||
{appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||||
|
|
@ -1943,7 +2052,7 @@ export function OAuthConnectStateScreen({
|
|||
unverifiedHost={unverifiedHost}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
{phase === "error" ? (
|
||||
|
|
@ -1975,10 +2084,6 @@ export function OAuthConnectStateScreen({
|
|||
)}
|
||||
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
|
||||
</div>
|
||||
<p className="mt-5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Your authorization stays in Paperclip’s encrypted secret store.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2002,20 +2107,8 @@ function ZapierConnectStep({
|
|||
const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
<Link2 className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-bold tracking-tight">Connect Zapier</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Paste the complete MCP URL Zapier gives you, including its token.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Zapier MCP URL</label>
|
||||
<Input
|
||||
type="password"
|
||||
|
|
@ -2030,15 +2123,12 @@ function ZapierConnectStep({
|
|||
className="mt-2 h-11"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
The token is part of the URL. Paperclip stores it securely and checks the connection before enabling actions.
|
||||
</p>
|
||||
{link.trim() && !isZapierLink && (
|
||||
<p className="mt-2 text-xs text-destructive">Paste a valid Zapier URL to continue.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
|
|
@ -2327,7 +2417,6 @@ function normalizeAppLink(value: string): string | null {
|
|||
function LinkConnectStep({
|
||||
link,
|
||||
name,
|
||||
onNameChange,
|
||||
needsKey,
|
||||
onNeedsKeyChange,
|
||||
keyValue,
|
||||
|
|
@ -2351,7 +2440,6 @@ function LinkConnectStep({
|
|||
}: {
|
||||
link: string;
|
||||
name: string;
|
||||
onNameChange: (next: string) => void;
|
||||
needsKey: boolean;
|
||||
onNeedsKeyChange: (next: boolean) => void;
|
||||
keyValue: string;
|
||||
|
|
@ -2388,28 +2476,17 @@ function LinkConnectStep({
|
|||
};
|
||||
const canSubmit = canSubmitGenericConnect(draft);
|
||||
const showSimpleKeyQuestion = authMode === "auto";
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link;
|
||||
|
||||
useEffect(() => {
|
||||
if (guidance?.focus === "name") nameInputRef.current?.focus();
|
||||
}, [guidance]);
|
||||
|
||||
const updateHeader = (id: string, patch: Partial<CustomHeaderRow>) => {
|
||||
onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
<Link2 className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-bold tracking-tight">Connect your own MCP server</h2>
|
||||
<p className="mt-1 truncate font-mono text-sm text-muted-foreground" title={displayedLink}>{displayedLink}</p>
|
||||
<UnverifiedServerBadge host={host} className="mt-2" />
|
||||
</div>
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="min-w-0 truncate font-mono text-sm text-muted-foreground" title={displayedLink}>{displayedLink}</p>
|
||||
<UnverifiedServerBadge host={host} />
|
||||
</div>
|
||||
|
||||
{matchedEntry && onUseMatchedEntry ? (
|
||||
|
|
@ -2432,22 +2509,7 @@ function LinkConnectStep({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-name">Name</label>
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
id="generic-mcp-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="My app"
|
||||
className="mt-2 h-11"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
We filled this in from the address. Change it if you'd like.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{showSimpleKeyQuestion && (
|
||||
<div>
|
||||
<label className="mr-2 text-sm font-medium text-foreground">Does it need a key?</label>
|
||||
|
|
@ -2485,7 +2547,6 @@ function LinkConnectStep({
|
|||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -2553,7 +2614,6 @@ function LinkConnectStep({
|
|||
Add another header
|
||||
</Button>
|
||||
{headerError ? <p className="text-xs text-destructive">{headerError}</p> : null}
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -2590,7 +2650,6 @@ function LinkConnectStep({
|
|||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
|
|
@ -2601,15 +2660,10 @@ function LinkConnectStep({
|
|||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
We'll check the server before turning anything on.
|
||||
</span>
|
||||
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting ? "Checking…" : "Check link"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting ? "Checking…" : "Check link"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2649,21 +2703,6 @@ const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string
|
|||
},
|
||||
];
|
||||
|
||||
function StoredSecurelyNote() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Stored securely.</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paperclip keeps this in its encrypted secret store. You can replace it anytime from this app's page,
|
||||
but it can't be read back.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedOption({
|
||||
label,
|
||||
selected,
|
||||
|
|
@ -2690,44 +2729,8 @@ function SegmentedOption({
|
|||
);
|
||||
}
|
||||
|
||||
function ConnectionNameField({
|
||||
name,
|
||||
onNameChange,
|
||||
}: {
|
||||
name: string;
|
||||
onNameChange: (next: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Name</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="My app"
|
||||
className="mt-2 h-11"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
We filled this in from the app. Change it to tell connections apart.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function connectionMethodAuthenticationLabel(method: ConnectionMethodDef): string {
|
||||
if (method.auth === "api_key") return "Authentication: API key";
|
||||
if (method.auth === "none") return "Authentication: no credentials";
|
||||
if (connectionMethodSupportsAutomaticOAuth(method)) {
|
||||
return connectionMethodAcceptsCustomerOAuthClient(method)
|
||||
? "Authentication: browser sign-in or your OAuth app"
|
||||
: "Authentication: browser sign-in";
|
||||
}
|
||||
return "Authentication: your OAuth app";
|
||||
}
|
||||
|
||||
function KeyStep({
|
||||
entry,
|
||||
name,
|
||||
onNameChange,
|
||||
values,
|
||||
onChange,
|
||||
oauthClientId,
|
||||
|
|
@ -2750,8 +2753,6 @@ function KeyStep({
|
|||
onConnect,
|
||||
}: {
|
||||
entry: AppDefinition;
|
||||
name: string;
|
||||
onNameChange: (next: string) => void;
|
||||
values: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
oauthClientId: string;
|
||||
|
|
@ -2777,7 +2778,6 @@ function KeyStep({
|
|||
onBack: () => void;
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
const copy = appCopyFor(entry.slug, entry.description);
|
||||
const methods = useMemo(
|
||||
() => connectionMethodsForCredentialSource(entry, credentialSource),
|
||||
[credentialSource, entry],
|
||||
|
|
@ -2908,7 +2908,6 @@ function KeyStep({
|
|||
options={capabilityMethods.map((candidate) => ({
|
||||
value: candidate.key,
|
||||
title: candidate.label ?? (candidate.auth === "oauth" ? `Sign in with ${entry.name}` : "Use an API key"),
|
||||
description: `${connectionMethodAuthenticationLabel(candidate)}. ${candidate.whenToUse}`,
|
||||
}))}
|
||||
/>
|
||||
{!method && <p className="mt-2 text-xs text-muted-foreground">Choose a connection method to continue.</p>}
|
||||
|
|
@ -2919,18 +2918,9 @@ function KeyStep({
|
|||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||||
const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} darkLogoUrl={entry.branding.darkLogoUrl} size={48} />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight sm:text-xl">Connect Google Sheets</h2>
|
||||
<p className="text-sm text-muted-foreground">{copy.short}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="space-y-6">
|
||||
{capabilitySelection}
|
||||
<ConnectionNameField name={name} onNameChange={onNameChange} />
|
||||
|
||||
{robotEmail ? (
|
||||
<div>
|
||||
|
|
@ -2992,43 +2982,28 @@ function KeyStep({
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} darkLogoUrl={entry.branding.darkLogoUrl} size={48} />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Connect {entry.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{copy.short}</p>
|
||||
</div>
|
||||
</div>
|
||||
const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl;
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
{requirementsUrl ? (
|
||||
<div className="mb-4 flex justify-end">
|
||||
<a
|
||||
href={requirementsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Review requirements
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
{capabilitySelection}
|
||||
{authenticationSelection}
|
||||
|
||||
{method?.warnings?.map((warning) => (
|
||||
<InlineBanner key={warning} tone="warning" compact>
|
||||
{warning}
|
||||
</InlineBanner>
|
||||
))}
|
||||
|
||||
{method ? (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<p className="text-sm text-foreground">{method.guidanceMd}</p>
|
||||
{entry.docsUrl || method.consoleLinks?.docs ? (
|
||||
<a
|
||||
href={method.consoleLinks?.docs ?? entry.docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||||
>
|
||||
Review {entry.name} setup requirements
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{usingVercel && vercelReview && vercelConnectAvailability ? (
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
|
|
@ -3065,8 +3040,6 @@ function KeyStep({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<ConnectionNameField name={name} onNameChange={onNameChange} />
|
||||
|
||||
{standardConfigFields.map((field) => (
|
||||
<MethodConfigField
|
||||
key={field.key}
|
||||
|
|
@ -3122,13 +3095,7 @@ function KeyStep({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{usingVercel ? null : method && fields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{method.auth === "oauth"
|
||||
? `You’ll continue to ${entry.name} to sign in securely.`
|
||||
: "This app doesn’t need a key. Just connect to continue."}
|
||||
</p>
|
||||
) : (
|
||||
{usingVercel || !method || fields.length === 0 ? null : (
|
||||
fields.map((field) => (
|
||||
<div key={field.configPath}>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
|
|
@ -3157,38 +3124,20 @@ function KeyStep({
|
|||
))
|
||||
)}
|
||||
|
||||
{!usingVercel && method?.auth === "api_key" && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Your key is stored securely.</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
You can replace it anytime from this app’s page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{method?.auth === "oauth"
|
||||
? "You’ll sign in before anything turns on."
|
||||
: "We’ll check the key before turning anything on."}
|
||||
</span>
|
||||
<Button onClick={onConnect} disabled={submitting || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting
|
||||
? "Checking…"
|
||||
: usingVercel
|
||||
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
|
||||
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={onConnect} disabled={submitting || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting
|
||||
? "Checking…"
|
||||
: usingVercel
|
||||
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
|
||||
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -3286,7 +3235,6 @@ function OAuthClientFields({
|
|||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3380,7 +3328,8 @@ export function AccessStep({
|
|||
setInstallAgentIds: (ids: Set<string>) => void;
|
||||
/** Task-hosted intents grant reach only to the agent that requested it. */
|
||||
lockedAgentId?: string;
|
||||
capabilities?: Pick<ToolConnectionCreateCapabilities, "canSetCompanyInstall"> & {
|
||||
capabilities?: Pick<ToolConnectionCreateCapabilities, "canCreateOrganizationGrant" | "canSetCompanyInstall"> & {
|
||||
organizationGrantReason?: string | null;
|
||||
companyInstallReason?: string | null;
|
||||
editableAgentIds?: string[];
|
||||
} | null;
|
||||
|
|
@ -3409,15 +3358,19 @@ export function AccessStep({
|
|||
// available the option stays visible and disabled with the reason, so the
|
||||
// scope stays legible instead of quietly disappearing.
|
||||
const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true;
|
||||
const canCreateOrganizationGrant = capabilities?.canCreateOrganizationGrant ?? true;
|
||||
const needsIdentityChoice = authKind !== "none";
|
||||
const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]);
|
||||
const canContinue = preserveAgentAccess
|
||||
const identityChoiceAllowed = !needsIdentityChoice
|
||||
|| grantKind === "user"
|
||||
|| canCreateOrganizationGrant;
|
||||
const canContinue = identityChoiceAllowed && (preserveAgentAccess
|
||||
? true
|
||||
: lockedAgentId
|
||||
? installAgentIds.has(lockedAgentId)
|
||||
: installChoice === "all"
|
||||
? canSetCompanyInstall
|
||||
: installAgentIds.size > 0;
|
||||
: installAgentIds.size > 0);
|
||||
const lockedAgentName = lockedAgentId
|
||||
? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent"
|
||||
: null;
|
||||
|
|
@ -3460,6 +3413,15 @@ export function AccessStep({
|
|||
value: "organization",
|
||||
title: "Any human in the company",
|
||||
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
|
||||
accessibleLabel: canCreateOrganizationGrant
|
||||
? "Any human in the company"
|
||||
: `Any human in the company. Unavailable: ${capabilities?.organizationGrantReason ??
|
||||
"Only a connection manager can share this credential with the organization."}`,
|
||||
tooltip: canCreateOrganizationGrant
|
||||
? undefined
|
||||
: capabilities?.organizationGrantReason ??
|
||||
"Only a connection manager can share this credential with the organization.",
|
||||
disabled: !canCreateOrganizationGrant,
|
||||
},
|
||||
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function useAppsEnabled() {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: query.data?.enableApps === true,
|
||||
loaded: query.isFetched,
|
||||
};
|
||||
}
|
||||
|
|
@ -58,7 +58,6 @@ const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle built-in agents experimental setting"]';
|
||||
const BETA_SKILLS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle beta skills experimental setting"]';
|
||||
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
|
||||
const SUMMARIES_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle summaries experimental setting"]';
|
||||
const STATUS_CARDS_TOGGLE_SELECTOR =
|
||||
|
|
@ -73,7 +72,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
enableApps: true,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableConferenceRoomChat: false,
|
||||
|
|
@ -190,17 +189,11 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
expect(warning?.textContent).toContain("no compatibility guarantees");
|
||||
});
|
||||
|
||||
it("enables the Apps UI from experimental settings", async () => {
|
||||
it("does not render an Apps experimental setting", async () => {
|
||||
await renderPage();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(() => toggle?.click());
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
|
||||
expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(container.querySelector('button[aria-label="Toggle apps experimental setting"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Show the Apps navigation");
|
||||
});
|
||||
|
||||
it("does not render the Conference Room Chat experimental setting for now", async () => {
|
||||
|
|
@ -664,19 +657,19 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => {
|
|||
it("renders a managed key locked with the badge while unmanaged keys stay editable", async () => {
|
||||
await renderPage({
|
||||
...defaultExperimentalSettings(),
|
||||
enableApps: true,
|
||||
enableBuiltInAgents: true,
|
||||
managedKeys: {
|
||||
enableApps: { managed: true, managedBy: "paperclip-cloud" },
|
||||
enableBuiltInAgents: { managed: true, managedBy: "paperclip-cloud" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(MANAGED_BADGE_TEXT);
|
||||
|
||||
const appsToggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
||||
expect(appsToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(appsToggle?.disabled).toBe(true);
|
||||
const builtInAgentsToggle = container.querySelector<HTMLButtonElement>(BUILT_IN_AGENTS_TOGGLE_SELECTOR);
|
||||
expect(builtInAgentsToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(builtInAgentsToggle?.disabled).toBe(true);
|
||||
|
||||
await act(() => appsToggle?.click());
|
||||
await act(() => builtInAgentsToggle?.click());
|
||||
await flushReact();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
||||
|
||||
|
|
@ -729,12 +722,12 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => {
|
|||
|
||||
expect(container.textContent).not.toContain(MANAGED_BADGE_TEXT);
|
||||
|
||||
const appsToggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
||||
expect(appsToggle?.disabled).toBe(false);
|
||||
const builtInAgentsToggle = container.querySelector<HTMLButtonElement>(BUILT_IN_AGENTS_TOGGLE_SELECTOR);
|
||||
expect(builtInAgentsToggle?.disabled).toBe(false);
|
||||
|
||||
await act(() => appsToggle?.click());
|
||||
await act(() => builtInAgentsToggle?.click());
|
||||
await flushReact();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableBuiltInAgents: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -863,7 +856,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => {
|
|||
|
||||
expect(container.textContent).not.toContain("Enable Environments");
|
||||
expect(container.textContent).toContain("Beta skills");
|
||||
expect(container.textContent).toContain("Apps");
|
||||
expect(container.textContent).not.toContain("Show the Apps navigation");
|
||||
});
|
||||
|
||||
it("shows every toggle when nothing is hidden", async () => {
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ export function InstanceExperimentalSettings() {
|
|||
const enableNativeRunner = experimentalQuery.data?.enableNativeRunner === true;
|
||||
const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const enableApps = experimentalQuery.data?.enableApps === true;
|
||||
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
|
||||
// experimental opt-out was retired, so it no longer surfaces a toggle here.
|
||||
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
|
||||
|
|
@ -275,17 +274,6 @@ export function InstanceExperimentalSettings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Apps"
|
||||
description="Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling."
|
||||
checked={enableApps}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableApps: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableApps"
|
||||
managed={managedKeys.enableApps}
|
||||
ariaLabel="Toggle apps experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Beta skills"
|
||||
description="Allow agents to pin beta releases of the Paperclip core skill. Disabling this returns every agent to the default live skill without removing saved pins."
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export function AppDetail() {
|
|||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
|
|
@ -250,13 +250,12 @@ export function AppDetail() {
|
|||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: appName, href: appTabHref(connectionId, "setup") },
|
||||
{ label: appTabLabel(activeTab) },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, appName, connectionId, activeTab]);
|
||||
}, [setBreadcrumbs, appName, connectionId, activeTab]);
|
||||
|
||||
const catalog = catalogQuery.data?.catalog ?? [];
|
||||
const profile = useMemo(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function AppNotConnected() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
|
||||
|
|
@ -115,13 +115,12 @@ export function AppNotConnected() {
|
|||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: appName, href: appApplicationTabHref(applicationId, "setup") },
|
||||
{ label: appTabLabel(activeTab) },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, appName, applicationId, activeTab]);
|
||||
}, [setBreadcrumbs, appName, applicationId, activeTab]);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => toolsApi.updateApplication(applicationId, { status: "archived" }),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ const connectAppMock = vi.hoisted(() => vi.fn());
|
|||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const getCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn());
|
||||
const startCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -31,6 +33,7 @@ const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog"
|
|||
const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!;
|
||||
const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!;
|
||||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
const GOOGLE_CALENDAR = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-calendar")!;
|
||||
const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
|
||||
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
|
||||
const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!;
|
||||
|
|
@ -46,6 +49,9 @@ vi.mock("@/api/tools", () => ({
|
|||
finishAppMock(companyId, connectionId, input),
|
||||
putConnectionInstalls: (connectionId: string, installs: unknown) =>
|
||||
putConnectionInstallsMock(connectionId, installs),
|
||||
getCloudConnectorEnrollment: () => getCloudConnectorEnrollmentMock(),
|
||||
startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) =>
|
||||
startCloudConnectorEnrollmentMock(companyId, label, returnTo),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -176,6 +182,7 @@ async function gotoLinkFrame(container: HTMLDivElement, url: string) {
|
|||
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
}
|
||||
|
||||
describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
|
|
@ -195,6 +202,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
GITHUB,
|
||||
],
|
||||
capabilities: {
|
||||
canCreateOrganizationGrant: true,
|
||||
organizationGrantReason: null,
|
||||
canSetCompanyInstall: true,
|
||||
companyInstallReason: null,
|
||||
},
|
||||
|
|
@ -209,6 +218,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
finishAppMock.mockResolvedValue({});
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValue({
|
||||
configured: true,
|
||||
status: "active",
|
||||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||||
instanceId: "inst-test",
|
||||
environment: "staging",
|
||||
origins: ["https://paperclip.example.test"],
|
||||
});
|
||||
startCloudConnectorEnrollmentMock.mockResolvedValue({
|
||||
configured: false,
|
||||
status: "pending",
|
||||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||||
instanceId: "inst-test",
|
||||
environment: "staging",
|
||||
origins: [],
|
||||
verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
|
||||
});
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "example.com" },
|
||||
|
|
@ -264,7 +290,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(document.activeElement).toBe(urlInput);
|
||||
});
|
||||
|
||||
it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => {
|
||||
it("an unrecognized URL routes to a minimal frame with the URL and key choice", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
|
|
@ -277,11 +303,29 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(buttonByText("No")).toBeTruthy();
|
||||
expect(buttonByText("Yes")).toBeTruthy();
|
||||
|
||||
// Name is auto-filled from the host with www. stripped.
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
expect(nameInput?.value).toBe("example.com/actions");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the shared Access step before configuring a pasted MCP server", async () => {
|
||||
await render();
|
||||
|
||||
const linkInput = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).find((input) => input.getAttribute("placeholder")?.startsWith("https://"));
|
||||
await act(async () => setInputValue(linkInput!, "https://mcp.example.com/actions"));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
expect(radioContaining("Just me")).toBeTruthy();
|
||||
expect(radioContaining("Any human in the company")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Just agents I pick")).toBeTruthy();
|
||||
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(container.textContent).not.toContain("Does it need a key?");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -511,7 +555,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(container.textContent).toContain("Your OAuth app");
|
||||
expect(container.textContent).toContain("Open Asana app settings");
|
||||
expect(container.textContent).toContain("Create an Asana MCP OAuth app");
|
||||
expect(container.textContent).not.toContain("Create an Asana MCP OAuth app");
|
||||
expect(container.textContent).toContain("Paperclip callback URL");
|
||||
expect(container.textContent).toContain(
|
||||
"http://localhost:3000/api/tools/oauth/callback",
|
||||
|
|
@ -614,6 +658,46 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||||
});
|
||||
|
||||
it("prompts for one-time Cloud registration inside Gmail when managed methods are not yet advertised", async () => {
|
||||
mockParams.appKey = "gmail";
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [{
|
||||
...GMAIL,
|
||||
methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
|
||||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||||
}],
|
||||
});
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValueOnce({
|
||||
configured: false,
|
||||
status: "not_configured",
|
||||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||||
instanceId: null,
|
||||
environment: "staging",
|
||||
origins: [],
|
||||
});
|
||||
|
||||
await render();
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect with Paperclip");
|
||||
expect(container.textContent).not.toContain("Required once for managed Google sign-in.");
|
||||
expect(container.textContent).not.toContain("Your OAuth app");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"Paperclip",
|
||||
"/apps/connect?source=gmail&stage=setup",
|
||||
);
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||||
"https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
|
||||
mockParams.appKey = "google-drive";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
|
||||
|
|
@ -624,14 +708,44 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers");
|
||||
expect(container.textContent).not.toContain("final project-registration email");
|
||||
expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment");
|
||||
expect(radioContaining("Just me")).toBeTruthy();
|
||||
expect(radioContaining("Any human in the company")?.getAttribute("aria-checked")).toBe("true");
|
||||
await passAccessStep();
|
||||
|
||||
expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(container.textContent).toContain("Before connecting, enroll the signed-in Workspace account");
|
||||
expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account");
|
||||
expect(container.textContent).toContain("Review requirements");
|
||||
expect(container.textContent).toContain("Your OAuth app");
|
||||
});
|
||||
|
||||
it("renders Google Calendar as one minimal, unboxed setup screen", async () => {
|
||||
mockParams.appKey = "google-calendar";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_CALENDAR] });
|
||||
|
||||
await render();
|
||||
await passAccessStep();
|
||||
|
||||
const duplicateHeadings = Array.from(container.querySelectorAll("h1, h2")).filter(
|
||||
(heading) => heading.textContent?.trim() === "Connect Google Calendar",
|
||||
);
|
||||
expect(duplicateHeadings).toHaveLength(1);
|
||||
expect(container.textContent).toContain("What should Paperclip be able to do?");
|
||||
expect(container.textContent).toContain("Review requirements");
|
||||
expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events.");
|
||||
expect(container.textContent).not.toContain("All event mutations require approval.");
|
||||
expect(container.textContent).not.toContain("Google Workspace MCP servers are in Developer Preview.");
|
||||
expect(container.textContent).not.toContain("We filled this in from the app.");
|
||||
expect(container.textContent).not.toContain("You’ll continue to Google Calendar");
|
||||
expect(container.textContent).not.toContain("You’ll sign in before anything turns on.");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
|
||||
const capabilityQuestion = Array.from(container.querySelectorAll("label")).find(
|
||||
(label) => label.textContent === "What should Paperclip be able to do?",
|
||||
);
|
||||
expect(capabilityQuestion?.closest(".max-w-xl")?.classList.contains("bg-card")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Shopify prerequisite copy off access before collecting the store domain", async () => {
|
||||
mockParams.appKey = "shopify";
|
||||
listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] });
|
||||
|
|
@ -661,6 +775,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [GITHUB],
|
||||
capabilities: {
|
||||
canCreateOrganizationGrant: false,
|
||||
organizationGrantReason: "Only connection managers can share this credential.",
|
||||
canSetCompanyInstall: false,
|
||||
companyInstallReason: "Your company policy limits this choice to connection managers.",
|
||||
},
|
||||
|
|
@ -684,6 +800,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(anyAgent?.getAttribute("aria-label")).toContain(
|
||||
"Your company policy limits this choice to connection managers.",
|
||||
);
|
||||
const organization = radioContaining("Any human in the company");
|
||||
expect(organization?.disabled).toBe(true);
|
||||
expect(organization?.getAttribute("title")).toBe(
|
||||
"Only connection managers can share this credential.",
|
||||
);
|
||||
expect(radioContaining("Just me")?.disabled).toBe(false);
|
||||
// "Just agents I pick" is the live alternative, so the step is not a dead end.
|
||||
const pick = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
|
|
@ -1735,7 +1857,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(input.credentialValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("choosing Yes reveals one masked key field plus the lock reassurance", async () => {
|
||||
it("choosing Yes reveals one masked key field without extra reassurance copy", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
|
|
@ -1755,7 +1877,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).filter((i) => i.type === "password");
|
||||
expect(passwordInputs).toHaveLength(1);
|
||||
expect(container.textContent).toContain("Stored securely.");
|
||||
expect(container.textContent).not.toContain("Stored securely.");
|
||||
|
||||
await act(async () => setInputValue(passwordInputs[0], "secret-key"));
|
||||
await flushReact();
|
||||
|
|
@ -1789,10 +1911,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).not.toContain(zapierUrl);
|
||||
expect(container.textContent).toContain("token=REDACTED");
|
||||
expect(nameInputFrom(container)?.value).toBe("Zapier");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -1810,7 +1936,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Zapier visible and finishes without a separate access or install step", async () => {
|
||||
it("gives Zapier the shared credential and agent access opener", async () => {
|
||||
mockSearch.value = "source=zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
|
|
@ -1845,14 +1971,22 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await render();
|
||||
|
||||
// The pasted-URL path enters its server address in this step, so an identity
|
||||
// question cannot precede it; it keeps today's every-agent default rather
|
||||
// than asking (PAP-17835 leaves the Access step to the curated app path).
|
||||
expect(container.textContent).toContain("Step 1 of 1");
|
||||
expect(container.textContent).toContain("Step 1 of 2");
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(container.querySelector('input[placeholder^="https://mcp.zapier.com"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
|
||||
const linkInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder^="https://mcp.zapier.com"]',
|
||||
|
|
@ -1871,13 +2005,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
link: zapierUrl,
|
||||
name: "Zapier for the company",
|
||||
name: "Zapier",
|
||||
galleryKey: "zapier",
|
||||
connectionMethodKey: "generated-url",
|
||||
grantKind: "user",
|
||||
});
|
||||
// No grantKind is sent: the pasted-URL path never offered the choice, and
|
||||
// sending "user" without asking would mis-scope the credential.
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind");
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["action-1", "action-2"],
|
||||
|
|
@ -1922,12 +2054,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
expect(nameInput?.value).toBe("Bla");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -1998,15 +2130,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// PAP-11283: the gallery step exposes a Name field (default = app name) so a
|
||||
// connection can be named at create time, matching the link flow.
|
||||
function nameInputFrom(root: HTMLDivElement): HTMLInputElement | undefined {
|
||||
return Array.from(root.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
}
|
||||
|
||||
it("gallery key step defaults the Name field to the app name", async () => {
|
||||
it("gallery setup names the connection automatically", async () => {
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -2016,7 +2140,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
expect(nameInputFrom(container)?.value).toBe("GitHub");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github&stage=setup");
|
||||
});
|
||||
|
||||
|
|
@ -2108,7 +2232,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
await render();
|
||||
await passAccessStep();
|
||||
expect(nameInputFrom(container)?.value).toBe("Engineering GitHub for the company");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
await act(async () => setInputValue(keyField!, "replacement-key"));
|
||||
await flushReact();
|
||||
|
|
@ -2127,7 +2251,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("a custom name in the gallery step is sent to the connect mutation", async () => {
|
||||
it("uses the gallery app name automatically", async () => {
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -2136,7 +2260,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
await act(async () => setInputValue(nameInputFrom(container)!, "GitHub (stdio smoke)"));
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
await act(async () => setInputValue(keyField!, "secret-key"));
|
||||
await flushReact();
|
||||
|
|
@ -2149,11 +2272,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
galleryKey: "github",
|
||||
name: "GitHub (stdio smoke) for the company",
|
||||
name: "GitHub for the company",
|
||||
});
|
||||
});
|
||||
|
||||
it("a custom name on the Google Sheets step is sent to the connect mutation", async () => {
|
||||
it("uses the Google Sheets app name automatically", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
|
||||
|
|
@ -2171,9 +2294,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
// Default is the app name.
|
||||
expect(nameInputFrom(container)?.value).toBe("Google Sheets");
|
||||
await act(async () => setInputValue(nameInputFrom(container)!, "Google Sheets (stdio smoke)"));
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
await act(async () =>
|
||||
setTextareaValue(textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit"),
|
||||
|
|
@ -2188,7 +2309,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
galleryKey: "google-sheets",
|
||||
name: "Google Sheets (stdio smoke) for the company",
|
||||
name: "Google Sheets for the company",
|
||||
configValues: { allowedSpreadsheetIds: ["sheet_123"] },
|
||||
});
|
||||
});
|
||||
|
|
@ -2308,12 +2429,18 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
|||
return new ApiError(message, status, { error: message, details: { code } });
|
||||
}
|
||||
|
||||
it("defaults the connection name from host, port, and path", async () => {
|
||||
it("defaults the hidden connection name from host, port, and path", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "http://127.0.0.1:47399/mcp");
|
||||
|
||||
expect(container.querySelector<HTMLInputElement>("#generic-mcp-name")?.value)
|
||||
.toBe("127.0.0.1:47399/mcp");
|
||||
expect(container.querySelector("#generic-mcp-name")).toBeNull();
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
name: "127.0.0.1:47399/mcp for the company",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the endpoint host visible while skipping action review", async () => {
|
||||
|
|
@ -2436,7 +2563,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
|||
expect(container.textContent).not.toContain("PAPERCLIP_PUBLIC_URL");
|
||||
});
|
||||
|
||||
it("renders a name conflict as name guidance and focuses the Name field", async () => {
|
||||
it("does not ask the operator to resolve an internal name conflict", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(
|
||||
409,
|
||||
"tool_access_name_conflict",
|
||||
|
|
@ -2450,9 +2577,9 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>("#generic-mcp-name");
|
||||
expect(container.textContent).toContain("That name is taken");
|
||||
expect(document.activeElement).toBe(nameInput);
|
||||
expect(container.textContent).toContain("Paperclip couldn’t name this connection");
|
||||
expect(container.textContent).not.toContain("Choose a different name");
|
||||
expect(container.querySelector("#generic-mcp-name")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens advanced authentication when the server wants a credential we can't discover", async () => {
|
||||
|
|
|
|||
|
|
@ -14,17 +14,16 @@ import { ReviewQueueCard } from "./ReviewQueueCard";
|
|||
* "Needs attention"; decisions live here.
|
||||
*/
|
||||
export function AppsReview() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: "Review" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to review approvals.</div>;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
|||
const archiveConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
|
|
@ -45,7 +46,7 @@ vi.mock("@/context/CompanyContext", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
|
|
@ -173,6 +174,10 @@ describe("Connectors landing page", () => {
|
|||
it("renders one connector list with the requested header and no gallery sections", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
expect(setBreadcrumbsMock).toHaveBeenCalledWith([]);
|
||||
expect(setBreadcrumbsMock).not.toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ href: "/dashboard" }),
|
||||
]));
|
||||
expect(container.querySelector("header")?.textContent).toBe("Connectors");
|
||||
expect(
|
||||
container.querySelector('header input[aria-label="Search connectors"]'),
|
||||
|
|
|
|||
|
|
@ -203,18 +203,18 @@ export function Browse() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [query, setQuery] = useState("");
|
||||
const [connectionToRemove, setConnectionToRemove] = useState<ConnectionRemovalTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Connectors" },
|
||||
]);
|
||||
// Apps is already its own navigation root and the page has its own title.
|
||||
// Leave the global bar empty instead of duplicating "Connectors" or
|
||||
// offering a path back out of Apps.
|
||||
setBreadcrumbs([]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
|
|
|
|||
|
|
@ -126,12 +126,11 @@ export function Connections() {
|
|||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: "Connections" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function GatewayDetail() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [snippetOpen, setSnippetOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
|
@ -103,13 +103,12 @@ export function GatewayDetail() {
|
|||
useEffect(() => {
|
||||
if (!gateway) return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: "Gateways", href: "/apps/gateways" },
|
||||
{ label: gateway.name },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, gateway]);
|
||||
}, [setBreadcrumbs, gateway]);
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
|
|
|
|||
|
|
@ -32,19 +32,18 @@ export function GatewaysList() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [search, setSearch] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
{ label: "Gateways" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const gatewaysQuery = useQuery({
|
||||
queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"),
|
||||
|
|
|
|||
|
|
@ -90,9 +90,10 @@ describe("genericConnectGuidance", () => {
|
|||
expect(genericConnectGuidance("oauth_redirect_origin_unsupported", null).focus).toBe("deployment");
|
||||
});
|
||||
|
||||
it("points at the name field when the connection name is already used", () => {
|
||||
it("does not ask the operator to resolve an internal name conflict", () => {
|
||||
const guidance = genericConnectGuidance("tool_access_name_conflict", null);
|
||||
expect(guidance).toMatchObject({ title: "That name is taken", focus: "name" });
|
||||
expect(guidance).toMatchObject({ title: "Paperclip couldn’t name this connection", focus: "none" });
|
||||
expect(guidance.body).not.toContain("different name");
|
||||
});
|
||||
|
||||
it("passes a rejected header's own message through", () => {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export function defaultGenericMcpName(url: string): string | null {
|
|||
* this to decide whether to reopen the URL field or the credential section, rather
|
||||
* than leaving them to guess which of the two was wrong.
|
||||
*/
|
||||
export type GenericConnectFocus = "url" | "name" | "credentials" | "deployment" | "none";
|
||||
export type GenericConnectFocus = "url" | "credentials" | "deployment" | "none";
|
||||
|
||||
export interface GenericConnectGuidance {
|
||||
title: string;
|
||||
|
|
@ -126,9 +126,9 @@ export function genericConnectGuidance(
|
|||
};
|
||||
case "tool_access_name_conflict":
|
||||
return {
|
||||
title: "That name is taken",
|
||||
body: "Choose a different name for this connection, then try again.",
|
||||
focus: "name",
|
||||
title: "Paperclip couldn’t name this connection",
|
||||
body: "Try connecting again.",
|
||||
focus: "none",
|
||||
};
|
||||
case "oauth_challenge":
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -269,13 +269,21 @@ function SeededAccessStep({
|
|||
initialGrantKind,
|
||||
initialChoice,
|
||||
initialAgentIds,
|
||||
capabilities = { canSetCompanyInstall: true, editableAgentIds: AGENT_IDS },
|
||||
capabilities = {
|
||||
canCreateOrganizationGrant: true,
|
||||
canSetCompanyInstall: true,
|
||||
editableAgentIds: AGENT_IDS,
|
||||
},
|
||||
}: {
|
||||
authKind: "oauth" | "api_key" | "none";
|
||||
initialGrantKind: "user" | "organization";
|
||||
initialChoice: "specific" | "all";
|
||||
initialAgentIds: Set<string>;
|
||||
capabilities?: { canSetCompanyInstall: boolean; editableAgentIds: string[] };
|
||||
capabilities?: {
|
||||
canCreateOrganizationGrant: boolean;
|
||||
canSetCompanyInstall: boolean;
|
||||
editableAgentIds: string[];
|
||||
};
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
|
|
|
|||
Loading…
Reference in New Issue