SSH keys in account settings; wrappers for agents, terminal and git
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
247ddcf14a
commit
6e8e739906
16 files changed
+517
-6
No files matched your search
@@ -3,6 +3,7 @@ import { post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import { GitAccount } from "./GitAccount";
|
||||
import { SshKeys } from "./SshKeys";
|
||||
import { ApiTokens } from "./ApiTokens";
|
||||
import { CliInstall } from "./CliInstall";
|
||||
import { Memory } from "./Memory";
|
||||
@@ -54,6 +55,7 @@ export function Account() {
|
||||
<Memory />
|
||||
<Snippets />
|
||||
<GitAccount />
|
||||
<SshKeys />
|
||||
<CliInstall />
|
||||
<ClaudeCode />
|
||||
<Telegram />
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { del, get, post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Key {
|
||||
id: string;
|
||||
name: string;
|
||||
public: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */
|
||||
export function SshKeys() {
|
||||
const t = useT();
|
||||
const [keys, setKeys] = useState<Key[]>();
|
||||
const [name, setName] = useState("");
|
||||
const [priv, setPriv] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
get<Key[]>("/account/ssh")
|
||||
.then(setKeys)
|
||||
.catch((e) => toast(e.message));
|
||||
}, []);
|
||||
if (!keys) return null;
|
||||
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setKeys(await post<Key[]>("/account/ssh", { name, private: importing ? priv : undefined }));
|
||||
setName("");
|
||||
setPriv("");
|
||||
toast(t("ssh.added"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed")));
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="lock" /> {t("ssh.title")}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("ssh.hint")}
|
||||
</div>
|
||||
{keys.length === 0 && <div className="hint">{t("ssh.none")}</div>}
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="ssh-key">
|
||||
<div className="row">
|
||||
<b>{k.name}</b>
|
||||
<span className="hint mono">{k.fingerprint}</span>
|
||||
<span className="spacer" />
|
||||
<button className="small" onClick={() => copy(k.public)}>
|
||||
<Icon name="copy" /> {t("ssh.copyPublic")}
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ message: t("ssh.deleteConfirm", { name: k.name }), danger: true })) {
|
||||
del<Key[]>(`/account/ssh/${k.id}`).then(setKeys).catch((e) => toast(e.message));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<input readOnly value={k.public} onFocus={(e) => e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" />
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ marginTop: 10, flexWrap: "wrap" }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} />
|
||||
<button className="primary" disabled={busy || !name.trim() || (importing && !priv.trim())} onClick={add}>
|
||||
{importing ? t("ssh.import") : t("ssh.generate")}
|
||||
</button>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<input type="checkbox" checked={importing} onChange={(e) => setImporting(e.target.checked)} />
|
||||
{t("ssh.importExisting")}
|
||||
</label>
|
||||
</div>
|
||||
{importing && (
|
||||
<>
|
||||
<textarea rows={5} className="mono" value={priv} onChange={(e) => setPriv(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" aria-label={t("ssh.private")} style={{ width: "100%", marginTop: 8 }} spellCheck={false} autoComplete="off" />
|
||||
<div className="hint">{t("ssh.importHint")}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user