diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.test.tsx | 30 | ||||
| -rw-r--r-- | frontend/src/App.tsx | 85 | ||||
| -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 |
5 files changed, 275 insertions, 33 deletions
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f1572a7..8e3d805 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,10 +1,32 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import App from './App'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('App', () => { - it('renders heading', () => { + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + it('renders login on initial load (unauthenticated)', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + }); + window.history.pushState({}, 'Test page', '/v2/login'); render(<App />); - expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); // Adjust based on actual App content + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('renders dashboard when authenticated', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + }); + + window.history.pushState({}, 'Test page', '/v2/'); + render(<App />); + + await waitFor(() => { + expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..b986198 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,62 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import React, { useEffect, useState } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import Login from './components/Login'; +import './App.css'; -function App() { - const [count, setCount] = useState(0) +// Protected Route wrapper +function RequireAuth({ children }: { children: React.ReactElement }) { + const [auth, setAuth] = useState<boolean | null>(null); + const location = useLocation(); + + useEffect(() => { + fetch('/api/auth') + .then((res) => { + if (res.ok) { + setAuth(true); + } else { + setAuth(false); + } + }) + .catch(() => setAuth(false)); + }, []); + + if (auth === null) { + return <div>Loading...</div>; + } + + if (!auth) { + return <Navigate to="/login" state={{ from: location }} replace />; + } + + return children; +} +function Dashboard() { + // Placeholder for now return ( - <> - <div> - <a href="https://vite.dev" target="_blank"> - <img src={viteLogo} className="logo" alt="Vite logo" /> - </a> - <a href="https://react.dev" target="_blank"> - <img src={reactLogo} className="logo react" alt="React logo" /> - </a> - </div> - <h1>Vite + React</h1> - <div className="card"> - <button onClick={() => setCount((count) => count + 1)}> - count is {count} - </button> - <p> - Edit <code>src/App.tsx</code> and save to test HMR - </p> - </div> - <p className="read-the-docs"> - Click on the Vite and React logos to learn more - </p> - </> + <div> + <h1>Dashboard</h1> + <p>Welcome to the new Neko/v2 frontend.</p> + </div> ) } -export default App +function App() { + return ( + <BrowserRouter basename="/v2"> + <Routes> + <Route path="/login" element={<Login />} /> + <Route + path="/*" + element={ + <RequireAuth> + <Dashboard /> + </RequireAuth> + } + /> + </Routes> + </BrowserRouter> + ); +} + +export default App; 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> + ); +} |
