diff --git a/AGENTS.md b/AGENTS.md index bbc6ddb..0601c3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -239,7 +239,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | :--- | :---: | :--- | | Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation | | MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) | -| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); SQLite swaps the URI field for a file-path + Browse input (v0.7.0) | +| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. **Pasting a URI fills every setting it can** — type, host, port, user, password, database, `?sslmode=`/`ssl-mode=` (Neon/Supabase → Require, MySQL `VERIFY_IDENTITY` → verify-full) — and offers a URL-derived label (database name, else host) as a placeholder that Save/Test uses when the field is left blank; Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints, PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); presets pre-select their TLS mode (Supabase/Neon Require, PlanetScale Verify Full); SQLite swaps the URI field for a file-path + Browse input (v0.7.0) | Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable | | DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer | | DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite | @@ -249,8 +249,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) | | SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields | | SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:` / `ssh_passphrase:`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel | -| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths | -| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) | +| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths. Selections (mode + CA/client cert/key) are persisted on create **and** edit — the form data type carries the `ssl_*` fields and the submit handlers pass them through (previously the SSH/SSL tab's values were dropped, so connections saved with TLS off and managed providers rejected them) | +| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity). Managed hosts (`*.neon.tech` / `*.supabase.co` / `*.psdb.cloud`) default to `require` when the connection has no `ssl_mode` (`db::tls::resolve_ssl_mode`), so pre-fix saved connections still connect; an explicit mode always wins | | Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) | ### Home Screen & Organization diff --git a/ROADMAP.md b/ROADMAP.md index 31e4b2e..d88cc62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,12 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md]( - **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql` - **Version bump** 0.7.15 → **0.8.0**. +## ✅ Shipped (unreleased) + +- **Connection-string SSL mode is honored end to end** — pasting a provider URL now carries its `?sslmode=` (Neon/Supabase `require`, `verify-full`, MySQL `ssl-mode=`) into the saved connection instead of silently connecting with TLS off, and the SSH/SSL tab's mode + certificate paths are persisted on create/edit (they were previously dropped before reaching the backend). Save/Test also fall back to a URL-derived label, so a pasted URL can be saved without typing anything. Fixes managed-provider connections failing with *"connection is insecure (try using `sslmode=require`)"*. +- **Managed hosts default to TLS** — `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` resolve to `require` when a connection has no `ssl_mode`, so connections saved before the form persisted it (or imported without it) connect again without being re-saved. An explicit mode — including `disable` — always wins. +- **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full. + ## 📋 In the queue ### Full Redis Support @@ -36,7 +42,6 @@ MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: C Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining: - **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset) -- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form ### Query Workbench Upgrades diff --git a/desktop/src-tauri/src/commands/db_viewer.rs b/desktop/src-tauri/src/commands/db_viewer.rs index 61d7e8c..f991fa2 100644 --- a/desktop/src-tauri/src/commands/db_viewer.rs +++ b/desktop/src-tauri/src/commands/db_viewer.rs @@ -1005,7 +1005,7 @@ pub(crate) async fn run_mysql_connect( // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // degrade to encrypt-only `require`. Direct connections honor the mode. let decision = crate::commands::ssh::effective_tls_decision( - crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()), via_tunnel, ); match decision { @@ -1084,7 +1084,7 @@ pub async fn db_connect( // connections honor the user's mode. Building this before opening the // tunnel means a config error can't leak the tunnel. let decision = crate::commands::ssh::effective_tls_decision( - crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()), will_tunnel, ); let tls = crate::db::tls::build_tls_config( diff --git a/desktop/src-tauri/src/commands/test_connection.rs b/desktop/src-tauri/src/commands/test_connection.rs index ba56551..106d83a 100644 --- a/desktop/src-tauri/src/commands/test_connection.rs +++ b/desktop/src-tauri/src/commands/test_connection.rs @@ -265,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // degrade to encrypt-only `require`. Direct connections honor the mode. let decision = crate::commands::ssh::effective_tls_decision( - crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()), target.via_tunnel, ); let tls = match crate::db::tls::build_tls_config( @@ -368,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // degrade to encrypt-only `require`. Direct connections honor the mode. let decision = crate::commands::ssh::effective_tls_decision( - crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()), target.via_tunnel, ); match decision { diff --git a/desktop/src-tauri/src/db/tls.rs b/desktop/src-tauri/src/db/tls.rs index cd34fe4..dbd08ca 100644 --- a/desktop/src-tauri/src/db/tls.rs +++ b/desktop/src-tauri/src/db/tls.rs @@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision { } } +/// Host suffixes of managed providers that reject plaintext connections with +/// `connection is insecure (try using 'sslmode=require')`. +const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"]; + +/// Resolve the SSL mode to use for a connection, defaulting these managed +/// providers to `require` when the connection carries no explicit mode. +/// +/// Connections saved before the UI persisted `ssl_mode` (or imported without +/// it) would otherwise connect with TLS disabled and be refused by the server. +/// An explicit mode always wins, so a deliberate `disable` is never overridden, +/// and the inferred mode only encrypts — it never verifies certificates. +pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option { + if let Some(mode) = ssl_mode { + return Some(mode.to_string()); + } + let host = host.to_ascii_lowercase(); + if MANAGED_SSL_HOST_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) + { + return Some("require".to_string()); + } + None +} + +/// TLS decision for a connection, applying the managed-provider default from +/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`]. +pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision { + tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref()) +} + /// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable. /// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth. pub fn build_tls_config( @@ -197,6 +228,50 @@ mod tests { assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable)); } + #[test] + fn resolve_ssl_mode_defaults_managed_hosts_to_require() { + // Saved connections that predate the form storing `ssl_mode` still work. + for host in [ + "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech", + "db.abcdefghijklmnopqrst.supabase.co", + "xxxx.us-east-2.psdb.cloud", + "EP-X-POOLER.EU.AWS.NEON.TECH", + ] { + assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require")); + } + } + + #[test] + fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() { + assert_eq!(resolve_ssl_mode("localhost", None), None); + assert_eq!(resolve_ssl_mode("prod.example.com", None), None); + // An explicit mode always wins — a deliberate `disable` is never overridden. + assert_eq!( + resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(), + Some("disable") + ); + assert_eq!( + resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(), + Some("verify-full") + ); + } + + #[test] + fn tls_decision_for_honors_managed_default_and_explicit_modes() { + assert!(matches!( + tls_decision_for("ep-x.eu.aws.neon.tech", None), + TlsDecision::Require + )); + assert!(matches!( + tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")), + TlsDecision::Disable + )); + assert!(matches!( + tls_decision_for("db.example.com", None), + TlsDecision::Disable + )); + } + #[test] fn build_tls_disable_returns_none() { assert!(build_tls_config(TlsDecision::Disable, None, None, None) diff --git a/desktop/src/components/connections/ConnectionMetadataRow.tsx b/desktop/src/components/connections/ConnectionMetadataRow.tsx index f51b1dd..eed4f55 100644 --- a/desktop/src/components/connections/ConnectionMetadataRow.tsx +++ b/desktop/src/components/connections/ConnectionMetadataRow.tsx @@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData"; interface ConnectionMetadataRowProps { form: ConnectionFormData; onChange: (updates: Partial) => void; + /** Shown when the label is empty — usually the name derived from a pasted + * connection string, which Test/Save uses as a fallback. */ + namePlaceholder?: string; } -export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) { +export function ConnectionMetadataRow({ form, onChange, namePlaceholder }: ConnectionMetadataRowProps) { return (
onChange({ name: value })} - placeholder="My Production Database" + placeholder={namePlaceholder || "My Production Database"} aria-label="Connection Label" />
diff --git a/desktop/src/components/connections/DetailedConnectionForm.tsx b/desktop/src/components/connections/DetailedConnectionForm.tsx index 9a54b88..0139bee 100644 --- a/desktop/src/components/connections/DetailedConnectionForm.tsx +++ b/desktop/src/components/connections/DetailedConnectionForm.tsx @@ -9,14 +9,17 @@ export interface DetailedConnectionFormProps { form: ConnectionFormData; onChange: (updates: Partial) => void; managedPreset?: "supabase" | "neon" | "planetscale" | null; + /** Placeholder for the Connection Label when the field is empty, e.g. the + * name derived from a pasted connection string. */ + namePlaceholder?: string; } -export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) { +export function DetailedConnectionForm({ form, onChange, managedPreset, namePlaceholder }: DetailedConnectionFormProps) { const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general"); return (
- +
diff --git a/desktop/src/components/connections/NewConnectionScreen.test.tsx b/desktop/src/components/connections/NewConnectionScreen.test.tsx index d0e71ea..3c1d322 100644 --- a/desktop/src/components/connections/NewConnectionScreen.test.tsx +++ b/desktop/src/components/connections/NewConnectionScreen.test.tsx @@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => { expect(screen.getByLabelText("User")).toHaveValue("u"); }); + it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => { + const user = userEvent.setup(); + render(); + await user.type( + screen.getByLabelText(/connection uri/i), + "postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require", + ); + // Every field the URL can supply is filled, so no manual cleanup is needed + expect(screen.getByLabelText("Host")).toHaveValue( + "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech", + ); + expect(screen.getByLabelText("Port")).toHaveValue(5432); + expect(screen.getByLabelText("User")).toHaveValue("neondb_owner"); + expect(screen.getByLabelText("Password")).toHaveValue("npg_secret"); + expect(screen.getByLabelText("Database")).toHaveValue("neondb"); + // The label stays untouched but is suggested from the database name. + expect(screen.getByLabelText(/connection label/i)).toHaveValue(""); + expect(screen.getByLabelText(/connection label/i)).toHaveAttribute( + "placeholder", + "neondb", + ); + + // SSL mode is visible on the SSH / SSL tab... + await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i })); + await user.click(screen.getByRole("button", { name: "SSL" })); + expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require"); + + // ...and reaches the save payload with no extra clicks. + await user.click(screen.getByText("Save Connection")); + await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1)); + expect(createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + name: "neondb", + db_type: "postgresql", + ssl_mode: "require", + username: "neondb_owner", + password: "npg_secret", + database: "neondb", + }), + ); + }); + + it("a typed label wins over the URL-derived suggestion", async () => { + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db"); + await user.type(screen.getByLabelText(/connection label/i), "My DB"); + await user.click(screen.getByText("Save Connection")); + await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1)); + expect(createConnection).toHaveBeenCalledWith( + expect.objectContaining({ name: "My DB" }), + ); + }); + it("clicking a provider tab reveals the form with that db_type", async () => { const user = userEvent.setup(); render(); @@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => { expect(screen.getByLabelText("Port")).toHaveValue(3306); }); + it("NeonDB card preselects Require SSL in the save payload", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /NeonDB/i })); + await user.type(screen.getByLabelText(/connection label/i), "Neon prod"); + await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech"); + await user.click(screen.getByText("Save Connection")); + await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1)); + expect(createConnection).toHaveBeenCalledWith( + expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }), + ); + }); + it("SQLite tab swaps the URI input for a File Path input", async () => { const user = userEvent.setup(); render(); @@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => { expect(notify).toHaveBeenCalledWith("Connection successful", "success"); }); + it("tests a pasted URL with its SSL mode", async () => { + const user = userEvent.setup(); + render(); + await user.type( + screen.getByLabelText(/connection uri/i), + "postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full", + ); + await user.click(screen.getByText("Test Connection")); + await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1)); + expect(testConnection).toHaveBeenCalledWith( + expect.objectContaining({ ssl_mode: "verify-full" }), + ); + }); + it("invokes onCancel when Back is clicked", async () => { const user = userEvent.setup(); const onCancel = vi.fn(); diff --git a/desktop/src/components/connections/NewConnectionScreen.tsx b/desktop/src/components/connections/NewConnectionScreen.tsx index eac2a9b..3498f69 100644 --- a/desktop/src/components/connections/NewConnectionScreen.tsx +++ b/desktop/src/components/connections/NewConnectionScreen.tsx @@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide"; import { parseConnectionString, detectProviderFromHost, + suggestConnectionLabel, + type ParsedConnectionString, } from "../../lib/connectionString"; import { getProviderById, type ProviderId } from "../../lib/providers"; import { validateConnectionInput } from "../../lib/utils"; @@ -53,6 +55,10 @@ function createEmptyForm( database: null, use_keychain: true, ssh_password: null, + ssl_mode: null, + ssl_ca_path: null, + ssl_cert_path: null, + ssl_key_path: null, }; } @@ -64,6 +70,40 @@ function getDefaultPort(dbType: string): number { ); } +type ManagedPreset = "supabase" | "neon" | "planetscale" | null; + +/** Managed-provider highlight for a parsed URL (Postgres/MySQL hosts only). */ +function providerForParsed(parsed: ParsedConnectionString): ManagedPreset { + return parsed.db_type === "postgresql" || parsed.db_type === "mysql" + ? detectProviderFromHost(parsed.host) + : null; +} + +/** + * Everything a pasted connection string can populate, so the connection can be + * tested and saved with no manual cleanup: connection type, host, port, + * user/password/database, and the `?sslmode=` TLS mode. + * + * The URL's SSL mode wins when present; otherwise the mode the user already + * picked under SSH / SSL is kept. The label is left blank when the user has not + * typed one (the derived name is offered as a placeholder and used on save by + * `buildPayload`), so a suggestion never clobbers or gets appended to. + */ +function parsedFormPatch( + parsed: ParsedConnectionString, + prev: ConnectionFormData, +): Partial { + return { + db_type: parsed.db_type, + host: parsed.host, + port: parsed.port ?? getDefaultPort(parsed.db_type), + username: parsed.username, + password: parsed.password, + database: parsed.database, + ssl_mode: parsed.ssl_mode ?? prev.ssl_mode ?? null, + }; +} + export function NewConnectionScreen({ defaultFolderId = null, prefilledConnectionString = "", @@ -73,9 +113,7 @@ export function NewConnectionScreen({ onCancel, }: NewConnectionScreenProps) { const [stage, setStage] = useState("entry"); - const [managedPreset, setManagedPreset] = useState< - "supabase" | "neon" | "planetscale" | null - >(null); + const [managedPreset, setManagedPreset] = useState(null); const [form, setForm] = useState(() => createEmptyForm( defaultFolderId, @@ -86,6 +124,12 @@ export function NewConnectionScreen({ const [saveLoading, setSaveLoading] = useState(false); const createConnection = useConnectionStore((s) => s.createConnection); const notify = useNotificationStore((s) => s.notify); + // Name to use when the label is left blank: derived from the pasted URL + // (database name, else host/file stem). Shown as the Connection Label's + // placeholder and applied at save/test time, so a suggestion never + // clobbers or gets appended to typed text. + const parsedUri = parseConnectionString(form.connection_string); + const suggestedName = parsedUri ? suggestConnectionLabel(parsedUri) : ""; const revealConfigured = useCallback( (updates: Partial) => { @@ -97,26 +141,17 @@ export function NewConnectionScreen({ const handleConnectionStringChange = useCallback((value: string) => { const parsed = parseConnectionString(value); - if (parsed) { - const provider = - parsed.db_type === "postgresql" || parsed.db_type === "mysql" - ? detectProviderFromHost(parsed.host) - : null; - setManagedPreset(provider); - setForm((prev) => ({ - ...prev, - connection_string: value, - db_type: parsed.db_type, - host: parsed.host, - port: parsed.port ?? getDefaultPort(parsed.db_type), - username: parsed.username, - password: parsed.password, - database: parsed.database, - })); - } else { + if (!parsed) { setManagedPreset(null); setForm((prev) => ({ ...prev, connection_string: value })); + return; } + setManagedPreset(providerForParsed(parsed)); + setForm((prev) => ({ + ...prev, + connection_string: value, + ...parsedFormPatch(parsed, prev), + })); }, []); useEffect(() => { @@ -150,7 +185,15 @@ export function NewConnectionScreen({ ? null : getDefaultPort(provider.dbType), ...(provider.dbType === "sqlite" ? { host: "" } : {}), - ...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}), + // Managed providers refuse plaintext connections. Preselect + // their documented mode so Test/Save works on the first try. + // (PlanetScale/MySQL requires identity verification.) + ...(id === "planetscale" + ? { ssl_mode: "verify-full" as const } + : {}), + ...(id === "supabase" || id === "neon" + ? { ssl_mode: "require" as const } + : {}), }); }, [revealConfigured], @@ -166,22 +209,11 @@ export function NewConnectionScreen({ const value = updates.connection_string; const parsed = parseConnectionString(value); if (parsed) { - const provider = - parsed.db_type === "postgresql" || parsed.db_type === "mysql" - ? detectProviderFromHost(parsed.host) - : null; - setManagedPreset(provider); + setManagedPreset(providerForParsed(parsed)); return { ...prev, ...updates, - db_type: parsed.db_type, - host: parsed.host, - port: - parsed.port ?? - getDefaultPort(parsed.db_type), - username: parsed.username, - password: parsed.password, - database: parsed.database, + ...parsedFormPatch(parsed, prev), }; } return { ...prev, ...updates }; @@ -194,7 +226,7 @@ export function NewConnectionScreen({ const buildPayload = useCallback((): ConnectionInput => { return { - name: form.name, + name: form.name.trim() || suggestedName, db_type: form.db_type, host: form.host, port: form.port, @@ -213,8 +245,12 @@ export function NewConnectionScreen({ ssh_private_key_path: form.ssh_private_key ?? null, ssh_password: form.ssh_password ?? null, ssh_passphrase: form.ssh_passphrase ?? null, + ssl_mode: form.ssl_mode ?? null, + ssl_ca_path: form.ssl_ca_path ?? null, + ssl_cert_path: form.ssl_cert_path ?? null, + ssl_key_path: form.ssl_key_path ?? null, }; - }, [form]); + }, [form, suggestedName]); const validate = useCallback((): string | null => { const result = validateConnectionInput(buildPayload()); @@ -336,6 +372,7 @@ export function NewConnectionScreen({ )} diff --git a/desktop/src/components/connections/SslFields.test.tsx b/desktop/src/components/connections/SslFields.test.tsx index 5183846..595cb15 100644 --- a/desktop/src/components/connections/SslFields.test.tsx +++ b/desktop/src/components/connections/SslFields.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { SslFields } from "./SslFields"; const notify = vi.fn(); @@ -35,4 +35,33 @@ describe("SslFields", () => { expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument(); }); + + it("writes certificate paths under the names the backend persists", () => { + // Regression guard: these used to be ssl_ca_cert / ssl_client_cert / + // ssl_client_key, which the Rust ConnectionInput silently ignores — the + // connection was then saved with TLS disabled. + const onChange = vi.fn(); + render( + , + ); + + expect(screen.getByLabelText("CA Certificate")).toHaveValue("/tmp/ca.pem"); + fireEvent.change(screen.getByLabelText("CA Certificate"), { + target: { value: "/tmp/root.pem" }, + }); + expect(onChange).toHaveBeenLastCalledWith({ ssl_ca_path: "/tmp/root.pem" }); + + fireEvent.change(screen.getByLabelText("Client Certificate"), { + target: { value: "/tmp/client.pem" }, + }); + expect(onChange).toHaveBeenLastCalledWith({ ssl_cert_path: "/tmp/client.pem" }); + + fireEvent.change(screen.getByLabelText("Client Key"), { + target: { value: "/tmp/client.key" }, + }); + expect(onChange).toHaveBeenLastCalledWith({ ssl_key_path: "/tmp/client.key" }); + }); }); \ No newline at end of file diff --git a/desktop/src/components/connections/SslFields.tsx b/desktop/src/components/connections/SslFields.tsx index 08ab3b0..03e64ae 100644 --- a/desktop/src/components/connections/SslFields.tsx +++ b/desktop/src/components/connections/SslFields.tsx @@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
onChange({ ssl_ca_cert: value })} + value={(values.ssl_ca_path as string) ?? ""} + onChange={(value) => onChange({ ssl_ca_path: value })} placeholder="/path/to/ca-cert.pem" aria-label="CA Certificate" />