2026-06-18 19:26:48 +00:00
import { useEffect , useRef , useState } from 'react'
import { api , ApiError , type Integration } from '../lib/api'
2026-06-18 20:08:30 +00:00
import { useAuth } from '../lib/AuthContext'
2026-06-18 18:44:26 +00:00
import {
User ,
Palette ,
Plug ,
Bell ,
Database ,
Info ,
Eye ,
EyeOff ,
Check ,
Download ,
Upload ,
Trash2 ,
2026-06-18 18:50:43 +00:00
Camera ,
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
ChevronDown ,
ChevronRight ,
2026-06-18 18:44:26 +00:00
} from 'lucide-react'
const navSections = [
{ id : 'profile' , label : 'Profile' , icon : User } ,
{ id : 'appearance' , label : 'Appearance' , icon : Palette } ,
{ id : 'integrations' , label : 'Integrations' , icon : Plug } ,
{ id : 'notifications' , label : 'Notifications' , icon : Bell } ,
{ id : 'data' , label : 'Data & Backup' , icon : Database } ,
{ id : 'about' , label : 'About' , icon : Info } ,
]
const accentColors = [
{ name : 'Gold' , color : '#C8A434' } ,
{ name : 'Teal' , color : '#2DD4BF' } ,
{ name : 'Purple' , color : '#A855F7' } ,
{ name : 'Blue' , color : '#3B82F6' } ,
{ name : 'Green' , color : '#2ECC71' } ,
{ name : 'Red' , color : '#E74C3C' } ,
]
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>
2026-06-20 08:11:32 -04:00
type FieldDef = { key : string ; label : string ; secret? : boolean ; hint? : string ; placeholder? : string ; file? : boolean }
2026-06-20 10:18:04 +00:00
const integrationTypeDefs : { type : string ; name : string ; multiInstance? : boolean ; fields : FieldDef [ ] } [ ] = [
{ type : 'proxmox' , name : 'Proxmox' , multiInstance : true , fields : [
{ key : 'baseUrl' , label : 'Host URL' , hint : 'e.g. https://192.168.1.10:8006' , placeholder : 'https://192.168.1.10:8006' } ,
{
key : 'apiKey' ,
label : 'API Token' ,
secret : true ,
hint : 'Must be the FULL token string from Datacenter → Permissions → API Tokens, in the form USER@REALM!TOKENID=SECRET — not just the secret.' ,
placeholder : 'root@pam!archnest=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ,
} ,
] } ,
{ type : 'docker' , name : 'Docker' , multiInstance : true , fields : [
{ key : 'baseUrl' , label : 'Socket / Remote URL' , hint : 'Unix socket path or remote daemon URL, e.g. unix:///var/run/docker.sock or tcp://host:2375' , placeholder : 'unix:///var/run/docker.sock' } ,
] } ,
{ type : 'netbird' , name : 'NetBird' , fields : [
{ key : 'apiKey' , label : 'API Key' , secret : true , hint : 'Personal access token from NetBird dashboard → Settings → Access Tokens.' } ,
] } ,
{ type : 'cloudflare' , name : 'Cloudflare' , fields : [
{ key : 'apiKey' , label : 'API Token' , secret : true , hint : 'A scoped API token (not your Global API Key) from My Profile → API Tokens.' } ,
{ key : 'zoneId' , label : 'Zone ID' , hint : 'Found on the domain overview page in the Cloudflare dashboard.' } ,
] } ,
{ type : 'aws' , name : 'AWS' , multiInstance : true , fields : [
{ key : 'accessKey' , label : 'Access Key ID' , hint : 'IAM user access key, e.g. AKIAIOSFODNN7EXAMPLE' } ,
{ key : 'secretKey' , label : 'Secret Access Key' , secret : true , hint : 'IAM user secret key — paired with the Access Key ID above.' } ,
{ key : 'region' , label : 'Region' , hint : 'e.g. us-east-1' , placeholder : 'us-east-1' } ,
] } ,
{ type : 'uptime_kuma' , name : 'Uptime Kuma' , fields : [
{ key : 'baseUrl' , label : 'URL' , placeholder : 'https://uptime.example.com' } ,
{ key : 'apiKey' , label : 'API Key' , secret : true } ,
] } ,
2026-06-18 19:26:48 +00:00
{ type : 'weather' , name : 'Weather API' , fields : [ { key : 'location' , label : 'Location' } , { key : 'units' , label : 'Units' } ] } ,
2026-06-20 10:18:04 +00:00
{ type : 'remote_desktop' , name : 'Remote Desktop' , multiInstance : true , fields : [
2026-06-19 15:25:10 +00:00
{ key : 'protocol' , label : 'Protocol (rdp / vnc / telnet)' } ,
{ key : 'hostname' , label : 'Hostname' } ,
{ key : 'port' , label : 'Port' } ,
{ key : 'username' , label : 'Username' } ,
{ key : 'domain' , label : 'Domain (RDP only)' } ,
{ key : 'password' , label : 'Password' , secret : true } ,
] } ,
2026-06-19 11:04:46 +00:00
]
const sshFields : FieldDef [ ] = [
{ key : 'host' , label : 'Host / IP' } ,
{ key : 'port' , label : 'Port (default 22)' } ,
{ key : 'username' , label : 'Username' } ,
{ key : 'password' , label : 'Password' , secret : true } ,
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>
2026-06-20 08:11:32 -04:00
{ key : 'privateKey' , label : 'Private Key (PEM)' , secret : true , file : true } ,
2026-06-19 11:04:46 +00:00
{ key : 'passphrase' , label : 'Key Passphrase' , secret : true } ,
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>
2026-06-20 08:11:32 -04:00
{ key : 'certificate' , label : 'OPKSSH Certificate (id_key-cert.pub)' , secret : true , file : true } ,
2026-06-18 18:44:26 +00:00
]
const cardBase : React.CSSProperties = {
backgroundColor : 'rgba(10, 10, 12, 0.92)' ,
border : '1px solid rgba(200, 164, 52, 0.08)' ,
borderRadius : '12px' ,
padding : '22px' ,
position : 'relative' ,
}
const sectionTitle : React.CSSProperties = {
fontSize : '11px' ,
textTransform : 'uppercase' ,
letterSpacing : '1.5px' ,
color : '#7A7D85' ,
fontWeight : 500 ,
marginBottom : '16px' ,
}
const labelStyle : React.CSSProperties = {
fontSize : '11px' ,
color : '#7A7D85' ,
marginBottom : '6px' ,
display : 'block' ,
}
const inputStyle : React.CSSProperties = {
width : '100%' ,
height : '34px' ,
borderRadius : '8px' ,
border : '1px solid rgba(200,164,52,0.12)' ,
backgroundColor : 'rgba(255,255,255,0.03)' ,
color : '#E8E6E0' ,
fontSize : '12px' ,
padding : '0 12px' ,
outline : 'none' ,
}
function Toggle ( { on , onClick } : { on : boolean ; onClick : ( ) = > void } ) {
return (
< button
onClick = { onClick }
className = "cursor-pointer border-none"
style = { {
width : '38px' ,
height : '20px' ,
borderRadius : '10px' ,
backgroundColor : on ? '#C8A434' : 'rgba(255,255,255,0.08)' ,
position : 'relative' ,
transition : 'background-color 0.2s ease' ,
flexShrink : 0 ,
} }
>
< span
style = { {
position : 'absolute' ,
top : '2px' ,
left : on ? '20px' : '2px' ,
width : '16px' ,
height : '16px' ,
borderRadius : '50%' ,
backgroundColor : '#0A0B0D' ,
transition : 'left 0.2s ease' ,
} }
/ >
< / button >
)
}
2026-06-18 20:08:30 +00:00
function GoldButton ( { children , danger , onClick , disabled } : { children : React.ReactNode ; danger? : boolean ; onClick ? : ( ) = > void ; disabled? : boolean } ) {
2026-06-18 18:44:26 +00:00
return (
< button
2026-06-18 20:08:30 +00:00
onClick = { onClick }
disabled = { disabled }
2026-06-18 18:44:26 +00:00
className = "flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style = { {
fontSize : '12px' ,
fontWeight : 600 ,
color : danger ? '#E74C3C' : '#0A0B0D' ,
backgroundColor : danger ? 'transparent' : '#C8A434' ,
border : danger ? '1px solid rgba(231,76,60,0.4)' : 'none' ,
borderRadius : '8px' ,
padding : '9px 16px' ,
boxShadow : danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)' ,
2026-06-18 20:08:30 +00:00
opacity : disabled ? 0.6 : 1 ,
2026-06-18 18:44:26 +00:00
} }
>
{ children }
< / button >
)
}
function ProfileSection() {
2026-06-18 20:08:30 +00:00
const { user , setUser } = useAuth ( )
2026-06-18 18:50:43 +00:00
const fileInputRef = useRef < HTMLInputElement > ( null )
2026-06-18 20:08:30 +00:00
const [ displayName , setDisplayName ] = useState ( user ? . display_name ? ? '' )
const [ email , setEmail ] = useState ( user ? . email ? ? '' )
const [ avatar , setAvatar ] = useState < string | null > ( user ? . avatar_data_url ? ? null )
const [ saving , setSaving ] = useState ( false )
const [ savedMsg , setSavedMsg ] = useState ( '' )
useEffect ( ( ) = > {
setDisplayName ( user ? . display_name ? ? '' )
setEmail ( user ? . email ? ? '' )
setAvatar ( user ? . avatar_data_url ? ? null )
} , [ user ] )
const initials = ( displayName || user ? . username || '?' ) . slice ( 0 , 2 ) . toUpperCase ( )
2026-06-18 18:50:43 +00:00
function handleAvatarChange ( e : React.ChangeEvent < HTMLInputElement > ) {
const file = e . target . files ? . [ 0 ]
if ( ! file ) return
const reader = new FileReader ( )
reader . onload = ( ) = > setAvatar ( reader . result as string )
reader . readAsDataURL ( file )
}
2026-06-18 20:08:30 +00:00
async function handleSave() {
setSaving ( true )
setSavedMsg ( '' )
try {
const { user : updated } = await api . updateMe ( { displayName , email , avatarDataUrl : avatar } )
setUser ( updated )
setSavedMsg ( 'Saved' )
} catch ( err ) {
setSavedMsg ( err instanceof ApiError ? err . message : 'Failed to save' )
} finally {
setSaving ( false )
}
}
2026-06-18 18:44:26 +00:00
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Profile < / h3 >
< div className = "flex items-center gap-4" style = { { marginBottom : '24px' } } >
< div
2026-06-18 18:50:43 +00:00
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
className = "relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
style = { {
width : '64px' ,
height : '64px' ,
borderColor : '#C8A434' ,
color : '#C8A434' ,
fontSize : '20px' ,
backgroundColor : 'rgba(200,164,52,0.08)' ,
backgroundImage : avatar ? ` url( ${ avatar } ) ` : undefined ,
backgroundSize : 'cover' ,
backgroundPosition : 'center' ,
overflow : 'hidden' ,
} }
title = "Upload photo"
2026-06-18 18:44:26 +00:00
>
2026-06-18 20:08:30 +00:00
{ ! avatar && initials }
2026-06-18 18:50:43 +00:00
< div
className = "absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style = { { backgroundColor : 'rgba(0,0,0,0.55)' } }
>
< Camera size = { 18 } color = "#E8E6E0" / >
< / div >
2026-06-18 18:44:26 +00:00
< / div >
2026-06-18 18:50:43 +00:00
< input ref = { fileInputRef } type = "file" accept = "image/*" onChange = { handleAvatarChange } className = "hidden" / >
2026-06-18 18:44:26 +00:00
< div >
2026-06-18 20:08:30 +00:00
< span style = { { fontSize : '15px' , color : '#E8E6E0' , fontWeight : 600 } } > { displayName || user ? . username } < / span >
< br / >
< span style = { { fontSize : '12px' , color : '#7A7D85' } } > { email || 'No email set' } < / span >
2026-06-18 18:44:26 +00:00
< / div >
< / div >
< div className = "grid grid-cols-2 gap-4" style = { { marginBottom : '20px' } } >
< div >
< label style = { labelStyle } > Display Name < / label >
2026-06-18 20:08:30 +00:00
< input style = { inputStyle } value = { displayName } onChange = { ( e ) = > setDisplayName ( e . target . value ) } / >
2026-06-18 18:44:26 +00:00
< / div >
< div >
< label style = { labelStyle } > Email < / label >
2026-06-18 20:08:30 +00:00
< input style = { inputStyle } value = { email } onChange = { ( e ) = > setEmail ( e . target . value ) } / >
2026-06-18 18:44:26 +00:00
< / div >
< / div >
2026-06-18 20:08:30 +00:00
< div className = "flex items-center gap-3" >
< GoldButton onClick = { handleSave } disabled = { saving } >
< Check size = { 14 } / >
{ saving ? 'Saving…' : 'Save Changes' }
< / GoldButton >
{ savedMsg && < span style = { { fontSize : '12px' , color : '#7A7D85' } } > { savedMsg } < / span > }
< / div >
2026-06-18 18:44:26 +00:00
< / div >
)
}
function AppearanceSection() {
const [ theme , setTheme ] = useState < 'dark' | 'light' > ( 'dark' )
const [ accent , setAccent ] = useState ( 'Gold' )
const [ fontSize , setFontSize ] = useState ( 13 )
const [ radius , setRadius ] = useState ( 12 )
const [ sidebarExpanded , setSidebarExpanded ] = useState ( true )
const [ animations , setAnimations ] = useState ( true )
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Appearance < / h3 >
< div className = "flex items-center justify-between" style = { { marginBottom : '20px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Theme < / span >
< div className = "flex items-center gap-1" style = { { backgroundColor : 'rgba(255,255,255,0.03)' , borderRadius : '8px' , padding : '3px' } } >
{ ( [ 'dark' , 'light' ] as const ) . map ( ( t ) = > (
< button
key = { t }
onClick = { ( ) = > setTheme ( t ) }
className = "cursor-pointer border-none capitalize"
style = { {
fontSize : '11px' ,
padding : '6px 14px' ,
borderRadius : '6px' ,
color : theme === t ? '#0A0B0D' : '#7A7D85' ,
backgroundColor : theme === t ? '#C8A434' : 'transparent' ,
fontWeight : 600 ,
} }
>
{ t }
< / button >
) ) }
< / div >
< / div >
< div style = { { marginBottom : '20px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' , marginBottom : '10px' , display : 'block' } } > Accent Color < / span >
< div className = "flex items-center gap-3" >
{ accentColors . map ( ( a ) = > (
< button
key = { a . name }
onClick = { ( ) = > setAccent ( a . name ) }
title = { a . name }
className = "cursor-pointer border-none rounded-full flex items-center justify-center"
style = { {
width : '28px' ,
height : '28px' ,
backgroundColor : a.color ,
outline : accent === a . name ? ` 2px solid ${ a . color } ` : 'none' ,
outlineOffset : '3px' ,
} }
>
{ accent === a . name && < Check size = { 14 } color = "#0A0B0D" / > }
< / button >
) ) }
< / div >
< / div >
< div style = { { marginBottom : '20px' } } >
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Font Size < / span >
< span style = { { fontSize : '11px' , color : '#C8A434' } } > { fontSize } px < / span >
< / div >
< input
type = "range"
min = { 12 }
max = { 16 }
value = { fontSize }
onChange = { ( e ) = > setFontSize ( Number ( e . target . value ) ) }
className = "w-full"
style = { { accentColor : '#C8A434' } }
/ >
< / div >
< div style = { { marginBottom : '20px' } } >
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Card Border Radius < / span >
< span style = { { fontSize : '11px' , color : '#C8A434' } } > { radius } px < / span >
< / div >
< input
type = "range"
min = { 4 }
max = { 16 }
value = { radius }
onChange = { ( e ) = > setRadius ( Number ( e . target . value ) ) }
className = "w-full"
style = { { accentColor : '#C8A434' } }
/ >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Sidebar Expanded by Default < / span >
< Toggle on = { sidebarExpanded } onClick = { ( ) = > setSidebarExpanded ( ( v ) = > ! v ) } / >
< / div >
< div className = "flex items-center justify-between" >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Animations < / span >
< Toggle on = { animations } onClick = { ( ) = > setAnimations ( ( v ) = > ! v ) } / >
< / div >
< / div >
)
}
2026-06-19 11:04:46 +00:00
function SshHostsSection() {
const [ hosts , setHosts ] = useState < Integration [ ] | null > ( null )
const [ revealed , setRevealed ] = useState < Set < string > > ( new Set ( ) )
const [ drafts , setDrafts ] = useState < Record < number , Record < string , string > > > ( { } )
const [ statusMsg , setStatusMsg ] = useState < Record < number , string > > ( { } )
const [ busy , setBusy ] = useState < Set < number > > ( new Set ( ) )
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const [ collapsed , setCollapsed ] = useState < Set < number > > ( new Set ( ) )
2026-06-19 11:04:46 +00:00
const [ newDrafts , setNewDrafts ] = useState < { key : number ; values : Record < string , string > } [ ] > ( [ ] )
const nextNewKey = useRef ( - 1 )
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>
2026-06-20 08:11:32 -04:00
const fileInputRefs = useRef < Record < string , HTMLInputElement | null > > ( { } )
2026-06-19 11:04:46 +00:00
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
function toggleCollapsed ( id : number ) {
setCollapsed ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( id ) ) next . delete ( id )
else next . add ( id )
return next
} )
}
2026-06-19 11:04:46 +00:00
useEffect ( ( ) = > {
refresh ( )
} , [ ] )
function refresh() {
api . listIntegrations ( ) . then ( ( { integrations } ) = > setHosts ( integrations . filter ( ( i ) = > i . type === 'ssh' ) ) )
}
function toggleReveal ( key : string ) {
setRevealed ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( key ) ) next . delete ( key )
else next . add ( key )
return next
} )
}
function setBusyFlag ( id : number , value : boolean ) {
setBusy ( ( prev ) = > {
const next = new Set ( prev )
if ( value ) next . add ( id )
else next . delete ( id )
return next
} )
}
function addNewHost() {
const key = nextNewKey . current --
setNewDrafts ( ( prev ) = > [ . . . prev , { key , values : { } } ] )
}
function setNewDraftField ( key : number , fieldKey : string , value : string ) {
setNewDrafts ( ( prev ) = > prev . map ( ( d ) = > ( d . key === key ? { . . . d , values : { . . . d . values , [ fieldKey ] : value } } : d ) ) )
}
function removeNewDraft ( key : number ) {
setNewDrafts ( ( prev ) = > prev . filter ( ( d ) = > d . key !== key ) )
}
function setDraftField ( id : number , fieldKey : string , value : string ) {
setDrafts ( ( prev ) = > ( { . . . prev , [ id ] : { . . . prev [ id ] , [ fieldKey ] : value } } ) )
}
2026-06-19 11:12:33 +00:00
const fieldsWithJumpHost = ( ) : FieldDef [ ] = > [
2026-06-19 11:04:46 +00:00
. . . sshFields ,
{ key : 'jumpHostIntegrationId' , label : 'Jump Host (optional)' } ,
2026-06-19 11:28:51 +00:00
{ key : 'sessionLogging' , label : 'Record session to disk' } ,
2026-06-19 11:04:46 +00:00
]
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
function buildPayload ( fields : FieldDef [ ] , values : Record < string , string > , existing? : Integration ) {
const config : Record < string , string > = { . . . ( existing ? . config ? ? { } ) }
2026-06-19 11:04:46 +00:00
const secrets : Record < string , string > = { }
for ( const f of fields ) {
const value = values [ f . key ]
if ( value === undefined ) continue
if ( f . secret ) secrets [ f . key ] = value
else config [ f . key ] = value
}
return { config , secrets }
}
async function handleSaveExisting ( host : Integration ) {
setBusyFlag ( host . id , true )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : '' } ) )
try {
const draft = drafts [ host . id ] ? ? { }
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const { config , secrets } = buildPayload ( fieldsWithJumpHost ( ) , draft , host )
2026-06-20 07:23:22 -04:00
const name = draft . __name ? . trim ( )
const { integration } = await api . updateIntegration ( host . id , { . . . ( name ? { name } : { } ) , config , secrets } )
2026-06-19 11:04:46 +00:00
setHosts ( ( prev ) = > ( prev ? ? [ ] ) . map ( ( h ) = > ( h . id === integration . id ? integration : h ) ) )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : 'Saved' } ) )
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
setCollapsed ( ( prev ) = > new Set ( prev ) . add ( host . id ) )
2026-06-19 11:04:46 +00:00
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
} finally {
setBusyFlag ( host . id , false )
}
}
async function handleSaveNew ( key : number , values : Record < string , string > ) {
setBusyFlag ( key , true )
try {
const { config , secrets } = buildPayload ( fieldsWithJumpHost ( ) , values )
Add explicit Host Name field for SSH hosts (#14)
* 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 explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
const name = values . __name ? . trim ( ) || ( values . host ? ` SSH: ${ values . host } ` : 'SSH Host' )
2026-06-19 11:04:46 +00:00
await api . createIntegration ( { type : 'ssh' , name , config , secrets } )
removeNewDraft ( key )
refresh ( )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ key ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
} finally {
setBusyFlag ( key , false )
}
}
async function handleTest ( host : Integration ) {
setBusyFlag ( host . id , true )
try {
const result = await api . testIntegration ( host . id )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : result . message } ) )
refresh ( )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : err instanceof ApiError ? err . message : 'Test failed' } ) )
} finally {
setBusyFlag ( host . id , false )
}
}
async function handleDelete ( host : Integration ) {
setBusyFlag ( host . id , true )
try {
await api . deleteIntegration ( host . id )
refresh ( )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : err instanceof ApiError ? err . message : 'Delete failed' } ) )
setBusyFlag ( host . id , false )
}
}
function renderFields (
fields : FieldDef [ ] ,
values : Record < string , string > ,
onChange : ( fieldKey : string , value : string ) = > void ,
idForReveal : number ,
existing : Integration | undefined ,
excludeHostId? : number ,
) {
return fields . map ( ( f ) = > {
const key = ` ${ idForReveal } - ${ f . key } `
if ( f . key === 'jumpHostIntegrationId' ) {
const options = ( hosts ? ? [ ] ) . filter ( ( h ) = > h . id !== excludeHostId )
const savedValue = existing ? . config [ f . key ] ? ? ''
const value = values [ f . key ] ? ? savedValue
return (
< div key = { key } >
< label style = { labelStyle } > { f . label } < / label >
< select style = { inputStyle } value = { value } onChange = { ( e ) = > onChange ( f . key , e . target . value ) } >
< option value = "" > None < / option >
{ options . map ( ( h ) = > (
< option key = { h . id } value = { String ( h . id ) } >
{ h . name }
< / option >
) ) }
< / select >
< / div >
)
}
2026-06-19 11:28:51 +00:00
if ( f . key === 'sessionLogging' ) {
const savedValue = existing ? . config [ f . key ] === 'true'
const value = values [ f . key ] !== undefined ? values [ f . key ] === 'true' : savedValue
return (
< div key = { key } className = "flex items-end pb-1.5" >
< label className = "flex items-center gap-2 text-xs" style = { { color : '#E8E6E0' } } >
< input type = "checkbox" checked = { value } onChange = { ( e ) = > onChange ( f . key , e . target . checked ? 'true' : 'false' ) } / >
{ f . label }
< / label >
< / div >
)
}
2026-06-19 11:04:46 +00:00
const isRevealed = revealed . has ( key )
const savedValue = f . secret ? '' : existing ? . config [ f . key ] ? ? ''
const value = values [ f . key ] ? ? savedValue
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>
2026-06-20 08:11:32 -04:00
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 >
)
}
2026-06-19 11:04:46 +00:00
return (
< div key = { key } >
< label style = { labelStyle } > { f . label } < / label >
< div className = "relative" >
< input
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>
2026-06-20 08:11:32 -04:00
style = { { . . . inputStyle , paddingRight : f.secret ? '32px' : undefined } }
2026-06-19 11:04:46 +00:00
type = { f . secret && ! isRevealed ? 'password' : 'text' }
value = { value }
onChange = { ( e ) = > onChange ( f . key , e . target . value ) }
placeholder = { f . secret && existing ? '••••••••••••' : 'Not configured' }
/ >
{ f . secret && (
< button
onClick = { ( ) = > toggleReveal ( key ) }
className = "absolute cursor-pointer border-none bg-transparent"
style = { { right : '8px' , top : '50%' , transform : 'translateY(-50%)' , color : '#7A7D85' } }
>
{ isRevealed ? < EyeOff size = { 13 } / > : < Eye size = { 13 } / > }
< / button >
) }
< / div >
< / div >
)
} )
}
if ( ! hosts ) {
return (
< div style = { cardBase } >
< p style = { { fontSize : '12px' , color : '#7A7D85' } } > Loading SSH hosts … < / p >
< / div >
)
}
return (
< div className = "flex flex-col gap-4" >
{ hosts . map ( ( host ) = > {
const online = host . status === 'connected'
const draft = drafts [ host . id ] ? ? { }
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const isCollapsed = collapsed . has ( host . id )
2026-06-19 11:04:46 +00:00
return (
< div key = { host . id } style = { cardBase } >
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
< div className = "flex items-center justify-between" style = { { marginBottom : isCollapsed ? 0 : '16px' } } >
< button
onClick = { ( ) = > toggleCollapsed ( host . id ) }
className = "flex items-center gap-2.5 cursor-pointer border-none bg-transparent"
style = { { padding : 0 } }
title = { isCollapsed ? 'Expand' : 'Collapse' }
>
{ isCollapsed ? < ChevronRight size = { 14 } color = "#7A7D85" / > : < ChevronDown size = { 14 } color = "#7A7D85" / > }
2026-06-19 11:04:46 +00:00
< span
style = { {
width : '8px' ,
height : '8px' ,
borderRadius : '50%' ,
backgroundColor : online ? '#2ECC71' : '#4A4D55' ,
boxShadow : online ? '0 0 6px rgba(46,204,113,0.6)' : 'none' ,
} }
/ >
Add explicit Host Name field for SSH hosts (#14)
* 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 explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
< span style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 } } > { draft . __name ? ? host . name } < / span >
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
< / button >
2026-06-19 11:04:46 +00:00
< div className = "flex items-center gap-2" >
{ statusMsg [ host . id ] && < span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ host . id ] } < / span > }
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
{ ! isCollapsed && (
< button
onClick = { ( ) = > handleSaveExisting ( host ) }
disabled = { busy . has ( host . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( host . id ) ? 0.6 : 1 } }
>
Save
< / button >
) }
2026-06-19 11:04:46 +00:00
< button
onClick = { ( ) = > handleTest ( host ) }
disabled = { busy . has ( host . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( host . id ) ? 0.6 : 1 } }
>
Test Connection
< / button >
< button
onClick = { ( ) = > handleDelete ( host ) }
disabled = { busy . has ( host . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#E74C3C' , backgroundColor : 'transparent' , border : '1px solid rgba(231,76,60,0.3)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( host . id ) ? 0.6 : 1 } }
>
< Trash2 size = { 12 } / >
< / button >
< / div >
< / div >
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
{ ! isCollapsed && (
< div className = "grid grid-cols-3 gap-4" style = { { marginTop : '16px' } } >
< div >
< label style = { labelStyle } > Host Name < / label >
< input
style = { inputStyle }
value = { draft . __name ? ? host . name }
onChange = { ( e ) = > setDraftField ( host . id , '__name' , e . target . value ) }
placeholder = "Not configured"
/ >
< / div >
{ renderFields ( fieldsWithJumpHost ( ) , draft , ( k , v ) = > setDraftField ( host . id , k , v ) , host . id , host , host . id ) }
Add explicit Host Name field for SSH hosts (#14)
* 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 explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
< / div >
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
) }
2026-06-19 11:04:46 +00:00
< / div >
)
} ) }
{ newDrafts . map ( ( d ) = > (
< div key = { d . key } style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 } } > New SSH Host < / span >
< div className = "flex items-center gap-2" >
{ statusMsg [ d . key ] && < span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ d . key ] } < / span > }
< button
onClick = { ( ) = > handleSaveNew ( d . key , d . values ) }
disabled = { busy . has ( d . key ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( d . key ) ? 0.6 : 1 } }
>
Save
< / button >
< button
onClick = { ( ) = > removeNewDraft ( d . key ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#7A7D85' , backgroundColor : 'transparent' , border : '1px solid rgba(255,255,255,0.08)' , borderRadius : '6px' , padding : '6px 12px' } }
>
Cancel
< / button >
< / div >
< / div >
< div className = "grid grid-cols-3 gap-4" >
Add explicit Host Name field for SSH hosts (#14)
* 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 explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
< div >
< label style = { labelStyle } > Host Name < / label >
< input
style = { inputStyle }
value = { d . values . __name ? ? '' }
onChange = { ( e ) = > setNewDraftField ( d . key , '__name' , e . target . value ) }
placeholder = "SSH Host"
/ >
< / div >
2026-06-19 11:04:46 +00:00
{ renderFields ( fieldsWithJumpHost ( ) , d . values , ( k , v ) = > setNewDraftField ( d . key , k , v ) , d . key , undefined ) }
< / div >
< / div >
) ) }
< button
onClick = { addNewHost }
className = "cursor-pointer border-none self-start"
style = { { fontSize : '12px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '8px' , padding : '9px 16px' } }
>
+ Add SSH Host
< / button >
< / div >
)
}
2026-06-20 10:18:04 +00:00
type NewIntegrationDraft = { id : number ; type : string ; values : Record < string , string > }
2026-06-18 18:44:26 +00:00
function IntegrationsSection() {
2026-06-18 19:26:48 +00:00
const [ integrations , setIntegrations ] = useState < Integration [ ] | null > ( null )
2026-06-18 18:44:26 +00:00
const [ revealed , setRevealed ] = useState < Set < string > > ( new Set ( ) )
2026-06-20 10:18:04 +00:00
const [ editDrafts , setEditDrafts ] = useState < Record < number , Record < string , string > > > ( { } )
const [ newDrafts , setNewDrafts ] = useState < NewIntegrationDraft [ ] > ( [ ] )
2026-06-18 19:26:48 +00:00
const [ statusMsg , setStatusMsg ] = useState < Record < string , string > > ( { } )
const [ busy , setBusy ] = useState < Set < string > > ( new Set ( ) )
2026-06-20 10:18:04 +00:00
const nextDraftId = useRef ( - 1 )
2026-06-18 19:26:48 +00:00
useEffect ( ( ) = > {
api . listIntegrations ( ) . then ( ( { integrations } ) = > setIntegrations ( integrations ) )
} , [ ] )
2026-06-18 18:44:26 +00:00
function toggleReveal ( key : string ) {
setRevealed ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( key ) ) next . delete ( key )
else next . add ( key )
return next
} )
}
2026-06-20 10:18:04 +00:00
function setBusyFlag ( rowKey : string , value : boolean ) {
2026-06-18 19:26:48 +00:00
setBusy ( ( prev ) = > {
const next = new Set ( prev )
2026-06-20 10:18:04 +00:00
if ( value ) next . add ( rowKey )
else next . delete ( rowKey )
2026-06-18 19:26:48 +00:00
return next
} )
}
2026-06-20 10:18:04 +00:00
function setEditField ( integrationId : number , fieldKey : string , value : string ) {
setEditDrafts ( ( prev ) = > ( { . . . prev , [ integrationId ] : { . . . prev [ integrationId ] , [ fieldKey ] : value } } ) )
}
function setNewDraftField ( draftId : number , fieldKey : string , value : string ) {
setNewDrafts ( ( prev ) = > prev . map ( ( d ) = > ( d . id === draftId ? { . . . d , values : { . . . d . values , [ fieldKey ] : value } } : d ) ) )
}
function addNewDraft ( type : string ) {
const id = nextDraftId . current --
setNewDrafts ( ( prev ) = > [ . . . prev , { id , type , values : { } } ] )
}
function removeNewDraft ( id : number ) {
setNewDrafts ( ( prev ) = > prev . filter ( ( d ) = > d . id !== id ) )
}
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
function buildPayload ( def : ( typeof integrationTypeDefs ) [ number ] , values : Record < string , string > , existing? : Integration ) {
const config : Record < string , string > = { . . . ( existing ? . config ? ? { } ) }
2026-06-20 10:18:04 +00:00
const secrets : Record < string , string > = { }
for ( const f of def . fields ) {
const value = values [ f . key ]
if ( value === undefined ) continue
if ( f . secret ) secrets [ f . key ] = value
else config [ f . key ] = value
}
return { config , secrets }
2026-06-18 19:26:48 +00:00
}
2026-06-20 10:18:04 +00:00
async function handleSaveExisting ( def : ( typeof integrationTypeDefs ) [ number ] , existing : Integration ) {
const rowKey = ` e- ${ existing . id } `
setBusyFlag ( rowKey , true )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : '' } ) )
2026-06-18 19:26:48 +00:00
try {
Fix integration save data loss; add SSH host card collapse (#16)
* 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.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const { config , secrets } = buildPayload ( def , editDrafts [ existing . id ] ? ? { } , existing )
Add editable name field to generic integrations (#13)
* 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
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
const name = editDrafts [ existing . id ] ? . __name ? . trim ( )
const { integration } = await api . updateIntegration ( existing . id , { . . . ( name ? { name } : { } ) , config , secrets } )
2026-06-20 10:18:04 +00:00
setIntegrations ( ( prev ) = > ( prev ? ? [ ] ) . map ( ( i ) = > ( i . id === integration . id ? integration : i ) ) )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : 'Saved' } ) )
2026-06-18 19:26:48 +00:00
} catch ( err ) {
2026-06-20 10:18:04 +00:00
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
2026-06-18 19:26:48 +00:00
} finally {
2026-06-20 10:18:04 +00:00
setBusyFlag ( rowKey , false )
2026-06-18 19:26:48 +00:00
}
}
2026-06-20 10:18:04 +00:00
async function handleSaveNew ( def : ( typeof integrationTypeDefs ) [ number ] , draft : NewIntegrationDraft ) {
const rowKey = ` n- ${ draft . id } `
setBusyFlag ( rowKey , true )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : '' } ) )
try {
const { config , secrets } = buildPayload ( def , draft . values )
Add editable name field to generic integrations (#13)
* 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
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
const name = draft . values . __name ? . trim ( ) || def . name
const { integration } = await api . createIntegration ( { type : def . type , name , config , secrets } )
2026-06-20 10:18:04 +00:00
setIntegrations ( ( prev ) = > [ . . . ( prev ? ? [ ] ) , integration ] )
removeNewDraft ( draft . id )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
} finally {
setBusyFlag ( rowKey , false )
2026-06-18 19:26:48 +00:00
}
2026-06-20 10:18:04 +00:00
}
async function handleTest ( existing : Integration ) {
const rowKey = ` e- ${ existing . id } `
setBusyFlag ( rowKey , true )
2026-06-18 19:26:48 +00:00
try {
const result = await api . testIntegration ( existing . id )
2026-06-20 10:18:04 +00:00
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : result . message } ) )
2026-06-18 19:26:48 +00:00
const { integrations } = await api . listIntegrations ( )
setIntegrations ( integrations )
} catch ( err ) {
2026-06-20 10:18:04 +00:00
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Test failed' } ) )
2026-06-18 19:26:48 +00:00
} finally {
2026-06-20 10:18:04 +00:00
setBusyFlag ( rowKey , false )
}
}
async function handleDelete ( existing : Integration ) {
if ( ! window . confirm ( ` Remove this ${ existing . name } integration? ` ) ) return
const rowKey = ` e- ${ existing . id } `
setBusyFlag ( rowKey , true )
try {
await api . deleteIntegration ( existing . id )
setIntegrations ( ( prev ) = > ( prev ? ? [ ] ) . filter ( ( i ) = > i . id !== existing . id ) )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Delete failed' } ) )
setBusyFlag ( rowKey , false )
2026-06-18 19:26:48 +00:00
}
}
if ( ! integrations ) {
return (
< div style = { cardBase } >
< p style = { { fontSize : '12px' , color : '#7A7D85' } } > Loading integrations … < / p >
< / div >
)
}
2026-06-20 10:18:04 +00:00
function renderFields ( def : ( typeof integrationTypeDefs ) [ number ] , rowKey : string , getValue : ( f : FieldDef ) = > string , onChange : ( f : FieldDef , value : string ) = > void , placeholderForSecret : string ) {
return (
< div className = "grid grid-cols-3 gap-4" >
{ def . fields . map ( ( f ) = > {
const key = ` ${ rowKey } - ${ f . key } `
const isRevealed = revealed . has ( key )
return (
< div key = { key } >
< label style = { labelStyle } > { f . label } < / label >
< div className = "relative" >
< input
style = { inputStyle }
type = { f . secret && ! isRevealed ? 'password' : 'text' }
value = { getValue ( f ) }
onChange = { ( e ) = > onChange ( f , e . target . value ) }
placeholder = { f . secret ? placeholderForSecret : f.placeholder ? ? 'Not configured' }
/ >
{ f . secret && (
< button
onClick = { ( ) = > toggleReveal ( key ) }
className = "absolute cursor-pointer border-none bg-transparent"
style = { { right : '8px' , top : '50%' , transform : 'translateY(-50%)' , color : '#7A7D85' } }
>
{ isRevealed ? < EyeOff size = { 13 } / > : < Eye size = { 13 } / > }
< / button >
) }
< / div >
{ f . hint && (
< p style = { { fontSize : '10.5px' , color : '#7A7D85' , marginTop : '4px' , lineHeight : 1.4 , maxWidth : '260px' } } > { f . hint } < / p >
) }
< / div >
)
} ) }
< / div >
)
}
2026-06-18 18:44:26 +00:00
return (
< div className = "flex flex-col gap-4" >
2026-06-19 11:04:46 +00:00
< div >
< h3 style = { sectionTitle } > SSH Hosts < / h3 >
< SshHostsSection / >
< / div >
< div >
< h3 style = { sectionTitle } > Other Integrations < / h3 >
< / div >
2026-06-18 19:26:48 +00:00
{ integrationTypeDefs . map ( ( def ) = > {
2026-06-20 10:18:04 +00:00
const existingRows = integrations . filter ( ( i ) = > i . type === def . type )
const draftRows = newDrafts . filter ( ( d ) = > d . type === def . type )
const canAddAnother = def . multiInstance || existingRows . length + draftRows . length === 0
2026-06-18 19:26:48 +00:00
return (
2026-06-20 10:18:04 +00:00
< div key = { def . type } className = "flex flex-col gap-3" >
{ existingRows . map ( ( existing ) = > {
const rowKey = ` e- ${ existing . id } `
const online = existing . status === 'connected'
const draft = editDrafts [ existing . id ] ? ? { }
return (
< div key = { existing . id } style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< div className = "flex items-center gap-2.5" >
< span
style = { {
width : '8px' ,
height : '8px' ,
borderRadius : '50%' ,
backgroundColor : online ? '#2ECC71' : '#4A4D55' ,
boxShadow : online ? '0 0 6px rgba(46,204,113,0.6)' : 'none' ,
} }
2026-06-18 19:26:48 +00:00
/ >
Add editable name field to generic integrations (#13)
* 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
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
< input
value = { draft . __name ? ? existing . name ? ? def . name }
onChange = { ( e ) = > setEditField ( existing . id , '__name' , e . target . value ) }
style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 , backgroundColor : 'transparent' , border : 'none' , outline : 'none' , padding : 0 , width : '160px' } }
/ >
2026-06-20 10:18:04 +00:00
{ existing . config . baseUrl || existing . config . hostname ? (
< span style = { { fontSize : '11px' , color : '#7A7D85' } } > { existing . config . baseUrl || existing . config . hostname } < / span >
) : null }
< / div >
< div className = "flex items-center gap-2" >
{ statusMsg [ rowKey ] && (
< span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ rowKey ] } < / span >
2026-06-18 19:26:48 +00:00
) }
2026-06-20 10:18:04 +00:00
< button
onClick = { ( ) = > handleSaveExisting ( def , existing ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Save
< / button >
< button
onClick = { ( ) = > handleTest ( existing ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Test Connection
< / button >
< button
onClick = { ( ) = > handleDelete ( existing ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#E74C3C' , backgroundColor : 'rgba(231,76,60,0.08)' , border : '1px solid rgba(231,76,60,0.2)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Remove
< / button >
2026-06-18 19:26:48 +00:00
< / div >
2026-06-18 18:44:26 +00:00
< / div >
2026-06-20 10:18:04 +00:00
{ renderFields (
def ,
rowKey ,
( f ) = > draft [ f . key ] ? ? ( f . secret ? '' : existing . config [ f . key ] ? ? '' ) ,
( f , value ) = > setEditField ( existing . id , f . key , value ) ,
'••••••••••••' ,
) }
< / div >
)
} ) }
{ draftRows . map ( ( draft ) = > {
const rowKey = ` n- ${ draft . id } `
return (
< div key = { draft . id } style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 } } > New { def . name } < / span >
< div className = "flex items-center gap-2" >
{ statusMsg [ rowKey ] && (
< span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ rowKey ] } < / span >
) }
< button
onClick = { ( ) = > handleSaveNew ( def , draft ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Save
< / button >
< button
onClick = { ( ) = > removeNewDraft ( draft . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#7A7D85' , backgroundColor : 'rgba(255,255,255,0.04)' , border : '1px solid rgba(255,255,255,0.1)' , borderRadius : '6px' , padding : '6px 12px' } }
>
Cancel
< / button >
< / div >
< / div >
Add editable name field to generic integrations (#13)
* 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
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
< div className = "grid grid-cols-3 gap-4" style = { { marginBottom : '16px' } } >
< div >
< label style = { labelStyle } > Name < / label >
< input
style = { inputStyle }
value = { draft . values . __name ? ? '' }
onChange = { ( e ) = > setNewDraftField ( draft . id , '__name' , e . target . value ) }
placeholder = { def . name }
/ >
< / div >
< / div >
2026-06-20 10:18:04 +00:00
{ renderFields (
def ,
rowKey ,
( f ) = > draft . values [ f . key ] ? ? '' ,
( f , value ) = > setNewDraftField ( draft . id , f . key , value ) ,
'' ,
) }
< / div >
)
} ) }
{ existingRows . length === 0 && draftRows . length === 0 && (
< button
onClick = { ( ) = > addNewDraft ( def . type ) }
className = "cursor-pointer self-start"
style = { { fontSize : '12px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '8px' , padding : '9px 16px' } }
>
+ Add { def . name }
< / button >
) }
{ canAddAnother && ( existingRows . length > 0 || draftRows . length > 0 ) && (
< button
onClick = { ( ) = > addNewDraft ( def . type ) }
className = "cursor-pointer self-start"
style = { { fontSize : '12px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '8px' , padding : '9px 16px' } }
>
+ Add Another { def . name }
< / button >
) }
2026-06-18 18:44:26 +00:00
< / div >
2026-06-18 19:26:48 +00:00
)
} ) }
2026-06-18 18:44:26 +00:00
< / div >
)
}
function NotificationsSection() {
const [ enabled , setEnabled ] = useState ( true )
const [ email , setEmail ] = useState ( true )
const [ push , setPush ] = useState ( false )
const [ sound , setSound ] = useState ( true )
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Notifications < / h3 >
< div className = "flex items-center justify-between" style = { { marginBottom : '20px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Enable Notifications < / span >
< Toggle on = { enabled } onClick = { ( ) = > setEnabled ( ( v ) = > ! v ) } / >
< / div >
< div style = { { marginBottom : '20px' } } >
< label style = { labelStyle } > Alert Threshold < / label >
< select style = { { . . . inputStyle , width : '220px' } } defaultValue = "all" >
< option value = "all" > All < / option >
< option value = "critical" > Critical Only < / option >
< option value = "warning" > Warning & Above < / option >
< / select >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Email Notifications < / span >
< Toggle on = { email } onClick = { ( ) = > setEmail ( ( v ) = > ! v ) } / >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Browser Push < / span >
< Toggle on = { push } onClick = { ( ) = > setPush ( ( v ) = > ! v ) } / >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Sound < / span >
< Toggle on = { sound } onClick = { ( ) = > setSound ( ( v ) = > ! v ) } / >
< / div >
{ sound && (
< input type = "range" min = { 0 } max = { 100 } defaultValue = { 70 } className = "w-full" style = { { accentColor : '#C8A434' } } / >
) }
< / div >
)
}
function DataBackupSection() {
2026-06-19 16:13:29 +00:00
const [ busy , setBusy ] = useState ( false )
const [ message , setMessage ] = useState < string | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const importRef = useRef < HTMLInputElement | null > ( null )
async function handleExport() {
setBusy ( true )
setError ( null )
setMessage ( null )
try {
const data = await api . exportData ( )
const blob = new Blob ( [ JSON . stringify ( data , null , 2 ) ] , { type : 'application/json' } )
const url = URL . createObjectURL ( blob )
const a = document . createElement ( 'a' )
a . href = url
a . download = ` archnest-backup- ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .json `
a . click ( )
URL . revokeObjectURL ( url )
setMessage ( ` Exported ${ data . integrations . length } integrations, ${ data . bookmarks . length } bookmarks, ${ data . tunnels . length } tunnels. ` )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Export failed' )
} finally {
setBusy ( false )
}
}
async function handleImportFile ( file : File ) {
setBusy ( true )
setError ( null )
setMessage ( null )
try {
const text = await file . text ( )
const parsed = JSON . parse ( text )
const result = await api . importData ( parsed )
const c = result . imported
setMessage ( ` Imported ${ c . integrations } integrations, ${ c . bookmarkCategories } categories, ${ c . bookmarks } bookmarks, ${ c . tunnels } tunnels. ` )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Import failed — is this a valid ArchNest backup file?' )
} finally {
setBusy ( false )
}
}
2026-06-18 18:44:26 +00:00
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Data & Backup < / h3 >
2026-06-19 16:13:29 +00:00
< p style = { { fontSize : '12px' , color : '#7A7D85' , marginBottom : '16px' , maxWidth : '460px' } } >
Export a portable backup of all integrations ( including their credentials ) , bookmarks , and tunnels as a single JSON file , or
import one into this instance . Imports are additive — existing data is kept and the backup ' s items are added alongside it .
The backup contains plaintext credentials , so store it securely .
< / p >
< div className = "flex flex-col gap-3" style = { { maxWidth : '460px' } } >
2026-06-18 18:44:26 +00:00
< div className = "flex items-center justify-between" >
2026-06-19 16:13:29 +00:00
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Export all data ( JSON ) < / span >
< GoldButton onClick = { handleExport } disabled = { busy } >
< Download size = { 13 } / > Export
< / GoldButton >
2026-06-18 18:44:26 +00:00
< / div >
< div className = "flex items-center justify-between" >
2026-06-19 16:13:29 +00:00
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Import from backup ( JSON ) < / span >
< GoldButton onClick = { ( ) = > importRef . current ? . click ( ) } disabled = { busy } >
< Upload size = { 13 } / > Import
< / GoldButton >
< input
ref = { importRef }
type = "file"
accept = "application/json,.json"
className = "hidden"
onChange = { ( e ) = > {
const file = e . target . files ? . [ 0 ]
if ( file ) handleImportFile ( file )
e . target . value = ''
} }
/ >
2026-06-18 18:44:26 +00:00
< / div >
2026-06-19 16:13:29 +00:00
{ message && < p style = { { fontSize : '12px' , color : '#2ECC71' } } > { message } < / p > }
{ error && < p style = { { fontSize : '12px' , color : '#E74C3C' } } > { error } < / p > }
2026-06-18 18:44:26 +00:00
< / div >
< / div >
)
}
function AboutSection() {
const rows : [ string , string ] [ ] = [
[ 'App' , 'ArchNest Dashboard v1.0.0' ] ,
[ 'Author' , 'Samuel James' ] ,
[ 'Repo' , 'github.com/SamuelSJames/archnest' ] ,
[ 'Stack' , 'React 19, Vite, TypeScript' ] ,
[ 'License' , 'MIT' ] ,
]
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > About < / h3 >
< div className = "flex flex-col gap-3" >
{ rows . map ( ( [ label , value ] ) = > (
< div key = { label } className = "flex items-center justify-between" >
< span style = { { fontSize : '12px' , color : '#7A7D85' } } > { label } < / span >
< span style = { { fontSize : '12px' , color : '#E8E6E0' } } > { value } < / span >
< / div >
) ) }
< / div >
< / div >
)
}
2026-06-19 11:12:33 +00:00
const sectionComponents : Record < string , ( ) = > React . ReactElement > = {
2026-06-18 18:44:26 +00:00
profile : ProfileSection ,
appearance : AppearanceSection ,
integrations : IntegrationsSection ,
notifications : NotificationsSection ,
data : DataBackupSection ,
about : AboutSection ,
}
export default function Settings() {
const [ active , setActive ] = useState ( 'profile' )
const ActiveSection = sectionComponents [ active ]
return (
< div className = "flex h-full w-full gap-5" >
{ /* Settings nav */ }
< div className = "flex flex-col gap-1 shrink-0" style = { { width : '200px' } } >
{ navSections . map ( ( s ) = > {
const Icon = s . icon
const isActive = active === s . id
return (
< button
key = { s . id }
onClick = { ( ) = > setActive ( s . id ) }
className = "flex items-center gap-2.5 cursor-pointer border-none bg-transparent transition-colors"
style = { {
fontSize : '13px' ,
fontWeight : 500 ,
padding : '10px 14px' ,
borderRadius : '8px' ,
color : isActive ? '#C8A434' : '#7A7D85' ,
backgroundColor : isActive ? 'rgba(200,164,52,0.1)' : 'transparent' ,
} }
>
< Icon size = { 15 } / >
{ s . label }
< / button >
)
} ) }
< / div >
{ /* Content */ }
< div className = "flex-1 overflow-y-auto" style = { { scrollbarWidth : 'none' } } >
< ActiveSection / >
< / div >
< / div >
)
}