# 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. ✅