From 1631c1698dc4a36b7fc653da2c6c415320d750a0 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 18:23:42 +0200 Subject: [PATCH] feat(worker): add logout + replace admin-only settings with Account screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Instellingen tab was activity management, which Phase 2 made admin-only — workers saw add/edit/delete controls that all 403. Replace it with an Account tab showing the signed-in name/email (via /api/me) and an Uitloggen button (wires the existing AuthContext signOut). Activity management belongs to the Phase 3 admin app, so the worker client drops the Settings screen and its now-unused activity-mutation hooks (useActivities read stays). Products affected: SoleLog worker client (apps/worker). --- apps/worker/src/App.test.tsx | 4 +- apps/worker/src/App.tsx | 4 +- apps/worker/src/api/activities.ts | 47 +---- apps/worker/src/api/me.ts | 11 + apps/worker/src/components/TabBar.tsx | 2 +- apps/worker/src/screens/Account.test.tsx | 54 +++++ apps/worker/src/screens/Account.tsx | 32 +++ apps/worker/src/screens/Settings.test.tsx | 88 -------- apps/worker/src/screens/Settings.tsx | 240 ---------------------- apps/worker/src/styles.css | 27 +++ 10 files changed, 133 insertions(+), 376 deletions(-) create mode 100644 apps/worker/src/api/me.ts create mode 100644 apps/worker/src/screens/Account.test.tsx create mode 100644 apps/worker/src/screens/Account.tsx delete mode 100644 apps/worker/src/screens/Settings.test.tsx delete mode 100644 apps/worker/src/screens/Settings.tsx diff --git a/apps/worker/src/App.test.tsx b/apps/worker/src/App.test.tsx index e89f6c2..f0b71a9 100644 --- a/apps/worker/src/App.test.tsx +++ b/apps/worker/src/App.test.tsx @@ -18,7 +18,7 @@ function renderApp() { return render( - , + ); } @@ -40,6 +40,6 @@ describe('App', () => { const tabbar = within(screen.getByRole('navigation')); expect(tabbar.getByText('Stopwatch')).toBeInTheDocument(); expect(tabbar.getByText('Geschiedenis')).toBeInTheDocument(); - expect(tabbar.getByText('Instellingen')).toBeInTheDocument(); + expect(tabbar.getByText('Account')).toBeInTheDocument(); }); }); diff --git a/apps/worker/src/App.tsx b/apps/worker/src/App.tsx index 5e00f58..96f6578 100644 --- a/apps/worker/src/App.tsx +++ b/apps/worker/src/App.tsx @@ -4,7 +4,7 @@ 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'; +import Account from './screens/Account'; function AuthedShell() { return ( @@ -13,7 +13,7 @@ function AuthedShell() { } /> } /> - } /> + } /> diff --git a/apps/worker/src/api/activities.ts b/apps/worker/src/api/activities.ts index bf5d00a..d048971 100644 --- a/apps/worker/src/api/activities.ts +++ b/apps/worker/src/api/activities.ts @@ -1,51 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { Activity, CreateActivityInput, UpdateActivityInput } from '@solelog/shared'; +import { useQuery } from '@tanstack/react-query'; +import type { Activity } from '@solelog/shared'; import { apiFetch } from '../lib/api'; +// Activities are read-only in the worker client; managing them is admin-only +// and lives in the Phase 3 admin app. export function useActivities() { return useQuery({ queryKey: ['activities'], queryFn: () => apiFetch('/api/activities'), }); } - -export function useCreateActivity() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input: CreateActivityInput) => - apiFetch('/api/activities', { - method: 'POST', - body: JSON.stringify(input), - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activities'] }); - }, - }); -} - -export function useUpdateActivity() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, input }: { id: number; input: UpdateActivityInput }) => - apiFetch(`/api/activities/${id}`, { - method: 'PUT', - body: JSON.stringify(input), - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activities'] }); - }, - }); -} - -export function useDeleteActivity() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (id: number) => - apiFetch<{ success: true }>(`/api/activities/${id}`, { method: 'DELETE' }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activities'] }); - // Deleting an activity cascades to its work sessions on the server. - queryClient.invalidateQueries({ queryKey: ['sessions'] }); - }, - }); -} diff --git a/apps/worker/src/api/me.ts b/apps/worker/src/api/me.ts new file mode 100644 index 0000000..9b42d65 --- /dev/null +++ b/apps/worker/src/api/me.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import type { MeResponse } from '@solelog/shared'; +import { apiFetch } from '../lib/api'; + +// The signed-in user (id, email, name). Used by the Account screen. +export function useMe() { + return useQuery({ + queryKey: ['me'], + queryFn: () => apiFetch('/api/me'), + }); +} diff --git a/apps/worker/src/components/TabBar.tsx b/apps/worker/src/components/TabBar.tsx index 9228fc5..a7ae938 100644 --- a/apps/worker/src/components/TabBar.tsx +++ b/apps/worker/src/components/TabBar.tsx @@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'; const tabs = [ { to: '/', label: 'Stopwatch' }, { to: '/history', label: 'Geschiedenis' }, - { to: '/settings', label: 'Instellingen' }, + { to: '/account', label: 'Account' }, ] as const; export default function TabBar() { diff --git a/apps/worker/src/screens/Account.test.tsx b/apps/worker/src/screens/Account.test.tsx new file mode 100644 index 0000000..ce57f3b --- /dev/null +++ b/apps/worker/src/screens/Account.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import Account from './Account'; +import { AuthProvider } from '../auth/AuthContext'; +import { apiFetch } from '../lib/api'; +import { clearToken, getToken, setToken } from '../lib/auth-storage'; + +// Mock the network layer so no real requests are made. +vi.mock('../lib/api', () => ({ + apiFetch: vi.fn(), +})); + +const mockedApiFetch = vi.mocked(apiFetch); + +function renderAccount() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe('Account', () => { + beforeEach(() => { + mockedApiFetch.mockReset(); + mockedApiFetch.mockResolvedValue({ + user: { id: 'u1', email: 'worker@solelog.local', name: 'Test Werker' }, + }); + }); + + afterEach(() => { + clearToken(); + vi.clearAllMocks(); + }); + + it('shows the signed-in user name and email', async () => { + renderAccount(); + expect(await screen.findByText('Test Werker')).toBeInTheDocument(); + expect(screen.getByText('worker@solelog.local')).toBeInTheDocument(); + }); + + it('logs out: clicking Uitloggen clears the token', async () => { + setToken('tok'); + const user = userEvent.setup(); + renderAccount(); + await user.click(screen.getByRole('button', { name: 'Uitloggen' })); + expect(getToken()).toBeNull(); + }); +}); diff --git a/apps/worker/src/screens/Account.tsx b/apps/worker/src/screens/Account.tsx new file mode 100644 index 0000000..e3fb66e --- /dev/null +++ b/apps/worker/src/screens/Account.tsx @@ -0,0 +1,32 @@ +import { useAuth } from '../auth/AuthContext'; +import { useMe } from '../api/me'; + +export default function Account() { + const { signOut } = useAuth(); + const meQuery = useMe(); + const user = meQuery.data?.user; + + return ( +
+

