Add file upload for SSH private key and certificate fields (#15)

* 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.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel James 2026-06-20 08:11:32 -04:00 committed by GitHub
parent c23724bade
commit b2b4709abe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -35,7 +35,7 @@ const accentColors = [
{ name: 'Red', color: '#E74C3C' }, { name: 'Red', color: '#E74C3C' },
] ]
type FieldDef = { key: string; label: string; secret?: boolean; hint?: string; placeholder?: string } type FieldDef = { key: string; label: string; secret?: boolean; hint?: string; placeholder?: string; file?: boolean }
const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean; fields: FieldDef[] }[] = [ const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean; fields: FieldDef[] }[] = [
{ type: 'proxmox', name: 'Proxmox', multiInstance: true, fields: [ { type: 'proxmox', name: 'Proxmox', multiInstance: true, fields: [
@ -83,9 +83,9 @@ const sshFields: FieldDef[] = [
{ key: 'port', label: 'Port (default 22)' }, { key: 'port', label: 'Port (default 22)' },
{ key: 'username', label: 'Username' }, { key: 'username', label: 'Username' },
{ key: 'password', label: 'Password', secret: true }, { key: 'password', label: 'Password', secret: true },
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true }, { key: 'privateKey', label: 'Private Key (PEM)', secret: true, file: true },
{ key: 'passphrase', label: 'Key Passphrase', secret: true }, { key: 'passphrase', label: 'Key Passphrase', secret: true },
{ key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true }, { key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true, file: true },
] ]
const cardBase: React.CSSProperties = { const cardBase: React.CSSProperties = {
@ -385,6 +385,7 @@ function SshHostsSection() {
const [busy, setBusy] = useState<Set<number>>(new Set()) const [busy, setBusy] = useState<Set<number>>(new Set())
const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([]) const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([])
const nextNewKey = useRef(-1) const nextNewKey = useRef(-1)
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
useEffect(() => { useEffect(() => {
refresh() refresh()
@ -546,12 +547,50 @@ function SshHostsSection() {
const isRevealed = revealed.has(key) const isRevealed = revealed.has(key)
const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
const value = values[f.key] ?? savedValue const value = values[f.key] ?? savedValue
if (f.file) {
return (
<div key={key} className="col-span-3">
<label style={labelStyle}>{f.label}</label>
<div className="relative">
<textarea
style={{ ...inputStyle, height: '90px', paddingRight: '32px', fontFamily: 'monospace', resize: 'vertical', whiteSpace: 'pre' }}
value={value}
onChange={(e) => onChange(f.key, e.target.value)}
placeholder={existing ? '•••••••••••• (paste to replace)' : 'Paste key contents here, or upload a file'}
/>
<input
type="file"
ref={(el) => { fileInputRefs.current[key] = el }}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => onChange(f.key, String(reader.result ?? '').trim())
reader.readAsText(file)
e.target.value = ''
}}
/>
<button
onClick={() => fileInputRefs.current[key]?.click()}
title="Upload from file"
className="absolute cursor-pointer border-none bg-transparent"
style={{ right: '8px', top: '8px', color: '#7A7D85' }}
>
<Upload size={13} />
</button>
</div>
</div>
)
}
return ( return (
<div key={key}> <div key={key}>
<label style={labelStyle}>{f.label}</label> <label style={labelStyle}>{f.label}</label>
<div className="relative"> <div className="relative">
<input <input
style={inputStyle} 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)}