feat(employees): management surface — /employees API routes + owner-only Employees page

Phase 2 of the quote-to-close funnel slice. Six /employees routes in
apps/hq/src/api.ts (GET requireAuth for the picker; POST/PATCH/deactivate/
reactivate/password owner-only, acting as the signed-in user); Employees
page (table + add/edit/reset-password forms, email immutable on edit,
count shown, no truncation); owner-only nav item beside Document Template;
typed client fns in hq-web api.ts. 6 new route tests (192 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 51e6d30e86
commit d4388c4f63

@ -25,8 +25,11 @@ export function Layout() {
if (!hasSession()) return <Navigate to="/login" replace />
// 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 (
<div className="hq-shell">

@ -211,6 +211,34 @@ export const recordPayment = (body: Record<string, unknown>): Promise<Payment> =
export const getLedger = (clientId: string): Promise<Ledger> =>
apiFetch<Ledger>(`/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<Employee> =>
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<Employee> =>
apiFetch<{ employee: Employee }>(`/employees/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.employee)
export const deactivateEmployee = (id: string): Promise<Employee> =>
apiFetch<{ employee: Employee }>(`/employees/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee)
export const reactivateEmployee = (id: string): Promise<Employee> =>
apiFetch<{ employee: Employee }>(`/employees/${id}/reactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee)
export const resetEmployeePassword = (id: string, password: string): Promise<Employee> =>
apiFetch<{ employee: Employee }>(`/employees/${id}/password`, {
method: 'POST', body: JSON.stringify({ password }),
}).then((r) => r.employee)
// ---------- HQ-2 shapes ----------
export type Cadence = 'monthly' | 'yearly'

@ -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() {
<Route path="/modules" element={<Modules />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings/template" element={<DocumentTemplate />} />
<Route path="/employees" element={<Employees />} />
<Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} />

@ -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<Employee | undefined>()
const [resetting, setResetting] = useState<Employee | undefined>()
const [error, setError] = useState<string | undefined>()
const act = (p: Promise<unknown>) => {
setError(undefined)
p.then(() => list.reload()).catch((e: Error) => setError(e.message))
}
const employees = list.data?.employees
return (
<div className="wf-page">
<PageHeader
title="Employees"
desc="Console users — owner manages accounts, roles and passwords. Deactivation locks the account out immediately."
actions={(
<Button tone="primary" onClick={() => { setAdding((v) => !v); setEditing(undefined); setResetting(undefined) }}>
+ Add employee
</Button>
)}
/>
{adding && (
<AddEmployeeForm
onDone={() => { setAdding(false); list.reload() }}
onCancel={() => setAdding(false)}
/>
)}
{editing !== undefined && (
<EditEmployeeForm
key={editing.id}
employee={editing}
onDone={() => { setEditing(undefined); list.reload() }}
onCancel={() => setEditing(undefined)}
/>
)}
{resetting !== undefined && (
<ResetPasswordForm
key={resetting.id}
employee={resetting}
onDone={() => { setResetting(undefined); list.reload() }}
onCancel={() => setResetting(undefined)}
/>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{list.error !== undefined && <Notice tone="err">{list.error}</Notice>}
{employees === undefined ? <EmptyState>Loading</EmptyState>
: employees.length === 0 ? <EmptyState>No employees yet.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' },
]}
rows={employees.map((e) => ({
name: e.displayName, email: e.email,
role: <Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>,
active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
actions: (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Button onClick={() => { setEditing(e); setAdding(false); setResetting(undefined) }}>Edit</Button>
<Button onClick={() => { setResetting(e); setAdding(false); setEditing(undefined) }}>Reset pw</Button>
{e.active
? <Button tone="danger" onClick={() => act(deactivateEmployee(e.id))}>Deactivate</Button>
: <Button onClick={() => act(reactivateEmployee(e.id))}>Reactivate</Button>}
</span>
),
}))}
/>
<Toolbar>
<Badge>{list.data!.total} employee{list.data!.total === 1 ? '' : 's'}</Badge>
<span style={{ fontSize: 12, opacity: 0.7 }}>
Guards: the last active owner cannot be demoted or deactivated; you cannot deactivate yourself.
</span>
</Toolbar>
</>
)}
</div>
)
}
function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) {
const [f, setF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [error, setError] = useState<string | undefined>()
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 (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Email (login id)"><input className="wf" type="email" autoFocus value={f.email} onChange={set('email')} /></Field>
<Field label="Name"><input className="wf" value={f.displayName} onChange={set('displayName')} /></Field>
<Field label="Role">
<select className="wf" value={f.role} onChange={set('role')}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</Field>
<Field label="Initial password (min 8)"><input className="wf" type="password" value={f.password} onChange={set('password')} /></Field>
<Button tone="primary" onClick={save}>Create employee</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
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<string | undefined>()
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 (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Email is the login id — immutable after creation. */}
<Field label="Email (immutable)"><input className="wf" value={props.employee.email} disabled /></Field>
<Field label="Name">
<input className="wf" autoFocus value={f.displayName} onChange={(e) => setF((p) => ({ ...p, displayName: e.target.value }))} />
</Field>
<Field label="Role">
<select className="wf" value={f.role} onChange={(e) => setF((p) => ({ ...p, role: e.target.value as EmployeeRole }))}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</Field>
<Button tone="primary" onClick={save}>Save changes</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
function ResetPasswordForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>()
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 (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label={`New password for ${props.employee.email} (min 8)`}>
<input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
<Button tone="primary" onClick={save}>Reset password</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}

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

@ -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)
})
})
Loading…
Cancel
Save