fix: surface Google connect failures to the user instead of dead-ending (#549)

This commit is contained in:
Ben Senescu 2026-08-26 13:23:20 -04:00 committed by GitHub
parent 9b12e073a8
commit b32c0bd841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 346 additions and 33 deletions

View File

@ -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"
}
>
<GoogleLinkErrorAlert provider="ga4" className="mb-4" />
{connectionQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-base-content/50">
<span className="loading loading-spinner loading-sm" />

View File

@ -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"
}
>
<GoogleLinkErrorAlert provider="gsc" className="mb-4" />
{connectionQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-base-content/50">
<span className="loading loading-spinner loading-sm" />

View File

@ -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<GoogleLinkProvider, string> = {
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 (
<div
role="alert"
className={`flex items-start justify-between gap-3 rounded-lg border border-error/30 bg-error/10 p-3.5 text-sm ${className ?? ""}`}
>
<div className="space-y-1">
<p className="font-semibold text-error">{copy.title}</p>
<p className="text-base-content/70">{copy.description}</p>
</div>
<button
type="button"
aria-label="Dismiss"
className="btn btn-ghost btn-xs shrink-0 px-1.5"
onClick={() => {
setDismissed(true);
clearGoogleLinkError();
}}
>
<X className="size-3.5" />
</button>
</div>
);
}

View File

@ -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.",
};
}
}

View File

@ -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,
});
}

View File

@ -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<void> {
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;
}
}

View File

@ -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 (
<SitePicker
loading={sitesQuery.isLoading}
error={sitesQuery.isError}
accounts={accounts}
selection={selection}
onSelect={setSelection}
onSave={() => selection && setSiteMutation.mutate(selection)}
saving={setSiteMutation.isPending}
onRetry={() => void sitesQuery.refetch()}
onReconnect={handleConnect}
/>
<div className="space-y-4">
<GoogleLinkErrorAlert provider="gsc" />
<SitePicker
loading={sitesQuery.isLoading}
error={sitesQuery.isError}
accounts={accounts}
selection={selection}
onSelect={setSelection}
onSave={() => selection && setSiteMutation.mutate(selection)}
saving={setSiteMutation.isPending}
onRetry={() => void sitesQuery.refetch()}
onReconnect={handleConnect}
/>
</div>
);
}
return (
<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>
<div className="space-y-4">
<GoogleLinkErrorAlert provider="gsc" />
<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>
</div>
);
}

View File

@ -104,6 +104,14 @@ function createAuth() {
},
},
socialProviders: getSocialProviders(),
// Where OAuth redirect-flow failures land when Better Auth can't honor a
// per-flow errorCallbackURL (Google-side errors like a canceled consent
// screen, replayed callback URLs, sign-in failures). Without this the
// default /api/auth/error page 302s to `/?error=...` and the dashboard
// silently discards the code. Self-hosted never serves these flows (its
// Google OAuth endpoints are hand-rolled), so the placeholder baseUrl
// there is harmless.
onAPIError: { errorURL: `${baseUrl}/auth-error` },
trustedOrigins: getTrustedOrigins(baseUrl),
database,
plugins: [

View File

@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as AuthErrorRouteImport } from './routes/auth-error'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
@ -72,6 +73,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const AuthErrorRoute = AuthErrorRouteImport.update({
id: '/auth-error',
path: '/auth-error',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
@ -309,6 +315,7 @@ const ProjectPProjectIdAuditIssuesResultIdRoute =
export interface FileRoutesByFullPath {
'/': typeof AppIndexRoute
'/auth-error': typeof AuthErrorRoute
'/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
@ -354,6 +361,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof AppIndexRoute
'/auth-error': typeof AuthErrorRoute
'/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
@ -399,6 +407,7 @@ export interface FileRoutesById {
'/_project': typeof ProjectRouteRouteWithChildren
'/_auth': typeof AuthRouteWithChildren
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/auth-error': typeof AuthErrorRoute
'/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
@ -447,6 +456,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/auth-error'
| '/forgot-password'
| '/reset-password'
| '/verify-email'
@ -492,6 +502,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/auth-error'
| '/forgot-password'
| '/reset-password'
| '/verify-email'
@ -536,6 +547,7 @@ export interface FileRouteTypes {
| '/_project'
| '/_auth'
| '/_authenticated'
| '/auth-error'
| '/forgot-password'
| '/reset-password'
| '/verify-email'
@ -586,6 +598,7 @@ export interface RootRouteChildren {
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
AuthRoute: typeof AuthRouteWithChildren
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
AuthErrorRoute: typeof AuthErrorRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute
@ -620,6 +633,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/auth-error': {
id: '/auth-error'
path: '/auth-error'
fullPath: '/auth-error'
preLoaderRoute: typeof AuthErrorRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
@ -1097,6 +1117,7 @@ const rootRouteChildren: RootRouteChildren = {
ProjectRouteRoute: ProjectRouteRouteWithChildren,
AuthRoute: AuthRouteWithChildren,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
AuthErrorRoute: AuthErrorRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
ResetPasswordRoute: ResetPasswordRoute,
VerifyEmailRoute: VerifyEmailRoute,

View File

@ -12,6 +12,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { AutumnProvider } from "autumn-js/react";
import * as React from "react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { captureGoogleLinkError } from "@/client/features/integrations/googleLinkError";
import { ExportToSheetsModal } from "@/client/components/table/ExportToSheetsModal";
import { themePreferenceInitScript } from "@/client/lib/theme";
import {
@ -28,6 +29,10 @@ import { Toaster } from "sonner";
import { queryClient } from "@/client/tanstack-db";
import { getActiveOrganizationId } from "@/lib/auth-session";
// Capture Google link error params before the router starts — a route loader
// redirect would otherwise replace the URL and lose them. See googleLinkError.ts.
captureGoogleLinkError();
export const Route = createRootRoute({
head: () => ({
meta: [

47
src/routes/auth-error.tsx Normal file
View File

@ -0,0 +1,47 @@
import { Link, createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
import { googleAuthErrorCopy } from "@/client/features/integrations/googleAuthErrorCopy";
const authErrorSearchSchema = z.object({
error: z.string().optional(),
error_description: z.string().optional(),
});
export const Route = createFileRoute("/auth-error")({
validateSearch: authErrorSearchSchema,
component: AuthErrorPage,
});
/**
* Landing page for Better Auth OAuth failures that can't be routed back to the
* page that started the flow (wired via `onAPIError.errorURL` in auth.ts):
* Google-side errors like a canceled consent screen, replayed callback URLs,
* and sign-in failures. Link failures that Better Auth can attribute to a
* specific flow return to the connect surface instead (see startGoogleLink's
* errorCallbackURL).
*/
function AuthErrorPage() {
const { error } = Route.useSearch();
const copy = googleAuthErrorCopy(error ?? "unknown");
return (
<AuthPageShell>
<AuthPageCard
title={copy.title}
helperText={copy.description}
footer={
error ? (
<p className="font-mono text-xs text-base-content/40">
Code: {error}
</p>
) : undefined
}
>
<Link to="/" className="btn btn-soft w-full">
Back to OpenSEO
</Link>
</AuthPageCard>
</AuthPageShell>
);
}