diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx
index 3735d9f..f7eec31 100644
--- a/apps/hq-web/src/Layout.tsx
+++ b/apps/hq-web/src/Layout.tsx
@@ -25,8 +25,11 @@ export function Layout() {
if (!hasSession()) return
- // Document Template (company.* + template.* letterhead data) is owner-only — staff never see the item.
- const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/template', label: 'Document Template' }] : BASE_NAV
+ // Document Template (letterhead data) and Employees (account management) are
+ // owner-only — staff never see the items; the server 403s mutations regardless.
+ const nav_items = role() === 'owner'
+ ? [...BASE_NAV, { to: '/settings/template', label: 'Document Template' }, { to: '/employees', label: 'Employees' }]
+ : BASE_NAV
return (
diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts
index 59d7e06..59aedcd 100644
--- a/apps/hq-web/src/api.ts
+++ b/apps/hq-web/src/api.ts
@@ -211,6 +211,34 @@ export const recordPayment = (body: Record
): Promise =
export const getLedger = (clientId: string): Promise =>
apiFetch(`/clients/${clientId}/ledger`)
+// ---------- employees (staff_user; D16) ----------
+
+export type EmployeeRole = 'owner' | 'manager' | 'staff'
+export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff']
+
+export interface Employee {
+ id: string; email: string; displayName: string; role: EmployeeRole; active: boolean
+}
+
+/** Full list + server-echoed total (bounded set, no truncation) — any signed-in user. */
+export const getEmployees = (): Promise<{ employees: Employee[]; total: number }> =>
+ apiFetch<{ employees: Employee[]; total: number }>('/employees')
+export const createEmployee = (
+ body: { email: string; displayName: string; role: EmployeeRole; password: string },
+): Promise =>
+ apiFetch<{ employee: Employee }>('/employees', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.employee)
+/** Email is the login id — immutable; only name and role may change. */
+export const patchEmployee = (id: string, body: { displayName?: string; role?: EmployeeRole }): Promise =>
+ apiFetch<{ employee: Employee }>(`/employees/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.employee)
+export const deactivateEmployee = (id: string): Promise =>
+ apiFetch<{ employee: Employee }>(`/employees/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee)
+export const reactivateEmployee = (id: string): Promise =>
+ apiFetch<{ employee: Employee }>(`/employees/${id}/reactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee)
+export const resetEmployeePassword = (id: string, password: string): Promise =>
+ apiFetch<{ employee: Employee }>(`/employees/${id}/password`, {
+ method: 'POST', body: JSON.stringify({ password }),
+ }).then((r) => r.employee)
+
// ---------- HQ-2 shapes ----------
export type Cadence = 'monthly' | 'yearly'
diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx
index 5daf72a..ec78293 100644
--- a/apps/hq-web/src/main.tsx
+++ b/apps/hq-web/src/main.tsx
@@ -15,6 +15,7 @@ import { Reports } from './pages/Reports'
import { DocumentTemplate } from './pages/DocumentTemplate'
import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView'
+import { Employees } from './pages/Employees'
function App() {
return (
@@ -28,6 +29,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/hq-web/src/pages/Employees.tsx b/apps/hq-web/src/pages/Employees.tsx
new file mode 100644
index 0000000..6d129d9
--- /dev/null
+++ b/apps/hq-web/src/pages/Employees.tsx
@@ -0,0 +1,191 @@
+import { useState } from 'react'
+import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
+import {
+ createEmployee, deactivateEmployee, getEmployees, patchEmployee,
+ reactivateEmployee, resetEmployeePassword,
+ EMPLOYEE_ROLES, type Employee, type EmployeeRole,
+} from '../api'
+import { useData } from './Clients'
+
+/**
+ * Employee management (owner-only page; the nav item is hidden for others and the
+ * server 403s every mutation anyway). Standard table per spec §6a: name, email,
+ * role, active, actions — plus add / edit / reset-password forms. Email is the
+ * login id and is immutable once created.
+ */
+export function Employees() {
+ const list = useData(getEmployees, [])
+ const [adding, setAdding] = useState(false)
+ const [editing, setEditing] = useState()
+ const [resetting, setResetting] = useState()
+ const [error, setError] = useState()
+
+ const act = (p: Promise) => {
+ setError(undefined)
+ p.then(() => list.reload()).catch((e: Error) => setError(e.message))
+ }
+
+ const employees = list.data?.employees
+ return (
+
+
{ setAdding((v) => !v); setEditing(undefined); setResetting(undefined) }}>
+ + Add employee
+
+ )}
+ />
+ {adding && (
+ { setAdding(false); list.reload() }}
+ onCancel={() => setAdding(false)}
+ />
+ )}
+ {editing !== undefined && (
+ { setEditing(undefined); list.reload() }}
+ onCancel={() => setEditing(undefined)}
+ />
+ )}
+ {resetting !== undefined && (
+ { setResetting(undefined); list.reload() }}
+ onCancel={() => setResetting(undefined)}
+ />
+ )}
+ {error !== undefined && {error}}
+ {list.error !== undefined && {list.error}}
+ {employees === undefined ? Loading…
+ : employees.length === 0 ? No employees yet. : (
+ <>
+ ({
+ name: e.displayName, email: e.email,
+ role: {e.role},
+ active: e.active ? active : inactive,
+ actions: (
+
+
+
+ {e.active
+ ?
+ : }
+
+ ),
+ }))}
+ />
+
+ {list.data!.total} employee{list.data!.total === 1 ? '' : 's'}
+
+ Guards: the last active owner cannot be demoted or deactivated; you cannot deactivate yourself.
+
+
+ >
+ )}
+
+ )
+}
+
+function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) {
+ const [f, setF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
+ const [error, setError] = useState()
+ const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
+ setF((p) => ({ ...p, [k]: e.target.value }))
+
+ const save = () => {
+ if (f.email.trim() === '' || f.displayName.trim() === '') { setError('Email and name are required'); return }
+ if (f.password.length < 8) { setError('Initial password must be at least 8 characters'); return }
+ setError(undefined)
+ createEmployee({ email: f.email.trim(), displayName: f.displayName.trim(), role: f.role, password: f.password })
+ .then(props.onDone)
+ .catch((e: Error) => setError(e.message))
+ }
+
+ return (
+
+ )
+}
+
+function EditEmployeeForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
+ const [f, setF] = useState({ displayName: props.employee.displayName, role: props.employee.role })
+ const [error, setError] = useState()
+
+ const save = () => {
+ if (f.displayName.trim() === '') { setError('Name is required'); return }
+ setError(undefined)
+ patchEmployee(props.employee.id, { displayName: f.displayName.trim(), role: f.role })
+ .then(props.onDone)
+ .catch((e: Error) => setError(e.message))
+ }
+
+ return (
+
+ )
+}
+
+function ResetPasswordForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState()
+
+ const save = () => {
+ if (password.length < 8) { setError('New password must be at least 8 characters'); return }
+ setError(undefined)
+ resetEmployeePassword(props.employee.id, password)
+ .then(props.onDone)
+ .catch((e: Error) => setError(e.message))
+ }
+
+ return (
+
+
+
+ setPassword(e.target.value)} />
+
+
+
+
+ {error !== undefined &&
{error}}
+
+ )
+}
diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts
index d606e3e..d98d509 100644
--- a/apps/hq/src/api.ts
+++ b/apps/hq/src/api.ts
@@ -6,6 +6,10 @@ import {
createClient, getClient, listClients, updateClient,
type Client, type ClientInput, type ClientPatch,
} from './repos-clients'
+import {
+ createEmployee, deactivateEmployee, getEmployee, listEmployees,
+ reactivateEmployee, setEmployeePassword, updateEmployee, type EmployeeRole,
+} from './repos-employees'
import {
assignModule, createModule, getClientModule, getModule, listClientModules,
listModules, listPrices, setPrice, updateClientModule, updateModule,
@@ -93,6 +97,58 @@ export function apiRouter(
res.json({ ok: true, ...out })
})
+ // ---------- employees (staff_user; D16) ----------
+ // GET is open to any signed-in user — the pipeline/owner pickers need names.
+ // Console users are a small bounded set: returned whole, `total` echoes the
+ // full count so the UI can display it (no silent truncation).
+ r.get('/employees', requireAuth, (_req, res) => {
+ const employees = listEmployees(db)
+ res.json({ ok: true, employees, total: employees.length })
+ })
+ // Mutations are owner-only; the signed-in user is the audited actor.
+ r.post('/employees', requireAuth, requireOwner, (req, res) => {
+ try {
+ const body = req.body as { email?: unknown; displayName?: unknown; role?: unknown; password?: unknown }
+ const employee = createEmployee(db, staffId(res), {
+ email: typeof body.email === 'string' ? body.email : '',
+ displayName: typeof body.displayName === 'string' ? body.displayName : '',
+ role: (typeof body.role === 'string' ? body.role : '') as EmployeeRole, // repo validates the enum
+ password: typeof body.password === 'string' ? body.password : '',
+ })
+ res.json({ ok: true, employee })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ const employeeAction = (fn: (id: string, res: Response, req: Request) => unknown): RequestHandler =>
+ (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getEmployee(db, id) === null) {
+ res.status(404).json({ ok: false, error: 'Employee not found' }); return
+ }
+ try {
+ res.json({ ok: true, employee: fn(id, res, req) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ }
+ r.patch('/employees/:id', requireAuth, requireOwner, employeeAction((id, res, req) => {
+ const body = req.body as { displayName?: unknown; role?: unknown }
+ const patch: { displayName?: string; role?: EmployeeRole } = {}
+ if (typeof body.displayName === 'string') patch.displayName = body.displayName
+ if (typeof body.role === 'string') patch.role = body.role as EmployeeRole // repo validates the enum
+ return updateEmployee(db, staffId(res), id, patch)
+ }))
+ r.post('/employees/:id/deactivate', requireAuth, requireOwner, employeeAction((id, res) =>
+ deactivateEmployee(db, staffId(res), id)))
+ r.post('/employees/:id/reactivate', requireAuth, requireOwner, employeeAction((id, res) =>
+ reactivateEmployee(db, staffId(res), id)))
+ r.post('/employees/:id/password', requireAuth, requireOwner, employeeAction((id, res, req) => {
+ const { password } = req.body as { password?: unknown }
+ setEmployeePassword(db, staffId(res), id, typeof password === 'string' ? password : '')
+ return getEmployee(db, id)
+ }))
+
// ---------- clients ----------
r.get('/clients', requireAuth, (req, res) => {
const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined
diff --git a/apps/hq/test/employees-routes.test.ts b/apps/hq/test/employees-routes.test.ts
new file mode 100644
index 0000000..b0cae6c
--- /dev/null
+++ b/apps/hq/test/employees-routes.test.ts
@@ -0,0 +1,136 @@
+// apps/hq/test/employees-routes.test.ts — Phase 2: /employees API surface.
+// Mutations are owner-only; GET is any signed-in user (name/owner picker).
+import { describe, it, expect, afterAll } from 'vitest'
+import express from 'express'
+import { openDb } from '../src/db'
+import { seedIfEmpty } from '../src/seed'
+import { createStaff, login } from '../src/auth'
+import { apiRouter } from '../src/api'
+
+function appWith() {
+ const db = openDb(':memory:'); seedIfEmpty(db)
+ createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
+ createStaff(db, { email: 'staff@test.in', displayName: 'Staffer', role: 'staff', password: 'staff-password' })
+ const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
+ const server = app.listen(0)
+ const base = `http://localhost:${(server.address() as { port: number }).port}/api`
+ return { db, server, base }
+}
+
+describe('/employees routes', () => {
+ const ctx = appWith()
+ afterAll(() => ctx.server.close())
+
+ const tokenOf = async (email: string, password: string) =>
+ (await (await fetch(`${ctx.base}/auth/login`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ })).json() as { token: string }).token
+
+ const call = async (token: string | null, method: string, path: string, body?: unknown) => {
+ const res = await fetch(`${ctx.base}${path}`, {
+ method,
+ headers: { 'content-type': 'application/json', ...(token !== null ? { authorization: `Bearer ${token}` } : {}) },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ })
+ return { status: res.status, json: await res.json() as any }
+ }
+
+ it('GET /employees requires auth', async () => {
+ expect((await call(null, 'GET', '/employees')).status).toBe(401)
+ })
+
+ it('GET /employees works for any signed-in user (staff picker) and returns all rows + total', async () => {
+ const token = await tokenOf('staff@test.in', 'staff-password')
+ const out = await call(token, 'GET', '/employees')
+ expect(out.status).toBe(200)
+ expect(out.json.ok).toBe(true)
+ // seed owner + test owner + staffer — every row present, no silent truncation
+ expect(out.json.employees.length).toBeGreaterThanOrEqual(3)
+ expect(out.json.total).toBe(out.json.employees.length)
+ for (const e of out.json.employees) {
+ expect(e).not.toHaveProperty('pw_salt')
+ expect(e).not.toHaveProperty('pw_hash')
+ }
+ })
+
+ it('owner CRUD round-trips: create → list → patch → deactivate → reactivate → password', async () => {
+ const token = await tokenOf('owner@test.in', 'owner-password')
+
+ const created = await call(token, 'POST', '/employees', {
+ email: 'New@Test.in', displayName: 'Newbie', role: 'manager', password: 'password-9',
+ })
+ expect(created.status).toBe(200)
+ expect(created.json.employee).toMatchObject({ email: 'new@test.in', displayName: 'Newbie', role: 'manager', active: true })
+ const id = created.json.employee.id as string
+
+ const listed = await call(token, 'GET', '/employees')
+ expect(listed.json.employees.some((e: { id: string }) => e.id === id)).toBe(true)
+
+ const patched = await call(token, 'PATCH', `/employees/${id}`, { displayName: 'Renamed', role: 'staff' })
+ expect(patched.status).toBe(200)
+ expect(patched.json.employee).toMatchObject({ id, displayName: 'Renamed', role: 'staff' })
+
+ const deactivated = await call(token, 'POST', `/employees/${id}/deactivate`)
+ expect(deactivated.status).toBe(200)
+ expect(deactivated.json.employee.active).toBe(false)
+
+ const reactivated = await call(token, 'POST', `/employees/${id}/reactivate`)
+ expect(reactivated.status).toBe(200)
+ expect(reactivated.json.employee.active).toBe(true)
+
+ const pw = await call(token, 'POST', `/employees/${id}/password`, { password: 'brand-new-secret' })
+ expect(pw.status).toBe(200)
+ expect(login(ctx.db, 'new@test.in', 'password-9')).toBeNull()
+ expect(login(ctx.db, 'new@test.in', 'brand-new-secret')).not.toBeNull()
+ })
+
+ it('non-owner gets 403 on every mutation route', async () => {
+ const owner = await tokenOf('owner@test.in', 'owner-password')
+ const target = (await call(owner, 'POST', '/employees', {
+ email: 'victim@test.in', displayName: 'Victim', role: 'staff', password: 'password-9',
+ })).json.employee.id as string
+
+ const token = await tokenOf('staff@test.in', 'staff-password')
+ const attempts = [
+ call(token, 'POST', '/employees', { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'password-9' }),
+ call(token, 'PATCH', `/employees/${target}`, { displayName: 'Hacked' }),
+ call(token, 'POST', `/employees/${target}/deactivate`),
+ call(token, 'POST', `/employees/${target}/reactivate`),
+ call(token, 'POST', `/employees/${target}/password`, { password: 'password-hacked' }),
+ ]
+ for (const attempt of await Promise.all(attempts)) expect(attempt.status).toBe(403)
+ })
+
+ it('repo validation surfaces as 400; unknown id as 404', async () => {
+ const token = await tokenOf('owner@test.in', 'owner-password')
+ const badRole = await call(token, 'POST', '/employees', {
+ email: 'y@x.co', displayName: 'Y', role: 'admin', password: 'password-9',
+ })
+ expect(badRole.status).toBe(400)
+ expect(badRole.json.error).toMatch(/role/i)
+
+ const shortPw = await call(token, 'POST', '/employees', {
+ email: 'y@x.co', displayName: 'Y', role: 'staff', password: 'short',
+ })
+ expect(shortPw.status).toBe(400)
+
+ expect((await call(token, 'PATCH', '/employees/nope', { displayName: 'Z' })).status).toBe(404)
+ expect((await call(token, 'POST', '/employees/nope/deactivate')).status).toBe(404)
+ expect((await call(token, 'POST', '/employees/nope/reactivate')).status).toBe(404)
+ expect((await call(token, 'POST', '/employees/nope/password', { password: 'password-9' })).status).toBe(404)
+ })
+
+ it('mutations act as the signed-in user (audit userId = actor)', async () => {
+ const token = await tokenOf('owner@test.in', 'owner-password')
+ const created = await call(token, 'POST', '/employees', {
+ email: 'audited@test.in', displayName: 'Audited', role: 'staff', password: 'password-9',
+ })
+ const id = created.json.employee.id as string
+ const ownerId = (ctx.db.prepare(`SELECT id FROM staff_user WHERE email='owner@test.in'`).get() as { id: string }).id
+ const row = ctx.db.prepare(
+ `SELECT user_id FROM audit_log WHERE entity='staff_user' AND entity_id=? AND action='create'`,
+ ).get(id) as { user_id: string }
+ expect(row.user_id).toBe(ownerId)
+ })
+})