Files
solelog/docs/superpowers/plans/2026-06-17-phase-3a-admin-panel.md
2026-06-17 18:46:11 +02:00

16 KiB

Phase 3a — Admin Panel (MVP) Implementation Plan

For agentic workers: Implement task-by-task with TDD. Steps use checkbox (- [ ]) syntax. Spec: docs/superpowers/specs/2026-06-17-phase-3a-admin-panel-design.md.

Goal: A new apps/admin desktop SPA where an admin logs in, watches who is working live (auto-refreshing, read-only), and manages handelingen — on existing endpoints plus role added to /api/me.

Architecture: Vite + React 18 + TS client (dev port 5174) mirroring apps/worker's toolchain. Bearer-token auth reused from worker; admin gate reads role from /api/me. Left-sidebar shell. Live view polls /api/admin/sessions/active; activities CRUD via /api/activities.

Tech Stack: Vite 7, React 18.3, react-router-dom 6, @tanstack/react-query 5, vitest 3, @testing-library/react 16, TypeScript 5.7, @solelog/shared (zod contracts).

Global Constraints

  • Reuse, don't reinvent: apps/worker is the canonical template — copy lib/api.ts, lib/auth-storage.ts, tsconfig/vite/vitest configs, test/setup.ts, main.tsx structure verbatim, adjusting only names/title/port.
  • Dutch UI strings throughout (worker app is the reference for tone/terms).
  • Lint/format: oxlint + oxfmt — 2-space, single quotes, semicolons, width 100, es5 trailing commas (no trailing comma in function params or last call args). Run scoped to changed files only; do not reformat unrelated files.
  • TDD: write the failing test, see it fail, implement minimally, see it pass, commit.
  • Commit per task with a conventional-commit message.
  • No backend change beyond role on /api/me (Task 1). Live view is read-only.
  • Windows libsql lock trap: any agent that starts the API server must kill the server tree afterward (a lingering tsx/node holding data/app.db or port 3000 breaks the next run). Prefer not starting the server unless verifying live.
  • Admin dev port 5174 (worker uses 5173).

File Structure

packages/shared/src/index.ts            MODIFY  add role to PublicUser
apps/api/src/routes/me.ts               MODIFY  return role
apps/api/test/me.test.ts                MODIFY  assert role present (create if absent)

apps/admin/                             NEW workspace (mirror apps/worker)
  package.json, index.html, .gitignore, README.md
  tsconfig.json, tsconfig.app.json, tsconfig.node.json
  vite.config.ts (port 5174), vitest.config.ts
  src/
    main.tsx, vite-env.d.ts, styles.css
    test/setup.ts
    lib/api.ts            (copied from worker)
    lib/auth-storage.ts   (copied from worker)
    lib/elapsed.ts        (formatTime ported from worker stopwatch)
    auth/AuthContext.tsx  (signIn + admin-role gate)
    api/me.ts             (useMe / fetchMe)
    api/admin-sessions.ts (useActiveSessions, refetchInterval 5000)
    api/activities.ts     (useActivities + create/update/delete)
    components/Sidebar.tsx
    screens/Login.tsx, Live.tsx, Activities.tsx
    App.tsx

Task 1: Backend — role on /api/me

Files:

  • Modify: packages/shared/src/index.ts (PublicUser)
  • Modify: apps/api/src/routes/me.ts
  • Test: apps/api/test/me.test.ts (extend; or apps/api/src/routes/me.test.ts — match where existing route tests live)

Interfaces:

  • Produces: PublicUser now has role: Role; MeResponse.user.role is 'worker' | 'admin'.

  • Step 1: Write/extend the failing test. Using the test helpers (apps/api/test/helpers.ts: createTestUser, authToken/bearer), assert that GET /api/me with a worker token returns user.role === 'worker', and with an admin token returns user.role === 'admin'. (Create an admin via auth.api.createUser with role: 'admin' — see seed.ts for the cast pattern.)

  • Step 2: Run it, watch it fail (role undefined). yarn workspace @solelog/api test (filter to the me test).

  • Step 3: Add role to PublicUser in shared:

