fix: surface Google connect failures to the user instead of dead-ending (#549)
This commit is contained in:
parent
9b12e073a8
commit
b32c0bd841
@ -6,6 +6,7 @@ import {
|
|||||||
type Ga4PropertySelection,
|
type Ga4PropertySelection,
|
||||||
} from "@/client/features/ga4/Ga4PropertyPicker";
|
} from "@/client/features/ga4/Ga4PropertyPicker";
|
||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { GoogleLinkErrorAlert } from "@/client/features/integrations/GoogleLinkErrorAlert";
|
||||||
import { GoogleOAuthSetupWarning } from "@/client/features/integrations/GoogleOAuthSetupWarning";
|
import { GoogleOAuthSetupWarning } from "@/client/features/integrations/GoogleOAuthSetupWarning";
|
||||||
import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
|
import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
|
||||||
import { GoogleAnalyticsLogo } from "@/client/features/integrations/GoogleProductLogos";
|
import { GoogleAnalyticsLogo } from "@/client/features/integrations/GoogleProductLogos";
|
||||||
@ -122,6 +123,7 @@ export function GoogleAnalyticsConnectionCard({
|
|||||||
: "disconnected"
|
: "disconnected"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<GoogleLinkErrorAlert provider="ga4" className="mb-4" />
|
||||||
{connectionQuery.isLoading ? (
|
{connectionQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
|||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { GoogleLinkErrorAlert } from "@/client/features/integrations/GoogleLinkErrorAlert";
|
||||||
import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
|
import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
|
||||||
import { GoogleSearchConsoleLogo } from "@/client/features/integrations/GoogleProductLogos";
|
import { GoogleSearchConsoleLogo } from "@/client/features/integrations/GoogleProductLogos";
|
||||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||||
@ -152,6 +153,7 @@ export function SearchConsoleConnectionCard({
|
|||||||
: "disconnected"
|
: "disconnected"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<GoogleLinkErrorAlert provider="gsc" className="mb-4" />
|
||||||
{connectionQuery.isLoading ? (
|
{connectionQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
|||||||
63
src/client/features/integrations/GoogleLinkErrorAlert.tsx
Normal file
63
src/client/features/integrations/GoogleLinkErrorAlert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/client/features/integrations/googleAuthErrorCopy.ts
Normal file
43
src/client/features/integrations/googleAuthErrorCopy.ts
Normal 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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/client/features/integrations/googleLinkError.ts
Normal file
79
src/client/features/integrations/googleLinkError.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { GOOGLE_LINK_ERROR_PARAM } from "@/client/features/integrations/googleLinkError";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { startSelfHostedGa4Link } from "@/serverFunctions/ga4";
|
import { startSelfHostedGa4Link } from "@/serverFunctions/ga4";
|
||||||
@ -18,35 +19,70 @@ const googleProviders = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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
|
* Kick off an incremental Google OAuth grant. On success this redirects the
|
||||||
* whole page to Google's consent screen; `callbackURL` is where Google returns
|
* whole page to Google's consent screen; `callbackURL` is where Google returns
|
||||||
* the user afterward. Shared by the connection cards, onboarding, property
|
* the user afterward. Failures during the Google round-trip redirect to the
|
||||||
* pickers, and re-engagement prompt so the link/error/redirect flow stays in
|
* same page with an error marker that GoogleLinkErrorAlert surfaces. Shared by
|
||||||
* one place — callers keep their own analytics and dismissal behavior.
|
* 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(
|
export async function startGoogleLink(
|
||||||
provider: "gsc" | "ga4",
|
provider: "gsc" | "ga4",
|
||||||
callbackURL: string,
|
callbackURL: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (linkRedirectPending) return;
|
||||||
|
linkRedirectPending = true;
|
||||||
|
let redirecting = false;
|
||||||
try {
|
try {
|
||||||
const config = googleProviders[provider];
|
const config = googleProviders[provider];
|
||||||
|
let url: string | undefined;
|
||||||
if (!isHostedClientAuthMode()) {
|
if (!isHostedClientAuthMode()) {
|
||||||
const res = await config.startSelfHosted({ data: { callbackURL } });
|
const res = await config.startSelfHosted({ data: { callbackURL } });
|
||||||
window.location.href = res.url;
|
url = res.url;
|
||||||
return;
|
} 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({
|
redirecting = true;
|
||||||
providerId: config.providerId,
|
window.location.href = url;
|
||||||
callbackURL,
|
// The page is about to unload, so the guard normally never needs to
|
||||||
});
|
// release — but the browser can cancel a pending navigation (Esc, a
|
||||||
if (res.error) {
|
// beforeunload prompt). Revive the buttons instead of leaving the page
|
||||||
toast.error(res.error.message ?? "Could not start Google sign-in");
|
// dead until reload.
|
||||||
return;
|
setTimeout(() => {
|
||||||
}
|
linkRedirectPending = false;
|
||||||
if (res.data?.url) window.location.href = res.data.url;
|
}, 15_000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getStandardErrorMessage(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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { GoogleLinkErrorAlert } from "@/client/features/integrations/GoogleLinkErrorAlert";
|
||||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||||
import {
|
import {
|
||||||
SitePicker,
|
SitePicker,
|
||||||
@ -178,29 +179,35 @@ function GscConnect({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
if (hasGrant) {
|
if (hasGrant) {
|
||||||
return (
|
return (
|
||||||
<SitePicker
|
<div className="space-y-4">
|
||||||
loading={sitesQuery.isLoading}
|
<GoogleLinkErrorAlert provider="gsc" />
|
||||||
error={sitesQuery.isError}
|
<SitePicker
|
||||||
accounts={accounts}
|
loading={sitesQuery.isLoading}
|
||||||
selection={selection}
|
error={sitesQuery.isError}
|
||||||
onSelect={setSelection}
|
accounts={accounts}
|
||||||
onSave={() => selection && setSiteMutation.mutate(selection)}
|
selection={selection}
|
||||||
saving={setSiteMutation.isPending}
|
onSelect={setSelection}
|
||||||
onRetry={() => void sitesQuery.refetch()}
|
onSave={() => selection && setSiteMutation.mutate(selection)}
|
||||||
onReconnect={handleConnect}
|
saving={setSiteMutation.isPending}
|
||||||
/>
|
onRetry={() => void sitesQuery.refetch()}
|
||||||
|
onReconnect={handleConnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="space-y-4">
|
||||||
type="button"
|
<GoogleLinkErrorAlert provider="gsc" />
|
||||||
onClick={handleConnect}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={handleConnect}
|
||||||
<GoogleGlyph className="size-[18px]" />
|
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"
|
||||||
Connect with Google
|
>
|
||||||
</button>
|
<GoogleGlyph className="size-[18px]" />
|
||||||
|
Connect with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -104,6 +104,14 @@ function createAuth() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
socialProviders: getSocialProviders(),
|
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),
|
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||||
database,
|
database,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-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 AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
||||||
@ -72,6 +73,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
|||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthErrorRoute = AuthErrorRouteImport.update({
|
||||||
|
id: '/auth-error',
|
||||||
|
path: '/auth-error',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
||||||
id: '/_authenticated',
|
id: '/_authenticated',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
@ -309,6 +315,7 @@ const ProjectPProjectIdAuditIssuesResultIdRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AppIndexRoute
|
'/': typeof AppIndexRoute
|
||||||
|
'/auth-error': typeof AuthErrorRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
@ -354,6 +361,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof AppIndexRoute
|
'/': typeof AppIndexRoute
|
||||||
|
'/auth-error': typeof AuthErrorRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
@ -399,6 +407,7 @@ export interface FileRoutesById {
|
|||||||
'/_project': typeof ProjectRouteRouteWithChildren
|
'/_project': typeof ProjectRouteRouteWithChildren
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
|
'/auth-error': typeof AuthErrorRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
@ -447,6 +456,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/auth-error'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
@ -492,6 +502,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/auth-error'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
@ -536,6 +547,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_project'
|
| '/_project'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/_authenticated'
|
| '/_authenticated'
|
||||||
|
| '/auth-error'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
@ -586,6 +598,7 @@ export interface RootRouteChildren {
|
|||||||
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
|
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||||
|
AuthErrorRoute: typeof AuthErrorRoute
|
||||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
@ -620,6 +633,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ForgotPasswordRouteImport
|
preLoaderRoute: typeof ForgotPasswordRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/auth-error': {
|
||||||
|
id: '/auth-error'
|
||||||
|
path: '/auth-error'
|
||||||
|
fullPath: '/auth-error'
|
||||||
|
preLoaderRoute: typeof AuthErrorRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_authenticated': {
|
'/_authenticated': {
|
||||||
id: '/_authenticated'
|
id: '/_authenticated'
|
||||||
path: ''
|
path: ''
|
||||||
@ -1097,6 +1117,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||||
|
AuthErrorRoute: AuthErrorRoute,
|
||||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { AutumnProvider } from "autumn-js/react";
|
import { AutumnProvider } from "autumn-js/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
||||||
|
import { captureGoogleLinkError } from "@/client/features/integrations/googleLinkError";
|
||||||
import { ExportToSheetsModal } from "@/client/components/table/ExportToSheetsModal";
|
import { ExportToSheetsModal } from "@/client/components/table/ExportToSheetsModal";
|
||||||
import { themePreferenceInitScript } from "@/client/lib/theme";
|
import { themePreferenceInitScript } from "@/client/lib/theme";
|
||||||
import {
|
import {
|
||||||
@ -28,6 +29,10 @@ import { Toaster } from "sonner";
|
|||||||
import { queryClient } from "@/client/tanstack-db";
|
import { queryClient } from "@/client/tanstack-db";
|
||||||
import { getActiveOrganizationId } from "@/lib/auth-session";
|
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({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
|
|||||||
47
src/routes/auth-error.tsx
Normal file
47
src/routes/auth-error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user