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
+96
View File
@@ -0,0 +1,96 @@
/**
* Unit tests for `signInWithGoogleAction` in `src/actions/auth-actions.ts`.
*
* The action wraps Neon Auth's `signIn.social({ provider: "google" })`
* and returns the redirect URL to the client. It must:
* - Return `{ url, error: null }` when Neon Auth responds with a URL.
* - Return `{ url: null, error }` on any auth error / network failure.
* - Pass through `callbackURL` and `errorCallbackURL` to Neon Auth.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockSocial } = vi.hoisted(() => ({
mockSocial: vi.fn(),
}));
vi.mock("server-only", () => ({}));
// `signIn` is a namespace object re-exported from @/lib/auth with
// methods like `email`, `social`, etc. — mock the whole thing.
vi.mock("@/lib/auth", () => ({
signIn: { social: mockSocial },
signOut: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
beforeEach(() => {
vi.clearAllMocks();
});
describe("signInWithGoogleAction", () => {
it("returns the Neon Auth redirect URL on success", async () => {
mockSocial.mockResolvedValue({
data: { url: "https://accounts.google.com/o/oauth2/v2/auth?..." },
error: null,
});
const r = await signInWithGoogleAction({ callbackURL: "/admin" });
expect(r.error).toBeNull();
expect(r.url).toBe("https://accounts.google.com/o/oauth2/v2/auth?...");
expect(mockSocial).toHaveBeenCalledWith({
provider: "google",
callbackURL: "/admin",
errorCallbackURL: "/login?error=oauth",
});
});
it("defaults callbackURL to /admin and errorCallbackURL to /login?error=oauth", async () => {
mockSocial.mockResolvedValue({ data: { url: "https://x" }, error: null });
await signInWithGoogleAction({});
expect(mockSocial).toHaveBeenCalledWith({
provider: "google",
callbackURL: "/admin",
errorCallbackURL: "/login?error=oauth",
});
});
it("passes through a custom errorCallbackURL", async () => {
mockSocial.mockResolvedValue({ data: { url: "https://x" }, error: null });
await signInWithGoogleAction({
callbackURL: "/wholesale/portal",
errorCallbackURL: "/wholesale/login?error=oauth",
});
expect(mockSocial).toHaveBeenCalledWith({
provider: "google",
callbackURL: "/wholesale/portal",
errorCallbackURL: "/wholesale/login?error=oauth",
});
});
it("returns a structured error when Neon Auth rejects the request", async () => {
mockSocial.mockResolvedValue({
data: null,
error: { code: "PROVIDER_NOT_CONFIGURED", message: "Google OAuth is not enabled" },
});
const r = await signInWithGoogleAction({});
expect(r.url).toBeNull();
expect(r.error).toBe("Google OAuth is not enabled");
});
it("returns a structured error when the response has no URL", async () => {
mockSocial.mockResolvedValue({ data: {}, error: null });
const r = await signInWithGoogleAction({});
expect(r.url).toBeNull();
expect(r.error).toMatch(/no redirect URL/i);
});
it("returns a structured error on unexpected exceptions", async () => {
mockSocial.mockRejectedValue(new Error("network down"));
const r = await signInWithGoogleAction({});
expect(r.url).toBeNull();
expect(r.error).toBe("network down");
});
});