Skip to content
Closed
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 app/(auth)/forgot-password/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import ForgotPasswordPage from "./page";
import { server } from "@/test/msw/server";

const genericMessage =
"If an account exists for that email, we sent password reset instructions.";

async function submitEmail(email: string) {
const user = userEvent.setup();
await user.type(screen.getByLabelText("Email"), email);
await user.click(
screen.getByRole("button", { name: "Send reset instructions" }),
);
}

describe("ForgotPasswordPage", () => {
it("calls POST /auth/forgot-password with the email", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/proxy/auth/forgot-password", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json({ message: "Sent" });
}),
);
render(<ForgotPasswordPage />);

await submitEmail("user@example.com");

await waitFor(() => {
expect(requests).toEqual([{ email: "user@example.com" }]);
});
});

it("shows the same generic success message when the email does not exist", async () => {
server.use(
http.post("*/api/proxy/auth/forgot-password", () =>
HttpResponse.json({ message: "Email not found" }, { status: 404 }),
),
);
render(<ForgotPasswordPage />);

await submitEmail("missing@example.com");

expect(await screen.findByRole("status")).toHaveTextContent(genericMessage);
});

it("does not reveal whether an email exists", async () => {
server.use(
http.post("*/api/proxy/auth/forgot-password", () =>
HttpResponse.json(
{ message: "There is no account for this email" },
{ status: 400 },
),
),
);
render(<ForgotPasswordPage />);

await submitEmail("private@example.com");

expect(await screen.findByRole("status")).toHaveTextContent(genericMessage);
expect(screen.queryByText(/no account/i)).not.toBeInTheDocument();
expect(screen.queryByText("private@example.com")).not.toBeInTheDocument();
});
});
73 changes: 72 additions & 1 deletion app/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,74 @@
"use client";

import Link from "next/link";
import { useState } from "react";
import {
AuthShell,
buttonClassName,
inputClassName,
} from "@/components/auth/auth-shell";
import { apiClient } from "@/lib/api-client";

const SUCCESS_MESSAGE =
"If an account exists for that email, we sent password reset instructions.";

export default function ForgotPasswordPage() {
return <div>Forgot Password</div>;
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);

try {
await apiClient("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email: email.trim() }),
});
} catch {
// The response is intentionally identical to avoid exposing account existence.
} finally {
setIsSubmitted(true);
setIsLoading(false);
}
};

return (
<AuthShell
title="Reset your password"
subtitle="Enter your email and we will send the next steps."
footer={
<Link className="font-semibold text-neutral-950 underline" href="/login">
Back to sign in
</Link>
}
>
{isSubmitted ? (
<div
className="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm leading-6 text-emerald-800"
role="status"
>
{SUCCESS_MESSAGE}
</div>
) : (
<form className="space-y-5" noValidate onSubmit={handleSubmit}>
<label className="block text-sm font-medium text-neutral-800">
Email
<input
autoComplete="email"
className={inputClassName}
disabled={isLoading}
onChange={(event) => setEmail(event.target.value)}
type="email"
value={email}
/>
</label>
<button className={buttonClassName} disabled={isLoading} type="submit">
{isLoading ? "Sending..." : "Send reset instructions"}
</button>
</form>
)}
</AuthShell>
);
}
127 changes: 127 additions & 0 deletions app/(auth)/login/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import LoginPage from "./page";
import { mockPush } from "@/test/mocks/navigation";
import { server } from "@/test/msw/server";

async function enterCredentials(email = "user@example.com", password = "password123") {
const user = userEvent.setup();

await user.type(screen.getByLabelText("Email"), email);
await user.type(screen.getByLabelText("Password"), password);

return user;
}

describe("LoginPage", () => {
it("renders email and password fields", () => {
render(<LoginPage />);

expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
});

it("shows a validation error when email is empty on submit", async () => {
const user = userEvent.setup();
render(<LoginPage />);

await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByRole("button", { name: "Sign in" }));

expect(screen.getByText("Email is required")).toBeInTheDocument();
});

it("shows a validation error when password is empty on submit", async () => {
const user = userEvent.setup();
render(<LoginPage />);

await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.click(screen.getByRole("button", { name: "Sign in" }));

expect(screen.getByText("Password is required")).toBeInTheDocument();
});

it("calls POST /auth/login with the correct payload", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/proxy/auth/login", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json({ message: "OTP sent" });
}),
);
render(<LoginPage />);
const user = await enterCredentials();

await user.click(screen.getByRole("button", { name: "Sign in" }));

await waitFor(() => {
expect(requests).toEqual([
{ email: "user@example.com", password: "password123" },
]);
});
});

it("shows an inline error when the API returns a 4xx response", async () => {
server.use(
http.post("*/api/proxy/auth/login", () =>
HttpResponse.json(
{ message: "Invalid email or password" },
{ status: 401 },
),
),
);
render(<LoginPage />);
const user = await enterCredentials();

await user.click(screen.getByRole("button", { name: "Sign in" }));

expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid email or password",
);
});

it("redirects to /verify-otp on success", async () => {
server.use(
http.post("*/api/proxy/auth/login", () =>
HttpResponse.json({ message: "OTP sent" }),
),
);
render(<LoginPage />);
const user = await enterCredentials();

await user.click(screen.getByRole("button", { name: "Sign in" }));

await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/verify-otp");
});
});

it("shows a loading state while the request is in flight", async () => {
let releaseRequest: (() => void) | undefined;
const requestGate = new Promise<void>((resolve) => {
releaseRequest = resolve;
});
server.use(
http.post("*/api/proxy/auth/login", async () => {
await requestGate;
return HttpResponse.json({ message: "OTP sent" });
}),
);
render(<LoginPage />);
const user = await enterCredentials();

await user.click(screen.getByRole("button", { name: "Sign in" }));

const loadingButton = await screen.findByRole("button", {
name: "Signing in...",
});
expect(loadingButton).toBeDisabled();

releaseRequest?.();
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/verify-otp");
});
});
});
Loading