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