DocDoeAI / src /app /auth /google /callback /GoogleCallbackClient.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
7.72 kB
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { ArrowRight, CheckCircle, LoaderCircle, XCircle } from "lucide-react";
import {
getAuthToken,
getStoredUser,
setAuthSession,
startGoogleSignIn,
} from "@/lib/auth/auth-client";
import type { AuthTokenResponse } from "@/lib/api/types";
import { hydratePendingProductIntentAfterAuthBounded } from "@/lib/onboarding-intent";
function readSafeNextPath(value: string | null): string {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/study-chat";
return value;
}
function userSafeGoogleError(rawError: string | null): string {
if (!rawError) return "Could not finish Google sign-in right now.";
const normalized = rawError.toLowerCase();
if (normalized.includes("cancel")) return "Google sign-in was cancelled.";
if (normalized.includes("not ready")) return "Google sign-in is not set up yet. Contact support.";
if (normalized.includes("expired") || normalized.includes("already used")) {
return "That Google sign-in attempt expired. Start a fresh sign-in to continue.";
}
return "Could not finish Google sign-in right now.";
}
type Status =
| { kind: "loading" }
| { kind: "redirecting" }
| { kind: "error"; message: string; nextPath: string };
/** Parse the OAuth fragment after hydration so server and first-client markup match. */
export function GoogleCallbackClient() {
const [status, setStatus] = useState<Status>({ kind: "loading" });
useEffect(() => {
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const requestedNextPath = readSafeNextPath(params.get("next"));
window.history.replaceState(null, "", "/auth/google/callback");
let nextStatus: Status;
let shouldRedirect = false;
const callbackError = params.get("error");
const accessToken = params.get("access_token");
const expiresIn = Number(params.get("expires_in"));
const userValue = params.get("user");
if (callbackError) {
nextStatus = {
kind: "error",
message: userSafeGoogleError(callbackError),
nextPath: requestedNextPath,
};
} else if (!accessToken || !userValue || !Number.isFinite(expiresIn) || expiresIn <= 0) {
// The callback fragment is intentionally removed after it is consumed.
// If the student refreshes or revisits this URL after a successful
// exchange, resume the already-valid account instead of showing a false
// sign-in failure.
if (getAuthToken() && getStoredUser()) {
nextStatus = { kind: "redirecting" };
shouldRedirect = true;
} else {
nextStatus = {
kind: "error",
message: "Could not finish Google sign-in right now.",
nextPath: requestedNextPath,
};
}
} else {
try {
const parsedSession: AuthTokenResponse = {
access_token: accessToken,
token_type: params.get("token_type") ?? "bearer",
expires_in: expiresIn,
user: JSON.parse(userValue) as AuthTokenResponse["user"],
};
setAuthSession(parsedSession);
nextStatus = { kind: "redirecting" };
shouldRedirect = true;
} catch {
nextStatus = {
kind: "error",
message: "Session data was malformed. Please try again.",
nextPath: requestedNextPath,
};
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- URL hash parsing must happen after hydration.
setStatus(nextStatus);
if (shouldRedirect) {
void hydratePendingProductIntentAfterAuthBounded().then((handoff) => {
// RootLoadingGuard evaluates the freshly persisted account and owns
// the onboarding and session decisions after the full navigation.
// The backend just minted this OAuth token, so holding the callback
// behind another /auth/session request only turns a cold or transient
// API response into a false sign-in failure.
const nextPath = handoff.redirectTo ?? requestedNextPath;
// OAuth changes the authentication identity outside React context.
// A full same-origin navigation guarantees every provider and route
// guard boots from the newly persisted account instead of retaining a
// stale guest state through a client transition.
window.location.replace(nextPath);
}).catch(() => {
// Product-intent hydration is optional post-auth work. It must never
// turn a successfully issued account session into a login-loop screen.
window.location.replace(requestedNextPath);
});
}
}, []);
if (status.kind === "error") {
return (
<section className="w-full max-w-[420px] rounded-[16px] border border-[#e3e1da] bg-[#fcfcfa] p-6 text-center text-[#171716] shadow-[0_18px_50px_rgba(23,23,22,0.08)] sm:p-8">
<div className="mx-auto grid size-12 place-items-center rounded-[12px] border border-[#e2b5b2] bg-[#fff6f5] text-[#b33a3a]">
<XCircle className="size-6" strokeWidth={1.7} aria-hidden="true" />
</div>
<h1 className="mt-5 text-[24px] font-semibold tracking-[-0.035em] text-[#171716]">Sign-in needs another try</h1>
<p className="mt-3 text-sm leading-6 text-[#62635e]" role="alert">
{status.message}
</p>
<div className="mt-6 grid gap-2">
<button
type="button"
onClick={() => startGoogleSignIn({ nextPath: status.nextPath })}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-[#171716] px-5 text-sm font-semibold text-white transition hover:bg-[#30302e] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#171716] focus-visible:ring-offset-2"
>
Try Google again
<ArrowRight className="size-4" aria-hidden="true" />
</button>
<Link
href="/login"
className="inline-flex min-h-11 items-center justify-center rounded-[10px] px-5 text-sm font-semibold text-[#4f504c] transition hover:bg-[#f5f4f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#171716] focus-visible:ring-offset-2"
>
Use another sign-in method
</Link>
</div>
</section>
);
}
if (status.kind === "redirecting") {
return (
<section className="w-full max-w-[420px] rounded-[16px] border border-[#e3e1da] bg-[#fcfcfa] p-6 text-center text-[#171716] shadow-[0_18px_50px_rgba(23,23,22,0.08)] sm:p-8">
<div className="mx-auto grid size-12 place-items-center border border-[#b7cdbd] bg-[#f3f8f4] text-[#2f6240]">
<CheckCircle className="size-6" strokeWidth={1.7} aria-hidden="true" />
</div>
<h1 className="mt-5 text-[24px] font-semibold tracking-[-0.035em] text-[#171716]">Welcome to DocDoe</h1>
<p className="mt-2 text-[13px] leading-6 text-[#62635e]" role="status" aria-live="polite">
Taking you to your study workspace…
</p>
</section>
);
}
return (
<section className="w-full max-w-[420px] rounded-[16px] border border-[#e3e1da] bg-[#fcfcfa] p-6 text-center text-[#171716] shadow-[0_18px_50px_rgba(23,23,22,0.08)] sm:p-8">
<LoaderCircle className="mx-auto size-7 animate-spin text-[#4c4d48]" strokeWidth={1.8} aria-hidden="true" />
<h1 className="mt-5 text-[24px] font-semibold tracking-[-0.035em] text-[#171716]">Completing Google sign-in</h1>
<p className="mt-2 text-[13px] leading-6 text-[#62635e]" role="status" aria-live="polite">
Connecting your Google account securely.
</p>
</section>
);
}