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
+55 -7
View File
@@ -11,7 +11,8 @@
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
* installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
* shared HTML file carries runs on the dashboard's origin.
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
* card offers the same actions as its buttons, plus copying a page's link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,6 +44,8 @@ import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { copyText } from "../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import {
FileUploadPanel,
PluginAddPanel,
@@ -325,12 +328,20 @@ function ArtifactCard({
}) {
const { t } = useTranslation("artifacts");
const Icon = KIND_ICON[a.kind];
const menu = useContextMenu<null>();
const pick = (fn: () => void) => () => {
menu.close();
fn();
};
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
const canPreview = (a.kind === "file" || a.kind === "skill") && a.files.length > 0;
const btn =
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
return (
<li
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
>
<div className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
@@ -363,12 +374,8 @@ function ArtifactCard({
{t("open")}
</a>
)}
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
<button
type="button"
className={btn}
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
>
{canPreview && (
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
<Eye className="h-3 w-3" />
{t("preview")}
</button>
@@ -410,6 +417,47 @@ function ArtifactCard({
)}
</span>
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
{a.kind === "page" && a.url && (
<>
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
{t("open")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
</>
)}
{canPreview && (
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
)}
{a.kind === "file" && a.files.length > 0 && (
<MenuItem
onSelect={pick(() => {
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
})}
>
{t("download")}
</MenuItem>
)}
{(a.kind === "skill" || a.kind === "plugin") && (
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
{t("install")}
</MenuItem>
)}
{a.canChange && (
<>
<MenuSeparator />
{server && (
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
)}
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
<MenuItem danger onSelect={pick(onDelete)}>
{t("delete")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</li>
);
}
+49 -2
View File
@@ -13,14 +13,15 @@
* tmux server beside the installed version, restartable by an admin
* (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set.
* name and email under the username when set; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
@@ -52,6 +53,8 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { MemberTmuxCard } from "../components/users/MemberTmuxCard";
import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -67,6 +70,8 @@ export function Users() {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const rowMenu = useContextMenu<ServerUserUsage>();
const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
@@ -231,6 +236,7 @@ export function Users() {
return (
<tr
key={u.id}
onContextMenu={(e) => rowMenu.open(e, u)}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
@@ -298,6 +304,47 @@ export function Users() {
</table>
</div>
{rowMenu.menu &&
(() => {
const u = rowMenu.menu.item;
const pick = (fn: () => void) => () => {
rowMenu.close();
fn();
};
return (
<ContextMenu
x={rowMenu.menu.x}
y={rowMenu.menu.y}
onClose={rowMenu.close}
testId="users-row-menu"
>
{isAdmin && (
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
)}
{isAdmin && !u.disabled && (
<MenuItem
onSelect={pick(() =>
navigate(
`/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}`
)
)}
>
{t("rowMenu.openHome")}
</MenuItem>
)}
{isAdmin && <MenuSeparator />}
<MenuItem onSelect={pick(() => void copyText(u.username))}>
{t("rowMenu.copyUsername")}
</MenuItem>
{u.email && (
<MenuItem onSelect={pick(() => void copyText(u.email!))}>
{t("rowMenu.copyEmail")}
</MenuItem>
)}
</ContextMenu>
);
})()}
<AccountPoolCard isAdmin={isAdmin} />
<MemberTmuxCard isAdmin={isAdmin} />
+27 -3
View File
@@ -6,10 +6,11 @@
* a plugin is picked from the list its marketplace link answers (a link the
* server cannot read opens the by-hand form), a skill is uploaded from one
* Markdown file under the name the user types, or written in the dialog.
* A card's right-click menu runs the same actions (Preview, Delete).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Artifacts } from "../Artifacts";
@@ -81,7 +82,13 @@ beforeEach(() => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({}),
item({ id: 2, kind: "skill", title: "deploy", source: "deploy", files: [{ path: "SKILL.md", size: 3 }] }),
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
files: [{ path: "SKILL.md", size: 3 }],
}),
],
});
vi.mocked(confirmDialog).mockReset();
@@ -145,7 +152,9 @@ describe("Adding", () => {
await addMenu(user, "Skill");
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("skill-files"), md);
expect(await screen.findByTestId("skill-shape")).toHaveTextContent("becomes the skill's SKILL.md");
expect(await screen.findByTestId("skill-shape")).toHaveTextContent(
"becomes the skill's SKILL.md"
);
const name = screen.getByLabelText(/Skill name/);
await waitFor(() => expect(name).toHaveValue("notes"));
await user.clear(name);
@@ -229,4 +238,19 @@ describe("Artifacts", () => {
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
});
it("opens a card's actions on right-click", async () => {
const user = userEvent.setup();
open();
await screen.findByText("report.html");
fireEvent.contextMenu(screen.getByTestId("artifact-1"));
const menu = screen.getByTestId("artifact-menu");
expect(within(menu).getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Install" })).toBeNull();
await user.click(within(menu).getByRole("menuitem", { name: "Preview" }));
expect(screen.queryByTestId("artifact-menu")).toBeNull();
expect((await screen.findByTestId("artifact-preview")).getAttribute("src")).toBe(
"/api/artifacts/1/raw?path=report.html"
);
});
});
+28 -3
View File
@@ -5,14 +5,15 @@
* with their own password typed (the confirm stays disabled until then), sees
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
* separate role/password/workspace/disable buttons any more), and sees the
* display name and email under a username.
* display name and email under a username; right-clicking a row opens that
* user's home in Files (an admin) — an analyst's menu only copies.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AuthState, ServerUserUsage } from "../../lib/api";
let authState: AuthState = { mode: "local" };
@@ -60,12 +61,17 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { Users } from "../Users";
function FilesProbe() {
return <div data-testid="files">{useLocation().search}</div>;
}
function renderPage() {
return render(
<MemoryRouter initialEntries={["/users"]}>
<Routes>
<Route path="/users" element={<Users />} />
<Route path="/" element={<div data-testid="home" />} />
<Route path="/files" element={<FilesProbe />} />
</Routes>
</MemoryRouter>
);
@@ -192,4 +198,23 @@ describe("Users page", () => {
expect(screen.getByText("@mia")).toBeTruthy();
expect(screen.getByText("mia@x.vn")).toBeTruthy();
});
it("opens a row's menu on right-click: Open home for an admin, copy only for an analyst", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderPage();
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
const menu = screen.getByTestId("users-row-menu");
expect(within(menu).getByRole("menuitem", { name: "Copy username" })).toBeTruthy();
fireEvent.click(within(menu).getByRole("menuitem", { name: "Open home in Files" }));
expect((await screen.findByTestId("files")).textContent).toBe("?home=mia");
});
it("gives an analyst's row menu no admin actions", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderPage();
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
const menu = screen.getByTestId("users-row-menu");
expect(within(menu).queryByRole("menuitem", { name: "Open home in Files" })).toBeNull();
expect(within(menu).queryByRole("menuitem", { name: "Edit" })).toBeNull();
});
});