Файли
openidea/src/features/settings/pages/ssh.tsx
T

397 рядки
13 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* Tools › SSH Configurations: the app-wide SSH hosts used by data source tunnels,
* deployment servers, run targets and Tools › Start SSH Session.
*/
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";
import {
DEFAULT_SSH_CONFIG,
newRemoteId,
sshLabel,
type SshAuthValid,
type SshConfigValid,
} from "@/platform/remoteSettings";
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, sshTestRemedy, type SshTestRemedy } from "./sshConnection";
import { TOOLS_GROUP } from "./tools";
import styles from "./Workflow.module.css";
const AUTH: Array<SelectOption<SshAuthValid>> = [
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair (OpenSSH or PuTTY)" },
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
];
type TestState =
| { status: "idle" }
| { status: "running" }
| { status: "ok"; text: string }
| { status: "failed"; text: string; remedy: SshTestRemedy };
function Field({
label,
value,
onChange,
placeholder,
mono,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
mono?: boolean;
}) {
const id = useId();
const tx = useTx();
return (
<Row label={label} htmlFor={id} className={styles.alignedRow}>
<Input
id={id}
mono={mono}
className={styles.wide}
value={value}
placeholder={placeholder && tx(placeholder)}
onChange={(e) => onChange(e.target.value)}
/>
</Row>
);
}
function HostRow({ config, update }: { config: SshConfigValid; update: (p: Partial<SshConfigValid>) => void }) {
const hostId = useId();
const portId = useId();
const tx = useTx();
// What is typed (e.g. an invalid port); the form is keyed by configuration.
const [portText, setPortText] = useState(config.port?.toString() ?? "");
return (
<Row label="Host" htmlFor={hostId} className={styles.alignedRow}>
<span className={styles.inlineRow}>
<Input
id={hostId}
className={styles.medium}
value={config.host}
placeholder={tx(config.auth === "openSsh" ? "host or ~/.ssh/config alias" : "example.com")}
onChange={(e) => update({ host: e.target.value })}
/>
<label htmlFor={portId}>
<T>Port:</T>
</label>
<Input
id={portId}
className={styles.small}
inputMode="numeric"
value={portText}
placeholder="22"
invalid={!!portText.trim() && parseSshPort(portText) === null}
onChange={(e) => {
setPortText(e.target.value);
update({ port: parseSshPort(e.target.value) });
}}
/>
</span>
</Row>
);
}
function KeyFileRow({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const id = useId();
const tx = useTx();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ multiple: false, title: tx("Private Key File") });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) onChange(path);
};
return (
<Row label="Private key file" htmlFor={id} className={styles.alignedRow}>
<span className={styles.inlineRow}>
<Input
id={id}
mono
className={styles.wide}
value={value}
placeholder="~/.ssh/id_ed25519"
onChange={(e) => onChange(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
aria-label="Browse"
disabled={!isTauri()}
onClick={() => void browse()}
/>
</span>
</Row>
);
}
/** 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 [state, setState] = useState<TestState>({ status: "idle" });
const run = async () => {
setState({ status: "running" });
try {
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 () => {
setState({ status: "running" });
try {
await call(commands.sshTrustHost(config.id));
} catch (e) {
setState({ status: "failed", text: e instanceof Error ? e.message : String(e), remedy: null });
return;
}
await run();
};
const running = state.status === "running";
return (
<div className={styles.toolsLayout}>
<span className={styles.inlineRow}>
<Button size="small" disabled={running || !config.host.trim()} onClick={() => void run()}>
<T>Test Connection</T>
</Button>
<span
role="status"
className={clsx(state.status === "ok" && styles.ok, state.status === "failed" && styles.error)}
>
{running ? tx("Connecting…") : state.status === "idle" ? "" : state.text}
</span>
</span>
{state.status === "failed" && state.remedy === "trustHostKey" && (
<span className={styles.inlineRow}>
<span className={styles.note}>
<T>The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts?</T>
</span>
<Button size="small" onClick={() => void trust()}>
<T>Trust Host and Retry</T>
</Button>
</span>
)}
{state.status === "failed" && state.remedy === "secret" && config.auth !== "openSsh" && (
<Button size="small" onClick={() => void run()}>
<T>Connect</T>
</Button>
)}
</div>
);
}
function SshConfigForm({
config,
update,
}: {
config: SshConfigValid;
update: (patch: Partial<SshConfigValid>) => void;
}) {
const tx = useTx();
const secretId = useId();
const secretInput = useRef<HTMLInputElement>(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 (
<Section title="Connection">
<Field label="Name" value={config.name} onChange={(name) => update({ name })} />
<HostRow config={config} update={update} />
<Field
label="User name"
value={config.user}
onChange={(user) => update({ user })}
placeholder={config.auth === "openSsh" ? "from ~/.ssh/config" : undefined}
/>
<Row label="Authentication type" className={styles.alignedRow}>
<Select
value={config.auth}
options={AUTH}
onChange={(auth) => update({ auth })}
aria-label="Authentication type"
/>
</Row>
{config.auth === "keyPair" && <KeyFileRow value={config.keyFile} onChange={(keyFile) => update({ keyFile })} />}
{config.auth !== "openSsh" && (
<Row
label={config.auth === "password" ? "Password" : "Passphrase"}
htmlFor={secretId}
className={styles.alignedRow}
>
<Input
ref={secretInput}
id={secretId}
type="password"
className={styles.wide}
value={secret ?? ""}
placeholder={stored && secret === undefined ? tx("Password already stored") : undefined}
onChange={(e) => stageSshSecret(config.id, e.target.value)}
/>
</Row>
)}
{config.auth === "openSsh" ? (
<div className={styles.note}>
<T>
{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}
</T>
</div>
) : (
<Checkbox
checked={config.saveSecret}
onChange={(saveSecret) => update({ saveSecret })}
label={config.auth === "password" ? "Save password" : "Save passphrase"}
/>
)}
<TestConnection config={config} secret={secret} focusSecret={() => secretInput.current?.focus()} />
</Section>
);
}
function SshConfigsEditor() {
const [stored, setConfigs] = useSetting<SshConfigValid[] | undefined>("ssh.configs", "global");
const configs = stored ?? [];
const [selected, setSelected] = useState<string | null>(configs[0]?.id ?? null);
const current = configs.find((c) => c.id === selected) ?? null;
const index = current ? configs.indexOf(current) : -1;
const update = (patch: Partial<SshConfigValid>) => {
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) => {
const config: SshConfigValid = base
? { ...base, id: newRemoteId("ssh"), name: `${base.name || sshLabel(base)} (copy)` }
: { ...DEFAULT_SSH_CONFIG, id: newRemoteId("ssh"), name: `SSH ${configs.length + 1}` };
setConfigs([...configs, config]);
setSelected(config.id);
};
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);
};
return (
<div className={styles.toolsLayout}>
<div className={styles.tableBox}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("SSH configurations actions")}>
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
<IconButton
icon="copy"
title="Copy"
size="small"
disabled={!current}
onClick={() => current && add(current)}
/>
</div>
<div className={styles.toolList} role="listbox" aria-label={tx("SSH configurations")}>
{configs.length === 0 && (
<div className={clsx(styles.toolRow, styles.empty)}>
<T>No SSH configurations. Click + to add one.</T>
</div>
)}
{configs.map((c) => (
<div
key={c.id}
role="option"
aria-selected={c.id === selected}
className={clsx(styles.toolRow, c.id === selected && styles.toolRowSelected)}
onMouseDown={() => setSelected(c.id)}
>
<span className={styles.toolName} title={c.name.trim() || sshLabel(c) || c.id}>
{c.name.trim() || sshLabel(c) || c.id}
</span>
<span className={styles.toolGroup} title={sshLabel(c)}>
{sshLabel(c)}
</span>
</div>
))}
</div>
</div>
{current && <SshConfigForm key={`${current.id}:${current.auth}`} config={current} update={update} />}
<div className={styles.note}>
<T>
{
"SSH configurations are shared by all projects: data source tunnels, deployment servers, run targets and Tools › Start SSH Session connect with them."
}
</T>
</div>
</div>
);
}
export const SSH_PAGES: PageSpec[] = [
{
id: "tools.sshConfigurations",
title: "SSH Configurations",
group: TOOLS_GROUP,
order: 603,
keywords: ["ssh", "remote", "host", "server", "key pair", "private key", "password", "known_hosts", "agent"],
sections: [
{
options: [
{
kind: "custom",
label: "SSH Configurations",
keywords: [
"Host",
"Port",
"User name",
"Authentication type",
"Private key file",
"Password",
"Passphrase",
"Save password",
"Save passphrase",
"Test Connection",
"OpenSSH config and authentication agent",
],
component: SshConfigsEditor,
},
],
},
],
},
];