feat(worker): scaffold Vite+React PWA with token storage and typed API client

This commit is contained in:
Bas van Rossem
2026-06-17 16:03:41 +02:00
parent 35f9aa5574
commit 3511fd8a89
22 changed files with 1545 additions and 4 deletions

3
apps/worker/src/App.tsx Normal file
View File

@@ -0,0 +1,3 @@
export default function App() {
return <div>SoleLog</div>;
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiFetch, signIn, ApiError } from './api';
import { getToken, setToken } from './auth-storage';
describe('api client', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('attaches the bearer token to requests', async () => {
const mock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal('fetch', mock);
setToken('tok');
await apiFetch('/api/me');
expect(mock).toHaveBeenCalledTimes(1);
const [url, init] = mock.mock.calls[0];
expect(url).toBe('http://localhost:3000/api/me');
const headers = new Headers(init.headers);
expect(headers.get('Authorization')).toBe('Bearer tok');
});
it('throws ApiError on a non-2xx response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
);
await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 });
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
});
it('signIn stores the token from the set-auth-token header', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(null, { status: 200, headers: { 'set-auth-token': 'xyz' } }),
),
);
await signIn('a@b.c', 'pw');
expect(getToken()).toBe('xyz');
});
it('signIn throws when the header is missing', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })));
await expect(signIn('a@b.c', 'pw')).rejects.toBeInstanceOf(ApiError);
});
});

View File

@@ -0,0 +1,47 @@
import { getToken, setToken } from './auth-storage';
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(init.headers);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
export async function signIn(email: string, password: string): Promise<void> {
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt');
const token = res.headers.get('set-auth-token');
if (!token) throw new ApiError(500, 'Geen token ontvangen');
setToken(token);
}
// Sign up affordance for testing: POST /api/auth/sign-up/email.
export async function signUp(email: string, password: string): Promise<void> {
const res = await fetch(`${API_URL}/api/auth/sign-up/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name: email.split('@')[0] || 'Worker' }),
});
if (!res.ok) throw new ApiError(res.status, 'Registreren mislukt');
}

View File

@@ -0,0 +1,15 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getToken, setToken, clearToken } from './auth-storage';
describe('auth-storage', () => {
beforeEach(() => {
localStorage.clear();
});
it('stores, reads, and clears the token', () => {
setToken('abc');
expect(getToken()).toBe('abc');
clearToken();
expect(getToken()).toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
const TOKEN_KEY = 'solelog.token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}

15
apps/worker/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles.css';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,24 @@
:root {
--primary: #2563eb;
--primary-light: #eff6ff;
--text: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--danger: #dc2626;
--amber: #d97706;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
color: var(--text);
background: #ffffff;
}

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

9
apps/worker/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}