diff --git a/web/src/App.tsx b/web/src/App.tsx index 16123b4..0227651 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,46 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import type { ReactNode } from 'react' +import { AuthProvider, useAuth } from './auth' +import Shell from './shell' +import SetupPage from './pages/SetupPage' +import LoginPage from './pages/LoginPage' +import LibraryPage from './pages/LibraryPage' +import ScanPage from './pages/ScanPage' +import AddPage from './pages/AddPage' +import SettingsPage from './pages/SettingsPage' + +function Gate({ children }: { children: ReactNode }) { + const { status } = useAuth() + if (status === 'loading') { + return
+ } + if (status === 'setup') return + if (status === 'unauthenticated') return + return <>{children} +} + export default function App() { return ( -
-

record-shop

-
+ + + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + + } /> + + + ) } diff --git a/web/src/pages/AddPage.tsx b/web/src/pages/AddPage.tsx new file mode 100644 index 0000000..93c0d4d --- /dev/null +++ b/web/src/pages/AddPage.tsx @@ -0,0 +1,3 @@ +export default function AddPage() { + return

Search goes here.

+} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx new file mode 100644 index 0000000..c785435 --- /dev/null +++ b/web/src/pages/LibraryPage.tsx @@ -0,0 +1,3 @@ +export default function LibraryPage() { + return

Library goes here.

+} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..7a1fe18 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,71 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { api, ApiError } from '../api' +import { useAuth } from '../auth' + +export default function LoginPage() { + const { onLogin } = useAuth() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + const { user } = await api.login(username, password) + onLogin(user) + navigate('/library', { replace: true }) + } catch (err) { + setError(err instanceof ApiError && err.status === 401 ? 'Wrong username or password' : 'Something went wrong') + } finally { + setBusy(false) + } + } + + return ( +
+
+

Sign in

+
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/web/src/pages/ScanPage.tsx b/web/src/pages/ScanPage.tsx new file mode 100644 index 0000000..711e97d --- /dev/null +++ b/web/src/pages/ScanPage.tsx @@ -0,0 +1,3 @@ +export default function ScanPage() { + return

Scanner goes here.

+} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..04f323c --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,14 @@ +import { useAuth } from '../auth' + +export default function SettingsPage() { + const { user, onLogout } = useAuth() + return ( +
+

Settings

+

Account

+

+ {user?.username} {user?.isAdmin ? '(admin)' : ''} +

+
+ ) +} diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx new file mode 100644 index 0000000..1dcacbc --- /dev/null +++ b/web/src/pages/SetupPage.tsx @@ -0,0 +1,74 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { api, ApiError } from '../api' +import { useAuth } from '../auth' + +export default function SetupPage() { + const { onSetupComplete } = useAuth() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + const { user } = await api.setup(username, password) + onSetupComplete(user) + navigate('/library', { replace: true }) + } catch (err) { + setError(err instanceof ApiError ? err.detail ?? err.code : 'Something went wrong') + } finally { + setBusy(false) + } + } + + return ( +
+
+

Welcome to record-shop

+

Create the admin account to get started.

+
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="username" + required + minLength={3} + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2" + autoComplete="new-password" + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/web/src/shell.tsx b/web/src/shell.tsx new file mode 100644 index 0000000..693e8d9 --- /dev/null +++ b/web/src/shell.tsx @@ -0,0 +1,79 @@ +import { NavLink, Outlet } from 'react-router-dom' +import type { ReactNode } from 'react' + +const TABS: { to: string; label: string; icon: ReactNode }[] = [ + { + to: '/library', + label: 'Library', + icon: ( + + + + + ), + }, + { + to: '/scan', + label: 'Scan', + icon: ( + + + + ), + }, + { + to: '/add', + label: 'Add', + icon: ( + + + + + ), + }, + { + to: '/settings', + label: 'Settings', + icon: ( + + + + + ), + }, +] + +export default function Shell({ title }: { title?: string }) { + return ( +
+
+

{title ?? 'record-shop'}

+
+
+ +
+ +
+ ) +} diff --git a/web/test/router.test.tsx b/web/test/router.test.tsx new file mode 100644 index 0000000..af39344 --- /dev/null +++ b/web/test/router.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import App from '../src/App' + +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + window.history.replaceState(null, '', '/') +}) + +function json(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function loggedInServer() { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + if (url === '/api/me') return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + return Promise.resolve(json(404, { error: 'not_found' })) + }) +} + +describe('routing', () => { + it('shows the setup form when the server needs setup', async () => { + fetchMock.mockImplementation((url: string) => + url === '/api/setup' ? Promise.resolve(json(200, { needed: true })) : Promise.resolve(json(404, {})) + ) + render() + await waitFor(() => expect(screen.getByRole('heading', { name: /welcome/i })).toBeTruthy()) + }) + + it('shows the login form when unauthenticated', async () => { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + return Promise.resolve(json(401, { error: 'unauthorized' })) + }) + render() + await waitFor(() => expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy()) + }) + + it('lands on Library with the tab bar when authenticated', async () => { + loggedInServer() + render() + await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy()) + for (const tab of ['Library', 'Scan', 'Add', 'Settings']) { + expect(screen.getByRole('tab', { name: new RegExp(tab, 'i') })).toBeTruthy() + } + }) + + it('login form authenticates and enters the app', async () => { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + if (url === '/api/me') return Promise.resolve(json(401, { error: 'unauthorized' })) + if (url === '/api/login') { + return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + } + return Promise.resolve(json(404, {})) + }) + render() + await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy()) + await userEvent.type(screen.getByLabelText(/username/i), 'sam') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy()) + }) + + it('setup form creates the admin account and enters the app', async () => { + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/setup' && (!init || !init.method || init.method === 'GET')) { + return Promise.resolve(json(200, { needed: true })) + } + if (url === '/api/setup' && init?.method === 'POST') { + return Promise.resolve(json(200, { user: { id: 1, username: 'boss', isAdmin: true } })) + } + return Promise.resolve(json(404, {})) + }) + render() + await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy()) + await userEvent.type(screen.getByLabelText(/username/i), 'boss') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /create/i })) + await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy()) + }) + + it('tab navigation switches pages', async () => { + loggedInServer() + render() + await waitFor(() => expect(screen.getByRole('tab', { name: /settings/i })).toBeTruthy()) + await userEvent.click(screen.getByRole('tab', { name: /settings/i })) + await waitFor(() => expect(screen.getByRole('heading', { name: /settings/i })).toBeTruthy()) + expect(screen.getByText(/account/i)).toBeTruthy() + }) +}) diff --git a/web/test/smoke.test.tsx b/web/test/smoke.test.tsx index 1ca1436..caf8445 100644 --- a/web/test/smoke.test.tsx +++ b/web/test/smoke.test.tsx @@ -1,10 +1,31 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' import App from '../src/App' +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + window.history.replaceState(null, '', '/') +}) + +function json(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + describe('App', () => { - it('renders the app title', () => { + it('renders the app title', async () => { + fetchMock.mockImplementation((url: string) => { + if (url === '/api/setup') return Promise.resolve(json(200, { needed: false })) + if (url === '/api/me') + return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + return Promise.resolve(json(404, {})) + }) render() - expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy() + await waitFor(() => expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy()) }) })