export const PublicUser = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
  role: Role,
});
  • Step 4: Return role from the route. In apps/api/src/routes/me.ts, add to the user body: role: ((session.user as { role?: string | null }).role ?? 'worker') as Role (import Role/MeResponse type from @solelog/shared). Keep the MeResponse typing.
  • Step 5: Run tests — pass. Also run yarn workspace @solelog/api typecheck.
  • Step 6: Confirm worker app unaffectedyarn workspace @solelog/worker test still green (it ignores the extra field).
  • Step 7: Commitfeat(api): include role in /api/me response.

Task 2: Scaffold apps/admin workspace

Files: all new under apps/admin/ (see File Structure). Copy from apps/worker.

Interfaces:

  • Produces: an installable @solelog/admin workspace that builds and runs an empty app shell; apiFetch, signIn, getToken/setToken/clearToken available.

  • Step 1: Copy config + boilerplate from worker, adjusting only identifiers:

    • package.json"name": "@solelog/admin", same scripts/deps/devDeps as worker.
    • index.html<title>SoleLog Admin</title>.
    • tsconfig.json, tsconfig.app.json, tsconfig.node.json → copy verbatim.
    • vite.config.tsserver: { host: true, port: 5174 }.
    • vitest.config.ts, src/test/setup.ts, src/vite-env.d.ts → copy verbatim.
    • .gitignore → copy from worker.
    • src/lib/api.ts, src/lib/auth-storage.ts → copy verbatim (token key solelog.token is shared intentionally — same backend, same browser origin is fine; admin runs on a different port so localStorage is separate anyway).
  • Step 2: Minimal src/main.tsx (copy worker's; renders <App/> inside QueryClientProvider) and a placeholder src/App.tsx returning <div>SoleLog Admin</div> and an empty src/styles.css.

  • Step 3: Smoke test src/App.test.tsx: renders App, expects "SoleLog Admin" text (wrap in QueryClientProvider).

  • Step 4: Install + verify — from repo root yarn install, then yarn workspace @solelog/admin test (smoke passes), yarn workspace @solelog/admin typecheck, yarn workspace @solelog/admin build.

  • Step 5: Commitfeat(admin): scaffold Vite+React admin workspace.


Task 3: Auth context + admin gate + Login screen

Files:

  • Create: apps/admin/src/api/me.ts, apps/admin/src/auth/AuthContext.tsx, apps/admin/src/screens/Login.tsx
  • Modify: apps/admin/src/App.tsx
  • Test: apps/admin/src/auth/AuthContext.test.tsx (or Login.test.tsx)

Interfaces:

  • Consumes: signIn from lib/api, getToken/clearToken from lib/auth-storage, MeResponse from @solelog/shared.

  • Produces: useAuth(): { isAuthed, signIn, signOut } where signIn rejects non-admins; fetchMe(): Promise<MeResponse>.

  • Step 1: Write failing tests (mock lib/api):

    • signing in as an admin (/api/merole: 'admin') sets isAuthed true;
    • signing in as a worker (role: 'worker') throws, clears the token, isAuthed false.
  • Step 2: Run — fail (AuthContext not implemented).

  • Step 3: Implement api/me.ts:

import type { MeResponse } from '@solelog/shared';
import { apiFetch } from '../lib/api';

export function fetchMe(): Promise<MeResponse> {
  return apiFetch<MeResponse>('/api/me');
}
  • Step 4: Implement auth/AuthContext.tsx — mirror worker's, but signIn does: call apiSignIn(email,password); then const me = await fetchMe(); if me.user.role !== 'admin'clearToken() and throw new Error('not-admin'); else setIsAuthed(true). signOut clears token + sets false. Initial isAuthed = getToken() !== null (a stale worker token is harmless — the admin endpoints 403 and the next /api/me-backed screen can sign out; keep 3a simple).
  • Step 5: Implement screens/Login.tsx — copy worker's Login; change the catch to set 'Geen toegang — alleen beheerders.' when the error is the not-admin error, else 'Inloggen mislukt'. (Distinguish by error message/instanceof.)
  • Step 6: Wire App.tsxAuthProvider + Gate (authed → shell placeholder, else <Login/>), following worker's App.tsx.
  • Step 7: Run tests — pass. typecheck.
  • Step 8: Commitfeat(admin): bearer auth with admin-only gate + login screen.

Task 4: Sidebar shell + routing

Files:

  • Create: apps/admin/src/components/Sidebar.tsx
  • Modify: apps/admin/src/App.tsx, apps/admin/src/styles.css
  • Test: apps/admin/src/App.test.tsx (replace the Task-2 smoke test)

Interfaces:

  • Consumes: useAuth (signOut), useMe/fetchMe for the signed-in email.

  • Produces: an authed shell with <nav> containing Live and Handelingen links, a header with the signed-in email + logout button, and a content <Routes> outlet.

  • Step 1: Write failing test — with a token set and apiFetch mocked (/api/me → admin), the authed app shows nav items "Live" and "Handelingen"; clicking logout clears the token. (Mock react-router via MemoryRouter or render through App.)

  • Step 2: Run — fail.

  • Step 3: Implement Sidebar.tsx<aside> with brand "SoleLog Admin", NavLinks to / ("Live") and /handelingen ("Handelingen") using an tab-active-style active class, a muted disabled group (Rapporten / Gebruikers / Handmatig — "binnenkort"), and a header strip with the signed-in email + a logout button (aria-label "Uitloggen").

  • Step 4: Update App.tsx — authed shell = <BrowserRouter> with Sidebar + <main> <Routes>: /Live (placeholder for now is fine, real in Task 5), /handelingenActivities (placeholder, real in Task 6). Use placeholders that the next tasks replace, OR sequence so Task 5/6 add the routes — either way keep tests green.

  • Step 5: Add sidebar/header CSS to styles.css (port worker tokens: :root vars, base body; new .admin-shell grid 220px 1fr, .sidebar, .nav-link, .nav-link-active, .nav-disabled, .topbar, .btn-logout).

  • Step 6: Run tests — pass. typecheck + build.

  • Step 7: Commitfeat(admin): sidebar shell + routing.


Task 5: Live active-work view

Files:

  • Create: apps/admin/src/api/admin-sessions.ts, apps/admin/src/lib/elapsed.ts, apps/admin/src/screens/Live.tsx
  • Modify: apps/admin/src/App.tsx (route), apps/admin/src/styles.css
  • Test: apps/admin/src/screens/Live.test.tsx, apps/admin/src/lib/elapsed.test.ts

Interfaces:

  • Consumes: apiFetch, WorkSession from @solelog/shared.

  • Produces: useActiveSessions() (react-query, refetchInterval: 5000, queryKey ['admin','sessions','active']); formatTime(seconds) HH:MM:SS.

  • Step 1: Write failing tests:

    • elapsed.test.ts: formatTime(0)==='00:00:00', formatTime(3661)==='01:01:01'.
    • Live.test.tsx (mock apiFetch): given two active sessions (with user_name, activity_name, insole_type, pair_count, start_time), renders a card per session showing the worker name + activity + type; header "Actief nu (2)". With [], shows "Niemand is nu aan het werk.".
  • Step 2: Run — fail.

  • Step 3: Implement lib/elapsed.ts — port formatTime from worker lib/stopwatch.ts (verbatim).

  • Step 4: Implement api/admin-sessions.ts:

import { useQuery } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared';
import { apiFetch } from '../lib/api';

export function useActiveSessions() {
  return useQuery({
    queryKey: ['admin', 'sessions', 'active'],
    queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions/active'),
    refetchInterval: 5000,
  });
}
  • Step 5: Implement screens/Live.tsxuseActiveSessions(); loading "Laden…", error "Kon gegevens niet laden.", empty "Niemand is nu aan het werk.". Header "Actief nu (N)". Per session, a .live-card with worker name (user_name), activity (activity_name), an insole-type pill, pair count, and a ticking elapsed timer: a now state updated by setInterval(…, 1000) in a useEffect; elapsed = formatTime((now - Date.parse(start_time)) / 1000).
  • Step 6: Add route /Live in App.tsx; add .live-card/.live-timer/pill CSS.
  • Step 7: Run tests — pass. typecheck + build.
  • Step 8: Commitfeat(admin): live active-work view (5s refresh).

Task 6: Activity management (port of legacy Settings)

Files:

  • Create: apps/admin/src/api/activities.ts, apps/admin/src/screens/Activities.tsx
  • Modify: apps/admin/src/App.tsx (route), apps/admin/src/styles.css
  • Test: apps/admin/src/screens/Activities.test.tsx

Interfaces:

  • Consumes: apiFetch, Activity/CreateActivityInput/InsoleType from @solelog/shared.

  • Produces: useActivities(), useCreateActivity(), useUpdateActivity(), useDeleteActivity() (queryKey ['activities'], mutations invalidate it).

  • Step 1: Write failing tests (mock apiFetch): adding a handeling POSTs /api/activities with {name, insole_types}; editing PUTs /api/activities/:id; deleting (confirm stubbed true) DELETEs /api/activities/:id. Render shows existing activities from the mocked GET.

  • Step 2: Run — fail.

  • Step 3: Implement api/activities.ts — recreate the hooks removed from the worker (reference git decb158:apps/worker/src/api/activities.ts): useActivities (GET /api/activities), useCreateActivity (POST), useUpdateActivity (PUT /api/activities/${id} with { id, input }), useDeleteActivity (DELETE), all invalidating ['activities'].

  • Step 4: Implement screens/Activities.tsx — port decb158:apps/worker/src/screens/Settings.tsx near-verbatim: TypeToggles/type pill helpers, add-form, list with inline edit, delete with window.confirm. Title "Handelingen", subtitle "Beheer handelingen per zooltype". Reuse the worker's .activity-*/.btn-*/.field-* class names (port the CSS).

  • Step 5: Add route /handelingenActivities; port the activity-management CSS block from worker styles.css into admin styles.css.

  • Step 6: Run tests — pass. typecheck + build.

  • Step 7: Commitfeat(admin): activity management (handelingen CRUD).


Task 7: Docs, lint, and verification

Files:

  • Modify: docs/roadmap.md, apps/admin/README.md

  • Create: docs/sessions/2026-06-17-phase-3a-admin-panel.md

  • Step 1: Lint/formatnpx oxlint clean; npx oxfmt scoped to changed files (do not touch unrelated files); fix any es5-trailing-comma issues.

  • Step 2: Full greenyarn workspace @solelog/api typecheck + test; yarn workspace @solelog/admin typecheck + test + build; yarn workspace @solelog/worker test (regression).

  • Step 3: Live smoke (optional but preferred) — start the API, seed (worker@solelog.local / admin@solelog.local), curl /api/me with an admin bearer to confirm role, and /api/admin/sessions/active. Kill the server tree afterward (Windows lock trap).

  • Step 4: Docs — update docs/roadmap.md (Phase 3 → "3a implemented; 3b remaining"); write the session log; fill apps/admin/README.md (dev on :5174, admin-only login, what 3a covers).

  • Step 5: Commitdocs(admin): phase 3a session log + roadmap status.

Self-Review notes

  • Type consistency: formatTime (Task 5) matches worker's name; useActiveSessions here hits /api/admin/sessions/active (admin), distinct from worker's same-named hook on /api/sessions/active — intentional, different app.
  • PublicUser.role (Task 1) is consumed by the admin gate (Task 3) — defined before use.
  • Activity hooks (Task 6) mirror the removed worker hooks exactly so the ported Settings component compiles unchanged.