feat(employees): self-service profile + change-own-password; phone/title fields (Phase 4, D18 WS-E)

- GET/PATCH /me (self-scoped whitelist — role/email untouchable) + POST /me/password
- changeOwnPassword: current password required; every OTHER session dies in the
  same txn, the changing session survives; audited without hashes
- staff_user.phone/title additive columns, surfaced on Employees (columns + edit
  dialog) and the new Profile page; user chip in the shell opens /profile
- 7 tests; suite 331/331 across 64 files

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

@ -164,11 +164,12 @@ export function Layout() {
</span>
)}
<ThemeSwitcher />
<span className="hq-user">
{/* D18 WS-E: the user chip opens the self-service profile. */}
<NavLink to="/profile" className="hq-user" title="My profile" style={{ textDecoration: 'none', color: 'inherit' }}>
<span className="hq-user-av" style={{ background: av.bg, color: av.fg }}>{initials(name)}</span>
<span className="hq-user-name">{name}</span>
<Badge>{role()}</Badge>
</span>
</NavLink>
<button
type="button" className="wf" title="Logout" aria-label="Logout"
onClick={() => { clearSession(); nav('/login') }}

@ -259,6 +259,7 @@ export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff']
export interface Employee {
id: string; email: string; displayName: string; role: EmployeeRole; active: boolean
phone: string | null; title: string | null
}
/** Full list + server-echoed total (bounded set, no truncation) — any signed-in user. */
@ -268,9 +269,23 @@ 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> =>
/** Email is the login id — immutable; name, role, phone and title may change. */
export const patchEmployee = (
id: string, body: { displayName?: string; role?: EmployeeRole; phone?: string; title?: string },
): Promise<Employee> =>
apiFetch<{ employee: Employee }>(`/employees/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.employee)
// ---------- self-service profile (D18 WS-E) ----------
export const getMe = (): Promise<Employee> =>
apiFetch<{ employee: Employee }>('/me').then((r) => r.employee)
/** Self-scoped: only name + phone; role/email untouchable server-side. */
export const patchMe = (body: { displayName?: string; phone?: string }): Promise<Employee> =>
apiFetch<{ employee: Employee }>('/me', { method: 'PATCH', body: JSON.stringify(body) })
.then((r) => { if (r.employee.displayName !== '') localStorage.setItem(NAME_KEY, r.employee.displayName); return r.employee })
export const changeMyPassword = (currentPassword: string, newPassword: string): Promise<void> =>
apiFetch<{ ok: boolean }>('/me/password', {
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
}).then(() => undefined)
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> =>

@ -16,6 +16,7 @@ import { DocumentTemplate } from './pages/DocumentTemplate'
import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView'
import { Employees } from './pages/Employees'
import { Profile } from './pages/Profile'
import { Pipeline } from './pages/Pipeline'
import { Documents } from './pages/Documents'
import { Reminders } from './pages/Reminders'
@ -35,6 +36,7 @@ function App() {
<Route path="/reports" element={<Reports />} />
<Route path="/settings/template" element={<DocumentTemplate />} />
<Route path="/employees" element={<Employees />} />
<Route path="/profile" element={<Profile />} />
<Route path="/documents" element={<Documents />} />
<Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} />

@ -55,11 +55,13 @@ export function Employees() {
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email', mono: true },
{ key: 'phone', label: 'Phone', mono: true }, { key: 'title', label: 'Title' },
{ key: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' },
]}
rows={employees.map((e) => ({
name: e.displayName, email: e.email,
phone: e.phone ?? '—', title: e.title ?? '—',
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: (
@ -111,7 +113,7 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
const employee = props.state?.kind === 'edit' || props.state?.kind === 'reset' ? props.state.employee : undefined
const [addF, setAddF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [editF, setEditF] = useState({ displayName: '', role: 'staff' as EmployeeRole })
const [editF, setEditF] = useState({ displayName: '', role: 'staff' as EmployeeRole, phone: '', title: '' })
const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
@ -120,7 +122,10 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
if (!open) return
setError(undefined)
setAddF({ email: '', displayName: '', role: 'staff', password: '' })
setEditF({ displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff' })
setEditF({
displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff',
phone: employee?.phone ?? '', title: employee?.title ?? '',
})
setPassword('')
}, [open, props.state])
@ -141,7 +146,10 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
if (editF.displayName.trim() === '') { setError('Name is required'); return }
setError(undefined)
setSaving(true)
patchEmployee(employee.id, { displayName: editF.displayName.trim(), role: editF.role })
patchEmployee(employee.id, {
displayName: editF.displayName.trim(), role: editF.role,
phone: editF.phone, title: editF.title,
})
.then(() => { toast.ok('Employee updated'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
@ -203,6 +211,12 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</FormField>
<FormField label="Phone">
<input className="wf" value={editF.phone} onChange={(e) => setEditF((p) => ({ ...p, phone: e.target.value }))} />
</FormField>
<FormField label="Title / designation">
<input className="wf" value={editF.title} onChange={(e) => setEditF((p) => ({ ...p, title: e.target.value }))} />
</FormField>
</FormGrid>
)}
{kind === 'reset' && (

@ -0,0 +1,92 @@
import { useState } from 'react'
import { Badge, Button, FormField, FormGrid, Notice, PageHeader, Skeleton, ErrorState, useToast } from '@sims/ui'
import { changeMyPassword, getMe, patchMe } from '../api'
import { useData } from './Clients'
/**
* Self-service profile (D18 WS-E): email/role read-only, edit own name + phone,
* change own password (current password required; other sessions die, this one
* survives server-enforced).
*/
export function Profile() {
const toast = useToast()
const me = useData(getMe, [])
const [name, setName] = useState<string | undefined>()
const [phone, setPhone] = useState<string | undefined>()
const [savingProfile, setSavingProfile] = useState(false)
const [profileErr, setProfileErr] = useState<string | undefined>()
const [pw, setPw] = useState({ current: '', next: '', confirm: '' })
const [savingPw, setSavingPw] = useState(false)
const [pwErr, setPwErr] = useState<string | undefined>()
if (me.error !== undefined) return <div className="wf-page"><ErrorState message={me.error} onRetry={me.reload} /></div>
if (me.data === undefined) return <div className="wf-page"><Skeleton rows={5} /></div>
const e = me.data
const nameVal = name ?? e.displayName
const phoneVal = phone ?? (e.phone ?? '')
const saveProfile = () => {
if (savingProfile) return
if (nameVal.trim() === '') { setProfileErr('Name is required'); return }
setProfileErr(undefined); setSavingProfile(true)
patchMe({ displayName: nameVal.trim(), phone: phoneVal })
.then(() => { toast.ok('Profile updated'); me.reload() })
.catch((err: Error) => setProfileErr(err.message))
.finally(() => setSavingProfile(false))
}
const savePassword = () => {
if (savingPw) return
if (pw.next.length < 8) { setPwErr('New password must be at least 8 characters'); return }
if (pw.next !== pw.confirm) { setPwErr('New passwords do not match'); return }
setPwErr(undefined); setSavingPw(true)
changeMyPassword(pw.current, pw.next)
.then(() => { toast.ok('Password changed — other sessions were signed out'); setPw({ current: '', next: '', confirm: '' }) })
.catch((err: Error) => setPwErr(err.message))
.finally(() => setSavingPw(false))
}
return (
<div className="wf-page">
<PageHeader title="My profile" desc="Your account — name and phone are yours to edit; role and email are set by the owner." />
<section>
<h2>Details</h2>
<FormGrid>
<FormField label="Email (login id)"><input className="wf" value={e.email} disabled /></FormField>
<FormField label="Role"><div style={{ paddingTop: 6 }}><Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>{e.title !== null ? <span style={{ marginLeft: 8, opacity: 0.75 }}>{e.title}</span> : null}</div></FormField>
<FormField label="Name">
<input className="wf" value={nameVal} onChange={(ev) => setName(ev.target.value)} />
</FormField>
<FormField label="Phone">
<input className="wf" value={phoneVal} onChange={(ev) => setPhone(ev.target.value)} />
</FormField>
</FormGrid>
{profileErr !== undefined && <Notice tone="err">{profileErr}</Notice>}
<Button tone="primary" onClick={saveProfile}>{savingProfile ? 'Saving…' : 'Save profile'}</Button>
</section>
<section>
<h2>Change password</h2>
<FormGrid>
<FormField label="Current password">
<input className="wf" type="password" value={pw.current} onChange={(ev) => setPw((p) => ({ ...p, current: ev.target.value }))} />
</FormField>
<FormField label="New password (min 8)">
<input className="wf" type="password" value={pw.next} onChange={(ev) => setPw((p) => ({ ...p, next: ev.target.value }))} />
</FormField>
<FormField label="Confirm new password">
<input className="wf" type="password" value={pw.confirm} onChange={(ev) => setPw((p) => ({ ...p, confirm: ev.target.value }))} />
</FormField>
</FormGrid>
{pwErr !== undefined && <Notice tone="err">{pwErr}</Notice>}
<Button tone="primary" onClick={savePassword}>{savingPw ? 'Changing…' : 'Change password'}</Button>
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
Changing your password signs out every other device; this session stays signed in.
</p>
</section>
</div>
)
}

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

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

@ -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. */

@ -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<string | null> {
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<string, unknown> }
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 })
})
})
Loading…
Cancel
Save