diff --git a/src/App.tsx b/src/App.tsx index 168099bc..a102216a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -772,7 +772,10 @@ export function App() { return; } catch (err) { const raw = String(err); - if (host && host.authMethod !== "password" && SSH_PASSPHRASE_RETRY_HINT.test(raw)) { + // When host is not yet in sshHosts state (e.g. just added via upsertSshHost + // and state hasn't refreshed), assume non-password auth so the passphrase + // dialog is still shown instead of falling through to a misleading error. + if ((!host || host.authMethod !== "password") && SSH_PASSPHRASE_RETRY_HINT.test(raw)) { const passphrase = await requestPassphrase(hostLabel); if (passphrase !== null) { try { @@ -785,7 +788,9 @@ export function App() { return; } catch (passphraseErr) { const passphraseRaw = String(passphraseErr); - const fallbackMessage = buildSshPassphraseConnectErrorMessage(passphraseRaw, hostLabel, t); + const fallbackMessage = buildSshPassphraseConnectErrorMessage( + passphraseRaw, hostLabel, t, { passphraseWasSubmitted: true }, + ); if (fallbackMessage) { throw new Error(fallbackMessage); } diff --git a/src/lib/__tests__/sshConnectErrors.test.ts b/src/lib/__tests__/sshConnectErrors.test.ts index 31e2e5c2..2fb2b051 100644 --- a/src/lib/__tests__/sshConnectErrors.test.ts +++ b/src/lib/__tests__/sshConnectErrors.test.ts @@ -10,13 +10,14 @@ import { } from "../sshConnectErrors"; const t = (key: string, opts: Record = {}) => { - const text = { + const text: Record = { "ssh.passphraseValidationFailed": "PASS_FAIL_{{host}}", "ssh.missingKeyFile": "MISSING_KEY_{{host}}", "ssh.publicKeyRejected": "PUBLIC_KEY_REJECTED_{{host}}", + "ssh.publicKeyAuthFailed": "PUBLIC_KEY_AUTH_FAILED_{{host}}", "ssh.passphraseCancelled": "CANCEL_{{host}}", - }[key] || key; - return text.replace("{{host}}", String(opts.host ?? "")); + }; + return (text[key] || key).replace("{{host}}", String(opts.host ?? "")); }; describe("sshConnectErrors", () => { @@ -46,8 +47,15 @@ describe("sshConnectErrors", () => { expect(msg).toBe("MISSING_KEY_hetzner"); }); - test("maps permission-denied error to localized message", () => { + test("maps permission-denied error to publicKeyAuthFailed when no passphrase was submitted", () => { const msg = buildSshPassphraseConnectErrorMessage("public key authentication failed", "hetzner", t); + expect(msg).toBe("PUBLIC_KEY_AUTH_FAILED_hetzner"); + }); + + test("maps permission-denied error to publicKeyRejected when passphrase was submitted", () => { + const msg = buildSshPassphraseConnectErrorMessage( + "public key authentication failed", "hetzner", t, { passphraseWasSubmitted: true }, + ); expect(msg).toBe("PUBLIC_KEY_REJECTED_hetzner"); }); diff --git a/src/lib/sshConnectErrors.ts b/src/lib/sshConnectErrors.ts index 4b47491c..9d34ed15 100644 --- a/src/lib/sshConnectErrors.ts +++ b/src/lib/sshConnectErrors.ts @@ -18,6 +18,7 @@ export function buildSshPassphraseConnectErrorMessage( rawError: string, hostLabel: string, t: SshTranslate, + options?: { passphraseWasSubmitted?: boolean }, ): string | null { if (SSH_PASSPHRASE_REJECT_HINT.test(rawError)) { return t("ssh.passphraseValidationFailed", { host: hostLabel }); @@ -26,7 +27,11 @@ export function buildSshPassphraseConnectErrorMessage( return t("ssh.missingKeyFile", { host: hostLabel }); } if (SSH_PUBLIC_KEY_PERMISSION_HINT.test(rawError)) { - return t("ssh.publicKeyRejected", { host: hostLabel }); + // Distinguish between "passphrase was entered but key still rejected" + // and "no passphrase was ever entered, auth simply failed". + return options?.passphraseWasSubmitted + ? t("ssh.publicKeyRejected", { host: hostLabel }) + : t("ssh.publicKeyAuthFailed", { host: hostLabel }); } return null; } diff --git a/src/locales/en.json b/src/locales/en.json index 0974f69a..771dfd46 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -612,6 +612,7 @@ "ssh.passphraseValidationFailed": "SSH passphrase check failed (host: {{host}}). Verify the passphrase is correct or unlock the key first.", "ssh.missingKeyFile": "No usable private key file found (host: {{host}}). Check IdentityFile in SSH config and confirm the key is readable.", "ssh.publicKeyRejected": "SSH authentication failed (host: {{host}}). Passphrase was submitted, but the remote host still rejected it. Check authorized_keys, use user root, and confirm the host fingerprint.", + "ssh.publicKeyAuthFailed": "SSH authentication failed (host: {{host}}). The remote host rejected the public key. Check authorized_keys, verify the key file, and confirm the user and host fingerprint.", "ssh.passphraseCancelled": "SSH passphrase input cancelled (host: {{host}}). Retry and enter a passphrase if the key is encrypted.", "ssh.passphrasePrompt": "Enter SSH key passphrase for {{host}}", "ssh.passphraseTitle": "SSH Key Passphrase", diff --git a/src/locales/zh.json b/src/locales/zh.json index 78bd8ec4..8c3dfb29 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -612,6 +612,7 @@ "ssh.passphraseValidationFailed": "SSH 口令校验失败(host: {{host}})。请确认私钥口令正确,或先解锁对应密钥后重试。", "ssh.missingKeyFile": "未找到可用私钥文件(host: {{host}})。请检查 SSH 配置里的 IdentityFile 是否可读。", "ssh.publicKeyRejected": "SSH 认证失败(host: {{host}})。已提交口令但远端仍拒绝。请确认 authorized_keys、用户为 root 且主机指纹匹配。", + "ssh.publicKeyAuthFailed": "SSH 认证失败(host: {{host}})。远端拒绝了公钥。请确认 authorized_keys、密钥文件、用户名及主机指纹。", "ssh.passphraseCancelled": "已取消输入 SSH 私钥口令(host: {{host}})。如果该密钥加密,请重试并输入口令。", "ssh.passphrasePrompt": "请输入 {{host}} 的 SSH 私钥口令", "ssh.passphraseTitle": "SSH 私钥口令",