feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host

- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
This commit is contained in:
2026-10-15 16:42:47 +07:00
parent 758609775f
commit b058179177
24 changed files with 1510 additions and 126 deletions
@@ -4,7 +4,9 @@
* 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, 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.
* other host under "Other SSH hosts" — so the sections never mix; a named key
* is made beside the default ones, and a second AWS account is saved under
* its own name with the one key it uses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -21,6 +23,7 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
removeKeyById: vi.fn(),
setTerminalSsh: vi.fn(),
setSshUser: vi.fn(),
removeSshUser: vi.fn(),
@@ -143,7 +146,7 @@ describe("GitIdentitySettings", () => {
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(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).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();
@@ -166,7 +169,7 @@ describe("GitIdentitySettings", () => {
host: "git.example.com",
user: "deploy",
});
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
@@ -194,4 +197,63 @@ describe("GitIdentitySettings", () => {
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});
it("makes a named key and saves a second AWS account under its name with that key", async () => {
const RSA = {
id: "id_rsa",
type: "rsa" as const,
name: null,
publicKey: "ssh-rsa A x",
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
type: "rsa" as const,
name: "aws-prod",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [RSA, PROD], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
const more = await screen.findByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
);
});
});