feat(git): a CodeCommit SSH key ID is saved for every region; a clone tries each saved account
Settings → Git → AWS CodeCommit asks for the key ID only: the default key's login is written for every CodeCommit region at once (allRegions), shown as one row, with Use in every region for an older one-region save. A clone of a CodeCommit URL with no user tries every saved CodeCommit login — the region's own first, then other regions' and each named key's — so a second AWS account clones from the plain AWS URL with no ssh://<alias>/ rewrite.
This commit is contained in:
@@ -41,32 +41,9 @@ 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",
|
||||
];
|
||||
// The host a key ID is saved under when no URL names one: AWS takes the same
|
||||
// key ID in every region, so the server writes the default login for all.
|
||||
const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com";
|
||||
|
||||
function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||
return (
|
||||
@@ -166,28 +143,25 @@ function KeyCard({
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||
* default key's login is the region's plain host; a named key's is saved
|
||||
* under an account name (its own name unless another is typed), tied to it.
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The
|
||||
* default key's login is saved for every region; a named key's under its own
|
||||
* name, tied to it. A clone of any CodeCommit URL tries each saved login.
|
||||
*/
|
||||
function AwsLoginForm({
|
||||
busy,
|
||||
named,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
/** The named key's name; undefined for the default key. */
|
||||
named?: string;
|
||||
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||
onSave: (v: { keyId: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||
const [v, setV] = useState({ keyId: "" });
|
||||
return (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||
void onSave(v).then((ok) => ok && setV({ keyId: "" }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -198,27 +172,6 @@ function AwsLoginForm({
|
||||
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={v.region}
|
||||
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={v.alias}
|
||||
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
@@ -413,28 +366,41 @@ export function GitIdentitySettings() {
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||
const saveAws = (v: { keyId: string }, k?: Key) => {
|
||||
let user = v.keyId.trim();
|
||||
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
let host = AWS_DEFAULT_HOST;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL: its key ID. The
|
||||
// region does not matter — the key ID logs in to every one.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase();
|
||||
}
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
k?.name
|
||||
? // A named key (another AWS account): its login goes by the key's
|
||||
// name; a clone of any CodeCommit URL tries it.
|
||||
api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! })
|
||||
: api.gitIdentity.setSshUser({ host, user, allRegions: true }),
|
||||
k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll")
|
||||
);
|
||||
};
|
||||
// The default key's logins, one row per key ID: "every region" once the
|
||||
// server's whole list is covered, else the regions saved (an older save) —
|
||||
// with a button that extends it to every region.
|
||||
const regionCount = id?.codecommitRegions?.length ?? 0;
|
||||
const awsDefaultGroups = (rows: typeof awsRows) => {
|
||||
const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = [];
|
||||
for (const r of rows.filter((x) => !x.alias)) {
|
||||
const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null));
|
||||
if (g) {
|
||||
g.hosts.push(r.host);
|
||||
g.regions.push(r.region);
|
||||
} else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const saveGitHost = (host: string, k: Key) => {
|
||||
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||
return run(
|
||||
@@ -689,14 +655,66 @@ export function GitIdentitySettings() {
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
rows.filter((r) => !!r.alias),
|
||||
(r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{awsDefaultGroups(rows).map((g) => {
|
||||
const all = regionCount > 0 && g.regions.length >= regionCount;
|
||||
return (
|
||||
<div
|
||||
key={`${g.user}\0${g.key ?? ""}`}
|
||||
className="mt-1 flex items-center gap-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")}
|
||||
{` · ${g.user} · ${keyLabel(g.key)}`}
|
||||
</span>
|
||||
{id.writable && !all && regionCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host: g.hosts[0]!,
|
||||
user: g.user,
|
||||
allRegions: true,
|
||||
}),
|
||||
t("gitIdentity.awsSavedAll")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("gitIdentity.awsUseAll")}
|
||||
</button>
|
||||
)}
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${g.user}`}
|
||||
onClick={() =>
|
||||
void run(() =>
|
||||
g.hosts.length > 1
|
||||
? api.gitIdentity.removeSshUser(g.hosts[0]!, {
|
||||
allRegions: true,
|
||||
user: g.user,
|
||||
})
|
||||
: api.gitIdentity.removeSshUser(g.hosts[0]!)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -139,27 +139,64 @@ describe("GitIdentitySettings", () => {
|
||||
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
|
||||
});
|
||||
|
||||
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
|
||||
it("saves the CodeCommit SSH key ID for every region — no region to pick", 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: [] }));
|
||||
const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"];
|
||||
const host = (r: string) => `git-codecommit.${r}.amazonaws.com`;
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
|
||||
view({
|
||||
sshKeys: [RSA],
|
||||
sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })),
|
||||
codecommitRegions: REGIONS,
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(aws).queryByLabelText("Region")).toBeNull();
|
||||
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 · Any key")).toBeInTheDocument();
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKA1",
|
||||
allRegions: true,
|
||||
});
|
||||
// One row for the key ID, not one per region.
|
||||
expect(await within(aws).findByText("Every region · APKA1 · Any key")).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);
|
||||
expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" }));
|
||||
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), {
|
||||
allRegions: true,
|
||||
user: "APKA1",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers an older one-region CodeCommit login for every region", async () => {
|
||||
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||
const H = "git-codecommit.ap-northeast-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA],
|
||||
sshUsers: [{ host: H, user: "APKA9" }],
|
||||
codecommitRegions: ["us-east-1", "ap-northeast-1"],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA9",
|
||||
allRegions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves a user for any other SSH host under the advanced section", async () => {
|
||||
@@ -247,18 +284,16 @@ describe("GitIdentitySettings", () => {
|
||||
// Another key can be added below it.
|
||||
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
// Its own step 3: only the key ID; the login goes by the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKA2",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
expect(
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
@@ -298,8 +333,6 @@ describe("GitIdentitySettings", () => {
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||
).toBeInTheDocument();
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user