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
parent
1a8a3faf5d
commit
d95bf6d467
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue