feat(git-identity): import an existing SSH key and sign commits with it
Settings → Git gains Commit signing: one picker signs every commit (terminal, Changes panel, agents) with one of the account's keys — gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign true in its own ~/.gitconfig; a signing key the user set up themselves is replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048) into ~/.config/agenthub/ssh/ as a named key, written like a generated one (member: write-as, 0600), and by default signs with it in the same step. A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key twice are refused; only the public half ever comes back. Deleting the signing key stops signing so commits keep working. The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the Gitea and Backlog steps, and the File Explorer's ESECRET toast now says that top-level dot folders of a home are edited in a terminal, and where the SSH keys live instead.
This commit is contained in:
@@ -529,7 +529,13 @@ export function FileBrowser({
|
||||
await fn();
|
||||
if (done) toast.success(done);
|
||||
} catch (e) {
|
||||
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
|
||||
toast.error(
|
||||
errCode(e) === "EUPGRADE"
|
||||
? t("fx.needsUpgrade")
|
||||
: errCode(e) === "ESECRET"
|
||||
? t("fx.secret")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
changed();
|
||||
@@ -1693,7 +1699,6 @@ export function FileBrowser({
|
||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -19,14 +19,30 @@
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
* table above it, so this lists them only for a member. **Commit signing**
|
||||
* (under the author): one picker signs every commit with one of the keys
|
||||
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
|
||||
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
|
||||
* default, signs with it in the same step. The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
FileKey,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
PenLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -216,6 +232,218 @@ function GitHostForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit signing: the key every commit is signed with (one picker — choosing
|
||||
* a key turns signing on, "Don't sign" off) and Import a key the user already
|
||||
* has, which signs with it in the same step unless unticked.
|
||||
*/
|
||||
function SigningCard({
|
||||
id,
|
||||
keys,
|
||||
keyLabel,
|
||||
busy,
|
||||
run,
|
||||
}: {
|
||||
id: GitIdentity;
|
||||
keys: Key[];
|
||||
keyLabel: (keyId: string | null | undefined) => string;
|
||||
busy: boolean;
|
||||
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
|
||||
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
|
||||
|
||||
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
|
||||
const choose = async (keyId: string | null) => {
|
||||
if (keyId && signing.state === "foreign") {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signReplaceConfirm", {
|
||||
path: signing.path,
|
||||
key: keyLabel(keyId),
|
||||
}),
|
||||
confirmLabel: t("gitIdentity.signReplace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
}
|
||||
await run(
|
||||
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
|
||||
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
|
||||
);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!form) return;
|
||||
let imported: string | null = null;
|
||||
let foreign = false;
|
||||
const ok = await run(
|
||||
async () => {
|
||||
const r = await api.gitIdentity.importKey({
|
||||
privateKey: form.text,
|
||||
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
sign: form.sign,
|
||||
});
|
||||
imported = r.imported?.id ?? null;
|
||||
foreign = r.signError?.code === "EFOREIGNSIGN";
|
||||
return r;
|
||||
},
|
||||
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||
);
|
||||
if (!ok) return;
|
||||
setForm(null);
|
||||
// Saved, but the user's own signing setup was left alone: ask once.
|
||||
if (imported && foreign) await choose(imported);
|
||||
};
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
if (!file || !form) return;
|
||||
// A key file is a few KB; anything far larger is not one.
|
||||
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
|
||||
const text = await file.text();
|
||||
setForm({ ...form, text });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
aria-label={t("gitIdentity.signTitle")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.signTitle")}
|
||||
</h3>
|
||||
{id.writable && !form && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setForm({ text: "", name: "", sign: true })}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
data-testid="git-signing-state"
|
||||
className={`min-w-0 flex-1 text-[11px] ${
|
||||
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{signing.state === "on"
|
||||
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
|
||||
: signing.state === "foreign"
|
||||
? t("gitIdentity.signForeign", { path: signing.path })
|
||||
: t("gitIdentity.signOff")}
|
||||
</span>
|
||||
{keys.length > 0 && (
|
||||
<select
|
||||
aria-label={t("gitIdentity.signKey")}
|
||||
value={signing.state === "on" ? (signing.key ?? "") : ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void choose(e.target.value || null)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">
|
||||
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
|
||||
</option>
|
||||
{keys
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<Step n={1}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
aria-label={t("gitIdentity.importTitle")}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void doImport();
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
|
||||
<label className={`${BTN} cursor-pointer`}>
|
||||
<FileKey className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importFile")}
|
||||
<input
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={(e) => void readFile(e.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
aria-label={t("gitIdentity.importPaste")}
|
||||
placeholder={t("gitIdentity.importPaste")}
|
||||
value={form.text}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
rows={4}
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
aria-label={t("gitIdentity.importName")}
|
||||
placeholder={t("gitIdentity.importName")}
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
className={`${INPUT} !w-40 font-mono`}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.sign}
|
||||
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
|
||||
/>
|
||||
{t("gitIdentity.importSign")}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !form.text.trim()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitIdentity.importSubmit")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -572,6 +800,10 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
{id.signing !== undefined && (
|
||||
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
|
||||
)}
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
@@ -616,6 +848,11 @@ export function GitIdentitySettings() {
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
{id.signing?.state === "on" && id.signing.key === k.id && (
|
||||
<div className="text-[10px] text-status-success">
|
||||
{t("gitIdentity.signingBadge")}
|
||||
</div>
|
||||
)}
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||
* the key's name with that key, and a second GitHub key is tied to its host;
|
||||
* commit signing: a key picked signs commits, an imported key signs in the
|
||||
* same step, and taking over the user's own signing key asks first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -31,6 +33,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
setSigning: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -335,4 +339,55 @@ describe("GitIdentitySettings", () => {
|
||||
});
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("signs commits with an imported key in one step", async () => {
|
||||
const signingKey = {
|
||||
...KEY,
|
||||
id: "id_ed25519_signing",
|
||||
type: "ed25519" as const,
|
||||
name: "signing",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||
...view({
|
||||
sshKeys: [signingKey],
|
||||
signing: { state: "on", key: "id_ed25519_signing", path: null },
|
||||
}),
|
||||
imported: signingKey,
|
||||
signError: null,
|
||||
});
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
|
||||
const form = screen.getByRole("form", { name: "Import an existing private key" });
|
||||
await userEvent.type(
|
||||
within(form).getByLabelText(/paste the private key/),
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----x"
|
||||
);
|
||||
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||
sign: true,
|
||||
});
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks before taking over a signing key the user set up themselves", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,6 +645,7 @@
|
||||
"fx.pasted": "Pasted.",
|
||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||
"fx.secret": "Hidden folders at the top of a home (.ssh, .config…) are not editable here — use a terminal. For an SSH key to clone or sign commits: Settings → Git.",
|
||||
"fx.conflictTitle": "Already there",
|
||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
|
||||
@@ -883,6 +883,28 @@
|
||||
"authorName": "Name",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||
"signTitle": "Commit signing",
|
||||
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
|
||||
"signOff": "Commits are not signed.",
|
||||
"signOn": "Every commit is signed with {{key}}.",
|
||||
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
|
||||
"signKey": "Signing key",
|
||||
"signNone": "Don't sign",
|
||||
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
|
||||
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
|
||||
"signReplace": "Switch",
|
||||
"signSaved": "Commits are now signed with {{key}}.",
|
||||
"signStopped": "Commits are no longer signed.",
|
||||
"importKey": "Import a key you have",
|
||||
"importTitle": "Import an existing private key",
|
||||
"importFile": "Choose the key file",
|
||||
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Name (optional)",
|
||||
"importSign": "Sign my commits with it",
|
||||
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
|
||||
"importSubmit": "Import",
|
||||
"imported": "Key {{name}} imported.",
|
||||
"signingBadge": "Signs commits",
|
||||
"authorSaved": "Commit author saved.",
|
||||
"title": "Your git identity",
|
||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||
@@ -904,9 +926,9 @@
|
||||
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
||||
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
||||
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||
"gitHubStepAdd": "Add it to your account on the host and paste the whole line: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Personal settings → Register SSH key.",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
@@ -932,10 +954,10 @@
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||
"namedGitSub": "ED25519 · for one more account on a git host.",
|
||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||
"namedAwsSub": "RSA · for one more AWS account.",
|
||||
"removeKeyNamed": "Delete key {{name}}"
|
||||
|
||||
@@ -645,6 +645,7 @@
|
||||
"fx.pasted": "Đã dán.",
|
||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||
"fx.secret": "Thư mục ẩn ở cấp đầu home (.ssh, .config…) không chỉnh được ở đây — hãy dùng terminal. Cần SSH key để clone hay ký commit: Settings → Git.",
|
||||
"fx.conflictTitle": "Đã có sẵn",
|
||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||
|
||||
@@ -871,6 +871,28 @@
|
||||
"authorName": "Tên",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||
"signTitle": "Ký commit",
|
||||
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
|
||||
"signOff": "Commit chưa được ký.",
|
||||
"signOn": "Mọi commit đang được ký bằng {{key}}.",
|
||||
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
|
||||
"signKey": "Key dùng để ký",
|
||||
"signNone": "Không ký",
|
||||
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
|
||||
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
|
||||
"signReplace": "Chuyển",
|
||||
"signSaved": "Commit giờ được ký bằng {{key}}.",
|
||||
"signStopped": "Commit không còn được ký.",
|
||||
"importKey": "Nhập key có sẵn",
|
||||
"importTitle": "Nhập private key có sẵn",
|
||||
"importFile": "Chọn file key",
|
||||
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
|
||||
"importName": "Tên (không bắt buộc)",
|
||||
"importSign": "Dùng key này để ký commit",
|
||||
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
|
||||
"importSubmit": "Nhập",
|
||||
"imported": "Đã nhập key {{name}}.",
|
||||
"signingBadge": "Ký commit",
|
||||
"authorSaved": "Đã lưu tác giả commit.",
|
||||
"title": "Danh tính git của bạn",
|
||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||
@@ -892,9 +914,9 @@
|
||||
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
||||
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
||||
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||
"gitHubStepAdd": "Thêm vào tài khoản trên host, dán nguyên dòng: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Cài đặt cá nhân → Đăng ký SSH key.",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
@@ -920,10 +942,10 @@
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản trên git host.",
|
||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||
"removeKeyNamed": "Xoá key {{name}}"
|
||||
|
||||
+39
-7
@@ -3356,6 +3356,22 @@ export const api = {
|
||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||
removeKeyById: (id: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/**
|
||||
* POST /api/me/git/ssh-key/import — a private key the user already has
|
||||
* (the file's text); `sign` signs commits with it at once, `replace`
|
||||
* over a signing key they set themselves. Only its public half returns.
|
||||
*/
|
||||
importKey: (body: { privateKey: string; name?: string; sign?: boolean; replace?: boolean }) =>
|
||||
request<GitIdentity & { imported: GitSshKey | null; signError: ApiErrorBody | null }>(
|
||||
"/me/git/ssh-key/import",
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
/** PUT /api/me/git/signing — sign commits with key `key` (its id), or stop (null). */
|
||||
setSigning: (key: string | null, replace = false) =>
|
||||
request<GitIdentity>("/me/git/signing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ key, replace }),
|
||||
}),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||
@@ -5232,6 +5248,21 @@ export interface GitCredential {
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
/** One SSH key of `GET /api/me/git` — its public half only. */
|
||||
export interface GitSshKey {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/** An API error's `{code, message}`. */
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||
export interface GitIdentity {
|
||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||
@@ -5249,13 +5280,14 @@ export interface GitIdentity {
|
||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||
*/
|
||||
sshKeys?: {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}[];
|
||||
sshKeys?: GitSshKey[];
|
||||
/**
|
||||
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
|
||||
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
|
||||
* or `foreign` — a signing key the user set themselves (`path`). Absent
|
||||
* from an older server.
|
||||
*/
|
||||
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user