Add 'Sign in with Google' button to /login and /wholesale/login
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s

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.
This commit is contained in:
Tyler
2026-06-17 11:54:06 -06:00
parent d75380eb9a
commit 9d9bc5d257
4 changed files with 287 additions and 1 deletions
+59 -1
View File
@@ -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<void> {
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.",
};
}
}
+60
View File
@@ -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<string | null>(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) */}
<button
type="button"
onClick={handleGoogleSignIn}
disabled={googleLoading || loading}
className="atelier-cta atelier-cta-secondary w-full"
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
>
{googleLoading ? (
<span className="relative z-10 inline-flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Redirecting to Google
</span>
) : (
<span className="relative z-10 inline-flex items-center gap-2.5">
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</span>
)}
</button>
{/* Divider */}
<div className="flex items-center gap-3" aria-hidden="true">
<div className="h-px flex-1 bg-stone-200" />
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-stone-400" style={{ fontFamily: "var(--font-fragment-mono)" }}>
or
</span>
<div className="h-px flex-1 bg-stone-200" />
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={emailId} className="atelier-section-label">
Email
+72
View File
@@ -53,6 +53,7 @@ export default function WholesaleLoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
const [submitting, setSubmitting] = useState(false);
const [googleSubmitting, setGoogleSubmitting] = useState(false);
// Read ?error=... from the URL in the lazy initializer. Safe in a client
// component since window is always defined here, and avoids a
// set-state-in-effect on mount.
@@ -69,6 +70,9 @@ export default function WholesaleLoginPage() {
if (err === "invalid_credentials") {
return "Invalid email or password. Please try again.";
}
if (err === "oauth") {
return "Google sign-in failed or was cancelled. Please try again or use your password.";
}
return null;
});
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
@@ -77,6 +81,37 @@ export default function WholesaleLoginPage() {
// form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array.
const selectedBrand = BRANDS.find(b => b.id === form.brandId) ?? BRANDS[1];
async function handleGoogleSignIn() {
setGoogleSubmitting(true);
setError(null);
try {
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
const result = await signInWithGoogleAction({
callbackURL: "/wholesale/portal",
errorCallbackURL: "/wholesale/login?error=oauth",
});
if (result.error || !result.url) {
// TODO: Wholesale auth still runs on Supabase Auth. The Google
// button works for admins (who use Neon Auth); wholesale buyers
// who hit it will be redirected through Neon Auth and then
// bounced at /wholesale/portal because the session isn't
// recognized by the wholesale auth path. Once wholesale auth
// is migrated to Neon Auth, this will start working for them
// too.
setError(
result.error ??
"Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now.",
);
setGoogleSubmitting(false);
return;
}
window.location.href = result.url;
} catch {
setError("Google sign-in failed. Please try again.");
setGoogleSubmitting(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
@@ -165,6 +200,43 @@ export default function WholesaleLoginPage() {
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Google sign-in — works once wholesale auth migrates to Neon Auth */}
<button
type="button"
onClick={handleGoogleSignIn}
disabled={googleSubmitting || submitting}
className="w-full rounded-xl bg-white py-3.5 text-sm font-bold text-zinc-900 hover:bg-zinc-100 active:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-zinc-300 flex items-center justify-center gap-2.5"
>
{googleSubmitting ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Redirecting to Google
</>
) : (
<>
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</>
)}
</button>
{/* Divider */}
<div className="flex items-center gap-3" aria-hidden="true">
<div className="h-px flex-1 bg-zinc-800" />
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-zinc-500">
or
</span>
<div className="h-px flex-1 bg-zinc-800" />
</div>
<FormField label="Company" id="brand_id" error={null}>
<select
id="brand_id"