From c8f874311c02eff7cdf5ab9e85dc37d80ed50147 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:22:25 -0500 Subject: [PATCH] fix(ui): hide Google connectors only on the Connections page (#14774) ## Thinking Path > - Paperclip helps people manage AI agents for work. > - The Connections page lists the apps and saved accounts that agents can use. > - Google Workspace verification is still pending. > - Google entries must be temporarily hidden from this page without removing their implementations. > - This PR filters the final page rows, including saved Google accounts, after the page resolves their provider. > - Definitions, direct setup routes, OAuth profiles, credentials, and runtime access stay intact. > - Review instances can keep the prior UI by staying on their pinned app release. ## Linked Issues or Issue Description **What existing behavior does this improve?** Temporary provider visibility on the Connections landing page. **Current behavior** The page can show Google Workspace catalog entries and saved accounts while verification is pending. **Proposed behavior** Hide all nine Google Workspace rows on this page. Keep every other connector and all Google integration code unchanged. Use an existing release pin for review instances instead of a hostname exception in the app. **Reason and benefit** Pause public discovery without disabling existing runtime tools or removing the implementation needed for verification and later re-enablement. **Breaking changes** Google accounts are no longer visible on this landing page. Direct setup and management routes remain available. This is not an access-control restriction. Related completed work: #13551 used catalog-level visibility. This change is deliberately limited to the landing page and also covers saved account rows. #14740 reduced Google scopes; this change leaves those scopes unchanged. No duplicate open PR or matching open issue was found. ## What Changed - Derive the Google app slugs from the existing Workspace profile registry. - Filter the combined catalog and saved-account rows only inside `Browse`. - Cover all nine Google entries, active/draft/disabled accounts, legacy connection metadata, mixed-provider rows, and independently identified non-Google connectors in regression tests. - Document the display-only hold, pinned review builds, and how to restore visibility after approval. ## Verification - Passed: `pnpm exec vitest run ui/src/pages/apps/Browse.test.tsx ui/src/pages/apps/AppsConnect.test.tsx` (199 tests, including the latest master changes). - Passed: `pnpm check:token-gates`. - Passed: `pnpm build`. - Passed: `pnpm -r typecheck` and `pnpm build` after merging the latest master. An earlier overlapping run hit a local runner codesign race; sequential checks passed. - Passed again after the final custom-provider fix: `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter @paperclipai/ui build`. - The full local `pnpm test:run` was started, then stopped after the full remote CI suite passed to avoid continuing duplicate long-running work on the developer machine. It is not claimed as a completed local pass. - All 54 latest-head CI checks passed. Two non-applicable Storybook jobs were skipped. One serialized server job lost its self-hosted runner connection; its single retry passed. - Greptile: 5/5 on `aeda167bf4494feed6ee0de2585960511fb02918`, with no unresolved review threads. - Confirmed in the existing review instance that all nine Google entries still appear after its current release was pinned. No new app release was deployed to that instance. - Reviewer steps: open Connections on this branch with Google catalog entries and saved Google accounts. None should appear. Non-Google connectors must remain. Direct Google setup routes must still load. ## Risks - Existing Google accounts cannot be found on this page during the hold. Their data and runtime access remain unchanged. - This is a UI-only filter, not an authorization gate. Direct routes and API access still work by design. - Review instances must not receive this UI build until the hold is removed. Their existing release pin excludes fleet app upgrades; an explicit targeted upgrade must still be avoided. - No migrations, backend changes, broker changes, or credential changes. ## Model Used OpenAI Codex (GPT-5-based coding agent), with reasoning, tool use, code execution, and browser inspection. The exact deployment model ID and context window are not exposed in this session. ## 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 --- doc/connections/GOOGLE-WORKSPACE.md | 14 ++++ ui/src/pages/apps/Browse.test.tsx | 125 ++++++++++++++++++++++++++-- ui/src/pages/apps/Browse.tsx | 23 ++++- 3 files changed, 154 insertions(+), 8 deletions(-) diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index 98b256a4d8..ff76addd51 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -22,6 +22,20 @@ Google's hosted Workspace MCP servers are Developer Preview services. The app cards remain independent even when several services use the same customer-owned Google OAuth client or the same Paperclip Cloud broker deployment. +## Temporary Connections page visibility hold + +While Google OAuth verification is pending, the Connections landing page +(`ui/src/pages/apps/Browse.tsx`) hides all nine Google Workspace entries, +including their saved accounts. This is a display-only filter. App definitions, +direct setup and management routes, OAuth profiles, saved credentials, and +runtime tools remain unchanged. This is not an access-control restriction. + +Keep verification instances pinned to their pre-hold app release so reviewers +can still find and test the integrations. After approval, remove the page's +`GOOGLE_CONNECTOR_SLUGS` filter and update its visibility tests before upgrading +those instances. Do not disable the shared definitions or broker profiles to +control this page's visibility. + ## Developer Preview enrollment Google grants preview access to the specific Workspace email addresses and diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 6fa85625e0..6c3bc76dc4 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -200,6 +200,124 @@ describe("Connectors landing page", () => { expect(container.textContent).not.toContain("Paused"); }); + const googleSlugs = [ + "gmail", "google-drive", "google-docs", "google-sheets", "google-slides", + "google-calendar", "google-chat", "google-people", "google-workspace-search", + ]; + + it("temporarily hides all Google Workspace catalog rows without changing their definitions", async () => { + const definitions = googleSlugs.map((slug) => getAppStoreDefinition(slug)!); + listGalleryMock.mockResolvedValue({ apps: [...definitions, getAppStoreDefinition("notion")] }); + const client = await renderBrowse(); + + for (const definition of definitions) { + expect(definition.methods.length).toBeGreaterThan(0); + expect(getAppStoreDefinition(definition.slug)).toBe(definition); + expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull(); + } + expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull(); + expect(client.getQueryData(queryKeys.apps.gallery("company-1"))).toEqual({ + apps: [...definitions, getAppStoreDefinition("notion")], + }); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + }); + + it.each(["active", "draft", "disabled"])( + "hides saved Google %s accounts without disabling or removing them", + async (status) => { + const applications = googleSlugs.map((slug) => application({ + id: `app-${slug}`, name: `Saved ${slug}`, applicationKey: `app-gallery:${slug}:one`, + metadata: { sourceTemplateKey: slug }, + })); + const connections = googleSlugs.map((slug) => connection({ + id: `conn-${slug}`, applicationId: `app-${slug}`, name: `Account for ${slug}`, + status, enabled: status !== "disabled", config: { sourceTemplateKey: slug }, + })); + listGalleryMock.mockResolvedValue({ apps: googleSlugs.map(getAppStoreDefinition) }); + listApplicationsMock.mockResolvedValue({ applications }); + listConnectionsMock.mockResolvedValue({ connections }); + const client = await renderBrowse(); + + for (const slug of googleSlugs) { + expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull(); + expect(container.textContent).not.toContain(`Account for ${slug}`); + } + expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); + expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications }); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["config", "transportConfig"])( + "hides a Google account identified by %s even when its gallery entry is absent", + async (sourceField) => { + listGalleryMock.mockResolvedValue({ apps: [] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ + id: "legacy-google", name: "My documents", applicationKey: null, metadata: null, + }), application()] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ + id: "legacy-google-account", applicationId: "legacy-google", name: "Saved Google account", + config: {}, transportConfig: {}, [sourceField]: { sourceTemplateKey: "google-docs" }, + }), connection()] }); + await renderBrowse(); + + expect(container.textContent).not.toContain("Saved Google account"); + expect(container.textContent).toContain("Notion"); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["catalog", "custom"])( + "preserves non-Google accounts in a mixed-provider %s row", + async (rowKind) => { + const notion = getAppStoreDefinition("notion")!; + listGalleryMock.mockResolvedValue({ apps: rowKind === "catalog" ? [notion] : [] }); + listApplicationsMock.mockResolvedValue({ applications: [application(rowKind === "custom" + ? { name: "My tools", applicationKey: null, metadata: null } + : {})] }); + const connections = [connection({ + id: "google-account", name: "Hidden Google account", + config: { sourceTemplateKey: "google-docs" }, + }), connection({ + id: "notion-account", name: "Visible Notion account", + config: { sourceTemplateKey: "notion" }, + })]; + listConnectionsMock.mockResolvedValue({ connections }); + const client = await renderBrowse(); + + expect(container.textContent).toContain("Visible Notion account"); + expect(container.textContent).not.toContain("Hidden Google account"); + expect(container.textContent).toContain(rowKind === "catalog" ? "Notion" : "My tools"); + expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["metadata", "applicationKey"])( + "keeps a non-Google custom connector identified by %s when only its Google accounts are hidden", + async (sourceField) => { + listGalleryMock.mockResolvedValue({ apps: [] }); + const savedApplication = application({ + name: "My custom connector", + metadata: sourceField === "metadata" ? { sourceTemplateKey: "custom-provider" } : null, + applicationKey: sourceField === "applicationKey" ? "custom-provider" : null, + }); + listApplicationsMock.mockResolvedValue({ applications: [savedApplication] }); + const connections = [connection({ + name: "Hidden Google account", config: { sourceTemplateKey: "google-docs" }, + })]; + listConnectionsMock.mockResolvedValue({ connections }); + const client = await renderBrowse(); + + expect(container.querySelector('[data-app-slug="custom-provider"]')).not.toBeNull(); + expect(container.textContent).toContain("My custom connector"); + expect(container.textContent).not.toContain("Hidden Google account"); + expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications: [savedApplication] }); + expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + }, + ); + it("hides cached memory connectors until enabled and preserves saved MCP connections", async () => { const providers = ["mem0", "zep", "supermemory", "cognee", "honcho"]; listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) }); @@ -326,7 +444,6 @@ describe("Connectors landing page", () => { ).toEqual([ "discord", "github-code-review-bot", - "gmail", "imessage-photon", "jira", "microsoft-teams", @@ -338,11 +455,7 @@ describe("Connectors landing page", () => { expect( container.querySelector('button[aria-label="Connect Jira"]'), ).toBeTruthy(); - expect( - container.querySelector( - 'button[aria-label="Unavailable Gmail"]', - )?.disabled, - ).toBe(true); + expect(container.querySelector('[data-app-slug="gmail"]')).toBeNull(); expect(container.textContent).toContain("Connect your own tool"); const customConnect = container.querySelector( diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index d111e52b56..70d79679e5 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -23,6 +23,7 @@ import { getAppStoreDefinition, isToolConnectionAttentionHealth, aiSubscriptionNeedsIsolatedLogin, + GOOGLE_WORKSPACE_CONNECTOR_PROFILES, } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled"; @@ -63,6 +64,7 @@ import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { AppLogo } from "./AppLogo"; import { appApplicationSourceSlug, + appConnectionSourceSlug, appDefinitionDarkLogoUrl, appDefinitionDescription, appDefinitionLogoUrl, @@ -111,6 +113,13 @@ type ConnectionRemovalTarget = { }; +// Temporary, page-only hold until Google OAuth verification is approved. +// Keep definitions, direct setup/management routes, and runtime access intact. +// Remove this filter after approval; reviewer instances stay on their pinned build. +const GOOGLE_CONNECTOR_SLUGS = new Set( + Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug), +); + function chatProviderForSlug(slug: string): ChatProvider | null { const method = getAppStoreDefinition(slug)?.methods.find( (candidate) => @@ -477,8 +486,18 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const customRows: ConnectorRowModel[] = []; for (const application of activeApplications) { - const appConnections = + const applicationSlug = appApplicationSourceSlug(application); + const savedAppConnections = connectionsByApplicationId.get(application.id) ?? []; + const appConnections = savedAppConnections.filter( + (connection) => !GOOGLE_CONNECTOR_SLUGS.has(appConnectionSourceSlug(connection) ?? ""), + ); + // Hide source-only Google rows, but keep independently identified connectors. + if ( + (!applicationSlug || applicationSlug === "link") && + savedAppConnections.length > 0 && + appConnections.length === 0 + ) continue; const configuredConnectionSlug = appConnections .map( (connection) => @@ -500,7 +519,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne : null, ) .find((value): value is string => Boolean(value)); - const applicationSlug = appApplicationSourceSlug(application); const resolvedSlug = applicationSlug && applicationSlug !== "link" && @@ -568,6 +586,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne } return [...rowsBySlug.values(), ...customRows] + .filter((row) => !GOOGLE_CONNECTOR_SLUGS.has(row.slug)) .map((row) => ({ ...row, connections: [...row.connections].sort(