feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work

AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a
plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x`
was refused as the Linux account name. The account can now save an SSH
user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` —
written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its
own ~/.gitconfig (as the member under S3), so the plain URL works in a
pane, in Clone and in the git bar. Only rules of exactly that shape are
listed or removed; any other insteadOf is the user's and left alone.

Settings → Git → Your git identity now shows the SSH keys one section
per kind of host, each with only its own numbered steps: GitHub · GitLab
· Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in
IAM → paste the key ID and pick the region, then a "Ready" clone line),
plus a collapsed "Other SSH hosts (advanced)". The shared key-type
picker is gone: each section has its own Generate/Replace/Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:31:30 +07:00
parent 1c5bd88747
commit 276cead826
10 changed files with 630 additions and 109 deletions
@@ -7,7 +7,9 @@
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82) — and, for
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
* that same file, so a plain `ssh://git-codecommit…` URL works) — 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
@@ -15,7 +17,7 @@
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
@@ -30,6 +32,140 @@ const INPUT =
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
<div className="flex gap-2 text-[11px] text-fg-secondary">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
{n}
</span>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
*/
function KeyCard({
title,
sub,
type,
k,
id,
busy,
onGenerate,
onRemove,
children,
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
k: Key | undefined;
id: GitIdentity;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
return (
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
{k ? (
<>
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
</Step>
{children}
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -71,21 +207,21 @@ export function GitIdentitySettings() {
// An account may hold one of each; an older server answers `ssh` only.
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
const typeTaken = keys.some((k) => (k.type ?? "ed25519") === keyType);
const generate = async () => {
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
typeTaken &&
taken &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(typeTaken, keyType), t("gitIdentity.keyMade"));
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
};
const removeKey = async (type?: "ed25519" | "rsa") => {
const removeKey = async (type: "ed25519" | "rsa") => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
@@ -113,6 +249,44 @@ export function GitIdentitySettings() {
if (ok) setForm(null);
};
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
// one host per region) gets its own form under the RSA key; anything else
// is the "other hosts" form.
const sshUsers = id?.sshUsers ?? [];
const awsRows = sshUsers.flatMap((r) => {
const m = CODECOMMIT_RE.exec(r.host);
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const ok = await run(
() => api.gitIdentity.setSshUser({ host, user }),
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
);
if (ok) setAws({ ...aws, keyId: "" });
};
const [other, setOther] = useState({ host: "", user: "" });
const saveOther = async () => {
let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1];
const ok = await run(
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
t("gitIdentity.otherSaved", { host })
);
if (ok) setOther({ host: "", user: "" });
};
const removeLogin = async (host: string) => {
if (
!(await confirmDialog({
@@ -206,89 +380,191 @@ export function GitIdentitySettings() {
)}
</form>
<div className="space-y-2 rounded-lg border border-border p-3">
<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 />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
{id.writable && (
<select
aria-label={t("gitIdentity.keyType")}
value={keyType}
disabled={busy}
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
className="select !py-1 !text-xs"
>
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
</select>
)}
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{typeTaken ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{typeTaken ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
</div>
{keys.length ? (
<>
{keys.map((k) => (
<div key={k.type ?? "ed25519"} className="space-y-1.5">
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={k.type === "rsa" ? 4 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.type === "rsa" ? "RSA 4096 · " : "ED25519 · "}
{k.fingerprint}
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.region} · {r.user}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.host}>git clone ssh://{r.host}/v1/repos/&lt;repo&gt;</Code>
))}
</div>
)}
</KeyCard>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
<summary className="cursor-pointer text-[11px] text-fg-primary">
{t("gitIdentity.otherTitle")}
</summary>
<div className="mt-1.5 space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.user}@{r.host}
</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void removeKey(k.type)}
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
</div>
))}
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
))}
{id.writable && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveOther();
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="git.example.com"
value={other.host}
onChange={(e) => setOther({ ...other, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.otherUser")}
placeholder={t("gitIdentity.otherUser")}
autoComplete="off"
value={other.user}
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button
type="submit"
className={BTN}
disabled={busy || !other.host.trim() || !other.user.trim()}
>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</div>
</details>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
</div>
{member && (
@@ -2,11 +2,13 @@
* @file Tests for Settings → Git → Your git identity: a user with no key
* makes one, a key shows only its public half and copies it, replacing asks
* first, and a member (S3) adds an HTTPS login of their own while the host's
* view leaves HTTPS to the shared Git credentials table.
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — so the sections never mix.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitIdentitySettings } from "../GitIdentitySettings";
import { api, type GitIdentity } from "../../../lib/api";
@@ -20,6 +22,8 @@ vi.mock("../../../lib/api", () => ({
generateKey: vi.fn(),
removeKey: vi.fn(),
setTerminalSsh: vi.fn(),
setSshUser: vi.fn(),
removeSshUser: vi.fn(),
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
@@ -49,7 +53,8 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
const gh = await screen.findByRole("region", { name: /GitHub/ });
await userEvent.click(within(gh).getByRole("button", { name: "Generate key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
@@ -86,43 +91,84 @@ describe("GitIdentitySettings", () => {
expect(await screen.findByText("github.com")).toBeInTheDocument();
});
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
it("makes the RSA key from the AWS CodeCommit section", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
);
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument();
});
it("adds an RSA key beside the ED25519 one and deletes each on its own", async () => {
it("keeps each key in its own section and deletes each on its own", async () => {
const RSA = {
type: "rsa" as const,
publicKey: "ssh-rsa AAAAB3 an@agenthub",
fingerprint: "SHA256:r",
};
const ED = { ...KEY, type: "ed25519" as const };
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
);
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
// No RSA key yet: a plain Generate, no replace prompt, the ED25519 key stays.
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
expect(screen.getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
const gh = await screen.findByRole("region", { name: /GitHub/ });
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
// Each section explains only its own host.
expect(within(gh).queryByText(/IAM/)).toBeNull();
expect(within(aws).queryByText(/GitHub/)).toBeNull();
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.eu-west-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
);
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
});
it("saves a user for any other SSH host under the advanced section", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY, sshUsers: [] }));
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByText("Other SSH hosts (advanced)"));
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
await userEvent.type(screen.getByLabelText("User name"), "deploy");
const form = screen.getByLabelText("Host").closest("form")!;
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "git.example.com",
user: "deploy",
});
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());