diff options
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/Login.css | 63 | ||||
| -rw-r--r-- | frontend/src/components/Login.test.tsx | 76 | ||||
| -rw-r--r-- | frontend/src/components/Login.tsx | 54 |
3 files changed, 193 insertions, 0 deletions
diff --git a/frontend/src/components/Login.css b/frontend/src/components/Login.css new file mode 100644 index 0000000..f1ca976 --- /dev/null +++ b/frontend/src/components/Login.css @@ -0,0 +1,63 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #f5f5f5; +} + +.login-form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; +} + +.login-form h1 { + margin-bottom: 2rem; + text-align: center; + color: #333; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.error-message { + color: #dc3545; + margin-bottom: 1rem; + text-align: center; +} + +button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button[type="submit"]:hover { + background-color: #0056b3; +} diff --git a/frontend/src/components/Login.test.tsx b/frontend/src/components/Login.test.tsx new file mode 100644 index 0000000..44d1371 --- /dev/null +++ b/frontend/src/components/Login.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Login from './Login'; + +// Mock fetch +global.fetch = vi.fn(); + +const renderLogin = () => { + render( + <BrowserRouter> + <Login /> + </BrowserRouter> + ); +}; + +describe('Login Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders login form', () => { + renderLogin(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('handles successful login', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + }); + + renderLogin(); + + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith('/api/login', expect.objectContaining({ + method: 'POST', + })); + }); + // Navigation assertion is tricky without mocking useNavigate, + // but if no error is shown, we assume success path was taken + expect(screen.queryByText(/login failed/i)).not.toBeInTheDocument(); + }); + + it('handles failed login', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Bad credentials' }), + }); + + renderLogin(); + + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByText(/bad credentials/i)).toBeInTheDocument(); + }); + }); + + it('handles network error', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + + renderLogin(); + + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx new file mode 100644 index 0000000..2e8bbf7 --- /dev/null +++ b/frontend/src/components/Login.tsx @@ -0,0 +1,54 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import './Login.css'; + +export default function Login() { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + + try { + // Use URLSearchParams to send as form-urlencoded, matching backend expectation + const params = new URLSearchParams(); + params.append('password', password); + + const res = await fetch('/api/login', { + method: 'POST', + body: params, + }); + + if (res.ok) { + navigate('/'); + } else { + const data = await res.json(); + setError(data.message || 'Login failed'); + } + } catch (err) { + setError('Network error'); + } + }; + + return ( + <div className="login-container"> + <form onSubmit={handleSubmit} className="login-form"> + <h1>neko rss mode</h1> + <div className="form-group"> + <label htmlFor="password">password</label> + <input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + autoFocus + /> + </div> + {error && <div className="error-message">{error}</div>} + <button type="submit">login</button> + </form> + </div> + ); +} |
