From d4388c4f63f665ee6164784005bc55126a740324 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 01:15:16 +0530 Subject: [PATCH] =?UTF-8?q?feat(employees):=20management=20surface=20?= =?UTF-8?q?=E2=80=94=20/employees=20API=20routes=20+=20owner-only=20Employ?= =?UTF-8?q?ees=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/Layout.tsx | 7 +- apps/hq-web/src/api.ts | 28 ++++ apps/hq-web/src/main.tsx | 2 + apps/hq-web/src/pages/Employees.tsx | 191 ++++++++++++++++++++++++++ apps/hq/src/api.ts | 56 ++++++++ apps/hq/test/employees-routes.test.ts | 136 ++++++++++++++++++ 6 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 apps/hq-web/src/pages/Employees.tsx create mode 100644 apps/hq/test/employees-routes.test.ts 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 ( +
+
+ + + + + + + + +
+ {error !== undefined && {error}} +
+ ) +} + +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 ( +
+
+ {/* Email is the login id — immutable after creation. */} + + + setF((p) => ({ ...p, displayName: e.target.value }))} /> + + + + + + +
+ {error !== undefined && {error}} +
+ ) +} + +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) + }) +})