Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/app/login/login-form.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LoginForm />);

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(<LoginForm />);

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(<LoginForm />);

expect(screen.queryByRole("alert")).toBeNull();
});
});
33 changes: 32 additions & 1 deletion src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,17 +13,30 @@ 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();

const [mode, setMode] = React.useState<"signin" | "signup">("signin");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [dismissedErrorCode, setDismissedErrorCode] = React.useState<string | null>(
null,
);

// Self-host / preview mode: Supabase isn't configured, so there's no auth.
if (!supabase) {
Expand Down Expand Up @@ -114,6 +127,24 @@ export function LoginForm() {
</p>
</div>

{errorMessage && dismissedErrorCode !== errorCode && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p className="flex-1">{errorMessage}</p>
<button
type="button"
aria-label="Dismiss sign-in error"
className="flex size-6 shrink-0 items-center justify-center rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setDismissedErrorCode(errorCode)}
>
<X className="size-4" aria-hidden="true" />
</button>
</div>
)}

{/* Card */}
<div className="rounded-xl border bg-card p-6 shadow-sm space-y-5">
{/* Google */}
Expand Down
Loading