diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx new file mode 100644 index 0000000..fb249b1 --- /dev/null +++ b/frontend/src/components/LoginPage.tsx @@ -0,0 +1,54 @@ +import { useState, FormEvent } from 'react' + +interface LoginPageProps { + onLogin: (email: string, password: string) => Promise +} + +export function LoginPage({ onLogin }: LoginPageProps) { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + await onLogin(email, password) + } catch { + setError('Invalid credentials') + } finally { + setLoading(false) + } + } + + return ( +
+
+

Research Workbench

+ {error &&
{error}
} + + setEmail(e.target.value)} + autoFocus + required + /> + + setPassword(e.target.value)} + required + /> + +
+
+ ) +} diff --git a/frontend/src/test/LoginPage.test.tsx b/frontend/src/test/LoginPage.test.tsx new file mode 100644 index 0000000..40fee00 --- /dev/null +++ b/frontend/src/test/LoginPage.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { LoginPage } from '../components/LoginPage' + +describe('LoginPage', () => { + it('renders a form with email and password inputs and heading', () => { + const onLogin = vi.fn().mockResolvedValue(undefined) + render() + + expect(screen.getByRole('heading', { name: 'Research Workbench' })).toBeTruthy() + expect(screen.getByRole('textbox', { name: /email/i })).toBeTruthy() + expect(screen.getByLabelText(/password/i)).toBeTruthy() + expect(screen.getByRole('button', { name: /sign in/i })).toBeTruthy() + }) + + it('renders login-page and login-card CSS classes', () => { + const onLogin = vi.fn().mockResolvedValue(undefined) + const { container } = render() + + expect(container.querySelector('.login-page')).toBeTruthy() + expect(container.querySelector('.login-card')).toBeTruthy() + }) + + it('does not show error message initially', () => { + const onLogin = vi.fn().mockResolvedValue(undefined) + render() + + expect(screen.queryByText('Invalid credentials')).toBeNull() + }) + + it('calls onLogin with email and password on submit', async () => { + const onLogin = vi.fn().mockResolvedValue(undefined) + render() + + await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'secret') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + expect(onLogin).toHaveBeenCalledWith('test@example.com', 'secret') + }) + + it('shows "Invalid credentials" when onLogin rejects', async () => { + const onLogin = vi.fn().mockRejectedValue(new Error('Unauthorized')) + render() + + await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'bad@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'wrong') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + const errorEl = await screen.findByText('Invalid credentials') + expect(errorEl).toBeTruthy() + }) + + it('disables the submit button and shows "Signing in..." while loading', async () => { + let resolve: () => void + const onLogin = vi.fn().mockImplementation( + () => new Promise(r => { resolve = r }) + ) + const { container } = render() + + await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'password') + + // Dispatch submit synchronously so loading state is captured before resolution + container.querySelector('form')!.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }) + ) + + await waitFor(() => { + const btn = screen.getByRole('button', { name: /signing in/i }) + expect(btn).toBeTruthy() + expect((btn as HTMLButtonElement).disabled).toBe(true) + }) + + // Clean up: resolve the pending promise + resolve!() + }) + + it('re-enables button after onLogin resolves', async () => { + const onLogin = vi.fn().mockResolvedValue(undefined) + render() + + await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'password') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + const btn = screen.getByRole('button', { name: /sign in/i }) + expect((btn as HTMLButtonElement).disabled).toBe(false) + }) + }) + + it('clears error on new submission attempt', async () => { + let callCount = 0 + const onLogin = vi.fn().mockImplementation(() => { + callCount++ + if (callCount === 1) return Promise.reject(new Error('Unauthorized')) + return Promise.resolve() + }) + + render() + + // First submit — fails and shows error + await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'bad@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'wrong') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + const errorEl = await screen.findByText('Invalid credentials') + expect(errorEl).toBeTruthy() + + // Second submit — error should be cleared + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.queryByText('Invalid credentials')).toBeNull() + }) + }) +})