fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with "connection is insecure (try using `sslmode=require`)" because the TLS mode never reached the backend: - ConnectionFormData carried no ssl_* fields and both submit handlers (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted them, so the SSH/SSL tab's mode was silently dropped before IPC. - SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path / ssl_key_path). - parseConnectionString ignored query params, so a pasted Neon/Supabase URL's ?sslmode=require was discarded. Pasting a URI now fills every setting it can (sslmode / ssl-mode, including MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label (database name, else host/file stem) as a placeholder that Test/Save uses when the field is left blank. Managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech / *.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so connections saved before this fix keep working; an explicit mode (including disable) always wins. Tests: +15 frontend (parser, form payload, SSL field names, edit modal), +4 Rust (resolve_ssl_mode / tls_decision_for).
This commit is contained in:
@@ -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 |
|
| 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) |
|
| 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 |
|
| 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: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
||||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
| 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) |
|
| 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 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:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), 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 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) |
|
| 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) |
|
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
|
||||||
|
|
||||||
### Home Screen & Organization
|
### Home Screen & Organization
|
||||||
|
|||||||
+6
-1
@@ -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`
|
- **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**.
|
- **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
|
## 📋 In the queue
|
||||||
|
|
||||||
### Full Redis Support
|
### 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:
|
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)
|
- **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
|
### Query Workbench Upgrades
|
||||||
|
|
||||||
|
|||||||
@@ -1005,7 +1005,7 @@ pub(crate) async fn run_mysql_connect(
|
|||||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
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,
|
via_tunnel,
|
||||||
);
|
);
|
||||||
match decision {
|
match decision {
|
||||||
@@ -1084,7 +1084,7 @@ pub async fn db_connect(
|
|||||||
// connections honor the user's mode. Building this before opening the
|
// connections honor the user's mode. Building this before opening the
|
||||||
// tunnel means a config error can't leak the tunnel.
|
// tunnel means a config error can't leak the tunnel.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
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,
|
will_tunnel,
|
||||||
);
|
);
|
||||||
let tls = crate::db::tls::build_tls_config(
|
let tls = crate::db::tls::build_tls_config(
|
||||||
|
|||||||
@@ -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
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
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,
|
target.via_tunnel,
|
||||||
);
|
);
|
||||||
let tls = match crate::db::tls::build_tls_config(
|
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
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
let decision = crate::commands::ssh::effective_tls_decision(
|
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,
|
target.via_tunnel,
|
||||||
);
|
);
|
||||||
match decision {
|
match decision {
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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.
|
/// 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.
|
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||||
pub fn build_tls_config(
|
pub fn build_tls_config(
|
||||||
@@ -197,6 +228,50 @@ mod tests {
|
|||||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
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]
|
#[test]
|
||||||
fn build_tls_disable_returns_none() {
|
fn build_tls_disable_returns_none() {
|
||||||
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData";
|
|||||||
interface ConnectionMetadataRowProps {
|
interface ConnectionMetadataRowProps {
|
||||||
form: ConnectionFormData;
|
form: ConnectionFormData;
|
||||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
onChange: (updates: Partial<ConnectionFormData>) => 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
||||||
<Input
|
<Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(value) => onChange({ name: value })}
|
onChange={(value) => onChange({ name: value })}
|
||||||
placeholder="My Production Database"
|
placeholder={namePlaceholder || "My Production Database"}
|
||||||
aria-label="Connection Label"
|
aria-label="Connection Label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ export interface DetailedConnectionFormProps {
|
|||||||
form: ConnectionFormData;
|
form: ConnectionFormData;
|
||||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
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");
|
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ConnectionMetadataRow form={form} onChange={onChange} />
|
<ConnectionMetadataRow form={form} onChange={onChange} namePlaceholder={namePlaceholder} />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-6 border-b border-border mb-4">
|
<div className="flex gap-6 border-b border-border mb-4">
|
||||||
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
|
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
|
||||||
|
|||||||
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
|
|||||||
expect(screen.getByLabelText("User")).toHaveValue("u");
|
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(<NewConnectionScreen />);
|
||||||
|
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(<NewConnectionScreen />);
|
||||||
|
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 () => {
|
it("clicking a provider tab reveals the form with that db_type", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<NewConnectionScreen />);
|
render(<NewConnectionScreen />);
|
||||||
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
|
|||||||
expect(screen.getByLabelText("Port")).toHaveValue(3306);
|
expect(screen.getByLabelText("Port")).toHaveValue(3306);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("NeonDB card preselects Require SSL in the save payload", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<NewConnectionScreen />);
|
||||||
|
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 () => {
|
it("SQLite tab swaps the URI input for a File Path input", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<NewConnectionScreen />);
|
render(<NewConnectionScreen />);
|
||||||
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
|
|||||||
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
|
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tests a pasted URL with its SSL mode", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<NewConnectionScreen />);
|
||||||
|
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 () => {
|
it("invokes onCancel when Back is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCancel = vi.fn();
|
const onCancel = vi.fn();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide";
|
|||||||
import {
|
import {
|
||||||
parseConnectionString,
|
parseConnectionString,
|
||||||
detectProviderFromHost,
|
detectProviderFromHost,
|
||||||
|
suggestConnectionLabel,
|
||||||
|
type ParsedConnectionString,
|
||||||
} from "../../lib/connectionString";
|
} from "../../lib/connectionString";
|
||||||
import { getProviderById, type ProviderId } from "../../lib/providers";
|
import { getProviderById, type ProviderId } from "../../lib/providers";
|
||||||
import { validateConnectionInput } from "../../lib/utils";
|
import { validateConnectionInput } from "../../lib/utils";
|
||||||
@@ -53,6 +55,10 @@ function createEmptyForm(
|
|||||||
database: null,
|
database: null,
|
||||||
use_keychain: true,
|
use_keychain: true,
|
||||||
ssh_password: null,
|
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<ConnectionFormData> {
|
||||||
|
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({
|
export function NewConnectionScreen({
|
||||||
defaultFolderId = null,
|
defaultFolderId = null,
|
||||||
prefilledConnectionString = "",
|
prefilledConnectionString = "",
|
||||||
@@ -73,9 +113,7 @@ export function NewConnectionScreen({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: NewConnectionScreenProps) {
|
}: NewConnectionScreenProps) {
|
||||||
const [stage, setStage] = useState<Stage>("entry");
|
const [stage, setStage] = useState<Stage>("entry");
|
||||||
const [managedPreset, setManagedPreset] = useState<
|
const [managedPreset, setManagedPreset] = useState<ManagedPreset>(null);
|
||||||
"supabase" | "neon" | "planetscale" | null
|
|
||||||
>(null);
|
|
||||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||||
createEmptyForm(
|
createEmptyForm(
|
||||||
defaultFolderId,
|
defaultFolderId,
|
||||||
@@ -86,6 +124,12 @@ export function NewConnectionScreen({
|
|||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const createConnection = useConnectionStore((s) => s.createConnection);
|
const createConnection = useConnectionStore((s) => s.createConnection);
|
||||||
const notify = useNotificationStore((s) => s.notify);
|
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(
|
const revealConfigured = useCallback(
|
||||||
(updates: Partial<ConnectionFormData>) => {
|
(updates: Partial<ConnectionFormData>) => {
|
||||||
@@ -97,26 +141,17 @@ export function NewConnectionScreen({
|
|||||||
|
|
||||||
const handleConnectionStringChange = useCallback((value: string) => {
|
const handleConnectionStringChange = useCallback((value: string) => {
|
||||||
const parsed = parseConnectionString(value);
|
const parsed = parseConnectionString(value);
|
||||||
if (parsed) {
|
if (!parsed) {
|
||||||
const provider =
|
setManagedPreset(null);
|
||||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
setForm((prev) => ({ ...prev, connection_string: value }));
|
||||||
? detectProviderFromHost(parsed.host)
|
return;
|
||||||
: null;
|
}
|
||||||
setManagedPreset(provider);
|
setManagedPreset(providerForParsed(parsed));
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
connection_string: value,
|
connection_string: value,
|
||||||
db_type: parsed.db_type,
|
...parsedFormPatch(parsed, prev),
|
||||||
host: parsed.host,
|
|
||||||
port: parsed.port ?? getDefaultPort(parsed.db_type),
|
|
||||||
username: parsed.username,
|
|
||||||
password: parsed.password,
|
|
||||||
database: parsed.database,
|
|
||||||
}));
|
}));
|
||||||
} else {
|
|
||||||
setManagedPreset(null);
|
|
||||||
setForm((prev) => ({ ...prev, connection_string: value }));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,7 +185,15 @@ export function NewConnectionScreen({
|
|||||||
? null
|
? null
|
||||||
: getDefaultPort(provider.dbType),
|
: getDefaultPort(provider.dbType),
|
||||||
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
...(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],
|
[revealConfigured],
|
||||||
@@ -166,22 +209,11 @@ export function NewConnectionScreen({
|
|||||||
const value = updates.connection_string;
|
const value = updates.connection_string;
|
||||||
const parsed = parseConnectionString(value);
|
const parsed = parseConnectionString(value);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const provider =
|
setManagedPreset(providerForParsed(parsed));
|
||||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
|
||||||
? detectProviderFromHost(parsed.host)
|
|
||||||
: null;
|
|
||||||
setManagedPreset(provider);
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
...updates,
|
...updates,
|
||||||
db_type: parsed.db_type,
|
...parsedFormPatch(parsed, prev),
|
||||||
host: parsed.host,
|
|
||||||
port:
|
|
||||||
parsed.port ??
|
|
||||||
getDefaultPort(parsed.db_type),
|
|
||||||
username: parsed.username,
|
|
||||||
password: parsed.password,
|
|
||||||
database: parsed.database,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...prev, ...updates };
|
return { ...prev, ...updates };
|
||||||
@@ -194,7 +226,7 @@ export function NewConnectionScreen({
|
|||||||
|
|
||||||
const buildPayload = useCallback((): ConnectionInput => {
|
const buildPayload = useCallback((): ConnectionInput => {
|
||||||
return {
|
return {
|
||||||
name: form.name,
|
name: form.name.trim() || suggestedName,
|
||||||
db_type: form.db_type,
|
db_type: form.db_type,
|
||||||
host: form.host,
|
host: form.host,
|
||||||
port: form.port,
|
port: form.port,
|
||||||
@@ -213,8 +245,12 @@ export function NewConnectionScreen({
|
|||||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||||
ssh_password: form.ssh_password ?? null,
|
ssh_password: form.ssh_password ?? null,
|
||||||
ssh_passphrase: form.ssh_passphrase ?? 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 validate = useCallback((): string | null => {
|
||||||
const result = validateConnectionInput(buildPayload());
|
const result = validateConnectionInput(buildPayload());
|
||||||
@@ -336,6 +372,7 @@ export function NewConnectionScreen({
|
|||||||
<DetailedConnectionForm
|
<DetailedConnectionForm
|
||||||
form={form}
|
form={form}
|
||||||
onChange={updateForm}
|
onChange={updateForm}
|
||||||
|
namePlaceholder={suggestedName || undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
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";
|
import { SslFields } from "./SslFields";
|
||||||
|
|
||||||
const notify = vi.fn();
|
const notify = vi.fn();
|
||||||
@@ -35,4 +35,33 @@ describe("SslFields", () => {
|
|||||||
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText("Client Key")).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(
|
||||||
|
<SslFields
|
||||||
|
values={{ ssl_mode: "verify-full", ssl_ca_path: "/tmp/ca.pem" }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
|||||||
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
|
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={(values.ssl_ca_cert as string) ?? ""}
|
value={(values.ssl_ca_path as string) ?? ""}
|
||||||
onChange={(value) => onChange({ ssl_ca_cert: value })}
|
onChange={(value) => onChange({ ssl_ca_path: value })}
|
||||||
placeholder="/path/to/ca-cert.pem"
|
placeholder="/path/to/ca-cert.pem"
|
||||||
aria-label="CA Certificate"
|
aria-label="CA Certificate"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePickFile("ssl_ca_cert")}
|
onClick={() => handlePickFile("ssl_ca_path")}
|
||||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Browse
|
Browse
|
||||||
@@ -74,14 +74,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
|||||||
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
|
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={(values.ssl_client_cert as string) ?? ""}
|
value={(values.ssl_cert_path as string) ?? ""}
|
||||||
onChange={(value) => onChange({ ssl_client_cert: value })}
|
onChange={(value) => onChange({ ssl_cert_path: value })}
|
||||||
placeholder="/path/to/client-cert.pem"
|
placeholder="/path/to/client-cert.pem"
|
||||||
aria-label="Client Certificate"
|
aria-label="Client Certificate"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePickFile("ssl_client_cert")}
|
onClick={() => handlePickFile("ssl_cert_path")}
|
||||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Browse
|
Browse
|
||||||
@@ -93,14 +93,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
|||||||
<label className="block text-sm text-text mb-1.5">Client Key</label>
|
<label className="block text-sm text-text mb-1.5">Client Key</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={(values.ssl_client_key as string) ?? ""}
|
value={(values.ssl_key_path as string) ?? ""}
|
||||||
onChange={(value) => onChange({ ssl_client_key: value })}
|
onChange={(value) => onChange({ ssl_key_path: value })}
|
||||||
placeholder="/path/to/client-key.pem"
|
placeholder="/path/to/client-key.pem"
|
||||||
aria-label="Client Key"
|
aria-label="Client Key"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePickFile("ssl_client_key")}
|
onClick={() => handlePickFile("ssl_key_path")}
|
||||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Browse
|
Browse
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DbType } from "../../lib/types";
|
import type { DbType } from "../../lib/types";
|
||||||
import type { Environment } from "./EnvironmentSelect";
|
import type { Environment } from "./EnvironmentSelect";
|
||||||
|
import type { SslMode } from "../../lib/connectionString";
|
||||||
|
|
||||||
export interface ConnectionFormData {
|
export interface ConnectionFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,4 +25,13 @@ export interface ConnectionFormData {
|
|||||||
ssh_private_key?: string | null;
|
ssh_private_key?: string | null;
|
||||||
ssh_password: string | null;
|
ssh_password: string | null;
|
||||||
ssh_passphrase?: string | null;
|
ssh_passphrase?: string | null;
|
||||||
|
// SSL/TLS fields. These must live on the form data (and survive into the
|
||||||
|
// submit payload) or the SSH/SSL tab's selections are silently dropped — the
|
||||||
|
// backend then connects with TLS disabled, which managed providers (Neon,
|
||||||
|
// Supabase, PlanetScale) reject with "connection is insecure".
|
||||||
|
// `null` = unset, which the backend treats as TLS disabled.
|
||||||
|
ssl_mode?: SslMode | null;
|
||||||
|
ssl_ca_path?: string | null;
|
||||||
|
ssl_cert_path?: string | null;
|
||||||
|
ssl_key_path?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,23 @@ const { updateConnection, loadAll } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../stores/connectionStore", () => ({
|
vi.mock("../../stores/connectionStore", () => ({
|
||||||
useConnectionStore: (sel: (s: any) => any) =>
|
useConnectionStore: Object.assign(
|
||||||
|
(sel: (s: any) => any) =>
|
||||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||||
|
{ getState: () => ({ getConnectionPassword: async () => "pw" }) },
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../lib/commands", async (importOriginal) => {
|
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
updateConnection: vi.fn(),
|
updateConnection: vi.fn(),
|
||||||
testConnection: vi.fn(),
|
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
saveConnectionPassword: vi.fn(),
|
saveConnectionPassword: vi.fn(),
|
||||||
saveConnectionSshPassword: vi.fn(),
|
saveConnectionSshPassword: vi.fn(),
|
||||||
saveConnectionSshPassphrase: vi.fn(),
|
saveConnectionSshPassphrase: vi.fn(),
|
||||||
|
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
|
||||||
|
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
|
||||||
deleteConnectionPassword: vi.fn(),
|
deleteConnectionPassword: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -121,4 +126,62 @@ describe("EditConnectionModal", () => {
|
|||||||
);
|
);
|
||||||
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists the existing SSL settings on save", async () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{
|
||||||
|
...baseConn,
|
||||||
|
ssl_mode: "verify-full",
|
||||||
|
ssl_ca_path: "/tmp/ca.pem",
|
||||||
|
ssl_cert_path: "/tmp/client.pem",
|
||||||
|
ssl_key_path: "/tmp/client.key",
|
||||||
|
}}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
await waitFor(() => expect(vi.mocked(commands.updateConnection)).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(commands.updateConnection)).toHaveBeenCalledWith(
|
||||||
|
"c1",
|
||||||
|
expect.objectContaining({
|
||||||
|
ssl_mode: "verify-full",
|
||||||
|
ssl_ca_path: "/tmp/ca.pem",
|
||||||
|
ssl_cert_path: "/tmp/client.pem",
|
||||||
|
ssl_key_path: "/tmp/client.key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests with the same SSL + SSH config it would save", async () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{
|
||||||
|
...baseConn,
|
||||||
|
ssl_mode: "require",
|
||||||
|
ssh_host: "jump.example.com",
|
||||||
|
ssh_port: 22,
|
||||||
|
ssh_user: "tunnel",
|
||||||
|
ssh_auth_method: "key",
|
||||||
|
ssh_private_key_path: "/tmp/id_ed25519",
|
||||||
|
}}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^test$/i }));
|
||||||
|
await waitFor(() => expect(vi.mocked(commands.testConnection)).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(commands.testConnection)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ssl_mode: "require",
|
||||||
|
ssh_host: "jump.example.com",
|
||||||
|
ssh_user: "tunnel",
|
||||||
|
ssh_auth_method: "key",
|
||||||
|
ssh_private_key_path: "/tmp/id_ed25519",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,9 +4,9 @@ import { Button } from "../ui/Button";
|
|||||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase, getConnectionSshPassword, getConnectionSshPassphrase } from "../../lib/commands";
|
||||||
import { persistDbPassword } from "../../lib/keychain";
|
import { persistDbPassword } from "../../lib/keychain";
|
||||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
import { detectProviderFromHost, type SslMode } from "../../lib/connectionString";
|
||||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||||
|
|
||||||
@@ -47,6 +47,10 @@ export function EditConnectionModal({
|
|||||||
ssh_private_key: connection.ssh_private_key_path ?? null,
|
ssh_private_key: connection.ssh_private_key_path ?? null,
|
||||||
ssh_password: null,
|
ssh_password: null,
|
||||||
ssh_passphrase: null,
|
ssh_passphrase: null,
|
||||||
|
ssl_mode: (connection.ssl_mode as SslMode | null | undefined) ?? null,
|
||||||
|
ssl_ca_path: connection.ssl_ca_path ?? null,
|
||||||
|
ssl_cert_path: connection.ssl_cert_path ?? null,
|
||||||
|
ssl_key_path: connection.ssl_key_path ?? null,
|
||||||
}));
|
}));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -75,6 +79,10 @@ export function EditConnectionModal({
|
|||||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||||
ssh_password: form.ssh_password ?? null,
|
ssh_password: form.ssh_password ?? null,
|
||||||
ssh_passphrase: form.ssh_passphrase ?? 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,
|
||||||
};
|
};
|
||||||
const updated = await updateConnection(connection.id, input);
|
const updated = await updateConnection(connection.id, input);
|
||||||
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||||
@@ -104,6 +112,18 @@ export function EditConnectionModal({
|
|||||||
if (!password) {
|
if (!password) {
|
||||||
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
||||||
}
|
}
|
||||||
|
// SSH secrets live in the keychain only, so a tunneled connection can be
|
||||||
|
// tested without re-entering them.
|
||||||
|
const sshPassword =
|
||||||
|
form.ssh_password ??
|
||||||
|
(form.ssh_host
|
||||||
|
? await getConnectionSshPassword(connection.id).catch(() => null)
|
||||||
|
: null);
|
||||||
|
const sshPassphrase =
|
||||||
|
form.ssh_passphrase ??
|
||||||
|
(form.ssh_host
|
||||||
|
? await getConnectionSshPassphrase(connection.id).catch(() => null)
|
||||||
|
: null);
|
||||||
|
|
||||||
const result = await testConnection({
|
const result = await testConnection({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -116,7 +136,19 @@ export function EditConnectionModal({
|
|||||||
folder_id: form.folder_id,
|
folder_id: form.folder_id,
|
||||||
environment: form.environment,
|
environment: form.environment,
|
||||||
tag_ids: form.tag_ids,
|
tag_ids: form.tag_ids,
|
||||||
ssh_password: form.ssh_password ?? null,
|
// Test the same config Save would persist — a tunneled/SSL connection
|
||||||
|
// must be probed through its tunnel and TLS mode.
|
||||||
|
ssh_host: form.ssh_host ?? null,
|
||||||
|
ssh_port: form.ssh_port ?? null,
|
||||||
|
ssh_user: form.ssh_user ?? null,
|
||||||
|
ssh_auth_method: form.ssh_auth_method ?? null,
|
||||||
|
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||||
|
ssh_password: sshPassword,
|
||||||
|
ssh_passphrase: sshPassphrase,
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
notify("Connection successful", "success");
|
notify("Connection successful", "success");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
|
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl, suggestConnectionLabel } from "./connectionString";
|
||||||
import type { Connection } from "./types";
|
import type { Connection } from "./types";
|
||||||
|
|
||||||
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
||||||
@@ -30,6 +30,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
database: "mydb",
|
database: "mydb",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "root",
|
username: "root",
|
||||||
password: null,
|
password: null,
|
||||||
database: "app",
|
database: "app",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
database: "0",
|
database: "0",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +95,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "user",
|
username: "user",
|
||||||
password: null,
|
password: null,
|
||||||
database: "db",
|
database: "db",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,12 +108,51 @@ describe("parseConnectionString", () => {
|
|||||||
username: "user",
|
username: "user",
|
||||||
password: null,
|
password: null,
|
||||||
database: "db",
|
database: "db",
|
||||||
|
ssl_mode: "require",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for an empty string", () => {
|
it("returns null for an empty string", () => {
|
||||||
expect(parseConnectionString("")).toBeNull();
|
expect(parseConnectionString("")).toBeNull();
|
||||||
});
|
});
|
||||||
|
it("lifts sslmode from a Neon-style URL (extra params ignored)", () => {
|
||||||
|
const result = parseConnectionString(
|
||||||
|
"postgresql://neondb_owner:pw@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||||
|
username: "neondb_owner",
|
||||||
|
password: "pw",
|
||||||
|
database: "neondb",
|
||||||
|
ssl_mode: "require",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts every supported sslmode value", () => {
|
||||||
|
const mode = (q: string) => parseConnectionString(`postgresql://u@h/db?sslmode=${q}`)?.ssl_mode;
|
||||||
|
expect(mode("disable")).toBe("disable");
|
||||||
|
expect(mode("require")).toBe("require");
|
||||||
|
expect(mode("verify-ca")).toBe("verify-ca");
|
||||||
|
expect(mode("verify-full")).toBe("verify-full");
|
||||||
|
expect(mode("VERIFY-FULL")).toBe("verify-full");
|
||||||
|
expect(mode("verify_full")).toBe("verify-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unsupported sslmode values instead of downgrading to plaintext", () => {
|
||||||
|
expect(parseConnectionString("postgresql://u@h/db?sslmode=allow")?.ssl_mode).toBeNull();
|
||||||
|
expect(parseConnectionString("postgresql://u@h/db?sslmode=prefer")?.ssl_mode).toBeNull();
|
||||||
|
expect(parseConnectionString("postgresql://u@h/db?sslmode=bogus")?.ssl_mode).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts MySQL's ssl-mode vocabulary and skips TLS for non-SQL db types", () => {
|
||||||
|
expect(parseConnectionString("mysql://u@h/db?ssl-mode=REQUIRED")?.ssl_mode).toBe("require");
|
||||||
|
expect(parseConnectionString("mysql://u@h/db?ssl-mode=verify-full")?.ssl_mode).toBe("verify-full");
|
||||||
|
expect(parseConnectionString("mysql://u@h/db?ssl-mode=VERIFY_IDENTITY")?.ssl_mode).toBe("verify-full");
|
||||||
|
expect(parseConnectionString("mysql://u@h/db?ssl-mode=PREFERRED")?.ssl_mode).toBeNull();
|
||||||
|
expect(parseConnectionString("redis://u@h?sslmode=require")?.ssl_mode).toBeNull();
|
||||||
|
expect(parseConnectionString("sqlite:///tmp/a.db?sslmode=require")?.ssl_mode).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null for a non-URL string", () => {
|
it("returns null for a non-URL string", () => {
|
||||||
expect(parseConnectionString("hello world")).toBeNull();
|
expect(parseConnectionString("hello world")).toBeNull();
|
||||||
@@ -122,6 +167,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "user",
|
username: "user",
|
||||||
password: null,
|
password: null,
|
||||||
database: "db",
|
database: "db",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +180,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "root",
|
username: "root",
|
||||||
password: "pw",
|
password: "pw",
|
||||||
database: "app",
|
database: "app",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,6 +193,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: "root",
|
username: "root",
|
||||||
password: "pw",
|
password: "pw",
|
||||||
database: "app",
|
database: "app",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,6 +206,7 @@ describe("parseConnectionString", () => {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -180,6 +229,33 @@ describe("looksLikeConnectionString", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("suggestConnectionLabel", () => {
|
||||||
|
const label = (url: string) => {
|
||||||
|
const parsed = parseConnectionString(url)!;
|
||||||
|
return suggestConnectionLabel(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("prefers the database name", () => {
|
||||||
|
expect(
|
||||||
|
label("postgresql://neondb_owner:pw@ep-x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"),
|
||||||
|
).toBe("neondb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the host when there is no database", () => {
|
||||||
|
expect(label("postgresql://u@db.example.com:5432")).toBe("db.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the file stem for SQLite paths", () => {
|
||||||
|
expect(label("sqlite:///Users/me/data/app-demo.sqlite")).toBe("app-demo");
|
||||||
|
expect(label("file:///tmp/notes.db")).toBe("notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the label at the 100-char validation limit", () => {
|
||||||
|
const host = `${"a".repeat(120)}.example.com`;
|
||||||
|
expect(label(`postgresql://u@${host}/`).length).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("detectProviderFromHost", () => {
|
describe("detectProviderFromHost", () => {
|
||||||
it("detects Supabase by host suffix", () => {
|
it("detects Supabase by host suffix", () => {
|
||||||
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
||||||
@@ -291,6 +367,7 @@ describe("buildConnectionUrl", () => {
|
|||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
database: "mydb",
|
database: "mydb",
|
||||||
|
ssl_mode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,53 @@ export interface ParsedConnectionString {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
database: string | null;
|
database: string | null;
|
||||||
|
/** SSL mode lifted from the URL's `sslmode` / `ssl-mode` query param, or
|
||||||
|
* `null` when absent/unsupported. Keeps managed-PostgreSQL URLs (Neon,
|
||||||
|
* Supabase, …) working — they ship with `?sslmode=require` and the server
|
||||||
|
* rejects a plaintext connection with "connection is insecure". */
|
||||||
|
ssl_mode: SslMode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSL modes the backend understands (mirrors `DbConfig.ssl_mode`). */
|
||||||
|
export type SslMode = "disable" | "require" | "verify-ca" | "verify-full";
|
||||||
|
|
||||||
|
/** Alternate spellings used by other clients, mapped onto our four modes:
|
||||||
|
* MySQL's `ssl-mode` vocabulary (`REQUIRED`, `VERIFY_IDENTITY`, …) and
|
||||||
|
* plural forms. `allow` / `prefer` are intentionally absent — they permit a
|
||||||
|
* plaintext fallback, which our mode set cannot express, so they return
|
||||||
|
* `null` and the caller keeps its current/default mode. */
|
||||||
|
const SSL_MODE_ALIASES: Record<string, SslMode> = {
|
||||||
|
disable: "disable",
|
||||||
|
disabled: "disable",
|
||||||
|
require: "require",
|
||||||
|
required: "require",
|
||||||
|
"verify-ca": "verify-ca",
|
||||||
|
"verify-full": "verify-full",
|
||||||
|
// MySQL's strongest mode: chain + hostname verification.
|
||||||
|
"verify-identity": "verify-full",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the TLS mode from a connection URL's query string.
|
||||||
|
*
|
||||||
|
* Accepts libpq's `sslmode` (PostgreSQL) and MySQL's `ssl-mode` spelling,
|
||||||
|
* case-insensitively and hyphen/underscore-insensitively (`verify-full`,
|
||||||
|
* `verify_full`, `VERIFY-FULL`, …). Unsupported values (`allow`, `prefer`)
|
||||||
|
* return `null` so the caller keeps its current/default mode rather than
|
||||||
|
* silently downgrading to plaintext.
|
||||||
|
*/
|
||||||
|
function parseSslMode(url: URL): SslMode | null {
|
||||||
|
let raw: string | null = null;
|
||||||
|
for (const [key, value] of url.searchParams) {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (k === "sslmode" || k === "ssl-mode") {
|
||||||
|
raw = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!raw) return null;
|
||||||
|
const normalized = raw.trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
return SSL_MODE_ALIASES[normalized] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_PROTOCOLS: Record<string, DbType> = {
|
const DB_PROTOCOLS: Record<string, DbType> = {
|
||||||
@@ -68,6 +115,9 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
database,
|
database,
|
||||||
|
ssl_mode: db_type === "postgresql" || db_type === "mysql" || db_type === "mariadb"
|
||||||
|
? parseSslMode(url)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +145,25 @@ export function detectProviderFromHost(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest a connection label for a freshly pasted connection string, so the
|
||||||
|
* connection can be saved without typing a name first.
|
||||||
|
*
|
||||||
|
* Preference order: the database name, then (for SQLite) the file stem, then
|
||||||
|
* the host. Capped at 100 chars to satisfy `validateConnectionInput`, which
|
||||||
|
* rejects longer names.
|
||||||
|
*/
|
||||||
|
export function suggestConnectionLabel(parsed: ParsedConnectionString): string {
|
||||||
|
let label: string;
|
||||||
|
if (parsed.db_type === "sqlite") {
|
||||||
|
const file = parsed.host.split(/[\\/]/).pop() ?? "";
|
||||||
|
label = file.replace(/\.(sqlite3?|db)$/i, "") || file;
|
||||||
|
} else {
|
||||||
|
label = parsed.database?.replace(/^\/+/, "") || parsed.host;
|
||||||
|
}
|
||||||
|
return label.trim().slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a connection URL string from a saved connection, for copying into
|
* Build a connection URL string from a saved connection, for copying into
|
||||||
* env vars / other tools. `password` is the decrypted keychain value (or
|
* env vars / other tools. `password` is the decrypted keychain value (or
|
||||||
|
|||||||
Reference in New Issue
Block a user