diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index fa51fcb87..15902ab36 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos"; import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; @@ -200,9 +200,24 @@ function defaultSshTunnel(): SshTunnelConfig { expose_lan: false, use_ssh_agent: false, ssh_agent_sock_path: "", + auth_method: "password", }; } +/** + * Infers a login method for connections saved before `auth_method` existed + * (or imported from a source that never set it), so the dropdown shows a + * sensible current state instead of defaulting blindly to "password". + * Mirrors the priority `connect_and_authenticate` actually uses at connect + * time (key > password > agent > none) — see `db/ssh_tunnel.rs`. + */ +function inferSshAuthMethod(hop: Partial): "password" | "key" | "agent" | "none" { + if (hop.key_path?.trim()) return "key"; + if (hop.password) return "password"; + if (hop.use_ssh_agent) return "agent"; + return "none"; +} + function normalizeSshTunnel(hop: Partial): SshTunnelConfig { return { id: hop.id || uuid(), @@ -218,6 +233,7 @@ function normalizeSshTunnel(hop: Partial): SshTunnelConfig { expose_lan: !!hop.expose_lan, use_ssh_agent: !!hop.use_ssh_agent, ssh_agent_sock_path: hop.ssh_agent_sock_path || "", + auth_method: hop.auth_method || inferSshAuthMethod(hop), }; } @@ -346,6 +362,7 @@ const mongoUseUrl = ref(false); const jdbcDriverPathsInput = ref(""); const jdbcDrivers = ref([]); const jdbcMavenBundles = ref([]); +const sshConfigHosts = ref([]); const agentDrivers = ref([]); const selectedJdbcDriverPath = ref(""); const jdbcManualClasspathOpen = ref(false); @@ -2796,6 +2813,7 @@ watch( if (!props.prefillConfig?.oneTime) { void loadJdbcDrivers(); void loadAgentDrivers(); + void loadSshConfigHosts(); } // Preload database names so the summary count is accurate right away. void nextTick(() => { @@ -2930,6 +2948,33 @@ function updateSelectedProxyType(value: unknown) { resetTestState(); } +/** + * "agent" is legacy-only: it's never chosen from this dropdown, only ever + * inherited from a connection saved before this selector existed. Once the + * user picks something else, the option (and its underlying checkbox) is + * gone from the form for good. + */ +function isLegacySshAgentMethod(hop: Partial | null | undefined) { + return hop?.auth_method === "agent"; +} + +function updateSelectedSshAuthMethod(value: unknown) { + const layer = selectedSshLayer.value; + if (!layer) return; + layer.auth_method = value === "key" ? "key" : value === "none" ? "none" : "password"; + // Scrub credential fields that do not apply to the selected method so + // they are not accidentally submitted or used by the backend fallback. + if (layer.auth_method !== "password") layer.password = ""; + if (layer.auth_method !== "key") { + layer.key_path = ""; + layer.key_passphrase = ""; + } + if (layer.auth_method !== "key") { + layer.use_ssh_agent = false; + } + resetTestState(); +} + function validateTransportLayers(config: LegacyConnectionConfig) { const layers = config.transport_layers || []; layers.forEach((layer, index) => { @@ -3013,6 +3058,28 @@ watch([() => editingId.value, () => open.value], () => { dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title"); }); +const sshConfigHostAliases = computed(() => sshConfigHosts.value.map((entry) => entry.alias)); + +/** + * Prefills user/port/key_path from a matching ~/.ssh/config alias, without + * overwriting values the user already changed away from the form defaults. + * This is a UX preview only — the authoritative resolution happens in the + * Rust backend at connect time (see resolve_ssh_tunnel_config), so imported + * configs that never touched this UI still resolve correctly. + */ +function applySshConfigHostAliasPrefill(target: SshTunnelConfig) { + const entry = sshConfigHosts.value.find((candidate) => candidate.alias === target.host); + if (!entry) return; + if (target.user === DEFAULT_SSH_USER && entry.user) target.user = entry.user; + if (target.port === 22 && entry.port) target.port = entry.port; + if (!target.key_path && entry.identity_file) { + target.key_path = entry.identity_file; + if ((!target.auth_method || target.auth_method === "password") && !target.password?.trim()) { + target.auth_method = "key"; + } + } +} + async function browseSshKeyPath(target?: SshTunnelConfig | null) { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -3229,6 +3296,14 @@ async function loadJdbcDrivers() { } } +async function loadSshConfigHosts() { + try { + sshConfigHosts.value = await api.listSshConfigHosts(); + } catch { + sshConfigHosts.value = []; + } +} + async function loadAgentDrivers() { try { agentDrivers.value = await api.listInstalledAgentsLocal(); @@ -4733,7 +4808,10 @@ function openExternalUrl(url: string) {