diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 004213b..ed58b8f 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -35,10 +35,16 @@ const REFERENCE_ACTIVITIES: { name: string; insoleTypes: string[] }[] = [ async function seedDevUsers(): Promise { if (process.env.NODE_ENV === 'production') return; + // better-auth's admin plugin types `role` as its built-in set ('user' | 'admin'); our custom + // 'worker' role is valid at runtime but not in that static type, so narrow createUser locally + // (same workaround as test/helpers.ts). + const createUser = auth.api.createUser as (args: { + body: { email: string; password: string; name: string; role: 'worker' | 'admin' }; + }) => Promise; for (const acc of DEV_ACCOUNTS) { const existing = await db.select().from(user).where(eq(user.email, acc.email)); if (existing.length > 0) continue; - await auth.api.createUser({ + await createUser({ body: { email: acc.email, password: acc.password, name: acc.name, role: acc.role }, }); console.log(`Seeded dev ${acc.role}: ${acc.email} / ${acc.password}`); diff --git a/apps/worker/README.md b/apps/worker/README.md index c55a5ca..5c685e7 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -39,10 +39,10 @@ From the repo root: yarn workspace @solelog/worker dev # Vite dev server on http://localhost:5173 ``` -Open **http://localhost:5173** in any browser. `db:seed` creates a ready-made **dev login**: -**`worker@solelog.local`** / **`werkplaats123`** (dev-only — skipped when `NODE_ENV=production`). -Or use the sign-up affordance on the login screen to create your own account. After signing in you -land on the Stopwatch tab. +Open **http://localhost:5173** in any browser. `db:seed` creates two **dev logins** (dev-only — +skipped when `NODE_ENV=production`): **worker** `worker@solelog.local` / `werkplaats123` and +**admin** `admin@solelog.local` / `werkplaats-admin`. Public self-registration is disabled — an +admin creates accounts. After signing in you land on the Stopwatch tab. The API base URL comes from `VITE_API_URL` (default `http://localhost:3000`). @@ -60,6 +60,7 @@ connected to the same Wi-Fi: ``` (On Windows PowerShell: `$env:VITE_API_URL='http://:3000'; yarn workspace @solelog/worker dev`.) + 4. Allow that origin on the API by setting `CORS_ORIGINS` when you start it — **no code edit**: ```bash @@ -91,7 +92,7 @@ yarn workspace @solelog/worker test # vitest run ## Architecture (Phase 1) - **Server-authoritative timing.** Start / stop / discard are API calls - (`POST /api/sessions/start`, `/:id/stop`, `/:id/discard`); the live timer only *displays* elapsed + (`POST /api/sessions/start`, `/:id/stop`, `/:id/discard`); the live timer only _displays_ elapsed time computed from the server `start_time`. An open session therefore survives a browser/phone restart and is recovered on load via `GET /api/sessions/active`. - **Shared contracts.** Request/response shapes are zod schemas in `@solelog/shared`, imported here diff --git a/docs/plans/phase-2-accounts-roles.md b/docs/plans/phase-2-accounts-roles.md new file mode 100644 index 0000000..3d45964 --- /dev/null +++ b/docs/plans/phase-2-accounts-roles.md @@ -0,0 +1,791 @@ +# Phase 2 — Accounts & Roles Implementation Plan + +> **For agentic workers:** Implement task-by-task. Each task is TDD (write the failing test, +> see it fail, implement, see it pass, commit). Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add worker/admin roles, admin-creates-users, and role-based data scoping to the +SoleLog backend — workers see only their own sessions; an admin exists and can manage users +and see all data. Backend-only; the React admin panel remains Phase 3. + +**Architecture:** Use better-auth's official `admin()` plugin for the role field and the +`createUser` / `listUsers` / `setRole` endpoints (auto-mounted at `/api/auth/admin/*`). Public +sign-up is disabled; the first admin + a dev worker are seeded via the server-side `createUser` +escape hatch. A small custom admin router exposes cross-user work-session views (for the Phase 3 +live board). Activity writes are gated to admins. + +**Tech Stack:** Hono, better-auth 1.6.18 (`admin` + `bearer` plugins), Drizzle ORM 0.36.4 over +libsql/SQLite, zod contracts in `@solelog/shared`, vitest, TypeScript ESM. Worker client: Vite + +React + TS. + +## Global Constraints + +- **Lint/format:** oxlint + oxfmt — 2-space indent, single quotes, semicolons, width 100. +- **drizzle-orm 0.36.4:** index callbacks MUST use the **object form** `(table) => ({ key: index('name').on(...) })`, never the array form. +- **better-auth schema columns:** drizzle **property** names are camelCase (the field name better-auth uses); SQL **column** names are snake_case. Match the existing convention in `schema.ts`. +- **Roles:** `defaultRole: 'worker'`, `adminRoles: ['admin']`. `'worker'` is intentionally NOT in better-auth's access-control role set, so workers are denied every admin-plugin permission automatically. Admin checks in our own routes are explicit `role === 'admin'`. +- **Dev seed accounts** are created ONLY when `process.env.NODE_ENV !== 'production'`. +- **Dutch UI strings** in the worker client. +- **Tracker:** Plane (project SoleLog / `SL`). Docs live in `docs/`. +- Run all commands from the repo root. API tests: `yarn workspace @solelog/api test`; worker tests: `yarn workspace @solelog/worker test`; typecheck: `yarn workspace @solelog/api typecheck` / `yarn workspace @solelog/worker typecheck`. + +--- + +### Task 1: Shared contracts — Role enum + user fields on WorkSession + +**Files:** + +- Modify: `packages/shared/src/index.ts` +- Test: `packages/shared` has no test runner; verification is `yarn workspace @solelog/api typecheck` consuming the types in later tasks. This task's gate is that the package builds/typechecks. + +**Interfaces:** + +- Produces: `Role` (`'worker' | 'admin'`), `AdminUser` type, and `WorkSession` extended with optional `user_name` / `user_email` (populated only on admin cross-user joins). + +- [ ] **Step 1: Add the Role enum and extend WorkSession.** In `packages/shared/src/index.ts`, after `InsoleType`, add: + +```ts +export const Role = z.enum(['worker', 'admin']); +export type Role = z.infer; +``` + +In the `WorkSession` object, after `activity_name`, add two optional fields: + +```ts + activity_name: z.string().optional(), // present on history/active joins + user_name: z.string().optional(), // present only on admin cross-user views + user_email: z.string().optional(), // present only on admin cross-user views +``` + +At the end of the file add an admin user contract (for Phase 3 typing; harmless now): + +```ts +export const AdminUser = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + role: Role, + created_at: z.string(), +}); +export type AdminUser = z.infer; +``` + +- [ ] **Step 2: Typecheck the consumer.** Run: `yarn workspace @solelog/api typecheck` + Expected: PASS (no usage yet; just confirms the contract compiles). + +- [ ] **Step 3: Commit.** + +```bash +git add packages/shared/src/index.ts +git commit -m "feat(shared): add Role enum + admin user fields on WorkSession contract" +``` + +--- + +### Task 2: Test helper using server-side createUser (pre-req for disabling sign-up) + +This MUST land before Task 3. It removes every test's dependency on the public sign-up route, so +flipping `disableSignUp` later keeps tests green. Created users go through `auth.api.createUser`, +which works regardless of sign-up being open. + +**Files:** + +- Create: `apps/api/test/helpers.ts` +- Modify: `apps/api/test/sessions.test.ts`, `apps/api/test/activities.test.ts`, `apps/api/test/me.test.ts`, `apps/api/test/export.test.ts` (remove their local `authToken` copies, import from helpers) + +**Interfaces:** + +- Produces: + - `createTestUser(email: string, role?: 'worker' | 'admin'): Promise` + - `authToken(app: Hono, email: string, role?: 'worker' | 'admin'): Promise` + - `bearer(token: string): Record` + - `seedActivity(name: string, insoleTypes?: string[]): Promise` — inserts directly via Drizzle (so session/export tests need no admin token) + +- [ ] **Step 1: Write the helper.** Create `apps/api/test/helpers.ts`: + +```ts +import type { Hono } from 'hono'; +import { auth } from '../src/auth'; +import { db } from '../src/db/client'; +import { activities } from '../src/db/schema'; + +const PASSWORD = 'sterk-wachtwoord-123'; +const json = { 'content-type': 'application/json' }; + +// Create a user server-side via the admin plugin's createUser. This bypasses +// `disableSignUp` (separate endpoint) and the admin-session check (no headers). +export async function createTestUser(email: string, role: 'worker' | 'admin' = 'worker') { + await auth.api.createUser({ + body: { email, password: PASSWORD, name: email.split('@')[0] || 'User', role }, + }); +} + +export async function authToken( + app: Hono, + email: string, + role: 'worker' | 'admin' = 'worker' +): Promise { + await createTestUser(email, role); + const signin = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password: PASSWORD }), + }); + const token = signin.headers.get('set-auth-token'); + if (!token) throw new Error('no token'); + return token; +} + +export function bearer(token: string): Record { + return { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; +} + +// Insert an activity straight into the DB (test setup that should not depend on authz). +export async function seedActivity( + name: string, + insoleTypes: string[] = ['Kurk', 'Berk', '3D'] +): Promise { + const [row] = await db.insert(activities).values({ name, insoleTypes }).returning(); + return row.id; +} +``` + +- [ ] **Step 2: Migrate `sessions.test.ts`.** Delete its local `authToken`, `bearer`, and `createActivity` functions and the now-unused `json` const if it becomes unused. Add at the top: + +```ts +import { authToken, bearer, seedActivity } from './helpers'; +``` + +Replace every `await createActivity(app, token, 'X')` call with `await seedActivity('X')` (drop the `app, token` args). Leave the `401 without token` test's inline `json` usage intact (keep a local `const json = { 'content-type': 'application/json' };` if still referenced). + +- [ ] **Step 3: Migrate `activities.test.ts`, `me.test.ts`, `export.test.ts`.** In each, delete the local `authToken`/`bearer` copies and import from `./helpers`. (Do not change assertions yet — Task 4 changes activity-authz expectations.) + +- [ ] **Step 4: Run the API tests.** Run: `yarn workspace @solelog/api test` + Expected: PASS — same behaviour, now via `createUser`. (Sign-up is still enabled at this point.) + +- [ ] **Step 5: Commit.** + +```bash +git add apps/api/test/ +git commit -m "test(api): centralize auth helpers on server-side createUser" +``` + +--- + +### Task 3: Wire the admin plugin, close sign-up, add schema columns + migration + +**Files:** + +- Modify: `apps/api/src/auth.ts` +- Modify: `apps/api/src/db/schema.ts` +- Create: `apps/api/drizzle/0002_*.sql` (via `db:generate`) +- Test: `apps/api/test/auth.test.ts` (add a "sign-up disabled" case) + +**Interfaces:** + +- Consumes: better-auth `admin` plugin. +- Produces: `user.role` available on sessions; `/api/auth/admin/*` endpoints mounted; public sign-up closed. + +- [ ] **Step 1: Write the failing test.** In `apps/api/test/auth.test.ts`, add: + +```ts +it('rejects public sign-up (admin creates users)', async () => { + const app = createApp(); + const res = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + email: 'should-not-exist@example.com', + password: 'sterk-wachtwoord-123', + name: 'Nope', + }), + }); + expect(res.status).toBeGreaterThanOrEqual(400); +}); +``` + +- [ ] **Step 2: Run it to verify it fails.** Run: `yarn workspace @solelog/api test auth` + Expected: FAIL (sign-up currently returns 200). + +- [ ] **Step 3: Wire the admin plugin + disable sign-up.** Edit `apps/api/src/auth.ts`: + +```ts +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { admin, bearer } from 'better-auth/plugins'; +import { db } from './db/client'; +import * as schema from './db/schema'; +import { env } from './env'; + +export const auth = betterAuth({ + secret: env.BETTER_AUTH_SECRET, + baseURL: env.BETTER_AUTH_URL, + trustedOrigins: [env.BETTER_AUTH_URL, 'http://localhost:3000', ...env.WEB_ORIGINS], + database: drizzleAdapter(db, { provider: 'sqlite', schema }), + emailAndPassword: { + enabled: true, + requireEmailVerification: false, + disableSignUp: true, // admin creates users; see docs/plans/phase-2-accounts-roles.md + }, + plugins: [bearer(), admin({ defaultRole: 'worker', adminRoles: ['admin'] })], +}); +``` + +- [ ] **Step 4: Add the admin-plugin columns to the schema.** In `apps/api/src/db/schema.ts`, inside the `user` table object (after `image`), add: + +```ts + role: text('role'), + banned: integer('banned', { mode: 'boolean' }), + banReason: text('ban_reason'), + banExpires: integer('ban_expires', { mode: 'timestamp_ms' }), +``` + +Inside the `session` table object (after `userAgent`, before `userId`), add: + +```ts + impersonatedBy: text('impersonated_by'), +``` + +(Optional cross-check: `npx --yes @better-auth/cli@latest generate --config apps/api/src/auth.ts --output /tmp/ba.ts` and diff the auth-table columns; they must match the above. Do not let it overwrite the domain tables.) + +- [ ] **Step 5: Generate and apply the migration.** Run: + +```bash +yarn workspace @solelog/api db:generate +yarn workspace @solelog/api db:migrate +``` + +Expected: a new `apps/api/drizzle/0002_*.sql` adding the 5 columns; migrate applies cleanly. + +- [ ] **Step 6: Run the tests.** Run: `yarn workspace @solelog/api test` + Expected: PASS — including the new sign-up-disabled test. (The test DB is rebuilt from migrations by `test/setup.ts`, so 0002 is picked up.) + +- [ ] **Step 7: Commit.** + +```bash +git add apps/api/src/auth.ts apps/api/src/db/schema.ts apps/api/drizzle/ +git commit -m "feat(api): add better-auth admin plugin + close public sign-up (migration 0002)" +``` + +--- + +### Task 4: Role-aware session helper + admin-gated activity writes + +**Files:** + +- Modify: `apps/api/src/lib/require-user.ts` +- Create: `apps/api/src/lib/work-session.ts` (extract the row→DTO mapper, add optional user fields) +- Modify: `apps/api/src/routes/sessions.ts` (use the extracted mapper) +- Modify: `apps/api/src/routes/activities.ts` (gate POST/PUT/DELETE to admin) +- Test: `apps/api/test/activities.test.ts` + +**Interfaces:** + +- Consumes: `getSessionUser` now returns role. +- Produces: + - `SessionUser = { id: string; role: string }`, `getSessionUser(c): Promise`, `isAdmin(u): boolean` + - `toWorkSession(row, opts?: { activityName?: string | null; userName?: string | null; userEmail?: string | null }): WorkSession` + +- [ ] **Step 1: Write failing authz tests.** In `apps/api/test/activities.test.ts`, add: + +```ts +it('forbids a worker from creating an activity (403)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-worker-create@example.com'); // default worker + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(403); +}); + +it('lets a worker read activities (200)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-worker-read@example.com'); + const res = await app.request('/api/activities', { headers: bearer(token) }); + expect(res.status).toBe(200); +}); +``` + +Then update the EXISTING create/update/delete/filter tests in this file so their user is an admin: +change each `await authToken(app, 'X')` used for a write/setup to `await authToken(app, 'X', 'admin')`. (Read-only assertions can stay worker.) + +- [ ] **Step 2: Run to verify failure.** Run: `yarn workspace @solelog/api test activities` + Expected: FAIL — worker currently gets 200 on POST; the new 403 test fails. + +- [ ] **Step 3: Add role to the session helper.** Replace `apps/api/src/lib/require-user.ts`: + +```ts +import type { Context } from 'hono'; +import { auth } from '../auth'; + +export interface SessionUser { + id: string; + role: string; +} + +export async function getSessionUser(c: Context): Promise { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (!session) return null; + const role = (session.user as { role?: string | null }).role ?? 'worker'; + return { id: session.user.id, role }; +} + +export function isAdmin(u: SessionUser | null): boolean { + return u?.role === 'admin'; +} +``` + +- [ ] **Step 4: Extract the work-session mapper.** Create `apps/api/src/lib/work-session.ts`: + +```ts +import type { WorkSession } from '@solelog/shared'; +import type { workSessions } from '../db/schema'; + +type WorkSessionRow = typeof workSessions.$inferSelect; + +export function toWorkSession( + row: WorkSessionRow, + opts: { activityName?: string | null; userName?: string | null; userEmail?: string | null } = {} +): WorkSession { + return { + id: row.id, + user_id: row.userId, + activity_id: row.activityId, + activity_name: opts.activityName ?? undefined, + user_name: opts.userName ?? undefined, + user_email: opts.userEmail ?? undefined, + insole_type: (row.insoleType ?? null) as WorkSession['insole_type'], + pair_count: row.pairCount, + start_time: new Date(row.startTime).toISOString(), + end_time: row.endTime ? new Date(row.endTime).toISOString() : null, + duration_seconds: row.durationSeconds ?? null, + status: row.status as WorkSession['status'], + source: row.source as WorkSession['source'], + notes: row.notes ?? null, + created_at: new Date(row.createdAt).toISOString(), + }; +} +``` + +In `apps/api/src/routes/sessions.ts`, delete the local `toWorkSession` + its `WorkSessionRow` type and import the shared one: `import { toWorkSession } from '../lib/work-session';`. Update its call sites: `toWorkSession(r.session, { activityName: r.activityName })` and `toWorkSession(updated)`. + +- [ ] **Step 5: Gate activity writes.** In `apps/api/src/routes/activities.ts`, import `isAdmin`: + `import { getSessionUser, isAdmin } from '../lib/require-user';` + In each of POST `/api/activities`, PUT `/api/activities/:id`, DELETE `/api/activities/:id`, after the existing `if (!sessionUser) return 401` line, add: + +```ts +if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); +``` + +Leave GET `/api/activities` open to any authenticated user. + +- [ ] **Step 6: Run the tests.** Run: `yarn workspace @solelog/api test` + Expected: PASS (worker 403 on writes, 200 on read; admin writes succeed; sessions still green). + +- [ ] **Step 7: Commit.** + +```bash +git add apps/api/src/lib/ apps/api/src/routes/ apps/api/test/activities.test.ts +git commit -m "feat(api): role-aware session helper + admin-only activity writes" +``` + +--- + +### Task 5: Admin router — cross-user work-session views + +**Files:** + +- Create: `apps/api/src/routes/admin.ts` +- Modify: `apps/api/src/app.ts` (mount `adminRoutes`) +- Test: `apps/api/test/admin.test.ts` + +**Interfaces:** + +- Consumes: `getSessionUser`, `isAdmin`, `toWorkSession`. +- Produces: `adminRoutes: Hono` with `GET /api/admin/sessions` and `GET /api/admin/sessions/active` (admin-only; all users; joined with activity + user name/email). 401 unauthenticated, 403 non-admin. + +- [ ] **Step 1: Write the failing test.** Create `apps/api/test/admin.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createApp } from '../src/app'; +import { authToken, bearer, createTestUser, seedActivity } from './helpers'; + +describe('admin session views', () => { + it('401s without a token', async () => { + const app = createApp(); + expect((await app.request('/api/admin/sessions')).status).toBe(401); + expect((await app.request('/api/admin/sessions/active')).status).toBe(401); + }); + + it('403s for a worker', async () => { + const app = createApp(); + const token = await authToken(app, 'admin-view-worker@example.com'); // worker + expect((await app.request('/api/admin/sessions', { headers: bearer(token) })).status).toBe(403); + }); + + it("returns ALL users' sessions for an admin, with user info", async () => { + const app = createApp(); + const adminTok = await authToken(app, 'admin-view-admin@example.com', 'admin'); + const workerTok = await authToken(app, 'admin-view-w2@example.com'); // worker + const activityId = await seedActivity('Frezen'); + + // Worker starts a session. + const started = await ( + await app.request('/api/sessions/start', { + method: 'POST', + headers: bearer(workerTok), + body: JSON.stringify({ activity_id: activityId, insole_type: 'Kurk', pair_count: 2 }), + }) + ).json(); + + const res = await app.request('/api/admin/sessions', { headers: bearer(adminTok) }); + expect(res.status).toBe(200); + const body = await res.json(); + const found = body.find((s: { id: number }) => s.id === started.id); + expect(found).toBeTruthy(); + expect(found.user_email).toBe('admin-view-w2@example.com'); + expect(found.activity_name).toBe('Frezen'); + + const active = await app.request('/api/admin/sessions/active', { headers: bearer(adminTok) }); + expect(active.status).toBe(200); + const activeBody = await active.json(); + expect(activeBody.some((s: { id: number }) => s.id === started.id)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure.** Run: `yarn workspace @solelog/api test admin` + Expected: FAIL (route not mounted → 404, not 401/403/200). + +- [ ] **Step 3: Implement the admin router.** Create `apps/api/src/routes/admin.ts`: + +```ts +import { Hono } from 'hono'; +import { and, desc, eq } from 'drizzle-orm'; +import { db } from '../db/client'; +import { activities, user, workSessions } from '../db/schema'; +import { getSessionUser, isAdmin } from '../lib/require-user'; +import { toWorkSession } from '../lib/work-session'; + +export const adminRoutes = new Hono(); + +// Gate the whole /api/admin/* surface to admins. +adminRoutes.use('/api/admin/*', async (c, next) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); + await next(); +}); + +const baseSelect = { + session: workSessions, + activityName: activities.name, + userName: user.name, + userEmail: user.email, +}; + +adminRoutes.get('/api/admin/sessions', async (c) => { + const rows = await db + .select(baseSelect) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .leftJoin(user, eq(workSessions.userId, user.id)) + .orderBy(desc(workSessions.startTime)); + return c.json( + rows.map((r) => + toWorkSession(r.session, { + activityName: r.activityName, + userName: r.userName, + userEmail: r.userEmail, + }) + ) + ); +}); + +adminRoutes.get('/api/admin/sessions/active', async (c) => { + const rows = await db + .select(baseSelect) + .from(workSessions) + .leftJoin(activities, eq(workSessions.activityId, activities.id)) + .leftJoin(user, eq(workSessions.userId, user.id)) + .where(eq(workSessions.status, 'active')) + .orderBy(desc(workSessions.startTime)); + return c.json( + rows.map((r) => + toWorkSession(r.session, { + activityName: r.activityName, + userName: r.userName, + userEmail: r.userEmail, + }) + ) + ); +}); +``` + +(`and` import may be unused — remove it if oxlint flags it.) + +- [ ] **Step 4: Mount it.** In `apps/api/src/app.ts`, import and mount after the other routes: + +```ts +import { adminRoutes } from './routes/admin'; +// ... +app.route('/', sessionsRoutes); +app.route('/', adminRoutes); +``` + +- [ ] **Step 5: Run the tests.** Run: `yarn workspace @solelog/api test` + Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add apps/api/src/routes/admin.ts apps/api/src/app.ts apps/api/test/admin.test.ts +git commit -m "feat(api): admin-only cross-user work-session views (/api/admin/sessions)" +``` + +--- + +### Task 6: Seed a dev admin + dev worker via createUser + +**Files:** + +- Modify: `apps/api/src/db/seed.ts` +- Modify: `apps/api/test/seed.test.ts` + +**Interfaces:** + +- Consumes: `auth.api.createUser`. +- Produces: dev worker `worker@solelog.local` (role worker) + dev admin `admin@solelog.local` (role admin), both dev-only, idempotent. + +- [ ] **Step 1: Write/extend the failing test.** In `apps/api/test/seed.test.ts`, replace the dev-account test with one covering both accounts and the admin role: + +```ts +it('seeds the dev worker + dev admin idempotently with correct roles', async () => { + await seed(); + const w = await db.select().from(user).where(eq(user.email, 'worker@solelog.local')); + const a = await db.select().from(user).where(eq(user.email, 'admin@solelog.local')); + expect(w).toHaveLength(1); + expect(a).toHaveLength(1); + expect((a[0] as { role?: string }).role).toBe('admin'); + + await seed(); + expect(await db.select().from(user).where(eq(user.email, 'admin@solelog.local'))).toHaveLength(1); +}); +``` + +- [ ] **Step 2: Run to verify failure.** Run: `yarn workspace @solelog/api test seed` + Expected: FAIL (admin account not seeded; current seed uses `signUpEmail`). + +- [ ] **Step 3: Update the seed.** In `apps/api/src/db/seed.ts`, replace the `DEV_USER` block + `seedDevUser` with: + +```ts +// Dev-only accounts so `db:seed` yields ready-made logins for local testing / phone demos. +// Created through better-auth's admin createUser (hashes the password, sets the role, and works +// even though public sign-up is disabled). NEVER seeded when NODE_ENV=production. +const DEV_ACCOUNTS = [ + { + email: 'worker@solelog.local', + password: 'werkplaats123', + name: 'Test Werker', + role: 'worker' as const, + }, + { + email: 'admin@solelog.local', + password: 'werkplaats-admin', + name: 'Test Beheerder', + role: 'admin' as const, + }, +]; + +async function seedDevUsers(): Promise { + if (process.env.NODE_ENV === 'production') return; + for (const acc of DEV_ACCOUNTS) { + const existing = await db.select().from(user).where(eq(user.email, acc.email)); + if (existing.length > 0) continue; + await auth.api.createUser({ + body: { email: acc.email, password: acc.password, name: acc.name, role: acc.role }, + }); + console.log(`Seeded dev ${acc.role}: ${acc.email} / ${acc.password}`); + } +} +``` + +In `seed()`, replace `await seedDevUser();` with `await seedDevUsers();`. + +- [ ] **Step 4: Run the tests.** Run: `yarn workspace @solelog/api test` + Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add apps/api/src/db/seed.ts apps/api/test/seed.test.ts +git commit -m "feat(api): seed dev admin + worker via admin createUser" +``` + +--- + +### Task 7: Worker client — remove self-signup (login-only) + +**Files:** + +- Modify: `apps/worker/src/lib/api.ts` (drop `signUp`) +- Modify: `apps/worker/src/auth/AuthContext.tsx` (drop `signUp`) +- Modify: `apps/worker/src/screens/Login.tsx` (login-only, no toggle) +- Modify: any worker test referencing sign-up/Registreren (search and fix) + +**Interfaces:** + +- Produces: `AuthContextValue` without `signUp`; `Login` renders only the sign-in form. + +- [ ] **Step 1: Find references.** Search the worker app for sign-up usage: + +```bash +grep -rn "signUp\|Registreren\|sign-up" apps/worker/src +``` + +- [ ] **Step 2: Remove `signUp` from the API client.** In `apps/worker/src/lib/api.ts`, delete the `signUp` function (and its leading comment). Keep `signIn`, `apiFetch`, `ApiError`, `API_URL`. + +- [ ] **Step 3: Remove `signUp` from AuthContext.** In `apps/worker/src/auth/AuthContext.tsx`: drop the `apiSignUp` import, the `signUp` from `AuthContextValue`, the `signUp` `useCallback`, and the `signUp` in the provider value. + +- [ ] **Step 4: Make Login login-only.** Replace `apps/worker/src/screens/Login.tsx`: + +```tsx +import { useState, type FormEvent } from 'react'; +import { useAuth } from '../auth/AuthContext'; + +export default function Login() { + const { signIn } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + await signIn(email, password); + } catch { + setError('Inloggen mislukt'); + } finally { + setBusy(false); + } + } + + return ( +
+

SoleLog

+
+ + + {error &&

{error}

} + +
+
+ ); +} +``` + +- [ ] **Step 5: Fix any broken worker tests.** Update tests found in Step 1 (e.g. an `App.test.tsx` or `Login` test asserting the Registreren toggle) to expect login-only. Run: `yarn workspace @solelog/worker test` + Expected: PASS. + +- [ ] **Step 6: Typecheck + build.** Run: `yarn workspace @solelog/worker typecheck && yarn workspace @solelog/worker build` + Expected: PASS. + +- [ ] **Step 7: Commit.** + +```bash +git add apps/worker/src/ +git commit -m "feat(worker): login-only client (admin creates users)" +``` + +--- + +### Task 8: Docs, lint, full verification + +**Files:** + +- Modify: `docs/roadmap.md` (mark Phase 2 done in the status note) +- Modify: `apps/worker/README.md` (creds: admin + worker; note no self-registration) +- Modify: `docs/sessions/2026-06-17-phase-2-accounts-roles.md` (fill "Work done") + +- [ ] **Step 1: Update worker README.** In the "Run it" section, document both seeded accounts and that registration is closed: + +> `db:seed` creates two **dev logins** (dev-only — skipped when `NODE_ENV=production`): +> **worker** `worker@solelog.local` / `werkplaats123` and **admin** `admin@solelog.local` / `werkplaats-admin`. +> Public self-registration is disabled — an admin creates accounts. + +Remove the "Or use the sign-up affordance..." sentence. + +- [ ] **Step 2: Update the roadmap status line / Phase 2 bullet** to note Phase 2 is implemented (workers scoped, admin exists, admin manages users via `/api/auth/admin/*`, admin sees all via `/api/admin/sessions`). + +- [ ] **Step 3: Lint + format + typecheck + both test suites.** Run: + +```bash +npx oxlint +npx oxfmt +yarn workspace @solelog/api typecheck && yarn workspace @solelog/api test +yarn workspace @solelog/worker typecheck && yarn workspace @solelog/worker test +``` + +Expected: all green. Re-commit any oxfmt changes. + +- [ ] **Step 4: Live smoke test (the Phase 2 "done when").** Fresh DB, seed, start the server, and prove the role rules over HTTP: + +```bash +rm -rf apps/api/data +yarn workspace @solelog/api db:migrate && yarn workspace @solelog/api db:seed +yarn workspace @solelog/api start # background; :3000 +``` + +Then, using curl (extract `set-auth-token` from sign-in response headers): + +1. Sign in as `admin@solelog.local` → token `ADMIN`. +2. Sign in as `worker@solelog.local` → token `WORKER`. +3. `POST /api/auth/sign-up/email` → 4xx (sign-up closed). ✅ +4. `POST /api/activities` with `WORKER` → 403; with `ADMIN` → 200. ✅ +5. `POST /api/auth/admin/create-user` with `ADMIN` (body `{email,password,name,role:'worker'}`) → 200; with `WORKER` → 403. ✅ +6. Worker starts a session; `GET /api/sessions` as that worker shows it; `GET /api/admin/sessions` as `ADMIN` shows it with `user_email`; as `WORKER` → 403. ✅ + Stop the server when done (TaskStop / kill the PID). + +- [ ] **Step 5: Commit docs.** + +```bash +git add docs/ apps/worker/README.md +git commit -m "docs: Phase 2 accounts & roles — roadmap, README, session log" +``` + +--- + +## Self-Review + +- **Spec coverage:** roles (Task 3), admin-creates-users (plugin endpoints, verified Task 8.5), + per-user scoping already in Phase 1 + admin-sees-all (Task 5), admin exists (Task 6), + activity lockdown (Task 4), sign-up closed (Task 3), client adjusted (Task 7). ✅ +- **Type consistency:** `SessionUser`, `toWorkSession(row, opts)`, `Role`, `AdminUser`, + `user_name`/`user_email` used consistently across tasks. ✅ +- **Ordering:** Task 2 (helpers off sign-up) precedes Task 3 (disable sign-up) so tests stay green. ✅ +- **No placeholders:** every code step is concrete. ✅ diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ea574a..674c826 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # Insole Production Time Tracker — Rebuild Roadmap & Project Overview - **Created:** 2026-06-17 -- **Status:** Approved — living project doc; Phase 0 plan written (`docs/plans/phase-0-foundation.md`) +- **Status:** Approved — living project doc; Phases 0–2 implemented (`docs/plans/phase-2-accounts-roles.md`) - **Type:** Greenfield rebuild of an inherited app - **Tracked in git** under `docs/` (the project's documentation source of truth). @@ -9,7 +9,7 @@ > (`apps/mobile`, `apps/web`, `publisher/`) was deleted in a full cleanup. The repo is now > a single-service backend (`apps/api`) plus `packages/shared`. Reference for the old code > is preserved in `docs/reference/` and in git history. The sections below describe the -> codebase *as inherited* and the rebuild plan; treat references to the legacy apps as +> codebase _as inherited_ and the rebuild plan; treat references to the legacy apps as > historical. --- @@ -32,7 +32,7 @@ Reverse-engineering established: cookies for web), but the core flow does not actually require login. - **The data and accounts are not ours.** Users, password hashes, and all task/time data live in the platform's managed Neon Postgres, bound to the friend's Create - account/organization. This repo contains only the *code* and an + account/organization. This repo contains only the _code_ and an `ANYTHING_PROJECT_TOKEN` — no database credentials. Nothing runs locally and no database is reachable from this code. @@ -61,21 +61,21 @@ it is saved → repeats. Because the **backend is the source of truth**: - An **admin panel** shows live who-is-working-on-what, generates reports, and manages users and activities. -Goals: it must **genuinely work in the workshop** (reliability, no data loss) *and* serve +Goals: it must **genuinely work in the workshop** (reliability, no data loss) _and_ serve as a **learning vehicle** the owner extends himself. ## 3. Decisions (resolved during brainstorming) -| # | Decision | Choice | -|---|---|---| -| 1 | Purpose | Real workshop tool **and** a learning vehicle | -| 2 | Platform relationship | **Clean break** from Create/Anything | -| 3 | Hosting | **Dockerized** stack; runs locally now, portable to any cloud later | -| 4 | Build strategy | **Greenfield rebuild**, porting only the good parts | -| 5 | Backend topology | **Dedicated backend service** (Option A): single owner of auth + DB; UIs are interchangeable clients | -| 6 | Auth | **better-auth** (don't hand-roll security for real use) | -| 7 | Database | **SQLite** (small userbase; one file, easy backup/Docker) | -| 8 | Language | **TypeScript everywhere** so types flow end-to-end | +| # | Decision | Choice | +| --- | --------------------- | ---------------------------------------------------------------------------------------------------- | +| 1 | Purpose | Real workshop tool **and** a learning vehicle | +| 2 | Platform relationship | **Clean break** from Create/Anything | +| 3 | Hosting | **Dockerized** stack; runs locally now, portable to any cloud later | +| 4 | Build strategy | **Greenfield rebuild**, porting only the good parts | +| 5 | Backend topology | **Dedicated backend service** (Option A): single owner of auth + DB; UIs are interchangeable clients | +| 6 | Auth | **better-auth** (don't hand-roll security for real use) | +| 7 | Database | **SQLite** (small userbase; one file, easy backup/Docker) | +| 8 | Language | **TypeScript everywhere** so types flow end-to-end | ## 4. Architecture @@ -99,6 +99,7 @@ as a **learning vehicle** the owner extends himself. ``` **Auth flow** + - **Mobile** — native email+password screen → `POST /api/auth/sign-in/email` → backend returns a bearer **token** → stored in Expo SecureStore → sent as `Authorization: Bearer ` on every request. (Drops the current app's embedded @@ -108,15 +109,15 @@ as a **learning vehicle** the owner extends himself. ## 5. Tech stack (recommended picks, approved) -| Layer | Pick | Notable alternative | -|---|---|---| -| Backend framework | **Hono** | Fastify | -| Auth | **better-auth** | — | -| DB access + migrations | **Drizzle ORM** | Kysely / Prisma | -| Database | **SQLite** | libsql/Turso (if cloud later) | -| Admin UI | **Vite + React** (SPA) | Next.js (if SSR wanted) | -| Mobile | **Expo / React Native** | — | -| Shared contracts | **`packages/shared`** (TS types + zod) | — | +| Layer | Pick | Notable alternative | +| ---------------------- | -------------------------------------- | ----------------------------- | +| Backend framework | **Hono** | Fastify | +| Auth | **better-auth** | — | +| DB access + migrations | **Drizzle ORM** | Kysely / Prisma | +| Database | **SQLite** | libsql/Turso (if cloud later) | +| Admin UI | **Vite + React** (SPA) | Next.js (if SSR wanted) | +| Mobile | **Expo / React Native** | — | +| Shared contracts | **`packages/shared`** (TS types + zod) | — | ## 6. Monorepo shape @@ -161,27 +162,30 @@ maps, 3D, audio, calendar, contacts, sensors, …). Each phase keeps the system working and is its own spec → plan → build cycle. - **Phase 0 — Foundation.** Greenfield monorepo; dockerized Hono backend with better-auth - + Drizzle + SQLite; `docker compose up` brings it up; health check + one auth - round-trip proven end-to-end. *Done when:* a client can sign in against the - containerised backend and the token authorises a protected call. + - Drizzle + SQLite; `docker compose up` brings it up; health check + one auth + round-trip proven end-to-end. _Done when:_ a client can sign in against the + containerised backend and the token authorises a protected call. - **Phase 1 — Worker timing.** Activities + server-authoritative work-sessions (open/close) + history + CSV export in the backend; mobile app rebuilt to use it. - *Done when:* a worker can pick an activity, start/stop a server-side session, and see + _Done when:_ a worker can pick an activity, start/stop a server-side session, and see history; CSV exports. -- **Phase 2 — Accounts & roles.** Worker/admin roles, admin-creates-users, per-user data - scoping. *Done when:* workers see only their own sessions; an admin account exists. +- **Phase 2 — Accounts & roles.** ✅ **Implemented.** Worker/admin roles (better-auth `admin` + plugin), admin-creates-users, per-user data scoping. Workers are scoped to their own sessions; + an admin account exists; admin manages users via `/api/auth/admin/*` and sees all sessions via + `/api/admin/sessions`. Public sign-up is closed; activity writes are admin-only. + _Done when:_ workers see only their own sessions; an admin account exists. - **Phase 3 — Admin panel.** The React admin app: live active-work view, reports/export, - user management, **manual entry/edit (the fallback)**. *Done when:* an admin can see + user management, **manual entry/edit (the fallback)**. _Done when:_ an admin can see who's working now, manage users, and hand-correct a session. - **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with - manual selection fallback. *Done when:* scanning a bench QR pre-fills the session. + manual selection fallback. _Done when:_ scanning a bench QR pre-fills the session. - **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the container to a chosen cloud host. ## 10. Open questions & assumptions (confirm as we go) - **Scanning = QR codes** on benches, read by the phone camera (no special hardware), - always with manual fallback. *(Assumption.)* + always with manual fallback. _(Assumption.)_ - **Start fresh, no data migration.** Assumes no live workshop data/users worth preserving in the friend's platform instance. If there are, they must be **exported from the friend's Create account** — unreachable from this repo. @@ -189,7 +193,7 @@ Each phase keeps the system working and is its own spec → plan → build cycle friend. - **Deployment host** is deferred (Phase 5); the dockerized stack keeps options open. - **Domain term:** "insole" / orthotic; Dutch UI (`Type zool`, `handeling`, `aantal - zolen`, etc.). +zolen`, etc.). ## 11. Risks diff --git a/docs/sessions/2026-06-17-phase-2-accounts-roles.md b/docs/sessions/2026-06-17-phase-2-accounts-roles.md new file mode 100644 index 0000000..1568834 --- /dev/null +++ b/docs/sessions/2026-06-17-phase-2-accounts-roles.md @@ -0,0 +1,62 @@ +# Session: 2026-06-17 — Phase 2 (Accounts & roles) + +## Goal + +Add worker/admin roles, admin-creates-users, and role-based data scoping to the backend. +Backend-only this phase; the React admin panel stays Phase 3. + +## Decisions (confirmed by maintainer this session) + +1. **Role mechanism** → better-auth `admin()` plugin (`defaultRole: 'worker'`, `adminRoles: ['admin']`). + Gives `createUser` / `listUsers` / `setRole` server+client APIs with access control for free + (roadmap Decision #6 "don't hand-roll security"). +2. **Public sign-up** → **closed** (`emailAndPassword.disableSignUp: true`). Admin creates users. + Worker client becomes login-only (Registreren toggle removed). Dev seed still creates a dev + worker **and** a dev admin via `auth.api.createUser` (bypasses disableSignUp server-side). +3. **Phase 2 client scope** → backend-only. Admin UI is Phase 3. Only worker-client change: drop self-signup. + +## Key better-auth facts established (read from installed v1.6.18 source) + +- `admin/schema.mjs`: plugin adds `user.role/banned/banReason/banExpires` + `session.impersonatedBy`. +- `admin/routes.mjs` `createUser`: `if (!session && (ctx.request || ctx.headers)) throw UNAUTHORIZED` + → calling `auth.api.createUser({ body })` with **no headers** skips the admin check ⇒ usable for seeding/tests. +- `api/routes/sign-up.mjs:143`: throws `BAD_REQUEST` when `emailAndPassword.disableSignUp` is set + (sign-in unaffected). createUser is a separate endpoint, so it still works. +- Admin endpoints auto-mount under `/api/auth/admin/*` via the existing `/api/auth/*` handler. + +## Work done + +Implemented Phase 2 task-by-task per `docs/plans/phase-2-accounts-roles.md` (TDD throughout): + +- **Task 1 — Shared contracts.** Added `Role` enum (`worker | admin`), optional `user_name` / + `user_email` on `WorkSession` (admin cross-user joins only), and an `AdminUser` contract in + `packages/shared/src/index.ts`. +- **Task 2 — Test helpers.** Centralized auth on `apps/api/test/helpers.ts` + (`createTestUser` / `authToken` / `bearer` / `seedActivity`) via server-side `auth.api.createUser`, + removing every test's dependency on the public sign-up route so it can be closed. +- **Task 3 — Admin plugin + close sign-up.** Wired better-auth's `admin()` plugin + (`defaultRole: 'worker'`, `adminRoles: ['admin']`), set `disableSignUp: true`, added the + admin-plugin columns (`user.role/banned/banReason/banExpires`, `session.impersonatedBy`) and + migration `0002`. New test asserts public sign-up is rejected. +- **Task 4 — Role-aware helper + activity lockdown.** `getSessionUser` now returns role; + added `isAdmin`; extracted `toWorkSession` into `apps/api/src/lib/work-session.ts`; gated + activity POST/PUT/DELETE to admins (GET stays open to any authenticated user). +- **Task 5 — Admin router.** `apps/api/src/routes/admin.ts` exposes admin-only + `GET /api/admin/sessions` and `/api/admin/sessions/active` (all users, joined with activity + + user name/email); 401 unauthenticated, 403 non-admin. +- **Task 6 — Dev seed.** Seeds dev worker `worker@solelog.local` + dev admin + `admin@solelog.local` via `auth.api.createUser`, dev-only and idempotent. +- **Task 7 — Worker client.** Removed self-signup: dropped `signUp` from the API client and + `AuthContext`, made `Login` login-only (no Registreren toggle). +- **Task 8 — Docs, lint, verification.** Updated this log, the roadmap status, and the worker + README (both dev logins; self-registration closed). Ran lint/format/typecheck and both test + suites green, plus the live HTTP smoke test proving the role rules. + +## Plane + +- Epic + tasks created under SoleLog (SL). See plan doc for the mapping. + +## Next + +Run the build (workflow), verify live (admin can manage users + see all sessions; worker cannot; +sign-up closed), then update roadmap status to Phase 2 = Done.