From e0a4bdb23b0c9d2f5b1f6f85d34701daffd337e3 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:19:23 +0100 Subject: [PATCH] fix(auth): show OAuth callback errors --- src/app/login/login-form.test.tsx | 67 +++++++++++++++++++++++++++++++ src/app/login/login-form.tsx | 33 ++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/app/login/login-form.test.tsx diff --git a/src/app/login/login-form.test.tsx b/src/app/login/login-form.test.tsx new file mode 100644 index 0000000..cbea730 --- /dev/null +++ b/src/app/login/login-form.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LoginForm } from "./login-form"; + +const { searchParamsGet } = vi.hoisted(() => ({ + searchParamsGet: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => ({ get: searchParamsGet }), +})); + +vi.mock("@/lib/supabase/client", () => ({ + createClient: () => ({ auth: {} }), +})); + +beforeEach(() => { + searchParamsGet.mockImplementation((name: string) => + name === "error" ? "auth_error" : null, + ); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("LoginForm OAuth errors", () => { + it("shows the callback error in a dismissible alert", () => { + render(); + + expect(screen.getByRole("alert").textContent).toContain( + "Sign-in failed or was cancelled. Please try again.", + ); + + fireEvent.click( + screen.getByRole("button", { name: "Dismiss sign-in error" }), + ); + + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("uses safe fallback copy for an unknown error code", () => { + searchParamsGet.mockImplementation((name: string) => + name === "error" ? "__proto__" : null, + ); + + render(); + + expect(screen.getByRole("alert").textContent).toContain( + "Sign-in failed. Please try again.", + ); + expect(screen.getByRole("alert").textContent).not.toContain( + "__proto__", + ); + }); + + it("does not show an alert without an error code", () => { + searchParamsGet.mockReturnValue(null); + + render(); + + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index e91df90..df7c842 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; +import { Loader2, TriangleAlert, X } from "lucide-react"; import { createClient } from "@/lib/supabase/client"; import { safeNext } from "@/lib/safe-next"; @@ -13,10 +13,20 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +const AUTH_ERROR_MESSAGES = new Map([ + ["auth_error", "Sign-in failed or was cancelled. Please try again."], +]); + +const DEFAULT_AUTH_ERROR_MESSAGE = "Sign-in failed. Please try again."; + export function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const next = safeNext(searchParams.get("next")); + const errorCode = searchParams.get("error"); + const errorMessage = errorCode + ? (AUTH_ERROR_MESSAGES.get(errorCode) ?? DEFAULT_AUTH_ERROR_MESSAGE) + : null; const supabase = createClient(); @@ -24,6 +34,9 @@ export function LoginForm() { const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [loading, setLoading] = React.useState(false); + const [dismissedErrorCode, setDismissedErrorCode] = React.useState( + null, + ); // Self-host / preview mode: Supabase isn't configured, so there's no auth. if (!supabase) { @@ -114,6 +127,24 @@ export function LoginForm() {

+ {errorMessage && dismissedErrorCode !== errorCode && ( +
+
+ )} + {/* Card */}
{/* Google */}