fix(review): frontend quality pass — a11y, UX safety, error handling (medium/low)
Red-team frontend clusters (a11y + UX + code quality), three coordinated passes: Accessibility: - DataTable clickable rows keyboard-operable (role=button, tabindex, Enter/Space). - <main> landmark + skip link; icon-only buttons get aria-labels; inputs relying on placeholder alone get aria-labels; AWS-ranking link is a real <button>; Login fields wrapped in a <form> so Enter submits from either field; toasts role=alert for errors (urgency not by color alone). UX safety: - 'Notify all' and reminder 'Send' now behind ConfirmDialogs naming the count/ client; document action bar disables while an action is in flight (no double-submit dupes); SendDialog surfaces why a send is blocked instead of failing silently; client search + ticket search debounced (~300ms); inline Client-360 edit failures surface via toast, not an off-screen banner; LivePreview gets a real Retry; destructive client-status transitions confirm first. Code quality: - ErrorBoundary wraps the app (a render throw no longer white-screens). - useData clears stale data on dep change (no record flashing under a new id). - Shared ErrorBoundary/SecretField/Pager components extracted; Pager adopted in Documents; write-call api types tightened off Record<string,unknown>. typecheck clean (root + both workspaces), web build clean, web tests 4/4, full suite 388 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
4cf513d528
commit
35a26de442
@ -0,0 +1,96 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue