From 75679256cd57db36435010eb00b6e55c716b0a2a Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 16:11:18 +0200 Subject: [PATCH] feat(worker): auth gate, Dutch login screen, router and 3-tab shell --- apps/worker/package.json | 1 + apps/worker/src/App.test.tsx | 45 +++++++++ apps/worker/src/App.tsx | 36 +++++++- apps/worker/src/auth/AuthContext.tsx | 48 ++++++++++ apps/worker/src/components/TabBar.tsx | 24 +++++ apps/worker/src/screens/History.tsx | 7 ++ apps/worker/src/screens/Login.tsx | 74 +++++++++++++++ apps/worker/src/screens/Settings.tsx | 7 ++ apps/worker/src/screens/Stopwatch.tsx | 7 ++ apps/worker/src/styles.css | 128 ++++++++++++++++++++++++++ yarn.lock | 92 +++++++++++++++++- 11 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 apps/worker/src/App.test.tsx create mode 100644 apps/worker/src/auth/AuthContext.tsx create mode 100644 apps/worker/src/components/TabBar.tsx create mode 100644 apps/worker/src/screens/History.tsx create mode 100644 apps/worker/src/screens/Login.tsx create mode 100644 apps/worker/src/screens/Settings.tsx create mode 100644 apps/worker/src/screens/Stopwatch.tsx diff --git a/apps/worker/package.json b/apps/worker/package.json index c616daa..d060760 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -19,6 +19,7 @@ "react-router-dom": "^6.26.0" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.0", diff --git a/apps/worker/src/App.test.tsx b/apps/worker/src/App.test.tsx new file mode 100644 index 0000000..e89f6c2 --- /dev/null +++ b/apps/worker/src/App.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import { clearToken, setToken } from './lib/auth-storage'; + +// Stub the network layer so stub screens render without real requests. +vi.mock('./lib/api', async () => { + const actual = await vi.importActual('./lib/api'); + return { + ...actual, + apiFetch: vi.fn().mockResolvedValue([]), + }; +}); + +function renderApp() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('App', () => { + afterEach(() => { + clearToken(); + }); + + it('shows the login screen when there is no token', () => { + clearToken(); + renderApp(); + expect(screen.getByText('E-mailadres')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Inloggen' })).toBeInTheDocument(); + }); + + it('shows the tab bar when a token is present', () => { + setToken('tok'); + renderApp(); + const tabbar = within(screen.getByRole('navigation')); + expect(tabbar.getByText('Stopwatch')).toBeInTheDocument(); + expect(tabbar.getByText('Geschiedenis')).toBeInTheDocument(); + expect(tabbar.getByText('Instellingen')).toBeInTheDocument(); + }); +}); diff --git a/apps/worker/src/App.tsx b/apps/worker/src/App.tsx index c637d7c..5e00f58 100644 --- a/apps/worker/src/App.tsx +++ b/apps/worker/src/App.tsx @@ -1,3 +1,35 @@ -export default function App() { - return
SoleLog
; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { AuthProvider, useAuth } from './auth/AuthContext'; +import Login from './screens/Login'; +import TabBar from './components/TabBar'; +import Stopwatch from './screens/Stopwatch'; +import History from './screens/History'; +import Settings from './screens/Settings'; + +function AuthedShell() { + return ( + +
+ + } /> + } /> + } /> + +
+ +
+ ); +} + +function Gate() { + const { isAuthed } = useAuth(); + return isAuthed ? : ; +} + +export default function App() { + return ( + + + + ); } diff --git a/apps/worker/src/auth/AuthContext.tsx b/apps/worker/src/auth/AuthContext.tsx new file mode 100644 index 0000000..6baa16a --- /dev/null +++ b/apps/worker/src/auth/AuthContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'; +import { clearToken, getToken } from '../lib/auth-storage'; +import { signIn as apiSignIn, signUp as apiSignUp } from '../lib/api'; + +interface AuthContextValue { + isAuthed: boolean; + signIn: (email: string, password: string) => Promise; + signUp: (email: string, password: string) => Promise; + signOut: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthed, setIsAuthed] = useState(() => getToken() !== null); + + const signIn = useCallback(async (email: string, password: string) => { + await apiSignIn(email, password); + setIsAuthed(true); + }, []); + + const signUp = useCallback( + async (email: string, password: string) => { + // Register, then sign in to obtain the bearer token. + await apiSignUp(email, password); + await apiSignIn(email, password); + setIsAuthed(true); + }, + [], + ); + + const signOut = useCallback(() => { + clearToken(); + setIsAuthed(false); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} diff --git a/apps/worker/src/components/TabBar.tsx b/apps/worker/src/components/TabBar.tsx new file mode 100644 index 0000000..9228fc5 --- /dev/null +++ b/apps/worker/src/components/TabBar.tsx @@ -0,0 +1,24 @@ +import { NavLink } from 'react-router-dom'; + +const tabs = [ + { to: '/', label: 'Stopwatch' }, + { to: '/history', label: 'Geschiedenis' }, + { to: '/settings', label: 'Instellingen' }, +] as const; + +export default function TabBar() { + return ( + + ); +} diff --git a/apps/worker/src/screens/History.tsx b/apps/worker/src/screens/History.tsx new file mode 100644 index 0000000..bff6ed5 --- /dev/null +++ b/apps/worker/src/screens/History.tsx @@ -0,0 +1,7 @@ +export default function History() { + return ( +
+

