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
parent
51e6d30e86
commit
d4388c4f63
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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…
Reference in New Issue