"use client"; import { Suspense, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/contexts/AuthContext"; import { StandaloneShell } from "@/components/layout/standalone-shell"; import { StandaloneCard, StandaloneCardHeader, } from "@/components/layout/standalone-card"; import { getApiErrorMessage } from "@/lib/api/error"; import { destinationForOutcome, signInHref } from "@/lib/auth-redirect"; import { consumeGoogleAttempt, googleRedirectUri } from "@/lib/google-oauth"; import { AlertCircle, Loader2 } from "lucide-react"; // Where Google returns a visitor who pressed "Continue with Google" - this is // `{FRONTEND_URL}/auth/google/callback`, the redirect URI the API derives and the // one an operator registers against their OAuth client. // // **It exchanges on load rather than waiting for a click, and the reason // `/auth/verify` does the opposite does not apply here.** That page requires an // explicit click because its URL arrives by email, and a mail client's // link-preview scanner can load it in a real, JS-executing browser. An OAuth // callback URL is never emailed. It is reached only by a redirect from Google, at // the end of something the visitor started in this browser moments earlier, and // it carries a code that is worthless without the API's client secret and the // PKCE verifier this browser kept to itself. Asking for a second click here would // buy nothing and cost every visitor an extra step. export function GoogleCallbackPageContent() { return ( // Not what makes the build pass. `useSearchParams()` is a context read on the // client and suspends only while a *static* shell is being validated at build // time, which `export const instant = false` on the root layout switches off // for every route in this app - see `DECISIONS.md`, "The click paints the // destination's frame, and the page is what paints it". Nothing under this // boundary suspends, so this fallback never commits. }> ); } const SIGN_IN_FAILED = "We couldn't finish signing you in with Google."; const UNKNOWN_ATTEMPT = "This sign-in didn't come from a Google sign-in started in this browser, or it took too long to come back."; // Where a visitor goes to try again. An attempt started on a join link or an // invite link goes back to that link rather than to `/signin`, which knows nothing // of it: a new visitor signing in there is refused as uninvited while the link is // still live. `/join` tells the two apart again by asking the API. function retryHref(via: string | null): string { return via ? `/join?via=${via}` : "/signin"; } function GoogleCallbackContent() { const searchParams = useSearchParams(); const router = useRouter(); const { signInWithGoogle } = useAuth(); const [failure, setFailure] = useState<{ message: string; via: string | null; } | null>(null); const errorParam = searchParams.get("error"); const state = searchParams.get("state"); const code = searchParams.get("code"); // The once-guard, and deliberately not the consumption of the stored attempt. // Google's authorization codes are single-use and React Strict Mode invokes // effects twice in development, so something has to stop the second invocation // from posting the same code again. Consuming the attempt before the request // looks like it does that and does not: the second mount would then find no // attempt and report a failure over a sign-in that had just succeeded - a // spurious error screen in the environment developers use, while a "confirm // exactly one POST" check still passed. A ref survives the remount, so the // second invocation returns before touching anything. const exchangedRef = useRef(false); useEffect(() => { if (exchangedRef.current) return; exchangedRef.current = true; // Cancelling at Google's account chooser comes back as // `error=access_denied`, and it is a decision rather than a failure: the // visitor is put back on the sign-in page with every method still available // and nothing phrased as though something went wrong. The attempt is // consumed on the way past so an abandoned one does not sit in storage, and // its destination is carried back into the sign-in link so a second try // still lands where the first one was headed - or, for one started on a join // link, back to that link (`retryHref`). if (errorParam) { const abandoned = consumeGoogleAttempt(state); router.replace( abandoned?.via ? retryHref(abandoned.via) : signInHref(abandoned?.redirectTo), ); return; } const attempt = consumeGoogleAttempt(state); if (!code || !attempt) { // No exchange at all. A `state` this browser did not issue, has already // used, or let expire is the one case where a code must not be posted - // the request would otherwise be this page doing a stranger's bidding. // // The one setState the rule has a point about, and it is unavoidable // rather than lazy: the verdict is only knowable after reading browser // storage, which is a side effect and so cannot happen during render the // way `/auth/verify` decides its own missing-token case. Every other // outcome here already reports from a promise continuation, which is the // shape the rule asks for. The cascade it costs is one render on a page // that has nothing further to do. // eslint-disable-next-line react-hooks/set-state-in-effect setFailure({ message: UNKNOWN_ATTEMPT, via: null }); return; } signInWithGoogle({ code, codeVerifier: attempt.codeVerifier, // Rebuilt from the same derivation the authorization request used, because // Google requires the exchange to repeat the URI it saw and the API // refuses any other. redirectUri: googleRedirectUri(), via: attempt.via, }) .then((outcome) => { // `replace`, not `push`: the code is in this page's own URL, and a // pushed entry would leave it in the visitor's history to be walked back // to. Every departure from this page replaces, including the error // card's link below. router.replace( destinationForOutcome(outcome.status, attempt.redirectTo), ); }) .catch((err) => { setFailure({ message: getApiErrorMessage(err, SIGN_IN_FAILED), via: attempt.via, }); }); }, [errorParam, state, code, router, signInWithGoogle]); return ( ); } function CallbackStatus({ error, retry = "/signin", }: { error?: string | null; retry?: string; }) { return ( {error ? ( <>

Back to sign in

) : ( )}
); }