diff --git a/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx b/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
index 673f4d4..d57b378 100644
--- a/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
+++ b/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
@@ -6,6 +6,7 @@ import {
type Ga4PropertySelection,
} from "@/client/features/ga4/Ga4PropertyPicker";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
+import { GoogleLinkErrorAlert } from "@/client/features/integrations/GoogleLinkErrorAlert";
import { GoogleOAuthSetupWarning } from "@/client/features/integrations/GoogleOAuthSetupWarning";
import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
import { GoogleAnalyticsLogo } from "@/client/features/integrations/GoogleProductLogos";
@@ -122,6 +123,7 @@ export function GoogleAnalyticsConnectionCard({
: "disconnected"
}
>
+
{connectionQuery.isLoading ? (
diff --git a/src/client/features/gsc/SearchConsoleConnectionCard.tsx b/src/client/features/gsc/SearchConsoleConnectionCard.tsx
index b165fec..f1ecdcc 100644
--- a/src/client/features/gsc/SearchConsoleConnectionCard.tsx
+++ b/src/client/features/gsc/SearchConsoleConnectionCard.tsx
@@ -5,6 +5,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
+import { GoogleLinkErrorAlert } from "@/client/features/integrations/GoogleLinkErrorAlert";
import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
import { GoogleSearchConsoleLogo } from "@/client/features/integrations/GoogleProductLogos";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
@@ -152,6 +153,7 @@ export function SearchConsoleConnectionCard({
: "disconnected"
}
>
+
{connectionQuery.isLoading ? (
diff --git a/src/client/features/integrations/GoogleLinkErrorAlert.tsx b/src/client/features/integrations/GoogleLinkErrorAlert.tsx
new file mode 100644
index 0000000..1d26722
--- /dev/null
+++ b/src/client/features/integrations/GoogleLinkErrorAlert.tsx
@@ -0,0 +1,63 @@
+import * as React from "react";
+import { X } from "lucide-react";
+import { googleAuthErrorCopy } from "./googleAuthErrorCopy";
+import {
+ clearGoogleLinkError,
+ getGoogleLinkError,
+ reportGoogleLinkErrorOnce,
+ type GoogleLinkProvider,
+} from "./googleLinkError";
+
+const PROVIDER_LABELS: Record = {
+ gsc: "Search Console",
+ ga4: "Google Analytics",
+};
+
+/**
+ * Inline error shown on a connect surface after a failed Google link flow.
+ * startGoogleLink sends OAuth failures back to the page that started the
+ * connect (see its errorCallbackURL); googleLinkError.ts captures the params
+ * before the router can redirect them away, and this renders the explanation
+ * next to the Connect button that retries it. Persists until dismissed or the
+ * user navigates.
+ */
+export function GoogleLinkErrorAlert({
+ provider,
+ className,
+}: {
+ provider: GoogleLinkProvider;
+ className?: string;
+}) {
+ const [error] = React.useState(() => getGoogleLinkError(provider));
+ const [dismissed, setDismissed] = React.useState(false);
+
+ React.useEffect(() => {
+ if (error) reportGoogleLinkErrorOnce();
+ }, [error]);
+
+ if (!error || dismissed) return null;
+ const copy = googleAuthErrorCopy(error.code, PROVIDER_LABELS[provider]);
+
+ return (
+
+
+
{copy.title}
+
{copy.description}
+
+
+
+ );
+}
diff --git a/src/client/features/integrations/googleAuthErrorCopy.ts b/src/client/features/integrations/googleAuthErrorCopy.ts
new file mode 100644
index 0000000..d7043b5
--- /dev/null
+++ b/src/client/features/integrations/googleAuthErrorCopy.ts
@@ -0,0 +1,43 @@
+/**
+ * Plain-language copy for Google OAuth failures, shared by the connect-surface
+ * inline alert (GoogleLinkErrorAlert) and the /auth-error fallback page.
+ * `code` is the `error` query param Better Auth appends on its error
+ * redirects.
+ *
+ * `providerLabel` ("Search Console" / "Google Analytics") is set when the
+ * failure came from a connect flow; without it the copy reads as a Google
+ * sign-in failure.
+ */
+export function googleAuthErrorCopy(
+ code: string,
+ providerLabel?: string,
+): { title: string; description: string } {
+ const what = providerLabel ? `${providerLabel} connection` : "Google sign-in";
+
+ switch (code) {
+ case "state_mismatch":
+ return {
+ title: `${what} didn't finish`,
+ description:
+ "The attempt expired or was interrupted. Try again in a single browser tab and finish the Google steps within 10 minutes. If it keeps happening, make sure your browser allows cookies for this site.",
+ };
+ case "access_denied":
+ return {
+ title: `${what} was canceled`,
+ description:
+ "Google's permission screen was closed or declined. Try again whenever you're ready.",
+ };
+ case "account_already_linked_to_different_user":
+ return {
+ title: "Google account already connected",
+ description:
+ "That Google account is already linked to a different OpenSEO account. Disconnect it there first, or contact support and we'll move it over.",
+ };
+ default:
+ return {
+ title: `${what} didn't finish`,
+ description:
+ "Something went wrong while talking to Google. Please try again — if it keeps failing, contact support.",
+ };
+ }
+}
diff --git a/src/client/features/integrations/googleLinkError.ts b/src/client/features/integrations/googleLinkError.ts
new file mode 100644
index 0000000..ce67807
--- /dev/null
+++ b/src/client/features/integrations/googleLinkError.ts
@@ -0,0 +1,79 @@
+import { captureClientEvent } from "@/client/lib/posthog";
+
+/**
+ * Marker appended to the errorCallbackURL so a failed Google link redirect can
+ * be told apart from any other `error` query param. Its value is the provider
+ * key ("gsc" | "ga4").
+ */
+export const GOOGLE_LINK_ERROR_PARAM = "google_link_error";
+
+export type GoogleLinkProvider = "gsc" | "ga4";
+
+type CapturedLinkError = {
+ provider: GoogleLinkProvider;
+ code: string;
+};
+
+/**
+ * Read and scrub the error params synchronously at module init, before
+ * TanStack Router starts. Routes are free to redirect on load (`/` bounces to
+ * the project dashboard), and a redirect fired from a route loader replaces
+ * the URL before any effect runs — reading window.location in useEffect would
+ * lose the params on exactly those pages. __root.tsx calls
+ * captureGoogleLinkError() at module scope so the capture stays in the entry
+ * chunk even when routes are code-split.
+ */
+function captureLinkErrorFromLocation(): CapturedLinkError | null {
+ if (typeof window === "undefined") return null;
+ const url = new URL(window.location.href);
+ const provider = url.searchParams.get(GOOGLE_LINK_ERROR_PARAM);
+ if (provider !== "gsc" && provider !== "ga4") return null;
+ const code = url.searchParams.get("error") ?? "unknown";
+ url.searchParams.delete(GOOGLE_LINK_ERROR_PARAM);
+ url.searchParams.delete("error");
+ url.searchParams.delete("error_description");
+ // history.replaceState rather than a router navigate: the params are
+ // one-shot and foreign to every route's search schema, and the router (not
+ // yet started) should never see them. Passing the current history.state
+ // through leaves whatever state the browser restored intact.
+ window.history.replaceState(window.history.state, "", url);
+ return { provider, code };
+}
+
+let captured: CapturedLinkError | null = null;
+let didCapture = false;
+let reported = false;
+
+/** Idempotent; the first call (from __root's module scope) wins. */
+export function captureGoogleLinkError() {
+ if (didCapture) return;
+ didCapture = true;
+ captured = captureLinkErrorFromLocation();
+}
+
+/** The captured link failure for this provider, if any survived the redirect. */
+export function getGoogleLinkError(
+ provider: GoogleLinkProvider,
+): { code: string } | null {
+ captureGoogleLinkError();
+ return captured?.provider === provider ? { code: captured.code } : null;
+}
+
+/** Called on dismiss so SPA navigation doesn't resurrect the alert. */
+export function clearGoogleLinkError() {
+ captured = null;
+}
+
+/**
+ * Emit the analytics event the first time the error is actually shown.
+ * Deliberately not at module init: PostHog capture only starts once the
+ * session has loaded, which is guaranteed by the time an authenticated
+ * connect surface renders the alert.
+ */
+export function reportGoogleLinkErrorOnce() {
+ if (!captured || reported) return;
+ reported = true;
+ captureClientEvent(`${captured.provider}:connect_error`, {
+ error_code: captured.code,
+ });
+}
diff --git a/src/client/features/integrations/startGoogleLink.ts b/src/client/features/integrations/startGoogleLink.ts
index ff52d4b..407e864 100644
--- a/src/client/features/integrations/startGoogleLink.ts
+++ b/src/client/features/integrations/startGoogleLink.ts
@@ -1,5 +1,6 @@
import { toast } from "sonner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { GOOGLE_LINK_ERROR_PARAM } from "@/client/features/integrations/googleLinkError";
import { authClient } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { startSelfHostedGa4Link } from "@/serverFunctions/ga4";
@@ -18,35 +19,70 @@ const googleProviders = {
},
} as const;
+function withGoogleLinkErrorParam(
+ callbackURL: string,
+ provider: "gsc" | "ga4",
+): string {
+ const url = new URL(callbackURL, window.location.origin);
+ url.searchParams.set(GOOGLE_LINK_ERROR_PARAM, provider);
+ return url.toString();
+}
+
+// One link flow at a time: a double-click, or a second Connect click while the
+// redirect to Google is pending, would overwrite the single Better Auth state
+// cookie and guarantee a state_mismatch for whichever consent screen finishes.
+let linkRedirectPending = false;
+
/**
* Kick off an incremental Google OAuth grant. On success this redirects the
* whole page to Google's consent screen; `callbackURL` is where Google returns
- * the user afterward. Shared by the connection cards, onboarding, property
- * pickers, and re-engagement prompt so the link/error/redirect flow stays in
- * one place — callers keep their own analytics and dismissal behavior.
+ * the user afterward. Failures during the Google round-trip redirect to the
+ * same page with an error marker that GoogleLinkErrorAlert surfaces. Shared by
+ * the connection cards, onboarding, property pickers, and re-engagement prompt
+ * so the link/error/redirect flow stays in one place — callers keep their own
+ * analytics and dismissal behavior.
*/
export async function startGoogleLink(
provider: "gsc" | "ga4",
callbackURL: string,
): Promise {
+ if (linkRedirectPending) return;
+ linkRedirectPending = true;
+ let redirecting = false;
try {
const config = googleProviders[provider];
+ let url: string | undefined;
if (!isHostedClientAuthMode()) {
const res = await config.startSelfHosted({ data: { callbackURL } });
- window.location.href = res.url;
- return;
+ url = res.url;
+ } else {
+ const res = await authClient.oauth2.link({
+ providerId: config.providerId,
+ callbackURL,
+ errorCallbackURL: withGoogleLinkErrorParam(callbackURL, provider),
+ });
+ if (res.error) {
+ toast.error(res.error.message ?? "Could not start Google sign-in");
+ return;
+ }
+ url = res.data?.url;
}
+ if (!url) return;
- const res = await authClient.oauth2.link({
- providerId: config.providerId,
- callbackURL,
- });
- if (res.error) {
- toast.error(res.error.message ?? "Could not start Google sign-in");
- return;
- }
- if (res.data?.url) window.location.href = res.data.url;
+ redirecting = true;
+ window.location.href = url;
+ // The page is about to unload, so the guard normally never needs to
+ // release — but the browser can cancel a pending navigation (Esc, a
+ // beforeunload prompt). Revive the buttons instead of leaving the page
+ // dead until reload.
+ setTimeout(() => {
+ linkRedirectPending = false;
+ }, 15_000);
} catch (error) {
toast.error(getStandardErrorMessage(error));
+ } finally {
+ // Single release point: any exit that didn't hand off to the browser
+ // (error, missing URL, thrown) re-arms the button immediately.
+ if (!redirecting) linkRedirectPending = false;
}
}
diff --git a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
index 5d577a5..5a43fec 100644
--- a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
+++ b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react";
import { toast } from "sonner";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
+import { GoogleLinkErrorAlert } from "@/client/features/integrations/GoogleLinkErrorAlert";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import {
SitePicker,
@@ -178,29 +179,35 @@ function GscConnect({ projectId }: { projectId: string }) {
if (hasGrant) {
return (
- selection && setSiteMutation.mutate(selection)}
- saving={setSiteMutation.isPending}
- onRetry={() => void sitesQuery.refetch()}
- onReconnect={handleConnect}
- />
+