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": "为空时使用系统根证书",