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