Remove GA4 launch gating; dashboard shows an Organic traffic card once connected (#505)

This commit is contained in:
Ben Senescu 2026-08-19 13:19:55 -04:00 committed by GitHub
parent 9305ea89d6
commit a6f96c516e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 281 additions and 149 deletions

View File

@ -1,5 +1,3 @@
import { GA4_OAUTH_APP_PENDING } from "@/shared/ga4";
type McpTool = {
name: string;
title: string;
@ -254,14 +252,10 @@ const toolCategories: ToolCategory[] = [
},
];
const visibleCategories = GA4_OAUTH_APP_PENDING
? toolCategories.filter((cat) => cat.label !== "Google Analytics")
: toolCategories;
export function AvailableTools() {
return (
<div className="grid gap-x-8 gap-y-8 md:grid-cols-2">
{visibleCategories.map((cat) => (
{toolCategories.map((cat) => (
<div key={cat.label}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
{cat.label}

View File

@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
import { captureClientEvent } from "@/client/lib/posthog";
import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard";
import {
computeNextStep,
isStepDone,
@ -15,6 +14,7 @@ import {
BacklinkPulseCard,
GscCard,
} from "@/client/features/dashboard/DashboardCards";
import { Ga4Card } from "@/client/features/dashboard/Ga4Card";
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -26,8 +26,6 @@ import {
refreshDashboardBacklinkSnapshot,
} from "@/serverFunctions/dashboard";
import { setProjectDomain } from "@/serverFunctions/projects";
import { useSession } from "@/lib/auth-client";
import { isGa4ConnectAvailable } from "@/shared/ga4";
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
const HERO_COPY: Record<
@ -236,7 +234,6 @@ function OnboardingChecklist({
export function DashboardPage({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const { data: session } = useSession();
const activationQuery = useQuery({
queryKey: ["dashboardActivation", projectId],
@ -338,17 +335,13 @@ export function DashboardPage({ projectId }: { projectId: string }) {
hasData: gscConnected,
node: <GscCard projectId={projectId} connected={gscConnected} />,
},
...(isGa4ConnectAvailable(session?.user?.email) &&
(ga4Connected || !activation.ga4.cardDismissedAt)
...(ga4Connected || !activation.ga4.cardDismissedAt
? [
{
key: "ga4",
hasData: ga4Connected,
node: (
<Ga4ConnectCard
projectId={projectId}
connected={ga4Connected}
/>
<Ga4Card projectId={projectId} connected={ga4Connected} />
),
},
]

View File

@ -0,0 +1,185 @@
import { Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
CardShell,
moreDetailsClass,
PercentDelta,
Stat,
} from "@/client/features/dashboard/cardParts";
import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard";
import {
formatCount,
formatCtr,
} from "@/client/features/search-performance/SearchPerformanceColumns";
import { getGa4DashboardReport } from "@/serverFunctions/ga4";
function formatTrendDay(date: string): string {
// Construct in local time: Date.parse("2026-08-01") is UTC midnight, which
// toLocaleDateString would render as the previous day west of Greenwich.
const [year, month, day] = date.split("-").map(Number);
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function statValue(
value: number | null,
format: (value: number) => string,
): string {
return value === null ? "—" : format(value);
}
function statDelta(current: number | null, previous: number | null) {
return current !== null && previous !== null ? (
<PercentDelta current={current} previous={previous} />
) : undefined;
}
function SessionsTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ value: number }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-md border border-base-300 bg-base-100 px-3 py-2 shadow-sm">
<p className="text-xs text-base-content/60">
{label ? formatTrendDay(label) : ""}
</p>
<p className="text-sm font-medium tabular-nums">
{formatCount(payload[0].value)} sessions
</p>
</div>
);
}
export function Ga4Card({
projectId,
connected,
}: {
projectId: string;
connected: boolean;
}) {
const reportQuery = useQuery({
queryKey: ["dashboardGa4Report", projectId],
queryFn: () => getGa4DashboardReport({ data: { projectId } }),
enabled: connected,
});
// Not connected (or a dead grant discovered by the report call): the
// connection card sells and runs the whole flow itself.
if (!connected || (reportQuery.data && !reportQuery.data.connected)) {
return <Ga4ConnectCard projectId={projectId} connected={connected} />;
}
const report = reportQuery.data;
return (
<CardShell
title="Organic traffic"
stamp="Google Analytics · last 28 days"
action={
<Link
to="/p/$projectId/settings"
params={{ projectId }}
hash="google-analytics"
className={moreDetailsClass}
>
Manage
</Link>
}
>
{reportQuery.isPending ? (
<div className="space-y-3" aria-busy>
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="skeleton h-16" />
))}
</div>
<div className="skeleton h-24" />
</div>
) : reportQuery.isError ? (
<p className="text-sm text-base-content/60">
Couldn&rsquo;t load Google Analytics data. Try again shortly.
</p>
) : report?.connected ? (
// Covers null (no report row) and 0: a zero-session period would
// otherwise render an all-zero flatline chart in an empty box.
!report.totals.sessions ? (
<p className="text-sm text-base-content/60">
No organic search traffic recorded in the last 28 days yet.
</p>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Stat
label="Sessions"
value={statValue(report.totals.sessions, formatCount)}
sub={statDelta(
report.totals.sessions,
report.prevTotals.sessions,
)}
/>
<Stat
label="Active users"
value={statValue(report.totals.activeUsers, formatCount)}
sub={statDelta(
report.totals.activeUsers,
report.prevTotals.activeUsers,
)}
/>
<Stat
label="Engagement rate"
value={statValue(report.totals.engagementRate, formatCtr)}
/>
<Stat
label="Key events"
value={statValue(report.totals.keyEvents, formatCount)}
sub={statDelta(
report.totals.keyEvents,
report.prevTotals.keyEvents,
)}
/>
</div>
<div className="h-24">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={report.trend}
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
>
<XAxis dataKey="date" hide />
<YAxis hide domain={[0, "auto"]} />
<Tooltip
content={<SessionsTooltip />}
cursor={{ stroke: "currentColor", strokeOpacity: 0.2 }}
/>
<Area
type="monotone"
dataKey="sessions"
stroke="var(--color-primary)"
strokeWidth={2}
fill="var(--color-primary)"
fillOpacity={0.08}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)
) : null}
</CardShell>
);
}

View File

@ -19,11 +19,7 @@ import {
listGa4Properties,
setGa4Property,
} from "@/serverFunctions/ga4";
import { useSession } from "@/lib/auth-client";
import {
GA4_SELF_HOSTED_SETUP_DOCS_URL,
isGa4ConnectAvailable,
} from "@/shared/ga4";
import { GA4_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/ga4";
export function GoogleAnalyticsConnectionCard({
projectId,
@ -38,7 +34,6 @@ export function GoogleAnalyticsConnectionCard({
}) {
const hosted = isHostedClientAuthMode();
const queryClient = useQueryClient();
const { data: session } = useSession();
const [picking, setPicking] = React.useState(false);
const [selection, setSelection] = React.useState<Ga4PropertySelection | null>(
null,
@ -50,14 +45,6 @@ export function GoogleAnalyticsConnectionCard({
});
const connection = connectionQuery.data;
const connected = Boolean(connection?.connected);
// Hide the hosted connect surface while the OAuth app awaits Google's
// approval, but keep the card for users who already hold a grant so they
// can finish property selection or disconnect.
const hiddenPendingApproval =
!isGa4ConnectAvailable(session?.user?.email) &&
hosted &&
!connected &&
!connection?.currentUserHasGrant;
const selfHostedNeedsSetup =
!hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
@ -92,6 +79,9 @@ export function GoogleAnalyticsConnectionCard({
void queryClient.invalidateQueries({
queryKey: ["dashboardActivation", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["dashboardGa4Report", projectId],
});
};
const setPropertyMutation = useMutation({
mutationFn: (selected: Ga4PropertySelection) =>
@ -116,8 +106,6 @@ export function GoogleAnalyticsConnectionCard({
});
const handleConnect = () => void startGoogleLink("ga4", window.location.href);
if (hiddenPendingApproval) return null;
return (
<>
{heading}

View File

@ -1,80 +0,0 @@
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

@ -6,7 +6,6 @@ import {
import type { z } from "zod";
import {
createMcpToolContext,
MCP_AUTH_CONTEXT_PROP,
type McpProps,
type ToolContext,
} from "@/server/mcp/context";
@ -63,7 +62,6 @@ import {
getSearchConsolePerformanceTool,
inspectUrlsTool,
} from "@/server/mcp/tools/search-console-tools";
import { GA4_OAUTH_APP_PENDING, isGa4ConnectAvailable } from "@/shared/ga4";
import {
getAuditIssuesTool,
getAuditPagesTool,
@ -186,10 +184,6 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
register(getKeywordMetricsTool);
register(getSearchConsolePerformanceTool);
register(inspectUrlsTool);
if (
!GA4_OAUTH_APP_PENDING ||
isGa4ConnectAvailable(authProps[MCP_AUTH_CONTEXT_PROP].userEmail)
) {
register(getGoogleAnalyticsOrganicLandingPagesTool);
register(getGoogleAnalyticsPagePerformanceTool);
register(getGoogleAnalyticsKeyEventsTool);
@ -200,7 +194,6 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
register(getGoogleAnalyticsEcommercePerformanceTool);
register(getGoogleAnalyticsSiteSearchTool);
register(getGoogleAnalyticsAudienceBreakdownTool);
}
register(runSiteAuditTool);
register(getAuditStatusTool);
register(getAuditIssuesTool);

View File

@ -2,7 +2,10 @@ import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { waitUntil } from "cloudflare:workers";
import { z } from "zod";
import { shiftGa4Date } from "@/server/features/ga4/services/Ga4Dates";
import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";
import { Ga4Service } from "@/server/features/ga4/services/Ga4Service";
import { Ga4ReportError } from "@/server/lib/ga4Errors";
import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config";
import {
createSelfHostedGoogleAuthorizationUrl,
@ -49,6 +52,81 @@ export const getGa4Connection = createServerFn({ method: "POST" })
};
});
function overviewMetric(
row: Record<string, string | number | null> | null,
name: string,
): number | null {
const value = row?.[name];
return typeof value === "number" ? value : null;
}
/** Zero-fill the daily trend across the resolved range: GA4 omits days with
* no organic sessions, which would silently shrink the chart's x-axis. */
function fillDailySessions(
rows: Array<Record<string, string | number | null>>,
range: { startDate: string; endDate: string },
): Array<{ date: string; sessions: number }> {
const sessionsByDate = new Map<string, number>();
for (const row of rows) {
// GA4's `date` dimension is YYYYMMDD; the range dates are YYYY-MM-DD.
if (typeof row.date !== "string" || typeof row.sessions !== "number") {
continue;
}
const iso = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
sessionsByDate.set(iso, row.sessions);
}
const days: Array<{ date: string; sessions: number }> = [];
for (
let date = range.startDate;
date <= range.endDate;
date = shiftGa4Date(date, 1)
) {
days.push({ date, sessions: sessionsByDate.get(date) ?? 0 });
}
return days;
}
/** The dashboard's GA4 card: organic totals vs the previous period plus a
* daily sessions trend, over the default (last 28 complete days) range. */
export const getGa4DashboardReport = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(projectScopedSchema)
.handler(async ({ context }) => {
try {
const overview = await Ga4OrganicOverviewService.getOrganicOverview({
projectId: context.projectId,
});
const totals = (row: Record<string, string | number | null> | null) => ({
sessions: overviewMetric(row, "sessions"),
activeUsers: overviewMetric(row, "activeUsers"),
engagementRate: overviewMetric(row, "engagementRate"),
keyEvents: overviewMetric(row, "keyEvents"),
});
return {
connected: true as const,
totals: totals(overview.current),
prevTotals: totals(overview.previous),
trend: fillDailySessions(
overview.trend,
overview.request.resolvedDateRange,
),
};
} catch (error) {
// Not connected, a dead grant, or a lost/deleted property: the dashboard
// card falls back to the connect card instead of retrying a report that
// can never succeed. Other report errors are real faults.
if (
error instanceof Ga4ReportError &&
(error.code === "ga4_not_connected" ||
error.code === "ga4_reconnect_required" ||
error.code === "ga4_property_inaccessible")
) {
return { connected: false as const };
}
throw error;
}
});
export const listGa4Properties = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(projectScopedSchema)

View File

@ -1,25 +1,6 @@
/** Better Auth provider ID for the dedicated Google Analytics grant. */
export const GA4_OAUTH_PROVIDER_ID = "google-analytics";
// Google hasn't approved the GA4 OAuth app yet, so hosted connect attempts
// show Google's "unverified app" warning. Gates every GA4 connect surface;
// flip to false once the app is approved. Self-hosted deployments use their
// own OAuth app, so only hosted mode is gated.
export const GA4_OAUTH_APP_PENDING = true;
// Google's OAuth verification reviewer tests with this account, so the GA4
// connect/disconnect surfaces stay visible for it while the app is pending
// approval. MCP tool registration stays gated for everyone until approval.
const GA4_OAUTH_REVIEWER_EMAILS = new Set(["walkthrough@everyapp.dev"]);
/** Whether GA4 connect surfaces are visible to this user despite the pending gate. */
export function isGa4ConnectAvailable(
email: string | null | undefined,
): boolean {
if (!GA4_OAUTH_APP_PENDING) return true;
return email != null && GA4_OAUTH_REVIEWER_EMAILS.has(email);
}
export const GA4_OAUTH_SCOPES = [
"openid",
"email",