Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop, Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate from the host/IP field, mirroring the SSH host rename pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Surface the new-integration name field as a labeled input The name field for new generic integrations was a faint header input with only placeholder text, easy to miss. Move it into the form grid as a proper labeled "Name" field next to the other connection fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Add file upload for SSH private key and certificate fields Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting its contents into the Private Key / OPKSSH Certificate fields. * Fix SSH private key paste corrupting multi-line PEM format Private Key and Certificate fields were single-line <input> elements, which strip newlines on paste and corrupt PEM-formatted keys (causing 'Unsupported key format' errors). Render them as multi-line textareas instead so pasted keys keep their line breaks. * Show saved indicator for secret fields instead of appearing blank/deleted GET /api/integrations never returns decrypted secret values (by design), so after navigating away and back, secret/key fields rendered empty - looking exactly like the saved key had been deleted, even though it was still intact and encrypted in the database. Expose which secret keys exist (names only, never values) via secretKeys, and use it to label fields as "saved" with an appropriate placeholder instead of blank. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7a1d260a35
commit
63adccb1c7
3 changed files with 25 additions and 7 deletions
|
|
@ -37,6 +37,9 @@ interface IntegrationRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(row: IntegrationRow) {
|
function serialize(row: IntegrationRow) {
|
||||||
|
const secretKeys = (
|
||||||
|
db.prepare('SELECT key FROM secrets WHERE integration_id = ?').all(row.id) as { key: string }[]
|
||||||
|
).map((r) => r.key)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
|
|
@ -44,6 +47,7 @@ function serialize(row: IntegrationRow) {
|
||||||
enabled: !!row.enabled,
|
enabled: !!row.enabled,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
config: JSON.parse(row.config_json),
|
config: JSON.parse(row.config_json),
|
||||||
|
secretKeys,
|
||||||
lastCheckedAt: row.last_checked_at,
|
lastCheckedAt: row.last_checked_at,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,7 @@ export interface Integration {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: string
|
status: string
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
|
secretKeys: string[]
|
||||||
lastCheckedAt: string | null
|
lastCheckedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -562,15 +562,19 @@ function SshHostsSection() {
|
||||||
const value = values[f.key] ?? savedValue
|
const value = values[f.key] ?? savedValue
|
||||||
|
|
||||||
if (f.file) {
|
if (f.file) {
|
||||||
|
const isSaved = existing?.secretKeys?.includes(f.key)
|
||||||
return (
|
return (
|
||||||
<div key={key} className="col-span-3">
|
<div key={key} className="col-span-3">
|
||||||
<label style={labelStyle}>{f.label}</label>
|
<label style={labelStyle}>
|
||||||
|
{f.label}
|
||||||
|
{isSaved && <span style={{ color: '#2ECC71', fontWeight: 400 }}> · saved</span>}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
style={{ ...inputStyle, height: '90px', paddingRight: '32px', fontFamily: 'monospace', resize: 'vertical', whiteSpace: 'pre' }}
|
style={{ ...inputStyle, height: '90px', paddingRight: '32px', fontFamily: 'monospace', resize: 'vertical', whiteSpace: 'pre' }}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(f.key, e.target.value)}
|
onChange={(e) => onChange(f.key, e.target.value)}
|
||||||
placeholder={existing ? '•••••••••••• (paste to replace)' : 'Paste key contents here, or upload a file'}
|
placeholder={isSaved ? '•••••••••••• (saved — paste to replace)' : 'Paste key contents here, or upload a file'}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -598,16 +602,20 @@ function SshHostsSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSavedSecret = f.secret && existing?.secretKeys?.includes(f.key)
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label style={labelStyle}>{f.label}</label>
|
<label style={labelStyle}>
|
||||||
|
{f.label}
|
||||||
|
{isSavedSecret && <span style={{ color: '#2ECC71', fontWeight: 400 }}> · saved</span>}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
style={{ ...inputStyle, paddingRight: f.secret ? '32px' : undefined }}
|
style={{ ...inputStyle, paddingRight: f.secret ? '32px' : undefined }}
|
||||||
type={f.secret && !isRevealed ? 'password' : 'text'}
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(f.key, e.target.value)}
|
onChange={(e) => onChange(f.key, e.target.value)}
|
||||||
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
|
placeholder={isSavedSecret ? '•••••••••••• (saved — type to replace)' : 'Not configured'}
|
||||||
/>
|
/>
|
||||||
{f.secret && (
|
{f.secret && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -888,22 +896,26 @@ function IntegrationsSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFields(def: (typeof integrationTypeDefs)[number], rowKey: string, getValue: (f: FieldDef) => string, onChange: (f: FieldDef, value: string) => void, placeholderForSecret: string) {
|
function renderFields(def: (typeof integrationTypeDefs)[number], rowKey: string, getValue: (f: FieldDef) => string, onChange: (f: FieldDef, value: string) => void, placeholderForSecret: string, existing?: Integration) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{def.fields.map((f) => {
|
{def.fields.map((f) => {
|
||||||
const key = `${rowKey}-${f.key}`
|
const key = `${rowKey}-${f.key}`
|
||||||
const isRevealed = revealed.has(key)
|
const isRevealed = revealed.has(key)
|
||||||
|
const isSavedSecret = f.secret && existing?.secretKeys?.includes(f.key)
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label style={labelStyle}>{f.label}</label>
|
<label style={labelStyle}>
|
||||||
|
{f.label}
|
||||||
|
{isSavedSecret && <span style={{ color: '#2ECC71', fontWeight: 400 }}> · saved</span>}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
type={f.secret && !isRevealed ? 'password' : 'text'}
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
||||||
value={getValue(f)}
|
value={getValue(f)}
|
||||||
onChange={(e) => onChange(f, e.target.value)}
|
onChange={(e) => onChange(f, e.target.value)}
|
||||||
placeholder={f.secret ? placeholderForSecret : f.placeholder ?? 'Not configured'}
|
placeholder={f.secret ? (isSavedSecret ? `${placeholderForSecret} (saved — type to replace)` : placeholderForSecret) : f.placeholder ?? 'Not configured'}
|
||||||
/>
|
/>
|
||||||
{f.secret && (
|
{f.secret && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -1003,6 +1015,7 @@ function IntegrationsSection() {
|
||||||
(f) => draft[f.key] ?? (f.secret ? '' : existing.config[f.key] ?? ''),
|
(f) => draft[f.key] ?? (f.secret ? '' : existing.config[f.key] ?? ''),
|
||||||
(f, value) => setEditField(existing.id, f.key, value),
|
(f, value) => setEditField(existing.id, f.key, value),
|
||||||
'••••••••••••',
|
'••••••••••••',
|
||||||
|
existing,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue