diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index 511f3f2741..d834be2d94 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -60,8 +60,10 @@ Google makes Workspace MCP generally available. | Google People | `https://people.googleapis.com/mcp/v1` | Read contacts | | Google Workspace Search | `https://workspacemcp.googleapis.com/mcp/v1` | Search Workspace | -The setup flow asks for the capability first. It then offers the authentication -methods available for that capability: +The setup flow asks for the capability first. When the managed method is +available, it uses Paperclip by default. A small **Use your own Google OAuth app** +link reveals the custom client fields; **Use Paperclip instead** returns to the +managed method. The available authentication methods are: - **Connect with Paperclip** uses the Paperclip Cloud broker when that exact profile is returned for this enrolled instance by the signed @@ -80,6 +82,14 @@ default organization grant, while still recording which signed-in Google principal completed consent so refresh and reconnect stay bound to that principal. +Catalog discovery and connection creation use the same signed, instance-specific +profile availability. Local enrollment files and Cloud-delivered environment +identities follow this same path; neither enables managed methods globally in +the static app definitions. Saved connections remain recognizable for OAuth +callback, refresh, and revoke, while the broker enforces current profile access. +Switching capability or authentication methods preserves the selected credential +owner when the new method supports that owner. + ## Broker profiles The Paperclip-managed method signs every broker request with one explicit diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 68c40b83fe..8f432c4f9f 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from "node:crypto"; +import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { @@ -53,6 +53,7 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { APP_STORE_HIDDEN_SLUGS, GITHUB_CONNECTOR_PROFILES, + GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAvailableConnectionMethod, getConnectableAppDefinition, @@ -84,7 +85,7 @@ import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; -import { type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; +import { invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported @@ -6934,6 +6935,118 @@ describeEmbeddedPostgres("tool access service", () => { expect(updated.transportConfig).toEqual(updated.config); }); + it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS.flatMap((profile) => [ + ["local_trusted", "private", "http://127.0.0.1:3102"] as const, + ["authenticated", "public", "https://tenant.paperclip.app"] as const, + ].map(([deploymentMode, deploymentExposure, origin]) => ({ profile, deploymentMode, deploymentExposure, origin }))))( + "connects advertised Workspace $profile without mutating definitions in $deploymentMode", + async ({ profile, deploymentMode, deploymentExposure, origin }) => { + const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug; + const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key; + const company = await createCompany(db); + const userId = "board-user"; + await grantBoardUser(db, company.id, userId, [], "owner"); + const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); + const definitionBefore = JSON.stringify(getConnectableAppDefinition(slug)); + const app = createRouteApp(db, + deploymentMode === "authenticated" ? boardSessionActor(company.id, "owner", userId) : undefined, + undefined, { deploymentMode, deploymentExposure, paperclipCloudConnector: connector }); + const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`); + const workspaceApp = gallery.body.apps.find((entry: { slug: string }) => entry.slug === slug); + expect(workspaceApp.methods.map((method: { key: string }) => method.key)).toContain(methodKey); + const connected = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({ + galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Personal ${slug}`, + }); + expect(connected.status).toBe(201); + expect(connected.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" }); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const actor = { actorType: "user" as const, actorId: userId }; + const started = await service.startOAuth(company.id, connected.body.connectionId, { + redirectUri: `${origin}/api/tools/oauth/cloud-connector/callback`, actor, + }); + expect(connector.startAuthorization).toHaveBeenCalledWith(expect.objectContaining({ + profile, companyId: company.id, subject: userId, + returnUri: `${origin}/api/tools/oauth/cloud-connector/callback`, + })); + mockToolsList([]); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: `${profile}-claim`, actor, + }); + expect(completed.connection).toMatchObject({ status: "active", credentialPolicy: "per_user" }); + expect(JSON.stringify(getConnectableAppDefinition(slug))).toBe(definitionBefore); + }); + + it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS)("connects advertised Workspace %s with a Cloud-delivered environment identity", async (profile) => { + const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug; + const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key; + const company = await createCompany(db); + const userId = `cloud-workspace-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const signing = generateKeyPairSync("ed25519"); + const sealing = generateKeyPairSync("x25519"); + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://tenant.paperclip.app"); + vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_BASE_URL", "https://my.paperclip.app"); + vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT", "production"); + vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID", "inst-cloud-workspace-regression"); + vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY", signing.privateKey.export({ type: "pkcs8", format: "pem" }).toString()); + vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY", sealing.privateKey.export({ type: "pkcs8", format: "pem" }).toString()); + invalidatePaperclipCloudConnectorCapabilities(); + const cloudRequest = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const signed = JSON.parse(String(init?.body)).request as string; + const claims = JSON.parse(Buffer.from(signed.split(".")[1]!, "base64url").toString()); + expect(claims).toMatchObject({ iss: "inst-cloud-workspace-regression", env: "production" }); + if (String(url) === "https://my.paperclip.app/v1/connector/instance-status") { + expect(claims.op).toBe("status"); + return Response.json({ active: true, status: "active", profiles: [profile] }); + } + expect(String(url)).toBe("https://my.paperclip.app/v1/connector/sessions"); + expect(claims).toMatchObject({ + op: "session", prf: profile, cid: company.id, sub: userId, + ruri: "https://tenant.paperclip.app/api/tools/oauth/cloud-connector/callback", + }); + return Response.json({ + confirmationUrl: "https://my.paperclip.app/connections/confirm?id=test-workspace-session", + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + }); + try { + const app = createRouteApp(db, boardSessionActor(company.id, "owner", userId), undefined, { + deploymentMode: "authenticated", deploymentExposure: "public", + }); + const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`); + expect(gallery.body.apps.find((entry: { slug: string }) => entry.slug === slug).methods + .map((method: { key: string }) => method.key)).toContain(methodKey); + const result = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({ + galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Cloud ${slug}`, + }); + expect(result.status, JSON.stringify(result.body)).toBe(201); + expect(result.body.auth.startUrl).toBe("https://my.paperclip.app/connections/confirm?id=test-workspace-session"); + expect(result.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" }); + expect(cloudRequest).toHaveBeenCalled(); + } finally { + invalidatePaperclipCloudConnectorCapabilities(); + } + }); + + it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS.flatMap((profile) => + [false, true].map((advertiseOther) => ({ profile, advertiseOther })), + ))("rejects unavailable Workspace $profile (other profile advertised: $advertiseOther)", async ({ profile, advertiseOther }) => { + const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug; + const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key; + const company = await createCompany(db); + const connector = fakeGmailConnector(company.id, "board-user"); + connector.getCapabilities = vi.fn(async (): Promise => + advertiseOther ? [profile === "gmail.read" ? "drive.read" : "gmail.read"] : [], + ); + const app = createRouteApp(db, undefined, undefined, { paperclipCloudConnector: connector }); + const response = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({ + galleryKey: slug, connectionMethodKey: methodKey, name: `Unavailable ${slug}`, + }); + expect(response.status).toBe(422); + expect(connector.startAuthorization).not.toHaveBeenCalled(); + expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toEqual([]); + }); + it("completes brokered Gmail OAuth with a single database connection", async () => { const company = await createCompany(db); const userId = `gmail-member-${randomUUID()}`; @@ -6946,12 +7059,6 @@ describeEmbeddedPostgres("tool access service", () => { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; - const gmailDefinition = getConnectableAppDefinition("gmail")!; - const previousOwnershipAvailability = gmailDefinition.ownershipAvailability; - gmailDefinition.ownershipAvailability = { - ...previousOwnershipAvailability, - platform_shared: true, - }; let deadline: ReturnType | null = null; mockToolsList([]); @@ -7024,7 +7131,6 @@ describeEmbeddedPostgres("tool access service", () => { ).resolves.toMatchObject({ status: "revoked" }); expect(connector.revoke).not.toHaveBeenCalled(); } finally { - gmailDefinition.ownershipAvailability = previousOwnershipAvailability; if (deadline) clearTimeout(deadline); await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined); } diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index df78af3f53..fffa85af70 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -4,7 +4,6 @@ import { agents, companies, connectionGrants, issueThreadInteractions, toolConne import { and, eq, or } from "drizzle-orm"; import { APP_STORE_DEFINITIONS, - DEFAULT_OWNERSHIP_AVAILABILITY, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, isGitHubConnectorProfileId, @@ -58,6 +57,7 @@ import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool- import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { + appWithPaperclipCloudConnectorAvailability, isPaperclipCloudConnectorStrategy, invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector, @@ -802,7 +802,6 @@ function connectorEnrollmentPrincipal(req: Request): string { : options.paperclipCloudConnector ? await options.paperclipCloudConnector.getCapabilities() : []; - const connectorProfiles = new Set(advertisedProfiles); const vercelConnect = vercelConnectIntegrationStatus(); res.json({ capabilities: await describeConnectionCreateCapabilities(req, companyId), @@ -819,20 +818,9 @@ function connectorEnrollmentPrincipal(req: Request): string { : "Vercel Connect setup is disabled on this Paperclip instance.", }, }, - apps: APP_STORE_DEFINITIONS.map((app) => { - const methods = app.methods.filter((method) => - !isPaperclipCloudConnectorStrategy(method.oauthStrategy) - || Boolean(method.connectorProfile && connectorProfiles.has(method.connectorProfile)) - ); - return { - ...app, - methods, - ownershipAvailability: { - ...DEFAULT_OWNERSHIP_AVAILABILITY, - platform_shared: methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy)), - }, - }; - }), + apps: APP_STORE_DEFINITIONS.map((app) => + appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles) + ), }); }); diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 3a9a018ddf..5a8195f13f 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -10,6 +10,8 @@ import { type KeyObject, } from "node:crypto"; import { + DEFAULT_OWNERSHIP_AVAILABILITY, + type AppDefinition, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, isGitHubConnectorProfileId, @@ -487,6 +489,27 @@ export function isPaperclipCloudConnectorStrategy(value: unknown): boolean { return value === "paperclip_cloud_connector" || value === "paperclip_id_connector"; } +/** Use the same signed instance profiles for catalog display and setup validation. */ +export function appWithPaperclipCloudConnectorAvailability( + app: AppDefinition, + profiles: readonly string[], +): AppDefinition { + const enabledProfiles = new Set(profiles); + const methods = app.methods.filter((method) => + !isPaperclipCloudConnectorStrategy(method.oauthStrategy) + || Boolean(method.connectorProfile && enabledProfiles.has(method.connectorProfile)) + ); + return { + ...app, + methods, + ownershipAvailability: { + ...DEFAULT_OWNERSHIP_AVAILABILITY, + ...app.ownershipAvailability, + platform_shared: methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy)), + }, + }; +} + let capabilityCache: { key: string; expiresAt: number; profiles: PaperclipCloudConnectorProfileId[] } | null = null; let capabilityCacheGeneration = 0; diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index d767491929..9239377f9e 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -235,6 +235,8 @@ import { createComposioSessionManager, } from "./composio-session-manager.js"; import { + appWithPaperclipCloudConnectorAvailability, + paperclipCloudConnectorCapabilitiesFromEnv, createPaperclipCloudConnector, isPaperclipCloudConnectorStrategy, paperclipCloudConnectorConfigFromEnv, @@ -999,9 +1001,15 @@ function connectionMethodFor(app: AppDefinition, methodKey?: string | null) { app.slug === "gmail" && methodKey === "paperclip-id-oauth" ? "paperclip-draft" : methodKey; - const toolMethods = getAvailableConnectionMethods(app).filter( + // Stored managed connections must remain recognizable for callback, refresh, + // and revoke even though static definitions omit instance availability. New + // setup passes a definition filtered by signed profiles before reaching here; + // the broker independently enforces availability on authorization and refresh. + const availableMethods = new Set(getAvailableConnectionMethods(app)); + const toolMethods = app.methods.filter( (candidate) => - candidate.purpose !== "channel" && candidate.transport !== "chat_sdk", + candidate.purpose !== "channel" && candidate.transport !== "chat_sdk" + && (availableMethods.has(candidate) || isPaperclipCloudConnectorStrategy(candidate.oauthStrategy)), ); const method = normalizedMethodKey ? (toolMethods.find((candidate) => candidate.key === normalizedMethodKey) ?? @@ -2982,6 +2990,15 @@ export function toolAccessService( : null; return cachedCloudConnector; }; + async function appForConnectionSetup(app: AppDefinition): Promise { + if (!app.methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy))) { + return app; + } + const profiles = connectorWasProvided + ? (await currentCloudConnector()?.getCapabilities() ?? []) + : await paperclipCloudConnectorCapabilitiesFromEnv(); + return appWithPaperclipCloudConnectorAvailability(app, profiles); + } let nextGitHubContinuitySweepAt = 0; const vercelConnect = options.vercelConnectClient === undefined @@ -12114,12 +12131,14 @@ export function toolAccessService( input: ConnectToolApp, actor?: ActorInfo, ): Promise { - const galleryEntry = input.galleryKey + const definition = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null; - if (input.galleryKey && !galleryEntry) + if (input.galleryKey && !definition) throw notFound("Tool app gallery entry not found"); + const galleryEntry = definition ? await appForConnectionSetup(definition) : null; + let existingApplication: typeof toolApplications.$inferSelect | null = null; let requestedResumeConnection: typeof toolConnections.$inferSelect | null = null; diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 894b866cfb..3a5b298536 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowUpRight, @@ -40,6 +40,7 @@ import { getConnectableAppDefinition, getAvailableConnectionMethods, getRecommendedConnectionMethod, + isGoogleWorkspaceConnectorProfileId, } from "@paperclipai/shared"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; @@ -2204,7 +2205,13 @@ export function ConnectionSetupFlow({ methodKey={connectionMethodKey} onMethodChange={(nextMethod) => { setConnectionMethodKey(nextMethod?.key ?? ""); - if (!reconnectGrantKind) { + // Capability/auth changes must not broaden the audience selected + // on Access (including choices restored after Cloud enrollment). + if ( + !reconnectGrantKind + && nextMethod?.grantKinds + && !nextMethod.grantKinds.includes(grantKind) + ) { setGrantKind(defaultGrantKindFor(nextMethod, Boolean(requestedAgentId))); } setCredentials({}); @@ -3381,7 +3388,29 @@ function KeyStep({ {!capabilityKey &&

Choose an access level to continue.

} ) : null; - const authenticationSelection = capabilityMethods.length > 1 ? ( + const managedGoogleMethod = capabilityMethods.find((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + && isGoogleWorkspaceConnectorProfileId(candidate.connectorProfile ?? ""), + ); + const customerGoogleMethod = managedGoogleMethod && capabilityMethods.find((candidate) => + connectionMethodAcceptsCustomerOAuthClient(candidate) + && !connectionMethodSupportsAutomaticOAuth(candidate), + ); + const usingCustomGoogleOAuth = method?.key === customerGoogleMethod?.key; + const googleOAuthFieldsId = useId(); + const authenticationSelection = managedGoogleMethod && customerGoogleMethod && capabilityMethods.length === 2 ? ( + + ) : capabilityMethods.length > 1 ? (
+
+ +
) : null} {usingVercel || !method || fields.length === 0 ? null : ( diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 49c0f2cab4..f7b3479b44 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -3,7 +3,7 @@ import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { CONNECTABLE_APP_DEFINITIONS, getAppStoreDefinition } from "@paperclipai/shared"; +import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; @@ -1187,6 +1187,74 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { })); }); + it.each([...new Set(Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug))] + .flatMap((slug) => [false, true].map((enrollmentReturn) => ({ slug, enrollmentReturn }))))( + "preserves personal Workspace access for $slug when changing method (enrollment return: $enrollmentReturn)", + async ({ slug, enrollmentReturn }) => { + const definition = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug)!; + const readMethod = definition.methods.find((method) => method.key === "paperclip-read")!; + mockSearch.value = enrollmentReturn + ? `source=${slug}&stage=setup&cloud_connector=enrolled` + : `source=${slug}`; + if (enrollmentReturn) { + window.sessionStorage.setItem(`paperclip.connector-enrollment-access:${slug}`, JSON.stringify({ + companyId: "company-1", grantKind: "user", installChoice: "all", agentIds: [], + })); + } + listGalleryMock.mockResolvedValue({ apps: [{ + ...definition, ownershipAvailability: { ...definition.ownershipAvailability, platform_shared: true }, + }] }); + await render(); + if (!enrollmentReturn) { + await act(async () => { + radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await passAccessStep(); + } + + if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) { + const readChoice = radioContaining(readMethod.capabilityProfile!.label); + expect(readChoice).not.toBeNull(); + await act(async () => { + readChoice!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + await flushReact(); + // Change auth methods too, including apps with only one capability. + expect(container.textContent).not.toContain("How do you want to connect?"); + expect(container.textContent).not.toContain("Connect with Paperclip"); + expect(container.textContent).not.toContain("Your OAuth app"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + const customerAuth = buttonByText("Use your own Google OAuth app"); + expect(customerAuth).toBeDefined(); + expect(customerAuth?.getAttribute("aria-expanded")).toBe("false"); + await act(async () => { + customerAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(container.textContent).toContain("Your OAuth app"); + expect(container.textContent).toContain("Client ID"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(true); + const managedAuth = buttonByText("Use Paperclip instead"); + expect(managedAuth).toBeDefined(); + expect(managedAuth?.getAttribute("aria-expanded")).toBe("true"); + const fieldsRegion = document.getElementById(managedAuth!.getAttribute("aria-controls")!); + expect(fieldsRegion?.getAttribute("role")).toBe("region"); + expect(fieldsRegion?.textContent).toContain("Client ID"); + await act(async () => { + managedAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(container.textContent).not.toContain("Your OAuth app"); + await act(async () => { + buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: slug, connectionMethodKey: "paperclip-read", grantKind: "user", + })); + }); + it("never renders self-host enrollment when the connector identity is already active", async () => { mockSearch.value = "source=gmail&stage=setup"; listGalleryMock.mockResolvedValueOnce({