Hide Analytics section when GA4 OAuth app is pending (#475)

This commit is contained in:
Ben Senescu 2026-08-12 19:57:29 -04:00 committed by GitHub
parent 75aec8424f
commit 72eaa09c32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 193 additions and 99 deletions

View File

@ -28,10 +28,12 @@ export function GoogleAnalyticsConnectionCard({
projectId, projectId,
onDismiss, onDismiss,
dismissing = false, dismissing = false,
heading,
}: { }: {
projectId: string; projectId: string;
onDismiss?: () => void; onDismiss?: () => void;
dismissing?: boolean; dismissing?: boolean;
heading?: React.ReactNode;
}) { }) {
const hosted = isHostedClientAuthMode(); const hosted = isHostedClientAuthMode();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -115,97 +117,100 @@ export function GoogleAnalyticsConnectionCard({
if (hiddenPendingApproval) return null; if (hiddenPendingApproval) return null;
return ( return (
<IntegrationConnectionCard <>
title="Google Analytics" {heading}
icon={<GoogleAnalyticsLogo className="size-5" />} <IntegrationConnectionCard
status={ title="Google Analytics"
connectionQuery.isLoading icon={<GoogleAnalyticsLogo className="size-5" />}
? undefined status={
: selfHostedNeedsSetup connectionQuery.isLoading
? "setup_required" ? undefined
: connected : selfHostedNeedsSetup
? "connected" ? "setup_required"
: "disconnected" : connected
} ? "connected"
> : "disconnected"
{connectionQuery.isLoading ? ( }
<div className="flex items-center gap-2 text-sm text-base-content/50"> >
<span className="loading loading-spinner loading-sm" /> {connectionQuery.isLoading ? (
Checking <div className="flex items-center gap-2 text-sm text-base-content/50">
</div> <span className="loading loading-spinner loading-sm" />
) : selfHostedNeedsSetup ? ( Checking
<div className="space-y-3"> </div>
<GoogleOAuthSetupWarning ) : selfHostedNeedsSetup ? (
integrationName="Google Analytics" <div className="space-y-3">
docsUrl={GA4_SELF_HOSTED_SETUP_DOCS_URL} <GoogleOAuthSetupWarning
/> integrationName="Google Analytics"
{onDismiss ? ( docsUrl={GA4_SELF_HOSTED_SETUP_DOCS_URL}
<DismissButton onClick={onDismiss} disabled={dismissing} /> />
) : null}
</div>
) : connected && !picking ? (
<ConnectedState
displayName={connection?.propertyDisplayName ?? ""}
propertyId={connection?.propertyId ?? ""}
timeZone={connection?.propertyTimeZone ?? ""}
currencyCode={connection?.propertyCurrencyCode ?? ""}
connectedByEmail={connection?.connectedByEmail ?? null}
onChange={() => {
setSelection(null);
setPicking(true);
}}
onDisconnect={() => disconnectMutation.mutate()}
disconnecting={disconnectMutation.isPending}
/>
) : showPicker ? (
<Ga4PropertyPicker
loading={propertiesQuery.isLoading}
error={propertiesQuery.isError}
accounts={accounts}
selection={selection}
onSelect={setSelection}
onSave={() => selection && setPropertyMutation.mutate(selection)}
saving={setPropertyMutation.isPending}
onRetry={() => void propertiesQuery.refetch()}
secondaryAction={
connected
? { label: "Cancel", onClick: () => setPicking(false) }
: onDismiss
? {
label: "Dismiss",
disabled: dismissing,
onClick: onDismiss,
}
: {
label: "Disconnect",
destructive: true,
disabled: disconnectMutation.isPending,
onClick: () => disconnectMutation.mutate(),
}
}
/>
) : (
<div className="space-y-4">
<p className="text-sm text-base-content/70">
Connect GA4 to understand what organic visitors do after they land
on your site.
</p>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
onClick={handleConnect}
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<GoogleGlyph className="size-[18px]" />
Connect with Google
</button>
{onDismiss ? ( {onDismiss ? (
<DismissButton onClick={onDismiss} disabled={dismissing} /> <DismissButton onClick={onDismiss} disabled={dismissing} />
) : null} ) : null}
</div> </div>
</div> ) : connected && !picking ? (
)} <ConnectedState
</IntegrationConnectionCard> displayName={connection?.propertyDisplayName ?? ""}
propertyId={connection?.propertyId ?? ""}
timeZone={connection?.propertyTimeZone ?? ""}
currencyCode={connection?.propertyCurrencyCode ?? ""}
connectedByEmail={connection?.connectedByEmail ?? null}
onChange={() => {
setSelection(null);
setPicking(true);
}}
onDisconnect={() => disconnectMutation.mutate()}
disconnecting={disconnectMutation.isPending}
/>
) : showPicker ? (
<Ga4PropertyPicker
loading={propertiesQuery.isLoading}
error={propertiesQuery.isError}
accounts={accounts}
selection={selection}
onSelect={setSelection}
onSave={() => selection && setPropertyMutation.mutate(selection)}
saving={setPropertyMutation.isPending}
onRetry={() => void propertiesQuery.refetch()}
secondaryAction={
connected
? { label: "Cancel", onClick: () => setPicking(false) }
: onDismiss
? {
label: "Dismiss",
disabled: dismissing,
onClick: onDismiss,
}
: {
label: "Disconnect",
destructive: true,
disabled: disconnectMutation.isPending,
onClick: () => disconnectMutation.mutate(),
}
}
/>
) : (
<div className="space-y-4">
<p className="text-sm text-base-content/70">
Connect GA4 to understand what organic visitors do after they land
on your site.
</p>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
onClick={handleConnect}
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<GoogleGlyph className="size-[18px]" />
Connect with Google
</button>
{onDismiss ? (
<DismissButton onClick={onDismiss} disabled={dismissing} />
) : null}
</div>
</div>
)}
</IntegrationConnectionCard>
</>
); );
} }

View File

@ -63,8 +63,14 @@ export function ProjectSettings({ projectId }: { projectId: string }) {
</section> </section>
<section id="google-analytics" className="space-y-3 scroll-mt-6"> <section id="google-analytics" className="space-y-3 scroll-mt-6">
<h2 className="text-sm font-medium text-base-content/50">Analytics</h2> <GoogleAnalyticsConnectionCard
<GoogleAnalyticsConnectionCard projectId={projectId} /> projectId={projectId}
heading={
<h2 className="text-sm font-medium text-base-content/50">
Analytics
</h2>
}
/>
</section> </section>
<DangerSection project={project} canArchive={projects.length > 1} /> <DangerSection project={project} canArchive={projects.length > 1} />

View File

@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOpenSeoMcpServer } from "./server";
const mcpServerMocks = vi.hoisted(() => ({
registerTool: vi.fn<(name: string, ...args: unknown[]) => void>(),
}));
vi.mock("@modelcontextprotocol/server", () => ({
McpServer: class {
registerTool(name: string, ...args: unknown[]) {
mcpServerMocks.registerTool(name, ...args);
}
},
}));
vi.mock("cloudflare:workers", () => ({
DurableObject: vi.fn(),
env: {},
waitUntil: vi.fn(),
}));
vi.mock("@/server/mcp/instrumentation", () => ({
instrumentMcpToolHandler: (
_name: string,
_outputSchema: unknown,
handler: unknown,
) => handler,
}));
describe("createOpenSeoMcpServer", () => {
beforeEach(() => {
mcpServerMocks.registerTool.mockClear();
});
it("does not expose GA4-backed tools while OAuth approval is pending", () => {
createOpenSeoMcpServer({
openSeoAuth: {
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://example.com",
},
});
const registeredToolNames = mcpServerMocks.registerTool.mock.calls.map(
([name]) => name,
);
expect(registeredToolNames).toContain("whoami");
expect(registeredToolNames).toContain("get_search_console_performance");
expect(registeredToolNames).not.toContain(
"get_google_analytics_organic_landing_pages",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_page_performance",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_key_events",
);
expect(registeredToolNames).not.toContain("get_search_opportunities");
expect(registeredToolNames).not.toContain(
"get_google_analytics_organic_overview",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_traffic_acquisition",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_measurement_health",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_ecommerce_performance",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_site_search",
);
expect(registeredToolNames).not.toContain(
"get_google_analytics_audience_breakdown",
);
});
});

View File

@ -51,6 +51,7 @@ import {
getSearchConsolePerformanceTool, getSearchConsolePerformanceTool,
inspectUrlsTool, inspectUrlsTool,
} from "@/server/mcp/tools/search-console-tools"; } from "@/server/mcp/tools/search-console-tools";
import { GA4_OAUTH_APP_PENDING } from "@/shared/ga4";
import { import {
getAuditIssuesTool, getAuditIssuesTool,
getAuditPagesTool, getAuditPagesTool,
@ -166,16 +167,18 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
register(getKeywordMetricsTool); register(getKeywordMetricsTool);
register(getSearchConsolePerformanceTool); register(getSearchConsolePerformanceTool);
register(inspectUrlsTool); register(inspectUrlsTool);
register(getGoogleAnalyticsOrganicLandingPagesTool); if (!GA4_OAUTH_APP_PENDING) {
register(getGoogleAnalyticsPagePerformanceTool); register(getGoogleAnalyticsOrganicLandingPagesTool);
register(getGoogleAnalyticsKeyEventsTool); register(getGoogleAnalyticsPagePerformanceTool);
register(getSearchOpportunitiesTool); register(getGoogleAnalyticsKeyEventsTool);
register(getGoogleAnalyticsOrganicOverviewTool); register(getSearchOpportunitiesTool);
register(getGoogleAnalyticsTrafficAcquisitionTool); register(getGoogleAnalyticsOrganicOverviewTool);
register(getGoogleAnalyticsMeasurementHealthTool); register(getGoogleAnalyticsTrafficAcquisitionTool);
register(getGoogleAnalyticsEcommercePerformanceTool); register(getGoogleAnalyticsMeasurementHealthTool);
register(getGoogleAnalyticsSiteSearchTool); register(getGoogleAnalyticsEcommercePerformanceTool);
register(getGoogleAnalyticsAudienceBreakdownTool); register(getGoogleAnalyticsSiteSearchTool);
register(getGoogleAnalyticsAudienceBreakdownTool);
}
register(runSiteAuditTool); register(runSiteAuditTool);
register(getAuditStatusTool); register(getAuditStatusTool);
register(getAuditIssuesTool); register(getAuditIssuesTool);