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:
parent
c23724bade
commit
b2b4709abe
1 changed files with 43 additions and 4 deletions
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue