diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index 765d8b8..2a9edf9 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -164,11 +164,12 @@ export function Layout() { )} - + {/* D18 WS-E: the user chip opens the self-service profile. */} + {initials(name)} {name} {role()} - + + + +
+

Change password

+ + + setPw((p) => ({ ...p, current: ev.target.value }))} /> + + + setPw((p) => ({ ...p, next: ev.target.value }))} /> + + + setPw((p) => ({ ...p, confirm: ev.target.value }))} /> + + + {pwErr !== undefined && {pwErr}} + +

+ Changing your password signs out every other device; this session stays signed in. +

+
+ + ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 40db362..2dc1b55 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -7,7 +7,7 @@ import { type Client, type ClientInput, type ClientPatch, } from './repos-clients' import { - createEmployee, deactivateEmployee, getEmployee, listEmployees, + changeOwnPassword, createEmployee, deactivateEmployee, getEmployee, listEmployees, reactivateEmployee, setEmployeePassword, updateEmployee, type EmployeeRole, } from './repos-employees' import { @@ -136,12 +136,49 @@ export function apiRouter( } } 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 } = {} + const body = req.body as { displayName?: unknown; role?: unknown; phone?: unknown; title?: unknown } + const patch: { displayName?: string; role?: EmployeeRole; phone?: string; title?: string } = {} if (typeof body.displayName === 'string') patch.displayName = body.displayName if (typeof body.role === 'string') patch.role = body.role as EmployeeRole // repo validates the enum + if (typeof body.phone === 'string') patch.phone = body.phone + if (typeof body.title === 'string') patch.title = body.title return updateEmployee(db, staffId(res), id, patch) })) + + // ---------- self-service profile (D18 WS-E) ---------- + // /me is strictly self-scoped: the id comes from the session, never the request, + // and the whitelist below cannot touch role, email or active. + r.get('/me', requireAuth, (_req, res) => { + const me = getEmployee(db, staffId(res)) + if (me === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return } + res.json({ ok: true, employee: me }) + }) + r.patch('/me', requireAuth, (req, res) => { + try { + const body = req.body as { displayName?: unknown; phone?: unknown } + const patch: { displayName?: string; phone?: string } = {} + if (typeof body.displayName === 'string') patch.displayName = body.displayName + if (typeof body.phone === 'string') patch.phone = body.phone + res.json({ ok: true, employee: updateEmployee(db, staffId(res), staffId(res), patch) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/me/password', requireAuth, (req, res) => { + try { + const body = req.body as { currentPassword?: unknown; newPassword?: unknown } + const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') + changeOwnPassword( + db, staffId(res), + typeof body.currentPassword === 'string' ? body.currentPassword : '', + typeof body.newPassword === 'string' ? body.newPassword : '', + token, + ) + res.json({ ok: true }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) 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) => diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 03246e7..4855037 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -9,6 +9,7 @@ const SCHEMA = ` CREATE TABLE IF NOT EXISTS staff_user ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner','manager','staff')), + phone TEXT, title TEXT, -- D18 WS-E: contact + designation shown on Employees/profile; nothing routes on them pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS session ( @@ -192,6 +193,13 @@ function migrate(db: DB): void { if (!docCols.some((c) => c.name === 'due_date')) { db.exec(`ALTER TABLE document ADD COLUMN due_date TEXT`) } + const staffCols = db.prepare(`PRAGMA table_info(staff_user)`).all() as { name: string }[] + if (!staffCols.some((c) => c.name === 'phone')) { + db.exec(`ALTER TABLE staff_user ADD COLUMN phone TEXT`) + } + if (!staffCols.some((c) => c.name === 'title')) { + db.exec(`ALTER TABLE staff_user ADD COLUMN title TEXT`) + } const clientCols = db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[] if (!clientCols.some((c) => c.name === 'owner_id')) { db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`) diff --git a/apps/hq/src/repos-employees.ts b/apps/hq/src/repos-employees.ts index 282c3e7..ec71020 100644 --- a/apps/hq/src/repos-employees.ts +++ b/apps/hq/src/repos-employees.ts @@ -2,7 +2,7 @@ // staff_user IS the employee table: renaming it would churn session.staff_id, // interaction.staff_id and audit entity names for zero gain. "Employee" lives in // the repo/API/UI vocabulary only. Password columns never leave this module. -import { hashPin } from '@sims/auth' +import { hashPin, verifyPin } from '@sims/auth' import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' @@ -12,17 +12,22 @@ const ROLES: readonly EmployeeRole[] = ['owner', 'manager', 'staff'] as const export interface Employee { id: string; email: string; displayName: string; role: EmployeeRole; active: boolean + phone: string | null; title: string | null } -interface EmployeeRow { id: string; email: string; display_name: string; role: string; active: number } +interface EmployeeRow { + id: string; email: string; display_name: string; role: string; active: number + phone: string | null; title: string | null +} // Explicit column list — pw_salt/pw_hash must never be selected here. -const COLS = 'id, email, display_name, role, active' +const COLS = 'id, email, display_name, role, active, phone, title' function toEmployee(r: EmployeeRow): Employee { return { id: r.id, email: r.email, displayName: r.display_name, role: r.role as EmployeeRole, active: r.active === 1, + phone: r.phone, title: r.title, } } @@ -78,7 +83,7 @@ export function createEmployee(db: DB, userId: string, input: { } export function updateEmployee(db: DB, userId: string, id: string, patch: { - displayName?: string; role?: EmployeeRole + displayName?: string; role?: EmployeeRole; phone?: string; title?: string }): Employee { return db.transaction(() => { const before = getEmployee(db, id) @@ -97,6 +102,9 @@ export function updateEmployee(db: DB, userId: string, id: string, patch: { sets.push('display_name=?'); args.push(patch.displayName) } if (patch.role !== undefined) { sets.push('role=?'); args.push(patch.role) } + // Contact fields are optional text; an emptied field stores NULL, not ''. + if (patch.phone !== undefined) { sets.push('phone=?'); args.push(patch.phone.trim() === '' ? null : patch.phone.trim()) } + if (patch.title !== undefined) { sets.push('title=?'); args.push(patch.title.trim() === '' ? null : patch.title.trim()) } // An empty patch is a caller error — succeeding silently would write a // no-op audit row and tell the caller a change landed when nothing did. if (sets.length === 0) throw new Error('Nothing to update') @@ -108,6 +116,30 @@ export function updateEmployee(db: DB, userId: string, id: string, patch: { })() } +/** + * Self-service password change (D18 WS-E): requires the CURRENT password, re-hashes, + * and kills every OTHER session in the same transaction — the session performing the + * change survives (currentToken), everything else dies with the old credential. + * Audited as change_own_password; hashes never leave this module or reach the audit. + */ +export function changeOwnPassword( + db: DB, userId: string, currentPassword: string, newPassword: string, currentToken: string, +): void { + if (newPassword.length < 8) throw new Error('Password must be at least 8 characters') + const row = db.prepare(`SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`) + .get(userId) as { pw_salt: string; pw_hash: string } | undefined + if (row === undefined) throw new Error('Employee not found') + if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) { + throw new Error('Current password is incorrect') + } + const { salt, hash } = hashPin(newPassword) + db.transaction(() => { + db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, userId) + db.prepare(`DELETE FROM session WHERE staff_id=? AND token != ?`).run(userId, currentToken) + writeAudit(db, userId, 'change_own_password', 'staff_user', userId) + })() +} + /** Reset an employee's password. Audits the action; never logs the hash. * Existing sessions are purged in the same transaction — a reset is the natural * "lock out the old credential" action, so a stolen token must not outlive it. */ diff --git a/apps/hq/test/profile-me.test.ts b/apps/hq/test/profile-me.test.ts new file mode 100644 index 0000000..0243035 --- /dev/null +++ b/apps/hq/test/profile-me.test.ts @@ -0,0 +1,115 @@ +// apps/hq/test/profile-me.test.ts — go-live cluster WS-E: self-service profile + own password (D18) +import express from 'express' +import { describe, it, expect, afterAll } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff, verifySession } from '../src/auth' +import { changeOwnPassword, getEmployee, updateEmployee } from '../src/repos-employees' +import { listAudit } from '../src/audit' +import { apiRouter } from '../src/api' + +function appWith() { + const db = openDb(':memory:'); seedIfEmpty(db) + createStaff(db, { email: 'staff@test.in', displayName: 'Priya', role: 'staff', password: 'first-pass-9' }) + const app = express(); app.use(express.json()); app.locals['db'] = db + app.use('/api', apiRouter(db)) + const server = app.listen(0) + const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api` + return { db, server, baseUrl } +} + +async function login(baseUrl: string, email: string, password: string): Promise { + const res = await fetch(`${baseUrl}/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) return null + return ((await res.json()) as { token: string }).token +} + +const H = (token: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${token}` }) + +describe('self-service profile (/me)', () => { + const servers: { close: () => void }[] = [] + afterAll(() => { for (const s of servers) s.close() }) + + it('GET /me returns the caller, without password columns', async () => { + const ctx = appWith(); servers.push(ctx.server) + const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! + const res = await fetch(`${ctx.baseUrl}/me`, { headers: H(token) }) + const json = await res.json() as { employee: Record } + expect(res.status).toBe(200) + expect(json.employee).toMatchObject({ email: 'staff@test.in', displayName: 'Priya', role: 'staff' }) + expect(json.employee).not.toHaveProperty('pw_salt') + expect(json.employee).not.toHaveProperty('pw_hash') + }) + + it('PATCH /me updates name + phone (audited); role/email in the body are ignored', async () => { + const ctx = appWith(); servers.push(ctx.server) + const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! + const res = await fetch(`${ctx.baseUrl}/me`, { + method: 'PATCH', headers: H(token), + body: JSON.stringify({ displayName: 'Priya N', phone: '+91 98765', role: 'owner', email: 'evil@x.co' }), + }) + const json = await res.json() as { employee: { displayName: string; phone: string | null; role: string; email: string } } + expect(res.status).toBe(200) + expect(json.employee).toMatchObject({ + displayName: 'Priya N', phone: '+91 98765', + role: 'staff', email: 'staff@test.in', // untouchable via /me + }) + const audit = listAudit(ctx.db).find((a) => a.action === 'update' && a.entity === 'staff_user') + expect(audit).toBeDefined() + }) + + it('PATCH /me with an empty patch is rejected', async () => { + const ctx = appWith(); servers.push(ctx.server) + const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! + const res = await fetch(`${ctx.baseUrl}/me`, { method: 'PATCH', headers: H(token), body: '{}' }) + expect(res.status).toBe(400) + }) + + it('POST /me/password rejects a wrong current password and leaves sessions alive', async () => { + const ctx = appWith(); servers.push(ctx.server) + const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! + const res = await fetch(`${ctx.baseUrl}/me/password`, { + method: 'POST', headers: H(token), + body: JSON.stringify({ currentPassword: 'wrong-guess', newPassword: 'brand-new-pw-9' }), + }) + expect(res.status).toBe(400) + expect(verifySession(ctx.db, token)).not.toBeNull() // unchanged + expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).not.toBeNull() // old pw still works + }) + + it('POST /me/password with the right current password: other sessions die, THIS one survives', async () => { + const ctx = appWith(); servers.push(ctx.server) + const other = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // e.g. old laptop + const current = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // the one changing it + const res = await fetch(`${ctx.baseUrl}/me/password`, { + method: 'POST', headers: H(current), + body: JSON.stringify({ currentPassword: 'first-pass-9', newPassword: 'brand-new-pw-9' }), + }) + expect(res.status).toBe(200) + expect(verifySession(ctx.db, current)).not.toBeNull() // survives + expect(verifySession(ctx.db, other)).toBeNull() // dead with the old credential + expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).toBeNull() + expect(await login(ctx.baseUrl, 'staff@test.in', 'brand-new-pw-9')).not.toBeNull() + const audit = listAudit(ctx.db).find((a) => a.action === 'change_own_password') + expect(audit).toBeDefined() + expect(audit!.after_json ?? '').not.toMatch(/hash/) + }) + + it('short new passwords are rejected in the repo', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' }) + expect(() => changeOwnPassword(db, id, 'first-pass-9', 'short', 'tok')).toThrow(/8/) + }) + + it('phone/title round-trip through updateEmployee, and emptying stores NULL', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' }) + updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' }) + expect(getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' }) + updateEmployee(db, id, id, { phone: '', title: ' ' }) + expect(getEmployee(db, id)).toMatchObject({ phone: null, title: null }) + }) +})