feat(clients): support-access data — AnyDesk/OS, district+sector filters, encrypted DB password with audited reveal (Phase 6, D18 WS-F)

- additive client columns; toClient never maps db_password_enc (payloads carry
  only hasDbPassword); listClients gains district/sector filters
- setClientDbPassword: AES-256-GCM via HQ_SECRET_KEY, refuses loudly without a
  key; revealClientDbPassword decrypts + writes one audit row per reveal
- PATCH /clients/🆔 dbPassword is owner/manager-gated inside the same route;
  POST /clients/:id/reveal-db-password (owner/manager)
- client dialog gains district/sector/AnyDesk/OS; Client 360 gains the Support
  card (copy AnyDesk, •••• + Reveal/Set for managerial)
- 5 tests; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 28aa08c9c9
commit 30c2e3b60b

@ -88,6 +88,9 @@ export interface Client {
address: string; contacts: ClientContact[]; status: ClientStatus; notes: string address: string; contacts: ClientContact[]; status: ClientStatus; notes: string
/** Account/enquiry owner (→ employee id); absent = unassigned. */ /** Account/enquiry owner (→ employee id); absent = unassigned. */
ownerId?: string ownerId?: string
/** D18 WS-F support-access data; the DB password itself is reveal-only. */
anydesk?: string; os?: string; district?: string; sector?: string
hasDbPassword: boolean
} }
export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage' export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage'
@ -173,9 +176,20 @@ export interface Ledger {
// ---------- typed calls ---------- // ---------- typed calls ----------
export const getClients = (q?: string): Promise<Client[]> => export const getClients = (
apiFetch<{ clients: Client[] }>(`/clients${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`) q?: string, filters?: { district?: string; sector?: string },
.then((r) => r.clients) ): Promise<Client[]> => {
const params = new URLSearchParams()
if (q !== undefined && q !== '') params.set('q', q)
if (filters?.district !== undefined && filters.district !== '') params.set('district', filters.district)
if (filters?.sector !== undefined && filters.sector !== '') params.set('sector', filters.sector)
const qs = params.toString()
return apiFetch<{ clients: Client[] }>(`/clients${qs !== '' ? `?${qs}` : ''}`).then((r) => r.clients)
}
/** Decrypt-and-return the client's DB password — owner/manager; every call is audited. */
export const revealDbPassword = (id: string): Promise<string> =>
apiFetch<{ password: string }>(`/clients/${id}/reveal-db-password`, { method: 'POST', body: '{}' })
.then((r) => r.password)
export const getClient = (id: string): Promise<Client> => export const getClient = (id: string): Promise<Client> =>
apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client) apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client)
export const createClient = (body: Record<string, unknown>): Promise<Client> => export const createClient = (body: Record<string, unknown>): Promise<Client> =>

