|
|
|
|
@ -1,4 +1,4 @@
|
|
|
|
|
import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
|
|
|
|
|
import { useState, type CSSProperties, type ReactNode } from 'react'
|
|
|
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
|
|
import {
|
|
|
|
|
@ -959,23 +959,27 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Per-service operational data (D20 — APEX sms_clients_list parity), following
|
|
|
|
|
* SupportCard's exact visual pattern: label/value cells, the portal password as
|
|
|
|
|
* •••• with managerial audited reveal (gated like the DB password), and Edit
|
|
|
|
|
* switching provider/username/remark + the labeled details into inputs — one
|
|
|
|
|
* audited PATCH saves the lot.
|
|
|
|
|
* Per-service operational data (D20 — APEX sms_clients_list parity) + D21 module-defined
|
|
|
|
|
* fields, rendered as a 50/50 report (Access | Details — D-CDR): read-only key/value rows,
|
|
|
|
|
* the portal password as •••• with managerial audited reveal (gated like the DB password),
|
|
|
|
|
* secret fieldSpec values as their own inline SecretField, and ONE Edit per column that
|
|
|
|
|
* swaps the whole card into a single form — provider/username/remark/password + the
|
|
|
|
|
* freeform details + the non-secret fieldSpec values all save through one Save (two
|
|
|
|
|
* audited calls in sequence: patchClientModule for D20, setModuleFields for D21).
|
|
|
|
|
*/
|
|
|
|
|
function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
const managerial = isManagerial()
|
|
|
|
|
const cm = props.cm
|
|
|
|
|
const sortedFieldSpec = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
|
|
|
|
const nonSecretFields = sortedFieldSpec.filter((fld) => fld.type !== 'secret')
|
|
|
|
|
const secretFields = sortedFieldSpec.filter((fld) => fld.type === 'secret')
|
|
|
|
|
const [revealed, setRevealed] = useState<string | undefined>()
|
|
|
|
|
const [settingPw, setSettingPw] = useState(false)
|
|
|
|
|
const [pw, setPw] = useState('')
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
const [editing, setEditing] = useState(false)
|
|
|
|
|
const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' })
|
|
|
|
|
const [details, setDetails] = useState<ServiceDetail[]>([])
|
|
|
|
|
const [fieldDraft, setFieldDraft] = useState<Record<string, string>>({})
|
|
|
|
|
|
|
|
|
|
const copy = (text: string, what: string) => {
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
|
|
|
|
|
@ -988,42 +992,68 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
}
|
|
|
|
|
const savePw = () => {
|
|
|
|
|
if (busy || pw === '') return
|
|
|
|
|
setBusy(true)
|
|
|
|
|
patchClientModule(cm.id, { password: pw })
|
|
|
|
|
.then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
}
|
|
|
|
|
const startEdit = () => {
|
|
|
|
|
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '', password: '' })
|
|
|
|
|
setDetails(cm.details.map((d) => ({ ...d })))
|
|
|
|
|
setFieldDraft(Object.fromEntries(nonSecretFields.map((fld) => [fld.key, cm.fieldValues[fld.key] ?? ''])))
|
|
|
|
|
setEditing(true)
|
|
|
|
|
}
|
|
|
|
|
const saveEdit = () => {
|
|
|
|
|
if (busy) return
|
|
|
|
|
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '')
|
|
|
|
|
if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return }
|
|
|
|
|
const missingRequired = nonSecretFields.find((fld) => fld.required === true && (fieldDraft[fld.key] ?? '').trim() === '')
|
|
|
|
|
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
|
|
|
|
|
setBusy(true)
|
|
|
|
|
// Only the fieldSpec keys that actually changed — '' clears a value server-side.
|
|
|
|
|
const changedFields = Object.fromEntries(
|
|
|
|
|
nonSecretFields
|
|
|
|
|
.filter((fld) => (fieldDraft[fld.key] ?? '') !== (cm.fieldValues[fld.key] ?? ''))
|
|
|
|
|
.map((fld) => [fld.key, fieldDraft[fld.key] ?? '']),
|
|
|
|
|
)
|
|
|
|
|
patchClientModule(cm.id, {
|
|
|
|
|
provider: f.provider, username: f.username, remark: f.remark,
|
|
|
|
|
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
|
|
|
|
|
// Password only when the managerial user typed one (blank = keep current).
|
|
|
|
|
...(managerial && f.password.trim() !== '' ? { password: f.password } : {}),
|
|
|
|
|
})
|
|
|
|
|
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
|
|
|
|
|
.then(() => (Object.keys(changedFields).length > 0 ? setModuleFields(cm.id, changedFields) : undefined))
|
|
|
|
|
.then(() => { toast.ok('Service data saved'); setEditing(false); 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>
|
|
|
|
|
)
|
|
|
|
|
// D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so
|
|
|
|
|
// it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline
|
|
|
|
|
// SecretField widget (independent Set/Update/Reveal) and stay out of this form.
|
|
|
|
|
const fieldControl = (fld: FieldDef) => {
|
|
|
|
|
const v = fieldDraft[fld.key] ?? ''
|
|
|
|
|
const set = (val: string) => setFieldDraft((p) => ({ ...p, [fld.key]: val }))
|
|
|
|
|
if (fld.type === 'boolean') {
|
|
|
|
|
return (
|
|
|
|
|
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
<option value="true">true</option>
|
|
|
|
|
<option value="false">false</option>
|
|
|
|
|
</select>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (fld.type === 'select') {
|
|
|
|
|
return (
|
|
|
|
|
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
{(fld.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (fld.type === 'number') {
|
|
|
|
|
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
|
|
|
|
|
}
|
|
|
|
|
if (fld.type === 'date') {
|
|
|
|
|
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
|
|
|
|
|
}
|
|
|
|
|
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (editing) {
|
|
|
|
|
return (
|
|
|
|
|
@ -1040,6 +1070,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
|
|
|
|
|
)}
|
|
|
|
|
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
|
|
|
|
|
</div>
|
|
|
|
|
{nonSecretFields.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 10px' }} />
|
|
|
|
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 10 }}>
|
|
|
|
|
{nonSecretFields.map((fld) => {
|
|
|
|
|
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
|
|
|
|
|
const val = fieldDraft[fld.key] ?? ''
|
|
|
|
|
const isUrl = /^https?:\/\//i.test(val.trim())
|
|
|
|
|
return (
|
|
|
|
|
<Field key={fld.key} label={fld.required === true ? `${fld.label} *` : fld.label}>
|
|
|
|
|
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
|
|
|
|
{fieldControl(fld)}
|
|
|
|
|
{isUrl && (
|
|
|
|
|
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
|
|
|
|
|
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open ↗</a>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</Field>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{details.map((d, i) => (
|
|
|
|
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
|
|
|
|
<input
|
|
|
|
|
@ -1065,145 +1118,59 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="wf-card" style={{ padding: '10px 14px' }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
|
|
|
|
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span>
|
|
|
|
|
{cell('Provider', cm.provider ?? undefined)}
|
|
|
|
|
{cell('Username', cm.username ?? undefined, true, true)}
|
|
|
|
|
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)}
|
|
|
|
|
{cell('Remark', cm.remark ?? undefined)}
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
|
|
|
|
|
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span>
|
|
|
|
|
{revealed !== undefined
|
|
|
|
|
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
|
|
|
|
|
: cm.hasPassword
|
|
|
|
|
? <><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)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
|
|
|
|
|
{managerial && settingPw && (
|
|
|
|
|
<>
|
|
|
|
|
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password" aria-label="Portal 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>
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
<Button onClick={startEdit}>Edit</Button>
|
|
|
|
|
<div className="cdr-split">
|
|
|
|
|
<div className="col">
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
|
|
|
<span className="lbl" style={{ fontWeight: 700 }}>Access</span>
|
|
|
|
|
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
|
|
|
<Button onClick={startEdit}>Edit</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<Kv label="Provider" value={cm.provider ?? undefined} />
|
|
|
|
|
<Kv label="Login" value={cm.username ?? undefined} mono copyable onCopy={() => copy(cm.username ?? '', 'Username')} />
|
|
|
|
|
<div className="cdr-kv">
|
|
|
|
|
<span className="k">Password</span>
|
|
|
|
|
<span className="v">
|
|
|
|
|
{revealed !== undefined
|
|
|
|
|
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
|
|
|
|
|
: cm.hasPassword
|
|
|
|
|
? <><span className="mono">••••••••</span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
|
|
|
|
|
: <span style={{ opacity: 0.6 }}>not set</span>}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{cm.remark !== null && cm.remark !== '' && <Kv label="Remark" value={cm.remark} />}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="col">
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
|
|
|
<span className="lbl" style={{ fontWeight: 700 }}>Details</span>
|
|
|
|
|
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
|
|
|
<Button onClick={startEdit}>Edit</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{nonSecretFields.map((fld) => (
|
|
|
|
|
<Kv key={fld.key} label={fld.label} value={cm.fieldValues[fld.key]} />
|
|
|
|
|
))}
|
|
|
|
|
{cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
|
|
|
|
|
{secretFields.map((fld) => (
|
|
|
|
|
<div className="cdr-kv" key={fld.key}>
|
|
|
|
|
<span className="k">{fld.label}</span>
|
|
|
|
|
<span className="v"><SecretField cm={cm} field={fld} managerial={managerial} onChanged={props.onChanged} /></span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */}
|
|
|
|
|
{props.fieldSpec.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '12px 0 10px' }} />
|
|
|
|
|
<ModuleFieldsForm cm={cm} fieldSpec={props.fieldSpec} onChanged={props.onChanged} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* D21 module-defined fields (config over code): renders one input per field the owning
|
|
|
|
|
* module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT
|
|
|
|
|
* (setModuleFields); secret fields never leave the server unrevealed — •••• with a
|
|
|
|
|
* managerial audited Reveal + Set/Update (setModuleSecret), exactly like the portal
|
|
|
|
|
* password. Values come from cm.fieldValues; secret set-state from cm.secretKeys.
|
|
|
|
|
*/
|
|
|
|
|
function ModuleFieldsForm(props: { cm: ClientModule; fieldSpec: FieldDef[]; onChanged: () => void }) {
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
const managerial = isManagerial()
|
|
|
|
|
const cm = props.cm
|
|
|
|
|
const sorted = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
|
|
|
|
const nonSecret = sorted.filter((f) => f.type !== 'secret')
|
|
|
|
|
const secrets = sorted.filter((f) => f.type === 'secret')
|
|
|
|
|
|
|
|
|
|
const seed = (): Record<string, string> =>
|
|
|
|
|
Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? '']))
|
|
|
|
|
const [draft, setDraft] = useState<Record<string, string>>(seed)
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
|
|
|
|
|
const dirty = nonSecret.some((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
|
|
|
|
|
const missingRequired = nonSecret.find((f) => f.required === true && (draft[f.key] ?? '').trim() === '')
|
|
|
|
|
|
|
|
|
|
const save = () => {
|
|
|
|
|
if (busy) return
|
|
|
|
|
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
|
|
|
|
|
setBusy(true)
|
|
|
|
|
// Send only the changed keys; '' clears server-side.
|
|
|
|
|
const values = Object.fromEntries(
|
|
|
|
|
nonSecret
|
|
|
|
|
.filter((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
|
|
|
|
|
.map((f) => [f.key, draft[f.key] ?? '']),
|
|
|
|
|
)
|
|
|
|
|
setModuleFields(cm.id, values)
|
|
|
|
|
.then((updated) => {
|
|
|
|
|
toast.ok('Fields saved')
|
|
|
|
|
setDraft(Object.fromEntries(nonSecret.map((f) => [f.key, updated.fieldValues[f.key] ?? ''])))
|
|
|
|
|
props.onChanged()
|
|
|
|
|
})
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const control = (f: FieldDef) => {
|
|
|
|
|
const v = draft[f.key] ?? ''
|
|
|
|
|
const set = (val: string) => setDraft((p) => ({ ...p, [f.key]: val }))
|
|
|
|
|
if (f.type === 'boolean') {
|
|
|
|
|
return (
|
|
|
|
|
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
<option value="true">true</option>
|
|
|
|
|
<option value="false">false</option>
|
|
|
|
|
</select>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (f.type === 'select') {
|
|
|
|
|
return (
|
|
|
|
|
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
{(f.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (f.type === 'number') {
|
|
|
|
|
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
|
|
|
|
|
}
|
|
|
|
|
if (f.type === 'date') {
|
|
|
|
|
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
|
|
|
|
|
}
|
|
|
|
|
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */
|
|
|
|
|
function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
|
|
|
|
|
const v = props.value
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
{nonSecret.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
|
|
|
{nonSecret.map((f) => {
|
|
|
|
|
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
|
|
|
|
|
const val = draft[f.key] ?? ''
|
|
|
|
|
const isUrl = /^https?:\/\//i.test(val.trim())
|
|
|
|
|
return (
|
|
|
|
|
<Field key={f.key} label={f.required === true ? `${f.label} *` : f.label}>
|
|
|
|
|
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
|
|
|
|
{control(f)}
|
|
|
|
|
{isUrl && (
|
|
|
|
|
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
|
|
|
|
|
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open ↗</a>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</Field>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<Toolbar>
|
|
|
|
|
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save fields'}</Button>
|
|
|
|
|
</Toolbar>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{secrets.map((f) => (
|
|
|
|
|
<SecretField key={f.key} cm={cm} field={f} managerial={managerial} onChanged={props.onChanged} />
|
|
|
|
|
))}
|
|
|
|
|
<div className="cdr-kv">
|
|
|
|
|
<span className="k">{props.label}</span>
|
|
|
|
|
<span className="v">
|
|
|
|
|
{v !== undefined && v !== '' ? <span className={`txt${props.mono === true ? ' mono' : ''}`}>{v}</span> : <span style={{ opacity: 0.5 }}>—</span>}
|
|
|
|
|
{props.copyable === true && v !== undefined && v !== '' && <Button onClick={props.onCopy}>Copy</Button>}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|