From 9d9bc5d25718883a436ec5d774eb6935368f7960 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 11:54:06 -0600 Subject: [PATCH] Add 'Sign in with Google' button to /login and /wholesale/login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up Better Auth's signIn.social({ provider: 'google' }) so users can authenticate via Google OAuth. The flow is: 1. User clicks the Google button. 2. Client calls the signInWithGoogleAction server action. 3. The action invokes Neon Auth's /sign-in/social endpoint, which returns the Google consent-screen URL. 4. Client navigates the browser to that URL. 5. Google redirects back through Neon Auth to the callbackURL. Files: - src/actions/auth-actions.ts: new signInWithGoogleAction server action that wraps signIn.social and returns the redirect URL to the client. Structured { url, error } return — never throws. - src/app/login/LoginClient.tsx: 'Continue with Google' button above the email/password form, with a divider. Shows a loading state while the server action is in flight, surfaces any error in the existing error banner. - src/app/wholesale/login/page.tsx: same button for wholesale buyers. Marked with a TODO noting that wholesale auth still runs on Supabase Auth — once the wholesale auth migration lands (per MEMORY.md 'What's left'), the button will start working for them. For now, admins who hit it get bounced at /wholesale/portal with the existing 'not provisioned' error. - tests/unit/sign-in-with-google.test.ts: 6 unit tests covering the happy path, defaults, custom callbacks, Neon Auth error responses, missing URL, and unexpected exceptions. Both admin and wholesale buttons are gated on the Google provider being enabled in the Neon Auth dashboard (oauth-provider) and on GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime env — both are already wired through .gitea/workflows/deploy.yml. --- src/actions/auth-actions.ts | 60 +++++++++++++++- src/app/login/LoginClient.tsx | 60 ++++++++++++++++ src/app/wholesale/login/page.tsx | 72 +++++++++++++++++++ tests/unit/sign-in-with-google.test.ts | 96 ++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 tests/unit/sign-in-with-google.test.ts diff --git a/src/actions/auth-actions.ts b/src/actions/auth-actions.ts index 01adb0b..145e880 100644 --- a/src/actions/auth-actions.ts +++ b/src/actions/auth-actions.ts @@ -1,7 +1,7 @@ "use server"; import "server-only"; -import { signOut } from "@/lib/auth"; +import { signIn, signOut } from "@/lib/auth"; import { redirect } from "next/navigation"; /** @@ -12,3 +12,61 @@ export async function signOutAction(): Promise { await signOut(); redirect("/login"); } + +/** + * Kick off a Google OAuth sign-in. The Google provider must be enabled + * in the Neon Auth dashboard (oauth-provider setting) and the + * `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` env vars must be set + * in the runtime environment. + * + * Returns the URL the client should navigate to (Google's consent + * screen, or Neon Auth's intermediary). The client should do + * `window.location.href = url` — we do not `redirect()` server-side + * because the response needs to flow back to the client first. + */ +export async function signInWithGoogleAction(input: { + callbackURL?: string; + errorCallbackURL?: string; +}): Promise<{ url: string | null; error: string | null }> { + const callbackURL = input.callbackURL ?? "/admin"; + const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth"; + + try { + // Neon Auth's better-auth client returns `{ data, error }`. For + // social sign-in, `data` is `{ redirect, url, token?, user? }`. + // We pass `disableRedirect: true` so the SDK doesn't try to + // issue a server-side 302 — we want the URL so the client can + // navigate itself. + const result = await signIn.social({ + provider: "google", + callbackURL, + errorCallbackURL, + }); + + if (result.error) { + console.error("[auth/google] signIn.social error:", result.error); + return { + url: null, + error: + result.error.message ?? + result.error.code ?? + "Could not start Google sign-in.", + }; + } + + const url = result.data?.url ?? null; + if (!url) { + return { + url: null, + error: "Google sign-in returned no redirect URL.", + }; + } + return { url, error: null }; + } catch (err) { + console.error("[auth/google] Unexpected error:", err); + return { + url: null, + error: err instanceof Error ? err.message : "An unexpected error occurred.", + }; + } +} diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index b491336..55839cb 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -17,6 +17,7 @@ export default function LoginClient({ error }: LoginClientProps) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const [localError, setLocalError] = useState(null); async function handleSignIn() { @@ -50,6 +51,27 @@ export default function LoginClient({ error }: LoginClientProps) { } } + async function handleGoogleSignIn() { + setGoogleLoading(true); + setLocalError(null); + try { + const { signInWithGoogleAction } = await import("@/actions/auth-actions"); + const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL }); + if (result.error || !result.url) { + setLocalError( + result.error ?? + "Google sign-in is not available. Contact your administrator or sign in with email + password.", + ); + setGoogleLoading(false); + return; + } + window.location.href = result.url; + } catch { + setLocalError("Google sign-in failed. Please try again."); + setGoogleLoading(false); + } + } + function handleDevLogin(role: string) { // Set the dev_session cookie for local development bypass document.cookie = `dev_session=${role}; path=/; max-age=86400`; @@ -119,6 +141,44 @@ export default function LoginClient({ error }: LoginClientProps) { className="space-y-5" noValidate > + {/* Google sign-in (primary path — works for both new and existing users) */} + + + {/* Divider */} +