Geschiedenis

+
+ ); +} diff --git a/apps/worker/src/screens/Login.tsx b/apps/worker/src/screens/Login.tsx new file mode 100644 index 0000000..3e815b2 --- /dev/null +++ b/apps/worker/src/screens/Login.tsx @@ -0,0 +1,74 @@ +import { useState, type FormEvent } from 'react'; +import { useAuth } from '../auth/AuthContext'; + +type Mode = 'signin' | 'signup'; + +export default function Login() { + const { signIn, signUp } = useAuth(); + const [mode, setMode] = useState('signin'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submitLabel = mode === 'signin' ? 'Inloggen' : 'Registreren'; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + if (mode === 'signin') { + await signIn(email, password); + } else { + await signUp(email, password); + } + } catch { + setError(mode === 'signin' ? 'Inloggen mislukt' : 'Registreren mislukt'); + } finally { + setBusy(false); + } + } + + return ( +
+

SoleLog

+
+ + + {error &&

{error}

} + +
+ +
+ ); +} diff --git a/apps/worker/src/screens/Settings.tsx b/apps/worker/src/screens/Settings.tsx new file mode 100644 index 0000000..8d3024d --- /dev/null +++ b/apps/worker/src/screens/Settings.tsx @@ -0,0 +1,7 @@ +export default function Settings() { + return ( +
+

Instellingen

+
+ ); +} diff --git a/apps/worker/src/screens/Stopwatch.tsx b/apps/worker/src/screens/Stopwatch.tsx new file mode 100644 index 0000000..8e1d718 --- /dev/null +++ b/apps/worker/src/screens/Stopwatch.tsx @@ -0,0 +1,7 @@ +export default function Stopwatch() { + return ( +
+

Stopwatch

+
+ ); +} diff --git a/apps/worker/src/styles.css b/apps/worker/src/styles.css index 3b34302..8a962cd 100644 --- a/apps/worker/src/styles.css +++ b/apps/worker/src/styles.css @@ -22,3 +22,131 @@ body { color: var(--text); background: #ffffff; } + +/* ---- App shell layout (mobile-first) ---- */ +.app-main { + min-height: 100vh; + padding: 16px 16px 80px; /* leave room for the fixed bottom tab bar */ +} + +.screen { + max-width: 640px; + margin: 0 auto; +} + +.screen-title { + font-size: 28px; + font-weight: 600; + margin: 8px 0 16px; +} + +/* ---- Bottom tab bar ---- */ +.tabbar { + position: fixed; + inset: auto 0 0 0; + display: flex; + background: #ffffff; + border-top: 1px solid var(--border); + padding-top: 4px; +} + +.tab { + flex: 1; + text-align: center; + padding: 12px 4px; + font-size: 12px; + font-weight: 500; + text-decoration: none; + color: var(--text-muted); +} + +.tab-active { + color: var(--primary); +} + +/* ---- Login screen (mobile-first) ---- */ +.login { + max-width: 420px; + margin: 0 auto; + padding: 48px 24px; + display: flex; + flex-direction: column; + gap: 24px; + min-height: 100vh; + justify-content: center; +} + +.login-title { + font-size: 36px; + font-weight: 600; + text-align: center; + color: var(--primary); + margin: 0; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field-label { + font-size: 14px; + font-weight: 500; + color: var(--text-muted); +} + +.field-input { + width: 100%; + padding: 14px 16px; + font-size: 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: #ffffff; + color: var(--text); +} + +.field-input:focus { + outline: none; + border-color: var(--primary); +} + +.btn-primary { + width: 100%; + padding: 16px; + font-size: 16px; + font-weight: 600; + color: #ffffff; + background: var(--primary); + border: none; + border-radius: 16px; + cursor: pointer; +} + +.btn-primary:disabled { + background: var(--border); + color: var(--text-muted); + cursor: not-allowed; +} + +.login-error { + color: var(--danger); + font-size: 14px; + margin: 0; + text-align: center; +} + +.login-toggle { + background: none; + border: none; + color: var(--primary); + font-size: 14px; + cursor: pointer; + padding: 8px; +} diff --git a/yarn.lock b/yarn.lock index c097f39..beac8ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,7 +25,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.29.7": +"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -1793,6 +1793,7 @@ __metadata: dependencies: "@solelog/shared": "workspace:*" "@tanstack/react-query": "npm:^5.0.0" + "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.4.0" "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.5.0" @@ -1834,6 +1835,22 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^10.4.1": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" + pretty-format: "npm:^27.0.2" + checksum: 10c0/19ce048012d395ad0468b0dbcc4d0911f6f9e39464d7a8464a587b29707eed5482000dad728f5acc4ed314d2f4d54f34982999a114d2404f36d048278db815b1 + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:^6.4.0": version: 6.9.1 resolution: "@testing-library/jest-dom@npm:6.9.1" @@ -1877,6 +1894,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + "@types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -2099,6 +2123,29 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 + languageName: node + linkType: hard + +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10c0/2bff0d4eba5852a9dd578ecf47eaef0e82cc52569b48469b0aac2db5145db0b17b7a58d9e01237706d1e14b7a1b0ac9b78e9c97027ad97679dd8f91b85da1469 + languageName: node + linkType: hard + "aria-query@npm:^5.0.0": version: 5.3.2 resolution: "aria-query@npm:5.3.2" @@ -2414,6 +2461,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 + languageName: node + linkType: hard + "detect-libc@npm:2.0.2": version: 2.0.2 resolution: "detect-libc@npm:2.0.2" @@ -2421,6 +2475,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + "dom-accessibility-api@npm:^0.6.3": version: 0.6.3 resolution: "dom-accessibility-api@npm:0.6.3" @@ -3401,6 +3462,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + languageName: node + linkType: hard + "magic-string@npm:^0.30.17": version: 0.30.21 resolution: "magic-string@npm:0.30.21" @@ -3710,7 +3780,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -3735,6 +3805,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + "proc-log@npm:^7.0.0": version: 7.0.0 resolution: "proc-log@npm:7.0.0" @@ -3768,6 +3849,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + "react-refresh@npm:^0.17.0": version: 0.17.0 resolution: "react-refresh@npm:0.17.0"