Account

+

Ingelogd als

+ +
+ {meQuery.isLoading ? ( +

Laden...

+ ) : user ? ( + <> + {user.name} + {user.email} + + ) : ( +

Kon accountgegevens niet laden.

+ )} +
+ + +
+ ); +} diff --git a/apps/worker/src/screens/Settings.test.tsx b/apps/worker/src/screens/Settings.test.tsx deleted file mode 100644 index e0ac0e6..0000000 --- a/apps/worker/src/screens/Settings.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { Activity } from '@solelog/shared'; -import Settings from './Settings'; -import { apiFetch } from '../lib/api'; - -// Mock the network layer so no real requests are made. -vi.mock('../lib/api', () => ({ - apiFetch: vi.fn(), -})); - -const mockedApiFetch = vi.mocked(apiFetch); - -function renderSettings() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - , - ); -} - -describe('Settings', () => { - beforeEach(() => { - mockedApiFetch.mockReset(); - mockedApiFetch.mockResolvedValue([]); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('renders the heading and add form in Dutch', () => { - renderSettings(); - expect(screen.getByText('Instellingen')).toBeInTheDocument(); - expect(screen.getByText('Beheer handelingen per zooltype')).toBeInTheDocument(); - expect(screen.getByText('Nieuwe handeling toevoegen')).toBeInTheDocument(); - expect( - screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), - ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Stap toevoegen' })).toBeInTheDocument(); - }); - - it('lists activities returned by the API', async () => { - const activities: Activity[] = [ - { - id: 1, - name: 'Frezen', - insole_types: ['Kurk', 'Berk'], - created_at: '2026-01-01T00:00:00.000Z', - }, - ]; - mockedApiFetch.mockResolvedValue(activities); - - renderSettings(); - - expect(await screen.findByText('Frezen')).toBeInTheDocument(); - expect(screen.getByText('Huidige stappen (1)')).toBeInTheDocument(); - // The type badges for the activity render. - const card = screen.getByText('Frezen').closest('.activity-card') as HTMLElement; - expect(card).not.toBeNull(); - expect(card.textContent).toContain('Kurk'); - expect(card.textContent).toContain('Berk'); - }); - - it('disables the add button until a name is entered', async () => { - const user = userEvent.setup(); - renderSettings(); - - const addButton = screen.getByRole('button', { name: 'Stap toevoegen' }); - expect(addButton).toBeDisabled(); - - const input = screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'); - await user.type(input, 'Leerrand'); - - await waitFor(() => expect(addButton).toBeEnabled()); - }); - - it('shows the empty state when there are no activities', async () => { - mockedApiFetch.mockResolvedValue([]); - renderSettings(); - expect( - await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'), - ).toBeInTheDocument(); - }); -}); diff --git a/apps/worker/src/screens/Settings.tsx b/apps/worker/src/screens/Settings.tsx deleted file mode 100644 index d4e91e1..0000000 --- a/apps/worker/src/screens/Settings.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { useState } from 'react'; -import type { InsoleType } from '@solelog/shared'; -import { - useActivities, - useCreateActivity, - useDeleteActivity, - useUpdateActivity, -} from '../api/activities'; - -const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D']; - -const TYPE_COLORS: Record = { - Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' }, - Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' }, - '3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' }, -}; - -function typePillStyle(type: InsoleType, selected: boolean): React.CSSProperties { - const c = TYPE_COLORS[type]; - return { - borderRadius: 999, - padding: '6px 14px', - fontSize: 14, - fontWeight: 600, - cursor: 'pointer', - border: `1px solid ${selected ? c.border : '#E5E7EB'}`, - background: selected ? c.bg : '#ffffff', - color: selected ? c.text : '#9CA3AF', - }; -} - -function typeBadgeStyle(type: InsoleType): React.CSSProperties { - const c = TYPE_COLORS[type]; - return { - borderRadius: 999, - padding: '2px 10px', - fontSize: 12, - fontWeight: 600, - border: `1px solid ${c.border}`, - background: c.bg, - color: c.text, - }; -} - -function TypeToggles({ - selected, - onToggle, -}: { - selected: InsoleType[]; - onToggle: (type: InsoleType) => void; -}) { - return ( -
- {ALL_TYPES.map((type) => ( - - ))} -
- ); -} - -export default function Settings() { - const activitiesQuery = useActivities(); - const createActivity = useCreateActivity(); - const updateActivity = useUpdateActivity(); - const deleteActivity = useDeleteActivity(); - - // Add form state. - const [newName, setNewName] = useState(''); - const [newTypes, setNewTypes] = useState([...ALL_TYPES]); - - // Edit state. - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [editTypes, setEditTypes] = useState([]); - - const activities = activitiesQuery.data ?? []; - - function toggle(list: InsoleType[], type: InsoleType): InsoleType[] { - return list.includes(type) ? list.filter((t) => t !== type) : [...list, type]; - } - - const addEnabled = newName.trim().length > 0 && newTypes.length > 0; - - function handleAdd() { - if (!addEnabled) return; - createActivity.mutate( - { name: newName.trim(), insole_types: newTypes }, - { - onSuccess: () => { - setNewName(''); - setNewTypes([...ALL_TYPES]); - }, - }, - ); - } - - function startEdit(id: number, name: string, types: InsoleType[]) { - setEditingId(id); - setEditName(name); - setEditTypes(types.length > 0 ? [...types] : [...ALL_TYPES]); - } - - function cancelEdit() { - setEditingId(null); - } - - function handleSave(id: number) { - if (editName.trim().length === 0 || editTypes.length === 0) return; - updateActivity.mutate( - { id, input: { name: editName.trim(), insole_types: editTypes } }, - { onSuccess: () => setEditingId(null) }, - ); - } - - function handleDelete(id: number, name: string) { - const ok = window.confirm( - `"${name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`, - ); - if (ok) deleteActivity.mutate(id); - } - - return ( -
-

Instellingen

-

Beheer handelingen per zooltype

- - {/* Add new handling card */} -
-

Nieuwe handeling toevoegen

- setNewName(e.target.value)} - /> -

