feat(worker): auth gate, Dutch login screen, router and 3-tab shell
This commit is contained in:
45
apps/worker/src/App.test.tsx
Normal file
45
apps/worker/src/App.test.tsx
Normal file
@@ -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<typeof import('./lib/api')>('./lib/api');
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
function renderApp() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,35 @@
|
||||
export default function App() {
|
||||
return <div>SoleLog</div>;
|
||||
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 (
|
||||
<BrowserRouter>
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Stopwatch />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<TabBar />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function Gate() {
|
||||
const { isAuthed } = useAuth();
|
||||
return isAuthed ? <AuthedShell /> : <Login />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Gate />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/worker/src/auth/AuthContext.tsx
Normal file
48
apps/worker/src/auth/AuthContext.tsx
Normal file
@@ -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<void>;
|
||||
signUp: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthed, setIsAuthed] = useState<boolean>(() => 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 (
|
||||
<AuthContext.Provider value={{ isAuthed, signIn, signUp, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
24
apps/worker/src/components/TabBar.tsx
Normal file
24
apps/worker/src/components/TabBar.tsx
Normal file
@@ -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 (
|
||||
<nav className="tabbar">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.to === '/'}
|
||||
className={({ isActive }) => (isActive ? 'tab tab-active' : 'tab')}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
7
apps/worker/src/screens/History.tsx
Normal file
7
apps/worker/src/screens/History.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function History() {
|
||||
return (
|
||||
<div className="screen">
|
||||
<h1 className="screen-title">Geschiedenis</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/worker/src/screens/Login.tsx
Normal file
74
apps/worker/src/screens/Login.tsx
Normal file
@@ -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<Mode>('signin');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="login">
|
||||
<h1 className="login-title">SoleLog</h1>
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label className="field">
|
||||
<span className="field-label">E-mailadres</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Wachtwoord</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
autoComplete={mode === 'signin' ? 'current-password' : 'new-password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<button className="btn-primary" type="submit" disabled={busy}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
className="login-toggle"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'signin' ? 'signup' : 'signin'));
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{mode === 'signin' ? 'Nog geen account? Registreren' : 'Al een account? Inloggen'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/worker/src/screens/Settings.tsx
Normal file
7
apps/worker/src/screens/Settings.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Settings() {
|
||||
return (
|
||||
<div className="screen">
|
||||
<h1 className="screen-title">Instellingen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/worker/src/screens/Stopwatch.tsx
Normal file
7
apps/worker/src/screens/Stopwatch.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Stopwatch() {
|
||||
return (
|
||||
<div className="screen">
|
||||
<h1 className="screen-title">Stopwatch</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user