/** * Host form dialog: create/edit one stored host entry. A fixed backdrop with * a centered card; Escape and backdrop clicks close it. Secrets (key path, * passphrase, password) are never pre-filled when editing (the API only * exposes the secret-free summary). */ import { useEffect, useState } from 'react' import type { SshApi } from '../api.ts' import type { HostPayload, SshAuthKind, SshHostSummary } from '../../protocol.ts' import { errorMessage, tt } from './helpers.ts' import css from './panel.module.css' /** Host form dialog props. */ export interface HostFormDialogProps { api: SshApi /** The host being edited; null/undefined means create. */ editing?: SshHostSummary | null onClose: () => void onSaved: (host: SshHostSummary) => void } /** The staged form values (all strings; parsed on save). */ interface FormState { alias: string host: string port: string user: string authKind: SshAuthKind keyPath: string passphrase: string password: string agentPath: string proxyJump: string proxyCommand: string description: string environment: string tags: string location: string } /** Split a comma-separated input into a trimmed, non-empty string list. */ function splitList(text: string): string[] { return text.split(',').map(part => part.trim()).filter(part => part !== '') } /** Initial form state: the summary's public fields plus empty secrets. */ function blankOf(editing: SshHostSummary | null | undefined): FormState { return { alias: editing?.alias ?? '', host: editing?.host ?? '', port: String(editing?.port ?? 22), user: editing?.user ?? '', authKind: editing?.auth ?? 'key', keyPath: '', passphrase: '', password: '', agentPath: '', proxyJump: (editing?.proxyJump ?? []).join(', '), proxyCommand: editing?.proxyCommand ?? '', description: editing?.description ?? '', environment: editing?.environment ?? '', tags: (editing?.tags ?? []).join(', '), location: editing?.location ?? '', } } /** The create/edit host modal. */ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialogProps) { const [form, setForm] = useState(() => blankOf(editing)) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) // Escape closes the dialog. useEffect(() => { const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() } document.addEventListener('keydown', onKey) return () => { document.removeEventListener('keydown', onKey) } }, [onClose]) const set = (key: K, value: FormState[K]): void => { setForm(prev => ({ ...prev, [key]: value })) } const save = async (): Promise => { const alias = form.alias.trim() const host = form.host.trim() const user = form.user.trim() if (alias === '' || host === '' || user === '') { setError(tt('form.required')) return } const port = Number(form.port) if (!Number.isInteger(port) || port < 1 || port > 65535) { setError(tt('form.portInvalid')) return } // A password-auth host needs a password on create; when editing, a blank // secret field preserves the stored credential instead. if (editing == null && form.authKind === 'password' && form.password === '') { setError(tt('form.passwordRequired')) return } // Secrets are never echoed back by the API: when editing and the secret // fields are left empty, the auth block is omitted so the stored // authentication is preserved. const secretEmpty = form.authKind === 'password' ? form.password === '' : form.authKind === 'key' ? form.keyPath.trim() === '' : form.agentPath.trim() === '' const auth: HostPayload['auth'] = editing != null && secretEmpty ? undefined : form.authKind === 'password' ? { kind: 'password', password: form.password } : form.authKind === 'key' ? { kind: 'key', keyPath: form.keyPath.trim(), passphrase: form.passphrase === '' ? undefined : form.passphrase } : { kind: 'agent', agentPath: form.agentPath.trim() === '' ? undefined : form.agentPath.trim() } const payload: HostPayload = { host, port, user, auth, proxyJump: splitList(form.proxyJump), // Always sent (never omitted): an empty value is the explicit clear, // because the API cannot express "remove this field" any other way. proxyCommand: form.proxyCommand.trim(), description: form.description.trim() === '' ? undefined : form.description.trim(), environment: form.environment.trim() === '' ? undefined : form.environment.trim(), tags: splitList(form.tags), location: form.location.trim() === '' ? undefined : form.location.trim(), } setSaving(true) setError(null) try { const saved = editing != null ? await api.updateHost(editing.alias, payload) : await api.createHost({ ...payload, alias }) onSaved(saved) } catch (cause) { setError(errorMessage(cause)) setSaving(false) } } return (
{ event.stopPropagation() }}>

{editing != null ? tt('form.title.edit', { alias: editing.alias }) : tt('form.title.create')}

{tt('form.auth')}
{editing != null && {tt('form.authKeepHint')}}
{form.authKind === 'key' ? (
) : form.authKind === 'agent' ? ( ) : ( )}
{error !== null &&

{tt('common.error', { error })}

}
) }