Van toepassing op

- setNewTypes((prev) => toggle(prev, type))} - /> - -
- - {/* Handling list */} -

Huidige stappen ({activities.length})

- {activitiesQuery.isLoading ? ( -

Laden...

- ) : activities.length === 0 ? ( -

Nog geen stappen. Voeg er een toe hierboven.

- ) : ( -
    - {activities.map((activity) => ( -
  • - {editingId === activity.id ? ( - <> - setEditName(e.target.value)} - /> -

    Van toepassing op

    - setEditTypes((prev) => toggle(prev, type))} - /> -
    - - -
    - - ) : ( - <> -
    - {activity.name} -
    - - -
    -
    -
    - {activity.insole_types.map((type) => ( - - {type} - - ))} -
    - - )} -
  • - ))} -
- )} -
- ); -} diff --git a/apps/worker/src/styles.css b/apps/worker/src/styles.css index ecde670..f9a0cce 100644 --- a/apps/worker/src/styles.css +++ b/apps/worker/src/styles.css @@ -274,6 +274,33 @@ body { cursor: pointer; } +/* ---- Account ---- */ +.account-card { + gap: 4px; +} + +.account-name { + font-size: 18px; + font-weight: 600; +} + +.account-email { + color: var(--text-muted); + font-size: 15px; +} + +.btn-logout { + width: 100%; + padding: 16px; + font-size: 16px; + font-weight: 600; + color: var(--danger); + background: #ffffff; + border: 1px solid var(--danger); + border-radius: 16px; + cursor: pointer; +} + /* ---- History (Geschiedenis) ---- */ .history-header { display: flex;