mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
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:
1 parent
d6fa1fd1ef
commit
c8f874311c
3 files changed
+154
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user