You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/components/SecretField.tsx

97 lines
4.1 KiB
TypeScript

import { useState } from 'react'
import { Button, useToast } from '@sims/ui'
/**
* Reveal-only secret cell: shows `••••••••` for a stored value, an owner/manager-gated
* audited Reveal → Copy/Hide, and an optional inline Set/Update editor. Factors out the
* three near-identical copies in ClientDetail (finding #4):
* - SupportCard DB password → onReveal: revealDbPassword(id), onSave: patchClient(id, { dbPassword })
* - ServiceDataCard portal password → onReveal: revealModulePassword(cmId), onSave: patchClientModule(cmId, { password })
* - SecretField module secret → onReveal: revealModuleSecret(cmId, k), onSave: setModuleSecret(cmId, k, v)
*
* Adoption note: ClientDetail.tsx is currently owned by another agent, so those three
* are NOT rewired here — swap them to this component in a later pass to delete the copies.
*/
export function SecretField(props: {
/** Uppercased eyebrow label (pass `${field.label} *` yourself for required fields). */
label: string
/** Whether a value is stored — drives `••••` vs "not set". */
hasValue: boolean
/** Owner/manager gate: Reveal + Set/Update render only when true. */
canManage: boolean
/** Decrypt-and-return the value (audited server-side). */
onReveal: () => Promise<string>
/** Store/replace the value ('' may clear server-side). Omit to hide the Set/Update editor. */
onSave?: (value: string) => Promise<void>
/** Toast shown after a successful save (default: `${label} stored (encrypted)`). */
savedMessage?: string
/** Set/Update input placeholder (default: the label). */
placeholder?: string
/** Extra classes for the value/input span (e.g. 'mono'). Defaults to 'mono'. */
valueClassName?: string
}) {
const toast = useToast()
const { label, hasValue, canManage, onReveal, onSave } = props
const cls = props.valueClassName ?? 'mono'
const [revealed, setRevealed] = useState<string | undefined>()
const [setting, setSetting] = useState(false)
const [val, setVal] = useState('')
const [busy, setBusy] = useState(false)
const copy = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => toast.ok(`${label} copied`))
.catch(() => toast.err('Copy failed'))
}
const reveal = () => {
if (busy) return
setBusy(true)
onReveal()
.then((v) => setRevealed(v))
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const save = () => {
if (busy || onSave === undefined) return
setBusy(true)
onSave(val)
.then(() => {
toast.ok(props.savedMessage ?? `${label} stored (encrypted)`)
setSetting(false); setVal(''); setRevealed(undefined)
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
return (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
{revealed !== undefined
? (
<>
<span className={cls}>{revealed}</span>
<Button onClick={() => copy(revealed)}>Copy</Button>
<Button onClick={() => setRevealed(undefined)}>Hide</Button>
</>
)
: hasValue
? <><span className={cls}></span>{canManage && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{onSave !== undefined && canManage && !setting && (
<Button onClick={() => setSetting(true)}>{hasValue ? 'Update' : 'Set…'}</Button>
)}
{onSave !== undefined && canManage && setting && (
<>
<input
className={`wf ${cls}`.trim()} type="password" style={{ width: 160 }}
placeholder={props.placeholder ?? label} aria-label={label}
value={val} onChange={(e) => setVal(e.target.value)}
/>
<Button tone="primary" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSetting(false); setVal('') }}>Cancel</Button>
</>
)}
</span>
)
}