Implement Login page with PocketBase auth (P1.6)
Add functional login page with email/password form: - Client component with controlled form inputs - PocketBase authentication integration - Error handling with visual feedback - Loading states (disabled inputs, button text change) - Form validation (prevents empty submissions) - Redirect to dashboard on successful login Test infrastructure improvements: - Add @testing-library/jest-dom for DOM matchers - Add global test setup with cleanup between tests - Configure vitest.config.ts with setupFiles 14 new tests covering form rendering, auth flow, error handling, and validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
261
src/app/login/page.test.tsx
Normal file
261
src/app/login/page.test.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
// ABOUTME: Unit tests for the Login page component.
|
||||
// ABOUTME: Tests form rendering, validation, auth flow, and error handling.
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock PocketBase
|
||||
const mockAuthWithPassword = vi.fn();
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
pb: {
|
||||
collection: () => ({
|
||||
authWithPassword: mockAuthWithPassword,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import LoginPage from "./page";
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the login form with email and password inputs", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a sign in button", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the PhaseFlow branding", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has email input with type email", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toHaveAttribute("type", "email");
|
||||
});
|
||||
|
||||
it("has password input with type password", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
expect(passwordInput).toHaveAttribute("type", "password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("form submission", () => {
|
||||
it("calls PocketBase auth with email and password on submit", async () => {
|
||||
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthWithPassword).toHaveBeenCalledWith(
|
||||
"test@example.com",
|
||||
"password123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to dashboard on successful login", async () => {
|
||||
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while authenticating", async () => {
|
||||
// Create a promise that we can control
|
||||
let resolveAuth: (value: unknown) => void;
|
||||
const authPromise = new Promise((resolve) => {
|
||||
resolveAuth = resolve;
|
||||
});
|
||||
mockAuthWithPassword.mockReturnValue(authPromise);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Button should show loading state
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /signing in/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Resolve the auth
|
||||
resolveAuth?.({ token: "test-token" });
|
||||
});
|
||||
|
||||
it("disables form inputs while loading", async () => {
|
||||
let resolveAuth: (value: unknown) => void;
|
||||
const authPromise = new Promise((resolve) => {
|
||||
resolveAuth = resolve;
|
||||
});
|
||||
mockAuthWithPassword.mockReturnValue(authPromise);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(passwordInput).toBeDisabled();
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveAuth?.({ token: "test-token" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("shows error message on failed login", async () => {
|
||||
mockAuthWithPassword.mockRejectedValueOnce(
|
||||
new Error("Invalid credentials"),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears error when user starts typing again", async () => {
|
||||
mockAuthWithPassword.mockRejectedValueOnce(
|
||||
new Error("Invalid credentials"),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Start typing again
|
||||
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-enables form after error", async () => {
|
||||
mockAuthWithPassword.mockRejectedValueOnce(
|
||||
new Error("Invalid credentials"),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Form should be re-enabled
|
||||
expect(emailInput).not.toBeDisabled();
|
||||
expect(passwordInput).not.toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
it("does not submit with empty email", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Should not call auth
|
||||
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit with empty password", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Should not call auth
|
||||
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,111 @@
|
||||
// ABOUTME: Login page for user authentication.
|
||||
// ABOUTME: Provides email/password login form using PocketBase auth.
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation - don't submit with empty fields
|
||||
if (!email.trim() || !password.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await pb.collection("users").authWithPassword(email, password);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Invalid credentials";
|
||||
setError(message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (
|
||||
setter: React.Dispatch<React.SetStateAction<string>>,
|
||||
value: string,
|
||||
) => {
|
||||
setter(value);
|
||||
// Clear error when user starts typing again
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-8 p-8">
|
||||
<h1 className="text-2xl font-bold text-center">PhaseFlow Login</h1>
|
||||
{/* Login form will be implemented here */}
|
||||
<p className="text-center text-gray-500">Login form placeholder</p>
|
||||
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => handleInputChange(setEmail, e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => handleInputChange(setPassword, e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user