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,
|
||||
} 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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
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 { 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;
|
||||
}
|
||||
if (res.data?.url) window.location.href = res.data.url;
|
||||
url = res.data?.url;
|
||||
}
|
||||
if (!url) return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,6 +179,8 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
|
||||
if (hasGrant) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GoogleLinkErrorAlert provider="gsc" />
|
||||
<SitePicker
|
||||
loading={sitesQuery.isLoading}
|
||||
error={sitesQuery.isError}
|
||||
@ -189,10 +192,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
onRetry={() => void sitesQuery.refetch()}
|
||||
onReconnect={handleConnect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GoogleLinkErrorAlert provider="gsc" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnect}
|
||||
@ -201,6 +207,7 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
<GoogleGlyph className="size-[18px]" />
|
||||
Connect with Google
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
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