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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-30 18:22:25 -05:00
1 parent d6fa1fd1ef
commit c8f874311c
3 files changed
+154 -8

No files matched your search

+14
View File
@@ -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
+119 -6
View File
@@ -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<HTMLButtonElement>(
'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<HTMLButtonElement>(
+21 -2
View File
@@ -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(