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(