From 43d434b8e1ae8aad6880f5df473ca2e82b536691 Mon Sep 17 00:00:00 2001 From: Valeh Date: Sat, 9 May 2026 22:12:51 +0400 Subject: [PATCH 1/2] fix(connect): six bugs in the new sidebar dashboard / Connect dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of #8 surfaced six runtime/UX bugs that the test suite didn't catch. Tests added for each. 1. handleJdbc had an empty `if (p.password) { /* comment */ }` block whose comment claimed to queue the password on the clipboard but did nothing. Removed the misleading code and updated the toast to be honest: "JDBC URL copied · use the row's Copy button for the password". 2. handleCommand copied the command and then copied the password, leaving the password on the clipboard while the toast said "command copied" — the user would paste the password instead of the command. Now copies the command alone; password lives on the per-row Copy button. 3. launchSshUrl assigned to `window.location.href`, which is unreliable in Chromium for custom-scheme URLs: when no handler is registered Chrome navigates the tab to an `ERR_UNKNOWN_URL_SCHEME` page, throwing the user out of the app. Switched to the synthetic-anchor-click pattern used by 1Password / Bitwarden — the click invokes the protocol handler if present and is a silent no-op otherwise. 4. The "Open RDP session" option was enabled for any credential with a host, then called `buildRdpFile({ ...item, protocol: "rdp" })` — which overrode the protocol but kept the credential's port. For a Postgres credential on port 5432 the resulting .rdp pointed at `host:5432`, which doesn't run RDP. The option is now gated through a new `canBuildRdp` helper that requires `effectiveProtocol === "rdp"`, and the disabled-state hint guides the user to set the protocol field. 5. VaultPage.toggleSelectAll compared `s.size === filtered.length` to decide between select-all and deselect-all. With cross-scope selection that comparison can be coincidentally true even when the visible rows aren't the selected ones, causing a deselect when the user expected a select. Switched to a per-row `every`-based check that matches what CredentialsGrid uses for the header-checkbox visual. 6. The Connect dialog's "where this points" subtitle ran a dedupe pass keyed on exact string equality — but "db-prod-01" and "db-prod-01:5432" are different strings, so the host appeared twice in the rendered header (visible in the v0 vault-connect screenshot). Extracted the logic into a tested `buildTargetSubtitle` helper that keeps host:port as the canonical segment and only adds the IP separately when the hostname was used as the primary identifier. Tests: 48 passing (was 37) — +11 covering buildTargetSubtitle's dedupe and canBuildRdp's gating, including the regression case where a Postgres credential on port 5432 must NOT enable the RDP option. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/connect/index.ts | 6 +- packages/web/src/connect/protocol.ts | 36 ++++++ packages/web/src/connect/ssh.ts | 20 +++- packages/web/src/pages/VaultPage.tsx | 17 ++- .../web/src/pages/vault/ConnectDialog.tsx | 51 +++++---- packages/web/tests/connect.test.ts | 103 ++++++++++++++++++ 6 files changed, 198 insertions(+), 35 deletions(-) diff --git a/packages/web/src/connect/index.ts b/packages/web/src/connect/index.ts index 58b2ee7..f4dc889 100644 --- a/packages/web/src/connect/index.ts +++ b/packages/web/src/connect/index.ts @@ -1,9 +1,11 @@ export { + buildTargetSubtitle, + canBuildRdp, + defaultPort, effectiveProtocol, - inferProtocolFromPort, engineCode, + inferProtocolFromPort, protocolLabel, - defaultPort, } from "./protocol.js"; export { buildJdbcUrl, supportsJdbc } from "./jdbc.js"; export { buildConnectCommand } from "./command.js"; diff --git a/packages/web/src/connect/protocol.ts b/packages/web/src/connect/protocol.ts index 67f85ba..9ea16a8 100644 --- a/packages/web/src/connect/protocol.ts +++ b/packages/web/src/connect/protocol.ts @@ -1,5 +1,7 @@ import type { Protocol, VaultLoginPlaintext } from "@passman/core"; + + /** * Map a port number to its most likely protocol. Used as a fallback when a * credential predates the protocol field, or for sane defaults in the Add @@ -77,3 +79,37 @@ export function defaultPort(protocol: Protocol | undefined): number | undefined default: return undefined; } } + +/** + * Build the dialog "where this points" subtitle. Concatenates the + * canonical host:port, then any non-redundant context (a separate IP if + * the hostname is the primary identifier, the username with optional AD + * domain). + */ +export function buildTargetSubtitle(item: VaultLoginPlaintext): string { + const host = item.hostname || item.ip; + const hostPort = host + ? item.port !== undefined ? `${host}:${item.port}` : host + : ""; + const userLabel = item.username + ? item.domain ? `${item.domain}\\${item.username}` : item.username + : ""; + // Show the IP as a separate segment only when the hostname was used as + // the primary identifier — otherwise we'd repeat it. + const extraIp = item.hostname && item.ip && item.ip !== item.hostname + ? item.ip + : ""; + return [hostPort, extraIp, userLabel].filter(Boolean).join(" · "); +} + +/** + * Whether a credential's Connect dialog should enable the "Open RDP session" + * action. Limited to credentials whose effective protocol is RDP — for any + * other protocol the credential's port is for a different service and the + * generated .rdp file would point at the wrong port (e.g. 5432 for a + * Postgres credential). + */ +export function canBuildRdp(item: VaultLoginPlaintext): boolean { + if (effectiveProtocol(item) !== "rdp") return false; + return Boolean(item.hostname || item.ip); +} diff --git a/packages/web/src/connect/ssh.ts b/packages/web/src/connect/ssh.ts index 877128d..6f09492 100644 --- a/packages/web/src/connect/ssh.ts +++ b/packages/web/src/connect/ssh.ts @@ -17,11 +17,21 @@ export function buildSshUrl(item: VaultLoginPlaintext): string | null { /** * Trigger the OS's `ssh://` handler. Some browsers gate this behind a user * prompt the first time — that's fine, that prompt only fires once per - * origin/scheme. We use `location.href` (rather than a ``) - * so the navigation stays in the existing tab. + * origin/scheme. + * + * Implementation notes: assigning `location.href` to a custom-scheme URL is + * unreliable in Chromium — if no handler is registered, Chrome navigates the + * tab to an "ERR_UNKNOWN_URL_SCHEME" error page, throwing the user out of + * the app. The synthetic-anchor-click pattern below is what 1Password and + * Bitwarden use: the click handler invokes the protocol handler if present + * and is a silent no-op otherwise. */ export function launchSshUrl(url: string): void { - // Assigning to location.href triggers the protocol handler without - // creating an extra history entry that the user would have to back out of. - window.location.href = url; + const a = document.createElement("a"); + a.href = url; + a.rel = "noopener"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + a.remove(); } diff --git a/packages/web/src/pages/VaultPage.tsx b/packages/web/src/pages/VaultPage.tsx index 33e430a..aaa3e14 100644 --- a/packages/web/src/pages/VaultPage.tsx +++ b/packages/web/src/pages/VaultPage.tsx @@ -191,8 +191,21 @@ export function VaultPage() { function toggleSelectAll() { setSelected((s) => { - if (s.size === filtered.length) return new Set(); - return new Set(filtered.map((it) => it.id)); + // "Are all *visible* rows currently selected?" — not just a size match. + // A size match can be coincidental (e.g. you selected items in another + // scope, then narrowed the view). The CredentialsGrid component's + // header-checkbox visual uses the same `every`-based check, so the + // toggle and the indicator stay in sync. + const allVisibleSelected = + filtered.length > 0 && filtered.every((it) => s.has(it.id)); + if (allVisibleSelected) { + const next = new Set(s); + for (const it of filtered) next.delete(it.id); + return next; + } + const next = new Set(s); + for (const it of filtered) next.add(it.id); + return next; }); } diff --git a/packages/web/src/pages/vault/ConnectDialog.tsx b/packages/web/src/pages/vault/ConnectDialog.tsx index 1dde06f..0a95bb7 100644 --- a/packages/web/src/pages/vault/ConnectDialog.tsx +++ b/packages/web/src/pages/vault/ConnectDialog.tsx @@ -3,8 +3,9 @@ import { useEffect } from "react"; import { buildConnectCommand, buildJdbcUrl, - buildRdpFile, buildSshUrl, + buildTargetSubtitle, + canBuildRdp, copyPlain, copySensitive, downloadRdpFile, @@ -42,20 +43,16 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) { const code = engineCode(protocol); const proto = protocolLabel(protocol); - const target = [ - p.hostname, - p.ip, - p.port !== undefined ? `${p.hostname || p.ip || ""}:${p.port}` : "", - p.username && (p.domain ? `${p.domain}\\${p.username}` : p.username), - ] - .filter(Boolean) - .filter((v, i, arr) => arr.indexOf(v) === i) - .join(" · "); + const target = buildTargetSubtitle(p); const jdbcUrl = supportsJdbc(protocol) ? buildJdbcUrl(p) : null; const sshUrl = buildSshUrl(p); const cmd = buildConnectCommand(p); - const canRdp = !!buildRdpFile({ ...p, protocol: "rdp" }); + // RDP is offered only when the credential is itself an RDP entry. For a + // Postgres credential on port 5432 we'd otherwise generate an .rdp file + // pointing at port 5432, which doesn't run RDP — that's a bug, not a + // feature. + const canRdp = canBuildRdp(p); function done(action: string) { onUsed(item!.id); @@ -66,19 +63,15 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) { async function handleJdbc() { if (!jdbcUrl) return; await copyPlain(jdbcUrl); - if (p.password) { - // Queue the password on the clipboard with auto-clear so the user can - // paste it into the next field. The plain URL was just overwritten by - // copySensitive's writeText, but that's the desired ordering — the - // user pastes the URL first into DBeaver, then "Copy" again on the - // next field grabs the password (we explicitly switched). For now we - // give them the URL only and let them re-trigger via Copy buttons. - } - done(`JDBC URL copied · ${jdbcUrl}`); + done("JDBC URL copied · use the row's Copy button for the password"); } async function handleSsh() { if (!sshUrl) return; + // Copy the password first (so it's on the clipboard when the SSH client + // prompts for it), then launch the URL handler. If we launched first, + // the synthetic anchor click could race with the writeText call in + // browsers that suspend the page on protocol-handler invocation. if (p.password) await copySensitive(p.password); launchSshUrl(sshUrl); done("Launching SSH · password on clipboard, clears in 30 s"); @@ -86,15 +79,19 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) { async function handleCommand() { if (!cmd) return; + // Copy the command alone. We deliberately do NOT also copy the password + // here — the second writeText would overwrite the command, leaving the + // user's clipboard holding the password they expected to paste a command + // from. The row's per-row Copy button is the password path. await copyPlain(cmd); - if (p.password) await copySensitive(p.password); - done("Connect command copied · password on clipboard"); + done("Connect command copied · use the row's Copy button for the password"); } async function handleRdp() { - const ok = downloadRdpFile({ ...p, protocol: "rdp" }); + if (!canRdp) return; + const ok = downloadRdpFile(p); if (!ok) { - onToast("Add hostname + RDP port to enable RDP"); + onToast("Add hostname or IP to this credential"); return; } if (p.password) await copySensitive(p.password); @@ -147,7 +144,7 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) { icon={} title="Copy connect command" meta={cmd ?? "No canonical command for this protocol"} - hint="Ready-to-paste shell command · password also on clipboard" + hint="Ready-to-paste shell command · use the row's Copy button for the password" cta="Copy" onClick={handleCommand} /> @@ -159,7 +156,9 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) { meta={ canRdp ? `${p.hostname || p.ip}:${p.port ?? 3389}` - : "Add hostname or IP to enable" + : protocol === "rdp" + ? "Add hostname or IP to enable" + : "Set protocol to RDP on this credential to enable" } hint="Downloads a pre-filled .rdp · password copied to clipboard, paste at the credential prompt" cta="Download .rdp" diff --git a/packages/web/tests/connect.test.ts b/packages/web/tests/connect.test.ts index 3fc3ce0..916408c 100644 --- a/packages/web/tests/connect.test.ts +++ b/packages/web/tests/connect.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { buildConnectCommand } from "../src/connect/command.js"; import { buildJdbcUrl, supportsJdbc } from "../src/connect/jdbc.js"; import { + buildTargetSubtitle, + canBuildRdp, defaultPort, effectiveProtocol, engineCode, @@ -351,3 +353,104 @@ describe("buildRdpFile", () => { expect(buildRdpFile({ ...baseItem, protocol: "rdp" })).toBeNull(); }); }); + +describe("buildTargetSubtitle (Connect dialog header subtitle)", () => { + it("renders host:port + user without duplicating the host", () => { + expect( + buildTargetSubtitle({ + ...baseItem, + hostname: "db-prod-01", + ip: "10.0.0.42", + port: 5432, + username: "postgres", + }), + ).toBe("db-prod-01:5432 · 10.0.0.42 · postgres"); + }); + + it("collapses ip when it equals the hostname", () => { + expect( + buildTargetSubtitle({ + ...baseItem, + hostname: "10.0.0.42", + ip: "10.0.0.42", + port: 5432, + }), + ).toBe("10.0.0.42:5432 · alice"); + }); + + it("falls back to ip when hostname is missing", () => { + expect( + buildTargetSubtitle({ + ...baseItem, + ip: "10.0.0.42", + port: 5432, + }), + ).toBe("10.0.0.42:5432 · alice"); + }); + + it("encodes Windows AD domain into the user segment for RDP entries", () => { + expect( + buildTargetSubtitle({ + ...baseItem, + protocol: "rdp", + hostname: "host", + port: 3389, + username: "admin", + domain: "EXAMPLE", + }), + ).toBe("host:3389 · EXAMPLE\\admin"); + }); + + it("omits port when unset", () => { + expect( + buildTargetSubtitle({ ...baseItem, hostname: "host", username: "alice" }), + ).toBe("host · alice"); + }); + + it("returns the empty string when no host or user", () => { + expect(buildTargetSubtitle({ name: "x", username: "", password: "" })).toBe(""); + }); +}); + +describe("canBuildRdp (gates the Open RDP session option)", () => { + it("is true for an RDP credential with a host", () => { + expect( + canBuildRdp({ + ...baseItem, + protocol: "rdp", + hostname: "erp-db-01", + port: 3389, + }), + ).toBe(true); + }); + + it("is true when protocol is inferred from port 3389", () => { + // No explicit protocol — port 3389 should infer RDP. + expect( + canBuildRdp({ ...baseItem, hostname: "host", port: 3389 }), + ).toBe(true); + }); + + it("is FALSE for a Postgres credential — its port isn't an RDP port", () => { + // This is the regression. Without this gate, canBuildRdp would yield + // an .rdp file pointing at port 5432, which doesn't run RDP. + expect( + canBuildRdp({ + ...baseItem, + protocol: "psql", + hostname: "db-prod-01", + port: 5432, + }), + ).toBe(false); + }); + + it("is false for any non-RDP protocol with a host", () => { + expect(canBuildRdp({ ...baseItem, protocol: "ssh", hostname: "h" })).toBe(false); + expect(canBuildRdp({ ...baseItem, protocol: "redis", hostname: "h" })).toBe(false); + expect(canBuildRdp({ ...baseItem, protocol: "mongo", hostname: "h" })).toBe(false); + }); + + it("is false for an RDP credential without a host", () => { + expect(canBuildRdp({ ...baseItem, protocol: "rdp", port: 3389 })).toBe(false); + }); +}); From 4b77e7eac0b0f03941592ebc5faba45d03d66e46 Mon Sep 17 00:00:00 2001 From: Valeh Date: Sat, 9 May 2026 22:16:47 +0400 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20add=20hero=20screenshots=20to=20REA?= =?UTF-8?q?DME=20=E2=80=94=20vault=20grid=20+=20Connect=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's "this is what it does" was an ASCII architecture diagram — informative but it doesn't communicate the product. Added two screenshots above the diagram: 1. The vault dashboard (sidebar groupings, engine-coloured rows, Connect button per row) — the at-a-glance "what does Passman look like?" answer for someone landing on the repo. 2. The Connect dialog with JDBC / SSH / copy-command / RDP options — the headline feature that distinguishes this from a generic password manager. Both images already exist under docs/img/ and are kept in sync with the live styles via the docs/preview/ mockups, so README screenshots track the actual product without a separate maintenance burden. Tightened the description's first line to mention the DBA / infrastructure focus that the screenshots make obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1a5d3fa..7152443 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ # Passman -A zero-knowledge password manager. Vault data is encrypted on the client with -a key derived from your master password, and the server only ever stores -ciphertext + KDF parameters. A database breach leaks nothing usable. +A zero-knowledge password manager built for DBAs and infrastructure teams. +Vault data is encrypted on the client with a key derived from your master +password, and the server only ever stores ciphertext + KDF parameters. A +database breach leaks nothing usable. + +![Passman vault dashboard](docs/img/vault.png) + +The vault treats credentials as connection targets, not just `name + +password` rows: every entry carries protocol, hostname, IP, port, and +optional service-name / Windows-domain / database fields. One click on +**Connect →** turns a saved credential into a working session — a JDBC +URL for DBeaver / DataGrip / DBVisualizer, a launched SSH terminal, a +ready-to-paste `psql` / `mysql` / `sqlplus` command, or a downloadable +`.rdp` file. The password lands on the clipboard with a 30-second +auto-clear; the server still sees only ciphertext. + +![Connect dialog with JDBC, SSH, copy-command, and RDP options](docs/img/vault-connect.png) + +## Architecture at a glance ``` ┌─────────────────┐ auth_key (one-way) ┌───────────────────┐