@ -14,7 +14,10 @@ export function ClientFormDialog(props: {
}) { }) {
const toast = useToast() const toast = useToast()
const editing = props.initial !== undefined const editing = props.initial !== undefined
const [f, setF] = useState({ name: '', gstin: '', stateCode: '32', address: '', notes: '' }) const [f, setF] = useState({
name: '', gstin: '', stateCode: '32', address: '', notes: '',
anydesk: '', os: '', district: '', sector: '',
})
const [contacts, setContacts] = useState<ContactDraft[]>([]) const [contacts, setContacts] = useState<ContactDraft[]>([])
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -26,6 +29,7 @@ export function ClientFormDialog(props: {
setF({ setF({
name: c?.name ?? '', gstin: c?.gstin ?? '', stateCode: c?.stateCode ?? '32', name: c?.name ?? '', gstin: c?.gstin ?? '', stateCode: c?.stateCode ?? '32',
address: c?.address ?? '', notes: c?.notes ?? '', address: c?.address ?? '', notes: c?.notes ?? '',
anydesk: c?.anydesk ?? '', os: c?.os ?? '', district: c?.district ?? '', sector: c?.sector ?? '',
}) })
setContacts((c?.contacts ?? []).map((ct) => ({ setContacts((c?.contacts ?? []).map((ct) => ({
name: ct.name, email: ct.email ?? '', phone: ct.phone ?? '', role: ct.role ?? '', name: ct.name, email: ct.email ?? '', phone: ct.phone ?? '', role: ct.role ?? '',
@ -53,6 +57,15 @@ export function ClientFormDialog(props: {
const body = { const body = {
name: f.name.trim(), stateCode: f.stateCode.trim(), name: f.name.trim(), stateCode: f.stateCode.trim(),
address: f.address, notes: f.notes, contacts: cleaned, address: f.address, notes: f.notes, contacts: cleaned,
// Support-access fields (D18 WS-F). PATCH clears on ''; POST omits blanks.
...(editing
? { anydesk: f.anydesk, os: f.os, district: f.district, sector: f.sector }
: {
...(f.anydesk.trim() !== '' ? { anydesk: f.anydesk.trim() } : {}),
...(f.os.trim() !== '' ? { os: f.os.trim() } : {}),
...(f.district.trim() !== '' ? { district: f.district.trim() } : {}),
...(f.sector.trim() !== '' ? { sector: f.sector.trim() } : {}),
}),
// Server quirk: PATCH accepts gstin: null to clear (repos-clients skips the checksum // Server quirk: PATCH accepts gstin: null to clear (repos-clients skips the checksum
// assert on null), but POST's ClientInput.gstin is a bare optional string — sending // assert on null), but POST's ClientInput.gstin is a bare optional string — sending
// null there crashes assertGstin's .toUpperCase() on null. So: editing may clear via // null there crashes assertGstin's .toUpperCase() on null. So: editing may clear via
@ -88,6 +101,10 @@ export function ClientFormDialog(props: {
<FormField label="GSTIN (optional)"><input className="wf mono" value={f.gstin} onChange={set('gstin')} /></FormField> <FormField label="GSTIN (optional)"><input className="wf mono" value={f.gstin} onChange={set('gstin')} /></FormField>
<FormField label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></FormField> <FormField label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></FormField>
<FormField label="Address" wide><textarea className="wf" rows={2} value={f.address} onChange={set('address')} /></FormField> <FormField label="Address" wide><textarea className="wf" rows={2} value={f.address} onChange={set('address')} /></FormField>
<FormField label="District"><input className="wf" value={f.district} onChange={set('district')} /></FormField>
<FormField label="Sector"><input className="wf" value={f.sector} onChange={set('sector')} /></FormField>
<FormField label="AnyDesk ID"><input className="wf mono" value={f.anydesk} onChange={set('anydesk')} /></FormField>
<FormField label="OS"><input className="wf" value={f.os} onChange={set('os')} /></FormField>
<FormField label="Notes" wide><textarea className="wf" rows={2} value={f.notes} onChange={set('notes')} /></FormField> <FormField label="Notes" wide><textarea className="wf" rows={2} value={f.notes} onChange={set('notes')} /></FormField>
</FormGrid> </FormGrid>
<h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3> <h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3>

@ -7,14 +7,14 @@ import {
} from '@sims/ui' } from '@sims/ui'
import { import {
assignClientModule, getClient, getClientModules, getLedger, getModules, assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, role, isManagerial, getEmployees, setClientOwner, patchClient, revealDbPassword, role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan, getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction, getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getClientAwsUsage,
CLIENT_STATUSES, KIND_LABEL, CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type ClientStatus, type Interaction, type Outcome, type AmcContract, type AmcPaidStatus, type Client, type ClientStatus, type Interaction,
type RecurringPlan, type Outcome, type RecurringPlan,
} from '../api' } from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients' import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import { import {
@ -164,6 +164,7 @@ export function ClientDetail() {
</Toolbar> </Toolbar>
)} )}
{actionErr !== undefined && <ErrorState message={actionErr} />} {actionErr !== undefined && <ErrorState message={actionErr} />}
<SupportCard client={c} onChanged={() => client.reload()} />
<ClientFormDialog <ClientFormDialog
open={editing} open={editing}
onClose={() => setEditing(false)} onClose={() => setEditing(false)}
@ -494,3 +495,73 @@ export function ClientDetail() {
</div> </div>
) )
} }
/**
* Support-access card (D18 WS-F): the fields the old APEX book carried on every
* client row AnyDesk id (44× referenced there), OS, district/sector plus the
* encrypted DB password: shown as , revealed only by owner/manager, and every
* reveal is audited server-side. Setting/updating it rides PATCH (managerial).
*/
function SupportCard(props: { client: Client; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const c = props.client
const [revealed, setRevealed] = useState<string | undefined>()
const [settingPw, setSettingPw] = useState(false)
const [pw, setPw] = useState('')
const [busy, setBusy] = useState(false)
const copy = (text: string, what: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
}
const reveal = () => {
if (busy) return
setBusy(true)
revealDbPassword(c.id)
.then((p) => setRevealed(p))
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const savePw = () => {
if (busy || pw === '') return
setBusy(true)
patchClient(c.id, { dbPassword: pw })
.then(() => { toast.ok('DB password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
</span>
)
return (
<div className="wf-card" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, padding: '10px 14px' }}>
{cell('AnyDesk', c.anydesk, true, true)}
{cell('OS', c.os)}
{cell('District', c.district)}
{cell('Sector', c.sector)}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>DB password</span>
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'DB password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: c.hasDbPassword
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{c.hasDbPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="DB password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
</span>
</div>
)
}

@ -3,7 +3,8 @@ import { fyOf, validateGstin } from '@sims/domain'
import { isManagerial, login, requireAuth, requireOwner } from './auth' import { isManagerial, login, requireAuth, requireOwner } from './auth'
import type { DB } from './db' import type { DB } from './db'
import { import {
createClient, getClient, listClients, setClientOwner, updateClient, createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword,
setClientOwner, updateClient,
type Client, type ClientInput, type ClientPatch, type Client, type ClientInput, type ClientPatch,
} from './repos-clients' } from './repos-clients'
import { import {
@ -192,7 +193,11 @@ export function apiRouter(
// ---------- clients ---------- // ---------- clients ----------
r.get('/clients', requireAuth, (req, res) => { r.get('/clients', requireAuth, (req, res) => {
const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined
res.json({ ok: true, clients: listClients(db, q) }) const filters = {
...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}),
...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}),
}
res.json({ ok: true, clients: listClients(db, q, filters) })
}) })
r.post('/clients', requireAuth, (req, res) => { r.post('/clients', requireAuth, (req, res) => {
try { try {
@ -202,6 +207,22 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
} }
}) })
// D18 WS-F: decrypt-and-return the support DB password — every reveal audited.
r.post('/clients/:id/reveal-db-password', requireAuth, (req, res) => {
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) {
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
try {
res.json({ ok: true, password: revealClientDbPassword(db, viewer.id, id, gmail().keyHex) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.get('/clients/:id', requireAuth, (req, res) => { r.get('/clients/:id', requireAuth, (req, res) => {
const client = getClient(db, String(req.params['id'] ?? '')) const client = getClient(db, String(req.params['id'] ?? ''))
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
@ -213,7 +234,20 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client not found' }); return res.status(404).json({ ok: false, error: 'Client not found' }); return
} }
try { try {
const client = updateClient(db, staffId(res), id, req.body as ClientPatch) // D18 WS-F: the DB password rides the same PATCH but is its own gated,
// audited write — owner/manager only, encrypted at rest, never in ClientPatch.
const { dbPassword, ...rest } = req.body as ClientPatch & { dbPassword?: unknown }
if (dbPassword !== undefined) {
if (typeof dbPassword !== 'string') throw new Error('dbPassword must be a string')
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) {
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
setClientDbPassword(db, viewer.id, id, dbPassword, gmail().keyHex)
}
const client = Object.keys(rest).length > 0
? updateClient(db, staffId(res), id, rest)
: getClient(db, id)!
res.json({ ok: true, client }) res.json({ ok: true, client })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })

@ -21,6 +21,9 @@ CREATE TABLE IF NOT EXISTS client (
contacts TEXT NOT NULL DEFAULT '[]', -- JSON [{name,phone,email,role}] contacts TEXT NOT NULL DEFAULT '[]', -- JSON [{name,phone,email,role}]
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')),
owner_id TEXT, -- account/enquiry owner staff_user.id; NULL = unassigned (routing, D-EMP A4) owner_id TEXT, -- account/enquiry owner staff_user.id; NULL = unassigned (routing, D-EMP A4)
-- D18 WS-F: support-access data from the APEX book. db_password_enc is AES-256-GCM
-- (HQ_SECRET_KEY) and never appears in list/get payloads reveal-only, audited.
anydesk TEXT, os TEXT, district TEXT, sector TEXT, db_password_enc TEXT,
notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS module ( CREATE TABLE IF NOT EXISTS module (
@ -204,6 +207,12 @@ function migrate(db: DB): void {
if (!clientCols.some((c) => c.name === 'owner_id')) { if (!clientCols.some((c) => c.name === 'owner_id')) {
db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`) db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`)
} }
// D18 WS-F: support-access fields from the APEX client book.
for (const col of ['anydesk', 'os', 'district', 'sector', 'db_password_enc']) {
if (!clientCols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE client ADD COLUMN ${col} TEXT`)
}
}
rebuildStaffUserRoleCheck(db) rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db) rebuildReminderRuleKindCheck(db)
} }

@ -1,5 +1,6 @@
import { uuidv7, validateGstin } from '@sims/domain' import { uuidv7, validateGstin } from '@sims/domain'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import { decrypt, encrypt } from './crypto'
import type { DB } from './db' import type { DB } from './db'
/** Client registry — plain functions over the handle (D12 portable-repo pattern). */ /** Client registry — plain functions over the handle (D12 portable-repo pattern). */
@ -12,11 +13,18 @@ export interface Client {
status: 'lead' | 'active' | 'dormant' | 'lost'; notes: string status: 'lead' | 'active' | 'dormant' | 'lost'; notes: string
/** Account/enquiry owner (→ staff_user.id); absent = unassigned. Routes leads (D-EMP A4). */ /** Account/enquiry owner (→ staff_user.id); absent = unassigned. Routes leads (D-EMP A4). */
ownerId?: string ownerId?: string
/** D18 WS-F support-access data (APEX parity). The DB password is NOT here
* it is encrypted at rest and readable only via revealClientDbPassword (audited). */
anydesk?: string; os?: string; district?: string; sector?: string
/** True when an encrypted DB password is stored (the UI shows •••• + Reveal). */
hasDbPassword: boolean
} }
interface ClientRow { interface ClientRow {
id: string; code: string; name: string; gstin: string | null; state_code: string id: string; code: string; name: string; gstin: string | null; state_code: string
address: string; contacts: string; status: string; owner_id: string | null; notes: string address: string; contacts: string; status: string; owner_id: string | null; notes: string
anydesk: string | null; os: string | null; district: string | null; sector: string | null
db_password_enc: string | null
source: string; created_at: string source: string; created_at: string
} }
@ -28,6 +36,11 @@ function toClient(r: ClientRow): Client {
contacts: JSON.parse(r.contacts) as ClientContact[], contacts: JSON.parse(r.contacts) as ClientContact[],
status: r.status as Client['status'], notes: r.notes, status: r.status as Client['status'], notes: r.notes,
...(r.owner_id !== null ? { ownerId: r.owner_id } : {}), ...(r.owner_id !== null ? { ownerId: r.owner_id } : {}),
...(r.anydesk !== null ? { anydesk: r.anydesk } : {}),
...(r.os !== null ? { os: r.os } : {}),
...(r.district !== null ? { district: r.district } : {}),
...(r.sector !== null ? { sector: r.sector } : {}),
hasDbPassword: r.db_password_enc !== null, // the ciphertext itself never leaves the repo
} }
} }
@ -36,16 +49,24 @@ function assertGstin(gstin: string): void {
if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`) if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`)
} }
export function listClients(db: DB, q?: string): Client[] { export function listClients(
let rows: ClientRow[] db: DB, q?: string, filters: { district?: string; sector?: string } = {},
): Client[] {
let where = ' WHERE 1=1'
const args: unknown[] = []
if (q !== undefined && q !== '') { if (q !== undefined && q !== '') {
const like = `%${q}%` const like = `%${q}%`
rows = db.prepare( where += ` AND (name LIKE ? OR code LIKE ? OR gstin LIKE ?)`
`SELECT * FROM client WHERE name LIKE ? OR code LIKE ? OR gstin LIKE ? ORDER BY name`, args.push(like, like, like)
).all(like, like, like) as ClientRow[] }
} else { // D18 WS-F: the old book's two working filters.
rows = db.prepare(`SELECT * FROM client ORDER BY name`).all() as ClientRow[] if (filters.district !== undefined && filters.district !== '') {
where += ` AND district=?`; args.push(filters.district)
} }
if (filters.sector !== undefined && filters.sector !== '') {
where += ` AND sector=?`; args.push(filters.sector)
}
const rows = db.prepare(`SELECT * FROM client${where} ORDER BY name`).all(...args) as ClientRow[]
return rows.map(toClient) return rows.map(toClient)
} }
@ -57,6 +78,7 @@ export function getClient(db: DB, id: string): Client | null {
export interface ClientInput { export interface ClientInput {
code?: string; name: string; gstin?: string; stateCode: string; address?: string code?: string; name: string; gstin?: string; stateCode: string; address?: string
contacts?: ClientContact[]; status?: Client['status']; notes?: string contacts?: ClientContact[]; status?: Client['status']; notes?: string
anydesk?: string; os?: string; district?: string; sector?: string
} }
export function createClient(db: DB, userId: string, input: ClientInput): Client { export function createClient(db: DB, userId: string, input: ClientInput): Client {
@ -65,12 +87,15 @@ export function createClient(db: DB, userId: string, input: ClientInput): Client
const n = (db.prepare(`SELECT COUNT(*) AS n FROM client`).get() as { n: number }).n const n = (db.prepare(`SELECT COUNT(*) AS n FROM client`).get() as { n: number }).n
const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}` const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}`
db.prepare( db.prepare(
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, notes, created_at) `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, anydesk, os, district, sector, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
id, code, input.name, input.gstin ?? null, input.stateCode, id, code, input.name, input.gstin ?? null, input.stateCode,
input.address ?? '', JSON.stringify(input.contacts ?? []), input.address ?? '', JSON.stringify(input.contacts ?? []),
input.status ?? 'active', input.notes ?? '', new Date().toISOString(), input.status ?? 'active',
input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null,
input.notes ?? '', new Date().toISOString(),
) )
const client = getClient(db, id)! const client = getClient(db, id)!
writeAudit(db, userId, 'create', 'client', id, undefined, client) writeAudit(db, userId, 'create', 'client', id, undefined, client)
@ -80,6 +105,8 @@ export function createClient(db: DB, userId: string, input: ClientInput): Client
export interface ClientPatch { export interface ClientPatch {
name?: string; gstin?: string | null; stateCode?: string; address?: string name?: string; gstin?: string | null; stateCode?: string; address?: string
contacts?: ClientContact[]; status?: Client['status']; notes?: string contacts?: ClientContact[]; status?: Client['status']; notes?: string
/** '' clears the field (stored NULL). */
anydesk?: string; os?: string; district?: string; sector?: string
} }
export function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Client { export function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Client {
@ -95,6 +122,13 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
if (patch.contacts !== undefined) { sets.push('contacts=?'); args.push(JSON.stringify(patch.contacts)) } if (patch.contacts !== undefined) { sets.push('contacts=?'); args.push(JSON.stringify(patch.contacts)) }
if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) } if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) }
if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) } if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) }
const optText = (col: string, v: string | undefined) => {
if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) }
}
optText('anydesk', patch.anydesk)
optText('os', patch.os)
optText('district', patch.district)
optText('sector', patch.sector)
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE client SET ${sets.join(', ')} WHERE id=?`).run(...args) db.prepare(`UPDATE client SET ${sets.join(', ')} WHERE id=?`).run(...args)
@ -104,6 +138,38 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
return after return after
} }
/**
* Store (or clear, with '') the client's DB password AES-256-GCM at rest (D18 WS-F).
* Deliberately NOT part of ClientPatch: writing a support credential is its own
* owner/manager-gated, audited action, and the value never appears in audit rows.
* An empty keyHex fails loudly never store a support credential unencrypted.
*/
export function setClientDbPassword(
db: DB, userId: string, id: string, plain: string, keyHex: string,
): Client {
const before = getClient(db, id)
if (before === null) throw new Error('Client not found')
if (plain !== '' && keyHex === '') {
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`)
}
const enc = plain === '' ? null : encrypt(plain, keyHex)
db.prepare(`UPDATE client SET db_password_enc=? WHERE id=?`).run(enc, id)
writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null })
return getClient(db, id)!
}
/** Decrypt-and-return the stored DB password — every reveal writes an audit row. */
export function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): string {
const row = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`)
.get(id) as { db_password_enc: string | null } | undefined
if (row === undefined) throw new Error('Client not found')
if (row.db_password_enc === null) throw new Error('No DB password stored for this client')
if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`)
const plain = decrypt(row.db_password_enc, keyHex)
writeAudit(db, userId, 'reveal_db_password', 'client', id)
return plain
}
/** /**
* Assign (or clear, with null) the account/enquiry owner. Deliberately NOT part of * Assign (or clear, with null) the account/enquiry owner. Deliberately NOT part of
* updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while * updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while

@ -0,0 +1,114 @@
// apps/hq/test/client-support.test.ts — go-live cluster WS-F: client support data (D18)
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import {
createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword, updateClient,
} from '../src/repos-clients'
import { listAudit } from '../src/audit'
import { apiRouter } from '../src/api'
const KEY = '22'.repeat(32)
describe('client support data (repo)', () => {
it('anydesk/os/district/sector round-trip on create and patch; emptying clears to absent', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', {
name: 'Acme Bank', stateCode: '32',
anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop',
})
expect(c).toMatchObject({ anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop' })
const patched = updateClient(db, 'u1', c.id, { anydesk: '', district: 'Satara' })
expect(patched.anydesk).toBeUndefined() // '' clears
expect(patched.district).toBe('Satara')
})
it('district/sector filter the client book', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' })
createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' })
createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' })
expect(listClients(db, undefined, { district: 'Pune' })).toHaveLength(2)
expect(listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1)
expect(listClients(db)).toHaveLength(3) // no filter = everything, unchanged
})
it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(c.hasDbPassword).toBe(false)
setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY)
const after = getClient(db, c.id)!
expect(after.hasDbPassword).toBe(true)
expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload
const raw = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`).get(c.id) as { db_password_enc: string }
expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest
expect(revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret')
const reveals = listAudit(db).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id)
expect(reveals).toHaveLength(1)
// The set audit records THAT it was set, never the value.
const sets = listAudit(db).filter((a) => a.action === 'set_db_password')
expect(sets).toHaveLength(1)
expect(sets[0]!.after_json).not.toContain('oracle')
})
it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(() => setClientDbPassword(db, 'u1', c.id, 'pw', '')).toThrow(/HQ_SECRET_KEY/)
setClientDbPassword(db, 'u1', c.id, 'pw', KEY)
expect(() => revealClientDbPassword(db, 'u1', c.id, '')).toThrow(/HQ_SECRET_KEY/)
})
})
describe('client support data (routes)', () => {
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' })
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0)
const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, baseUrl, c }
}
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
const login = async (baseUrl: string, email: string, password: string) =>
((await (await fetch(`${baseUrl}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json()) as { token: string }).token
it('staff can patch support text fields but NOT the db password; owner can; reveal is gated too', async () => {
const ctx = appWith(); servers.push(ctx.server)
const staffTok = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const ownerTok = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` })
// staff: text fields fine
const r1 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}`, {
method: 'PATCH', headers: H(staffTok), body: JSON.stringify({ anydesk: '999 888 777' }),
})
expect(r1.status).toBe(200)
// staff: dbPassword refused, and nothing else in that request is applied
const r2 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}`, {
method: 'PATCH', headers: H(staffTok), body: JSON.stringify({ dbPassword: 'sneaky' }),
})
expect(r2.status).toBe(403)
// owner: sets it; payload shows only the flag
const r3 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}`, {
method: 'PATCH', headers: H(ownerTok), body: JSON.stringify({ dbPassword: 'oracle#secret' }),
})
const j3 = await r3.json() as { client: Record<string, unknown> }
expect(r3.status).toBe(200)
expect(j3.client['hasDbPassword']).toBe(true)
expect(JSON.stringify(j3)).not.toContain('oracle#secret')
// reveal: staff 403, owner 200 + audited
expect((await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}/reveal-db-password`, { method: 'POST', headers: H(staffTok), body: '{}' })).status).toBe(403)
const r4 = await fetch(`${ctx.baseUrl}/clients/${ctx.c.id}/reveal-db-password`, { method: 'POST', headers: H(ownerTok), body: '{}' })
expect(await r4.json()).toMatchObject({ ok: true, password: 'oracle#secret' })
})
})
Loading…
Cancel
Save