aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/Login.css63
-rw-r--r--frontend/src/components/Login.test.tsx76
-rw-r--r--frontend/src/components/Login.tsx54
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>
+ );
+}