Files
solelog/docs/plans/phase-2-accounts-roles.md

29 KiB

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:

export const Role = z.enum(['worker', 'admin']);
export type Role = z.infer<typeof Role>;

In the WorkSession object, after activity_name, add two optional fields:

  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):

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<typeof AdminUser>;
  • Step 2: Typecheck the consumer. Run: yarn workspace @solelog/api typecheck Expected: PASS (no usage yet; just confirms the contract compiles).

  • Step 3: Commit.

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<void>
    • authToken(app: Hono, email: string, role?: 'worker' | 'admin'): Promise<string>
    • bearer(token: string): Record<string, string>
    • seedActivity(name: string, insoleTypes?: string[]): Promise<number> — inserts directly via Drizzle (so session/export tests need no admin token)
  • Step 1: Write the helper. Create apps/api/test/helpers.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<string> {
  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<string, string> {
  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<number> {
  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:
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.

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:

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:

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:
  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:

  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:
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.

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<SessionUser | null>, 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:

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:

import type { Context } from 'hono';
import { auth } from '../auth';

export interface SessionUser {
  id: string;
  role: string;
}

export async function getSessionUser(c: Context): Promise<SessionUser | null> {
  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:
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:
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.

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:

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:

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:
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.

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:

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:

// 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<void> {
  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.

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:

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:

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<string | null>(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 (
    <div className="login">
      <h1 className="login-title">SoleLog</h1>
      <form className="login-form" onSubmit={handleSubmit}>
        <label className="field">
          <span className="field-label">E-mailadres</span>
          <input
            className="field-input"
            type="email"
            autoComplete="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </label>
        <label className="field">
          <span className="field-label">Wachtwoord</span>
          <input
            className="field-input"
            type="password"
            autoComplete="current-password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </label>
        {error && <p className="login-error">{error}</p>}
        <button className="btn-primary" type="submit" disabled={busy}>
          Inloggen
        </button>
      </form>
    </div>
  );
}
  • 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.

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:

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:
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.
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.