From 0411a5b08a1187c3af0c2c0b1d30bc433b8aec3d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:33:25 +0800 Subject: [PATCH] fix(settings): show SSH password and passphrase inputs (#609) The SSH configuration form only asked for a password after a failed Test Connection, so choosing Password showed nothing but a "Save password" checkbox. It now shows a Password row, or a Passphrase row for key pairs. The typed secret is staged with the other settings changes: it is written on Apply / OK and discarded on Cancel or Reset, and Test Connection uses it without storing it. An emptied field leaves the stored secret alone. Deployment, run target and data source forms have their own secret inputs and are unchanged. --- .../settings/__tests__/dialog.test.tsx | 23 ++ src/features/settings/__tests__/ssh.test.tsx | 285 ++++++++++++++++++ .../settings/__tests__/sshConnection.test.ts | 9 +- .../settings/__tests__/staging.test.ts | 115 ++++++- .../settings/dialog/SettingsDialog.tsx | 17 +- src/features/settings/model/staging.ts | 57 +++- src/features/settings/pages/ssh.tsx | 99 +++--- src/features/settings/pages/sshConnection.ts | 7 - src/platform/locales/text/zh.json | 2 + 9 files changed, 552 insertions(+), 62 deletions(-) create mode 100644 src/features/settings/__tests__/ssh.test.tsx diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index bf60202c..4a11b98c 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { DEFAULT_SSH_CONFIG } from "@/platform/remoteSettings"; import { commands, type Settings } from "@/platform/ipc"; const settingsSet = vi.fn(); @@ -241,6 +242,28 @@ describe("Settings dialog", () => { ]); }); + it("enables Apply and Reset for a secret-only edit, and Reset discards it", async () => { + current = setAt(current, "ssh.configs", [{ ...DEFAULT_SSH_CONFIG, id: "ssh-dialog", auth: "password" }]); + useProject.setState({ settings: current }); + const needsSecret = vi.spyOn(commands, "sshNeedsSecret").mockResolvedValue({ status: "ok", data: true }); + const setSecret = vi.spyOn(commands, "sshSetSecret").mockResolvedValue(); + try { + const dialog = await openDialog("tools.sshConfigurations"); + const apply = within(dialog).getByRole("button", { name: "Apply" }); + expect(apply).toBeDisabled(); + fireEvent.change(within(dialog).getByLabelText("Password"), { target: { value: crypto.randomUUID() } }); + expect(apply).toBeEnabled(); + fireEvent.click(within(dialog).getByRole("button", { name: "Reset" })); + expect(within(dialog).getByLabelText("Password")).toHaveValue(""); + expect(apply).toBeDisabled(); + expect(setSecret).not.toHaveBeenCalled(); + expect(settingsSet).not.toHaveBeenCalled(); + } finally { + needsSecret.mockRestore(); + setSecret.mockRestore(); + } + }); + it("Cancel discards staged edits", async () => { const dialog = await openDialog("editor.codeStyle.other"); fireEvent.change(within(dialog).getByLabelText("Tab size"), { target: { value: "2" } }); diff --git a/src/features/settings/__tests__/ssh.test.tsx b/src/features/settings/__tests__/ssh.test.tsx new file mode 100644 index 00000000..1f24fa0b --- /dev/null +++ b/src/features/settings/__tests__/ssh.test.tsx @@ -0,0 +1,285 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SSH_CONFIG, type SshAuthValid } from "@/platform/remoteSettings"; +import { useProject } from "@/platform/project"; +import { PageContext } from "../components/context"; +import { validateSettings } from "../model/schema"; +import { applySettings, closeSettings, openSettings, resetPage, useSettingsDialog } from "../model/staging"; +import { SettingsFooter } from "../dialog/SettingsDialog"; +import { SSH_PAGES } from "../pages/ssh"; + +const mocks = vi.hoisted(() => ({ store: vi.fn(), test: vi.fn(), needsSecret: vi.fn(), trust: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { + ...actual.commands, + sshSetSecret: mocks.store, + sshTest: mocks.test, + sshNeedsSecret: mocks.needsSecret, + sshTrustHost: mocks.trust, + }, + }; +}); + +const option = SSH_PAGES[0]!.sections![0]!.options[0]!; +if (option.kind !== "custom") throw new Error("SSH editor missing"); +const Editor = option.component; +const savedDialog = useSettingsDialog.getState(); +const savedProject = useProject.getState(); +let typed: string; + +beforeEach(() => { + vi.resetAllMocks(); + typed = crypto.randomUUID(); + mocks.store.mockResolvedValue(undefined); + mocks.needsSecret.mockResolvedValue({ status: "ok", data: true }); + mocks.test.mockResolvedValue({ status: "ok", data: "Connected" }); + mocks.trust.mockResolvedValue({ status: "ok", data: null }); +}); +afterEach(() => { + cleanup(); + useSettingsDialog.setState(savedDialog, true); + useProject.setState(savedProject, true); +}); + +function mount(auth: SshAuthValid = "password", saveSecret = false) { + const config = { ...DEFAULT_SSH_CONFIG, id: "ssh-test", name: "Test host", host: "127.0.0.1", auth, saveSecret }; + const settings = validateSettings({ ssh: { configs: [config] } }); + useProject.setState({ settings, project: null }); + openSettings("tools.sshConfigurations"); + return render( + + + , + ); +} + +function enterSecret(label = "Password") { + const input = screen.getByLabelText(label); + fireEvent.change(input, { target: { value: typed } }); + return input; +} + +function expectStored(save: boolean) { + const args = mocks.store.mock.calls.at(-1); + expect(args?.[0]).toBe("ssh-test"); + expect(args?.[1] === typed).toBe(true); + expect(args?.[2]).toBe(save); +} + +describe("SSH configuration credentials", () => { + it.each([ + ["password", "Password", "Save password"], + ["keyPair", "Passphrase", "Save passphrase"], + ] as const)("shows the %s secret row before its Save checkbox", (auth, label, saveLabel) => { + mount(auth); + const input = screen.getByLabelText(label); + expect(input).toHaveAttribute("type", "password"); + const previous = + auth === "keyPair" + ? screen.getByLabelText("Private key file") + : screen.getByRole("button", { name: "Authentication type" }); + expect(previous.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect( + input.compareDocumentPosition(screen.getByRole("checkbox", { name: saveLabel })) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.queryByLabelText(auth === "password" ? "Passphrase" : "Password")).toBeNull(); + }); + + it("shows no secret or Save rows for OpenSSH", async () => { + const { container } = mount("openSsh"); + expect(container.querySelector('input[type="password"]')).toBeNull(); + expect(screen.queryByRole("checkbox")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + await screen.findByText("Connected"); + expect(mocks.test.mock.calls[0]?.[1]).toBeNull(); + expect(mocks.store).not.toHaveBeenCalled(); + expect(mocks.needsSecret).not.toHaveBeenCalled(); + }); + + it.each([false, true])("stages a secret-only edit until Apply with save=%s", async (save) => { + mount("password", save); + render(); + const apply = screen.getByRole("button", { name: "Apply" }); + expect(apply).toBeDisabled(); + const input = enterSecret(); + fireEvent.blur(input); + expect(mocks.store).not.toHaveBeenCalled(); + expect(apply).toBeEnabled(); + expect(useSettingsDialog.getState().staged).toEqual({}); + expect(JSON.stringify(useProject.getState().settings).includes(typed)).toBe(false); + await act(async () => fireEvent.click(apply)); + expect(mocks.store).toHaveBeenCalledOnce(); + expectStored(save); + expect(apply).toBeDisabled(); + expect(input).toHaveValue(""); + }); + + it.each([false, true])("Cancel discards a typed credential without storing it (new=%s)", async (isNew) => { + mount(); + if (isNew) fireEvent.click(screen.getByRole("button", { name: "Copy" })); + render(); + fireEvent.blur(enterSecret()); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mocks.store).not.toHaveBeenCalled(); + expect(useSettingsDialog.getState()).toMatchObject({ open: false, stagedSecrets: {} }); + }); + + it("uses the latest Save choice only when applying, including after a test", async () => { + mount(); + enterSecret(); + fireEvent.click(screen.getByRole("checkbox", { name: "Save password" })); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + await screen.findByText("Connected"); + expect(mocks.test.mock.calls[0]?.[1] === typed).toBe(true); + fireEvent.click(screen.getByRole("checkbox", { name: "Save password" })); + expect(mocks.store).not.toHaveBeenCalled(); + await act(async () => expect(await applySettings()).toBe(true)); + expect(mocks.store).toHaveBeenCalledOnce(); + expectStored(false); + }); + + it("treats an emptied field as no new credential for Apply and Test Connection", async () => { + mount(); + render(); + const input = enterSecret(); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.blur(input); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + await screen.findByText("Connected"); + expect(mocks.test.mock.calls[0]?.[1]).toBeNull(); + await act(async () => expect(await applySettings()).toBe(true)); + expect(mocks.store).not.toHaveBeenCalled(); + }); + + it("tests the staged passphrase without storing it when the connection fails", async () => { + const { container } = mount("keyPair", true); + mocks.test.mockResolvedValue({ status: "error", error: { kind: "io", message: "Connection refused" } }); + enterSecret("Passphrase"); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + await screen.findByText("Connection refused"); + expect(mocks.store).not.toHaveBeenCalled(); + expect(mocks.test.mock.calls[0]?.[1] === typed).toBe(true); + expect(container.querySelectorAll('input[type="password"]')).toHaveLength(1); + expect(screen.queryByRole("button", { name: "Connect" })).toBeNull(); + }); + + it.each([false, true])( + "reuses the existing input after a missing or rejected password (typed=%s)", + async (entered) => { + const { container } = mount(); + mocks.test.mockResolvedValueOnce({ + status: "error", + error: { kind: "permissionDenied", message: "SSH authentication failed for me@box:22" }, + }); + if (entered) enterSecret(); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + const retry = await screen.findByRole("button", { name: "Connect" }); + expect(container.querySelectorAll('input[type="password"]')).toHaveLength(1); + expect(screen.getByLabelText("Password")).toHaveFocus(); + if (!entered) expect(mocks.store).not.toHaveBeenCalled(); + if (!entered) enterSecret(); + fireEvent.click(retry); + await screen.findByText("Connected"); + expect(mocks.test.mock.calls.at(-1)?.[1] === typed).toBe(true); + expect(mocks.store).not.toHaveBeenCalled(); + }, + ); + + it("keeps the typed password when trusting a host and retrying", async () => { + mount(); + mocks.test.mockResolvedValueOnce({ + status: "error", + error: { kind: "protocol", message: "Unknown SSH host key for box:22" }, + }); + enterSecret(); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Trust Host and Retry" })); + await screen.findByText("Connected"); + expect(mocks.trust).toHaveBeenCalledWith("ssh-test"); + expect(mocks.test.mock.calls.every((args) => args[1] === typed)).toBe(true); + expect(mocks.store).not.toHaveBeenCalled(); + }); + + it("shows an existing password placeholder and leaves it untouched when nothing is typed", async () => { + mocks.needsSecret.mockResolvedValue({ status: "ok", data: false }); + mount("password", true); + const input = await screen.findByPlaceholderText("Password already stored"); + expect(input).toHaveValue(""); + fireEvent.blur(input); + fireEvent.click(screen.getByRole("button", { name: "Test Connection" })); + await screen.findByText("Connected"); + expect(mocks.store).not.toHaveBeenCalled(); + expect(mocks.test.mock.calls[0]?.[1]).toBeNull(); + }); + + it("does not interpret an existing key pair as an already stored password", async () => { + const user = userEvent.setup(); + mocks.needsSecret.mockResolvedValue({ status: "ok", data: false }); + mount("keyPair"); + await user.click(screen.getByRole("button", { name: "Authentication type" })); + await user.click(await screen.findByRole("option", { name: "Password" })); + expect(screen.getByLabelText("Password")).not.toHaveAttribute("placeholder", "Password already stored"); + expect(mocks.needsSecret).not.toHaveBeenCalled(); + }); + + it("reports an Apply failure and keeps the field for retry", async () => { + mount(); + render(); + mocks.store.mockRejectedValueOnce(new Error("Unavailable")); + const input = enterSecret(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Apply" }))); + expect(screen.getByText(/Could not save: SSH secret: Unavailable/)).toBeInTheDocument(); + expect((input as HTMLInputElement).value === typed).toBe(true); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Apply" }))); + expectStored(false); + expect(mocks.store).toHaveBeenCalledTimes(2); + expect(screen.queryByText(/Could not save:/)).toBeNull(); + }); + + it("retains typed values when switching configurations, and drops them on deletion or Reset", async () => { + mount(); + enterSecret(); + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + const second = screen.getByLabelText("Password"); + expect(second).toHaveValue(""); + fireEvent.change(second, { target: { value: crypto.randomUUID() } }); + fireEvent.mouseDown(screen.getByText("Test host", { exact: true })); + expect((screen.getByLabelText("Password") as HTMLInputElement).value === typed).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + expect(useSettingsDialog.getState().stagedSecrets["ssh-test"]).toBeUndefined(); + await act(async () => resetPage("tools.sshConfigurations")); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + act(() => closeSettings()); + expect(mocks.store).not.toHaveBeenCalled(); + }); + + it("clears the displayed secret on Reset", () => { + mount(); + const input = enterSecret(); + act(() => resetPage("tools.sshConfigurations")); + expect(input).toHaveValue(""); + expect(mocks.store).not.toHaveBeenCalled(); + }); + + it("discards a staged secret on authentication changes", async () => { + const user = userEvent.setup(); + mount(); + enterSecret(); + await user.click(screen.getByRole("button", { name: "Authentication type" })); + await user.click(await screen.findByRole("option", { name: "Key pair (OpenSSH or PuTTY)" })); + expect(screen.getByLabelText("Passphrase")).toHaveValue(""); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + await user.click(screen.getByRole("button", { name: "Authentication type" })); + await user.click(await screen.findByRole("option", { name: "Password" })); + expect(screen.getByLabelText("Password")).toHaveValue(""); + expect(mocks.store).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/settings/__tests__/sshConnection.test.ts b/src/features/settings/__tests__/sshConnection.test.ts index 95d7ad75..19e086e6 100644 --- a/src/features/settings/__tests__/sshConnection.test.ts +++ b/src/features/settings/__tests__/sshConnection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseSshPort, secretKind, sshTestRemedy } from "../pages/sshConnection"; +import { parseSshPort, sshTestRemedy } from "../pages/sshConnection"; describe("SSH configuration Test Connection", () => { it("offers to trust an unknown host key, never a changed one", () => { @@ -23,13 +23,6 @@ describe("SSH configuration Test Connection", () => { expect(sshTestRemedy("io", "connection refused", "password")).toBeNull(); }); - it("names the secret", () => { - expect(secretKind("password")).toBe("password"); - expect(secretKind("keyPair")).toBe("passphrase"); - expect(secretKind("openSsh", "SSH key passphrase required for /k")).toBe("passphrase"); - expect(secretKind("openSsh", "SSH password required for me@box")).toBe("password"); - }); - it("parses the port field", () => { expect(parseSshPort("")).toBeNull(); expect(parseSshPort(" 2222 ")).toBe(2222); diff --git a/src/features/settings/__tests__/staging.test.ts b/src/features/settings/__tests__/staging.test.ts index d47ca839..93c680ca 100644 --- a/src/features/settings/__tests__/staging.test.ts +++ b/src/features/settings/__tests__/staging.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Settings } from "@/platform/ipc"; +import { DEFAULT_SSH_CONFIG } from "@/platform/remoteSettings"; import { useProject } from "@/platform/project"; import { DEFAULT_SETTINGS, getAt, setAt, validateSettings } from "../model/schema"; import { @@ -10,20 +11,23 @@ import { persistChanges, resetPage, stage, + stageSshSecret, + SSH_SETTINGS_PAGE, stagedValue, useSettingsDialog, type PersistDeps, } from "../model/staging"; function fakeBackend(fail?: string) { - let current: Settings = structuredClone(DEFAULT_SETTINGS) as Settings; + let current: Settings = structuredClone(useProject.getState().settings) as Settings; const set = vi.fn(async (_scope: string, key: string, json: string) => { if (key === fail) throw new Error("invalid value"); current = setAt(current, key, JSON.parse(json)); return current; }); const reload = vi.fn(async () => useProject.setState({ settings: current })); - return { deps: { set, reload, backend: true } satisfies PersistDeps, set, reload }; + const setSecret = vi.fn(async (_id: string, _secret: string, _save: boolean) => {}); + return { deps: { set, setSecret, reload, backend: true } satisfies PersistDeps, set, setSecret, reload }; } describe("settings staging", () => { @@ -138,6 +142,7 @@ describe("settings staging", () => { it("applies to the session when there is no backend", async () => { const r = await persistChanges([{ scope: "global", key: "appearance.uiFontSize", value: 14, page: "appearance" }], { set: vi.fn(), + setSecret: vi.fn(), reload: vi.fn(), backend: false, }); @@ -146,6 +151,112 @@ describe("settings staging", () => { }); }); +describe("SSH secret staging", () => { + const id = "ssh-staging"; + let secret: string; + beforeEach(() => { + secret = crypto.randomUUID(); + useProject.setState({ + settings: validateSettings({ ssh: { configs: [{ ...DEFAULT_SSH_CONFIG, id, auth: "password" }] } }), + project: null, + }); + openSettings(SSH_SETTINGS_PAGE); + }); + + it.each([false, true])("writes only the final value once, after settings, with Save=%s", async (saveSecret) => { + const { deps, set, setSecret } = fakeBackend(); + stageSshSecret(id, crypto.randomUUID()); + stageSshSecret(id, secret); + const configs = useSettingsDialog.getState().globalBase.ssh.configs.map((c) => ({ ...c, saveSecret })); + stage(SSH_SETTINGS_PAGE, "global", "ssh.configs", configs); + expect(setSecret).not.toHaveBeenCalled(); + expect(await applySettings(deps)).toBe(true); + expect(setSecret).toHaveBeenCalledOnce(); + const args = setSecret.mock.calls[0]!; + expect(args[0]).toBe(id); + expect(args[1] === secret).toBe(true); + expect(args[2]).toBe(saveSecret); + if (set.mock.calls.length) { + expect(set.mock.invocationCallOrder[0]! < setSecret.mock.invocationCallOrder[0]!).toBe(true); + } + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + expect(JSON.stringify(useProject.getState().settings).includes(secret)).toBe(false); + expect(await applySettings(deps)).toBe(true); + expect(setSecret).toHaveBeenCalledOnce(); + }); + + it("saves a new configuration before its credential", async () => { + const config = useSettingsDialog.getState().globalBase.ssh.configs[0]!; + useProject.setState({ settings: validateSettings(undefined) }); + openSettings(SSH_SETTINGS_PAGE); + const { deps, setSecret } = fakeBackend(); + stage(SSH_SETTINGS_PAGE, "global", "ssh.configs", [config]); + stageSshSecret(id, secret); + setSecret.mockImplementation(async () => { + expect(useProject.getState().settings?.ssh?.configs?.some((c) => c.id === id)).toBe(true); + }); + expect(await applySettings(deps)).toBe(true); + expect(setSecret).toHaveBeenCalledOnce(); + }); + + it("Cancel discards the credential without writing it", async () => { + const { deps, setSecret } = fakeBackend(); + stageSshSecret(id, secret); + closeSettings(); + openSettings(SSH_SETTINGS_PAGE); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + expect(await applySettings(deps)).toBe(true); + expect(setSecret).not.toHaveBeenCalled(); + }); + + it("Reset clears only this page's credentials, and an empty field leaves storage untouched", async () => { + const { deps, setSecret } = fakeBackend(); + stageSshSecret(id, secret); + resetPage("appearance"); + expect(useSettingsDialog.getState().stagedSecrets[id] === secret).toBe(true); + resetPage(SSH_SETTINGS_PAGE); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + stageSshSecret(id, secret); + stageSshSecret(id, ""); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + expect(await applySettings(deps)).toBe(true); + expect(setSecret).not.toHaveBeenCalled(); + }); + + it("retains a failed write for retry without reapplying successful settings", async () => { + const { deps, set, setSecret } = fakeBackend(); + setSecret.mockRejectedValueOnce(new Error("Unavailable")); + stageSshSecret(id, secret); + stage("editor.general", "global", "editor.tabSize", 2); + expect(await applySettings(deps)).toBe(false); + expect(useSettingsDialog.getState().stagedSecrets[id] === secret).toBe(true); + expect(useSettingsDialog.getState()).toMatchObject({ error: "SSH secret: Unavailable", saving: false, staged: {} }); + expect(await applySettings(deps)).toBe(true); + expect(set).toHaveBeenCalledOnce(); + expect(setSecret).toHaveBeenCalledTimes(2); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + expect(useSettingsDialog.getState().error).toBeNull(); + }); + + it("does not write credentials when settings fail", async () => { + const { deps, setSecret } = fakeBackend("ssh.configs"); + stageSshSecret(id, secret); + stage(SSH_SETTINGS_PAGE, "global", "ssh.configs", []); + expect(await applySettings(deps)).toBe(false); + expect(setSecret).not.toHaveBeenCalled(); + expect(useSettingsDialog.getState().stagedSecrets[id] === secret).toBe(true); + }); + + it("does not write a credential for a configuration removed before Apply", async () => { + const { deps, setSecret } = fakeBackend(); + stageSshSecret(id, secret); + stage(SSH_SETTINGS_PAGE, "global", "ssh.configs", []); + expect(await applySettings(deps)).toBe(true); + expect(setSecret).not.toHaveBeenCalled(); + expect(useSettingsDialog.getState().stagedSecrets).toEqual({}); + }); +}); + describe("validateSettings", () => { it("defaults focus synchronization on and preserves an explicit opt-out", () => { expect(validateSettings({ files: {} }).files.syncOnFocus).toBe(true); diff --git a/src/features/settings/dialog/SettingsDialog.tsx b/src/features/settings/dialog/SettingsDialog.tsx index 99dc66d5..b7c0b67a 100644 --- a/src/features/settings/dialog/SettingsDialog.tsx +++ b/src/features/settings/dialog/SettingsDialog.tsx @@ -34,6 +34,7 @@ import { setPageScope, setQuery, settingsTitle, + SSH_SETTINGS_PAGE, useSettingsDialog, type Scope, } from "../model/staging"; @@ -219,7 +220,7 @@ function SettingsDialogInner() { /** Page tree + current page; reads the shared settings staging store. */ export function SettingsLayout() { const pages = useSettingsPages((s) => s.pages); - const { pageId, query, staged, saving } = useSettingsDialog(); + const { pageId, query, staged, stagedSecrets, saving } = useSettingsDialog(); const treeRef = useRef(null); const tree = useMemo(() => buildPageTree(pages.values()), [pages]); @@ -231,7 +232,11 @@ export function SettingsLayout() { const current = all.find((n) => n.key === pageId) ?? all.find((n) => n.page); const crumbs = current ? breadcrumb(tree, current.key) : []; - const dirtyPages = useMemo(() => new Set(Object.values(staged).map((c) => c.page)), [staged]); + const dirtyPages = useMemo(() => { + const pages = new Set(Object.values(staged).map((c) => c.page)); + if (Object.keys(stagedSecrets).length) pages.add(SSH_SETTINGS_PAGE); + return pages; + }, [staged, stagedSecrets]); const pageDirty = !!current && dirtyPages.has(current.key); // Back / forward through pages visited while the dialog is open. @@ -333,7 +338,7 @@ export function SettingsLayout() { /** OK / Cancel / Apply, ordered per platform by the dialog primitive. */ function useSettingsButtons(): DialogButton[] { - const { staged, base, globalBase, saving } = useSettingsDialog(); + const { staged, stagedSecrets, base, globalBase, saving } = useSettingsDialog(); const pending = useMemo(() => pendingChanges({ base, globalBase, staged }).length > 0, [base, globalBase, staged]); const ok = async () => { if (await applySettings()) closeSettings(); @@ -341,7 +346,11 @@ function useSettingsButtons(): DialogButton[] { return [ { label: "OK", role: "default", disabled: saving, onClick: () => void ok() }, { label: "Cancel", role: "cancel", onClick: closeSettings }, - { label: "Apply", disabled: !pending || saving, onClick: () => void applySettings() }, + { + label: "Apply", + disabled: (!pending && !Object.keys(stagedSecrets).length) || saving, + onClick: () => void applySettings(), + }, ]; } diff --git a/src/features/settings/model/staging.ts b/src/features/settings/model/staging.ts index 891678d4..8cde51ea 100644 --- a/src/features/settings/model/staging.ts +++ b/src/features/settings/model/staging.ts @@ -31,6 +31,8 @@ export interface SettingsDialogState { /** "Settings for New Projects": every page edits the global layer. */ newProjects: boolean; staged: Record; + /** SSH credentials stay outside settings JSON until Apply / OK. */ + stagedSecrets: Record; /** Scope chosen per page (Global / This project). */ scopes: Record; saving: boolean; @@ -38,6 +40,7 @@ export interface SettingsDialogState { } export const DEFAULT_PAGE = "appearance"; +export const SSH_SETTINGS_PAGE = "tools.sshConfigurations"; export const useSettingsDialog = create(() => ({ open: false, @@ -48,6 +51,7 @@ export const useSettingsDialog = create(() => ({ globalBase: validateSettings(undefined), newProjects: false, staged: {}, + stagedSecrets: {}, scopes: {}, saving: false, error: null, @@ -74,6 +78,7 @@ export function openSettings(pageId?: string, opts: { newProjects?: boolean } = globalBase: base, newProjects: opts.newProjects ?? false, staged: {}, + stagedSecrets: {}, saving: false, error: null, }); @@ -103,7 +108,7 @@ type Bases = Pick; const baseFor = (s: Bases, scope: Scope) => (scope === "global" ? s.globalBase : s.base); export function closeSettings() { - useSettingsDialog.setState({ open: false, staged: {}, error: null, saving: false }); + useSettingsDialog.setState({ open: false, staged: {}, stagedSecrets: {}, error: null, saving: false }); } /** Shows `pageId` (`page#focus` also selects `focus` on it). */ @@ -165,10 +170,21 @@ export function stage(page: string, scope: Scope, key: string, value: unknown) { }); } +/** An empty field leaves the stored credential unchanged. */ +export function stageSshSecret(id: string, value: string) { + useSettingsDialog.setState((s) => { + const stagedSecrets = { ...s.stagedSecrets }; + if (value) stagedSecrets[id] = value; + else delete stagedSecrets[id]; + return { stagedSecrets, error: null }; + }); +} + /** Discards staged edits of one page (the dialog's Reset button). */ export function resetPage(page: string) { useSettingsDialog.setState((s) => ({ staged: Object.fromEntries(Object.entries(s.staged).filter(([, c]) => c.page !== page)), + stagedSecrets: page === SSH_SETTINGS_PAGE ? {} : s.stagedSecrets, error: null, })); } @@ -182,6 +198,7 @@ export function pendingChanges(s: Bases & Pick): export interface PersistDeps { set(scope: Scope, key: string, json: string): Promise; + setSecret(id: string, secret: string, save: boolean): Promise; /** Re-reads effective settings into `useProject`. */ reload(): Promise; /** Without a backend (plain browser) changes apply to the session only. */ @@ -190,6 +207,7 @@ export interface PersistDeps { const defaultDeps = (): PersistDeps => ({ set: (scope, key, json) => call(commands.settingsSet(scope, key, json)), + setSecret: (id, secret, save) => commands.sshSetSecret(id, secret, save), reload: loadSettings, backend: isTauri(), }); @@ -231,16 +249,19 @@ export async function persistChanges( } /** Apply: writes pending changes; returns whether everything was saved. */ -export async function applySettings(deps?: PersistDeps): Promise { - const changes = pendingChanges(useSettingsDialog.getState()); - if (!changes.length) return true; +export async function applySettings(deps: PersistDeps = defaultDeps()): Promise { + const initial = useSettingsDialog.getState(); + if (initial.saving) return false; + const changes = pendingChanges(initial); + const secrets = Object.entries(initial.stagedSecrets); + if (!changes.length && !secrets.length) return true; useSettingsDialog.setState({ saving: true, error: null }); - const { written, error } = await persistChanges(changes, deps); + const result = await persistChanges(changes, deps); + const { written } = result; + let { error } = result; const done = new Set(written); const base = validateSettings(useProject.getState().settings); useSettingsDialog.setState((st) => ({ - saving: false, - error, base, // Written global values right away; the backend's copy follows. globalBase: useProject.getState().project @@ -251,5 +272,27 @@ export async function applySettings(deps?: PersistDeps): Promise { staged: Object.fromEntries(Object.entries(st.staged).filter(([k]) => !done.has(k))), })); await loadGlobalBase(); + if (!error) { + for (const [id, secret] of secrets) { + const current = useSettingsDialog.getState(); + const config = current.globalBase.ssh.configs.find((c) => c.id === id); + // A removed configuration or discarded credential must never be written. + if (current.stagedSecrets[id] !== secret) continue; + try { + if (config && config.auth !== "openSsh" && deps.backend) { + await deps.setSecret(id, secret, config.saveSecret); + } + useSettingsDialog.setState((s) => { + const stagedSecrets = { ...s.stagedSecrets }; + if (stagedSecrets[id] === secret) delete stagedSecrets[id]; + return { stagedSecrets }; + }); + } catch (e) { + error = `SSH secret: ${e instanceof Error ? e.message : String(e)}`; + break; + } + } + } + useSettingsDialog.setState({ saving: false, error }); return error === null; } diff --git a/src/features/settings/pages/ssh.tsx b/src/features/settings/pages/ssh.tsx index a87a2d8d..0a5eca0f 100644 --- a/src/features/settings/pages/ssh.tsx +++ b/src/features/settings/pages/ssh.tsx @@ -2,7 +2,7 @@ * Tools › SSH Configurations: the app-wide SSH hosts used by data source tunnels, * deployment servers, run targets and Tools › Start SSH Session. */ -import { useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import clsx from "clsx"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; import { useTx, tx } from "@/platform/i18n"; @@ -16,8 +16,9 @@ import { import { Button, Checkbox, IconButton, Input, type SelectOption, T } from "@/ui"; import { Row, Section, Select } from "../components/Form"; import { useSetting } from "../components/context"; +import { stageSshSecret, useSettingsDialog } from "../model/staging"; import type { PageSpec } from "./spec"; -import { parseSshPort, secretKind, sshTestRemedy, type SshTestRemedy } from "./sshConnection"; +import { parseSshPort, sshTestRemedy, type SshTestRemedy } from "./sshConnection"; import { TOOLS_GROUP } from "./tools"; import styles from "./Workflow.module.css"; @@ -131,24 +132,29 @@ function KeyFileRow({ value, onChange }: { value: string; onChange: (v: string) ); } -/** Test Connection with the configuration as edited, offering host key trust and a password prompt. */ -function TestConnection({ config }: { config: SshConfigValid }) { +/** Test with the staged credential, offering host key trust and authentication retries. */ +function TestConnection({ + config, + secret, + focusSecret, +}: { + config: SshConfigValid; + secret: string | undefined; + focusSecret: () => void; +}) { const tx = useTx(); - const secretId = useId(); const [state, setState] = useState({ status: "idle" }); - const [secret, setSecret] = useState(""); - const run = async (typed?: string) => { + const run = async () => { setState({ status: "running" }); try { - const text = await call(commands.sshTest(config, typed ?? null)); - if (typed !== undefined) await commands.sshSetSecret(config.id, typed, config.saveSecret); - setSecret(""); + const text = await call(commands.sshTest(config, config.auth === "openSsh" ? null : (secret ?? null))); setState({ status: "ok", text }); } catch (e) { const text = e instanceof Error ? e.message : String(e); const remedy = e instanceof IpcError ? sshTestRemedy(e.kind, e.message, config.auth) : null; setState({ status: "failed", text, remedy }); + if (remedy === "secret") focusSecret(); } }; const trust = async () => { @@ -163,7 +169,6 @@ function TestConnection({ config }: { config: SshConfigValid }) { }; const running = state.status === "running"; - const kind = state.status === "failed" ? secretKind(config.auth, state.text) : "password"; return (
@@ -187,27 +192,10 @@ function TestConnection({ config }: { config: SshConfigValid }) { )} - {state.status === "failed" && state.remedy === "secret" && ( -
{ - e.preventDefault(); - void run(secret); - }} - > - - setSecret(e.target.value)} - /> - -
+ {state.status === "failed" && state.remedy === "secret" && config.auth !== "openSsh" && ( + )}
); @@ -220,6 +208,28 @@ function SshConfigForm({ config: SshConfigValid; update: (patch: Partial) => void; }) { + const tx = useTx(); + const secretId = useId(); + const secretInput = useRef(null); + const secret = useSettingsDialog((s) => s.stagedSecrets[config.id]); + const [stored, setStored] = useState(false); + const storedPasswordConfig = useSettingsDialog((s) => + s.globalBase.ssh.configs.some((c) => c.id === config.id && c.auth === "password"), + ); + const hasSecret = secret !== undefined; + useEffect(() => { + let live = true; + if (!hasSecret && config.auth === "password" && storedPasswordConfig && isTauri()) { + void call(commands.sshNeedsSecret(config.id)).then( + (needsSecret) => live && setStored(!needsSecret), + () => {}, + ); + } + return () => { + live = false; + }; + }, [config.id, config.auth, storedPasswordConfig, hasSecret]); + return (
update({ name })} /> @@ -239,6 +249,23 @@ function SshConfigForm({ /> {config.auth === "keyPair" && update({ keyFile })} />} + {config.auth !== "openSsh" && ( + + stageSshSecret(config.id, e.target.value)} + /> + + )} {config.auth === "openSsh" ? (
@@ -252,7 +279,7 @@ function SshConfigForm({ label={config.auth === "password" ? "Save password" : "Save passphrase"} /> )} - + secretInput.current?.focus()} />
); } @@ -266,6 +293,7 @@ function SshConfigsEditor() { const update = (patch: Partial) => { if (!current) return; + if (patch.auth && patch.auth !== current.auth) stageSshSecret(current.id, ""); setConfigs(configs.map((c) => (c.id === current.id ? { ...c, ...patch } : c))); }; const add = (base?: SshConfigValid) => { @@ -277,6 +305,7 @@ function SshConfigsEditor() { }; const remove = () => { if (!current) return; + stageSshSecret(current.id, ""); const rest = configs.filter((c) => c.id !== current.id); setConfigs(rest); setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null); @@ -316,7 +345,7 @@ function SshConfigsEditor() { ))} - {current && } + {current && }
{ @@ -347,6 +376,8 @@ export const SSH_PAGES: PageSpec[] = [ "User name", "Authentication type", "Private key file", + "Password", + "Passphrase", "Save password", "Save passphrase", "Test Connection", diff --git a/src/features/settings/pages/sshConnection.ts b/src/features/settings/pages/sshConnection.ts index 1b3dbca9..61d87f44 100644 --- a/src/features/settings/pages/sshConnection.ts +++ b/src/features/settings/pages/sshConnection.ts @@ -17,13 +17,6 @@ export function sshTestRemedy(kind: string | undefined, message: string, auth: S return null; } -/** "Password" or "Passphrase", from the configuration and the prompt. */ -export function secretKind(auth: SshAuthValid, message = ""): "password" | "passphrase" { - if (/passphrase|decrypt/.test(message)) return "passphrase"; - if (/password/.test(message)) return "password"; - return auth === "password" ? "password" : "passphrase"; -} - /** A port typed in the Port field (empty or invalid: the default). */ export function parseSshPort(text: string): number | null { if (!/^\s*\d+\s*$/.test(text)) return null; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 95a21800..e4f4925c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1423,6 +1423,7 @@ "Language server JDK": "语言服务器 JDK", "Password": "密码", "Password:": "密码:", + "Password already stored": "已存储密码", "Paste": "粘贴", "Paste on middle mouse button click": "点击鼠标中键时粘贴", "Patch copied to the clipboard": "补丁已复制到剪贴板", @@ -2757,6 +2758,7 @@ "Startup script:": "启动脚本:", "Runs on every new connection, before any other statement.": "在每个新连接上、任何其他语句之前运行。", "Passphrase:": "密码短语:", + "Passphrase": "密码短语", "Mode:": "模式:", "CA file:": "CA 文件:", "System roots when empty": "为空时使用系统根证书",