+ )
+}
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