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} - /> +
+ + selection && setSiteMutation.mutate(selection)} + saving={setSiteMutation.isPending} + onRetry={() => void sitesQuery.refetch()} + onReconnect={handleConnect} + /> +
); } return ( - +
+ + +
); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 67eec5f..e10b372 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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: [ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ab13de3..8344dd4 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f527eca..66178a0 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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: [ diff --git a/src/routes/auth-error.tsx b/src/routes/auth-error.tsx new file mode 100644 index 0000000..c64c432 --- /dev/null +++ b/src/routes/auth-error.tsx @@ -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 ( + + + Code: {error} +

+ ) : undefined + } + > + + Back to OpenSEO + +
+
+ ); +}