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:
@@ -102,6 +102,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
let clipboard: Clip | null = null;
|
||||
|
||||
/** A menu item's handler that closes its menu first. */
|
||||
const pick = (m: { close: () => void }, fn: () => void) => () => {
|
||||
m.close();
|
||||
fn();
|
||||
};
|
||||
/** The workspace id of a place, or null for a home. */
|
||||
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
|
||||
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
|
||||
@@ -279,6 +284,9 @@ export function FileBrowser({
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
const ctx = useContextMenu<FsEntry | null>();
|
||||
// The sidebar's places and the path bar's crumbs have their own menus.
|
||||
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
|
||||
const crumbCtx = useContextMenu<string>();
|
||||
|
||||
// A new caller target (another workspace's Files tab, the page's URL) —
|
||||
// not the echo of our own `go()` through the page's URL, which would wipe
|
||||
@@ -668,6 +676,18 @@ export function FileBrowser({
|
||||
const item = trash.find((i) => i.id === id);
|
||||
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
|
||||
};
|
||||
/** Empty one user's whole Trash from the Trash root — asked first. */
|
||||
const emptyTrashOf = async (home: string, count: number) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("fx.emptyTrashTitle"),
|
||||
message: t("fx.emptyTrashBody", {
|
||||
count,
|
||||
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
|
||||
}),
|
||||
confirmLabel: t("fx.emptyTrash"),
|
||||
});
|
||||
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
|
||||
};
|
||||
const restoreItem = (id: string) => {
|
||||
const item = trash.find((i) => i.id === id);
|
||||
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
|
||||
@@ -772,6 +792,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
@@ -794,6 +815,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
@@ -814,6 +836,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
@@ -902,6 +925,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("")}
|
||||
onContextMenu={(e) => crumbCtx.open(e, "")}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, "")}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -921,6 +945,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
|
||||
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -1266,6 +1291,8 @@ export function FileBrowser({
|
||||
loading={loading}
|
||||
homeName={homeName}
|
||||
onOpen={openTrash}
|
||||
onEmpty={(home, count) => void emptyTrashOf(home, count)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
) : (
|
||||
<TrashView
|
||||
@@ -1274,6 +1301,7 @@ export function FileBrowser({
|
||||
laneName={laneName}
|
||||
onRestore={restoreItem}
|
||||
onPurge={(id) => void purge(id)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1449,6 +1477,78 @@ export function FileBrowser({
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{placeCtx.menu && (
|
||||
<ContextMenu
|
||||
x={placeCtx.menu.x}
|
||||
y={placeCtx.menu.y}
|
||||
onClose={placeCtx.close}
|
||||
testId="fx-place-menu"
|
||||
>
|
||||
{"lane" in placeCtx.menu.item
|
||||
? (() => {
|
||||
const l = placeCtx.menu.item.lane;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
|
||||
{t("fx.open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
const home = (placeCtx.menu.item as { home: string }).home;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
|
||||
{t("fx.open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
|
||||
{t("fx.openTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{crumbCtx.menu && (
|
||||
<ContextMenu
|
||||
x={crumbCtx.menu.x}
|
||||
y={crumbCtx.menu.y}
|
||||
onClose={crumbCtx.close}
|
||||
testId="fx-crumb-menu"
|
||||
>
|
||||
{(() => {
|
||||
const dir = crumbCtx.menu.item;
|
||||
const name = dir.split("/").pop() || placeName(target);
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick(crumbCtx, () =>
|
||||
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
|
||||
)}
|
||||
>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
{!ro && clip && (
|
||||
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
|
||||
{t("fx.pasteInto")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{ask && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
|
||||
@@ -1763,6 +1863,8 @@ function TrashFolders({
|
||||
loading,
|
||||
homeName,
|
||||
onOpen,
|
||||
onEmpty,
|
||||
onRefresh,
|
||||
}: {
|
||||
homes: FsHome[];
|
||||
/** Every Trash's items, merged, each tagged with its `home`. */
|
||||
@@ -1773,13 +1875,20 @@ function TrashFolders({
|
||||
loading: boolean;
|
||||
homeName: (id: string) => string;
|
||||
onOpen: (home: string) => void;
|
||||
onEmpty: (home: string, count: number) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<string | null>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
|
||||
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash-root">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="fx-trash-root"
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||
{t("fx.trashFoldersHint")}
|
||||
</div>
|
||||
@@ -1799,6 +1908,7 @@ function TrashFolders({
|
||||
type="button"
|
||||
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
|
||||
onClick={() => onOpen(h.id)}
|
||||
onContextMenu={(e) => menu.open(e, h.id)}
|
||||
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
|
||||
data-testid="fx-trash-folder"
|
||||
>
|
||||
@@ -1833,6 +1943,32 @@ function TrashFolders({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu
|
||||
x={menu.menu.x}
|
||||
y={menu.menu.y}
|
||||
onClose={menu.close}
|
||||
testId="fx-trash-root-menu"
|
||||
>
|
||||
{menu.menu.item !== null ? (
|
||||
(() => {
|
||||
const home = menu.menu.item;
|
||||
const n = counts.get(home) ?? 0;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
|
||||
{t("fx.emptyTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1843,16 +1979,23 @@ function TrashView({
|
||||
laneName,
|
||||
onRestore,
|
||||
onPurge,
|
||||
onRefresh,
|
||||
}: {
|
||||
items: FsTrashItem[];
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
laneName: (id: number) => string;
|
||||
onRestore: (id: string) => void;
|
||||
onPurge: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<FsTrashItem | null>();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="fx-trash"
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
{error && (
|
||||
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||
{error}
|
||||
@@ -1885,6 +2028,7 @@ function TrashView({
|
||||
key={i.id}
|
||||
className="border-b border-border/40 hover:bg-surface-2"
|
||||
data-testid="fx-trash-row"
|
||||
onContextMenu={(e) => menu.open(e, i)}
|
||||
>
|
||||
<td className="truncate px-4 py-1.5">
|
||||
<span className="flex items-center gap-2" title={i.path}>
|
||||
@@ -1932,6 +2076,37 @@ function TrashView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
|
||||
{menu.menu.item ? (
|
||||
(() => {
|
||||
const i = menu.menu.item;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
|
||||
{t("fx.restore")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
|
||||
{t("fx.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
|
||||
{t("fx.purge")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
|
||||
{t("fx.emptyTrash")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,6 +297,91 @@ describe("FileExplorer", () => {
|
||||
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
home: "bob",
|
||||
owner: "Bob",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
vi.mocked(api.files.trash).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "bob12345",
|
||||
name: "old",
|
||||
laneId: null,
|
||||
path: "old",
|
||||
isDir: true,
|
||||
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||
store: "host",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||
);
|
||||
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||
// My Trash is empty: its Empty Trash is disabled.
|
||||
fireEvent.contextMenu(folders[0]!);
|
||||
expect(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
).toHaveProperty("disabled", true);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.contextMenu(folders[1]!);
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||
name: "Empty Trash",
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
|
||||
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
|
||||
|
||||
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||
);
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
|
||||
);
|
||||
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||
});
|
||||
|
||||
it("shows why a Trash could not be read instead of an empty one", async () => {
|
||||
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
* it, and whether git typed in the account's own terminal uses it too (a
|
||||
* 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
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses — 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
|
||||
@@ -206,9 +209,36 @@ export function GitIdentitySettings() {
|
||||
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
// An account may hold one of each; an older server answers `ssh` only.
|
||||
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
|
||||
// The default key of a type (a named key sits beside it).
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKeyById(keyId));
|
||||
};
|
||||
const generate = async (type: "ed25519" | "rsa") => {
|
||||
const taken = !!keyOf(type);
|
||||
if (
|
||||
@@ -260,7 +290,7 @@ export function GitIdentitySettings() {
|
||||
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 [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
@@ -270,23 +300,55 @@ export function GitIdentitySettings() {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.setSshUser({ host, user }),
|
||||
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "" });
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const [other, setOther] = useState({ host: "", user: "" });
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
||||
if (m?.[1]) host = m[1];
|
||||
const alias = other.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
|
||||
t("gitIdentity.otherSaved", { host })
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user: other.user.trim(),
|
||||
...(alias ? { alias } : {}),
|
||||
...(other.key ? { key: other.key } : {}),
|
||||
}),
|
||||
t("gitIdentity.otherSaved", { host: alias || host })
|
||||
);
|
||||
if (ok) setOther({ host: "", user: "" });
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
@@ -423,17 +485,20 @@ export function GitIdentitySettings() {
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.host} className="mt-1 flex items-center gap-2">
|
||||
<div key={r.alias ?? 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}
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</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))}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -442,7 +507,7 @@ export function GitIdentitySettings() {
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
@@ -468,11 +533,22 @@ export function GitIdentitySettings() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
@@ -480,12 +556,119 @@ export function GitIdentitySettings() {
|
||||
<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/<repo></Code>
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
|
||||
{(named.length > 0 || id.writable) && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
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">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</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}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{terminal && (
|
||||
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -513,17 +696,20 @@ export function GitIdentitySettings() {
|
||||
<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">
|
||||
<div key={r.alias ?? 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}
|
||||
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||
</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))}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -532,7 +718,7 @@ export function GitIdentitySettings() {
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveOther();
|
||||
@@ -553,6 +739,16 @@ export function GitIdentitySettings() {
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={other.alias}
|
||||
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"install": "Install",
|
||||
|
||||
@@ -596,6 +596,7 @@
|
||||
"fx.trashAt": "Trash ({{name}})",
|
||||
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
|
||||
"fx.trashFolderEmpty": "Empty",
|
||||
"fx.openTrash": "Open Trash",
|
||||
"fx.purged": "Deleted forever.",
|
||||
"fx.emptyTrashTitle": "Empty the Trash?",
|
||||
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
|
||||
|
||||
@@ -937,7 +937,21 @@
|
||||
"otherHost": "Host",
|
||||
"otherUser": "User name",
|
||||
"otherSaved": "SSH user saved for {{host}}",
|
||||
"privateNote": "Only the public key is ever shown; the private key never leaves the server."
|
||||
"privateNote": "Only the public key is ever shown; the private key never leaves the server.",
|
||||
"anyKey": "Any key",
|
||||
"defaultEd": "ED25519 (default)",
|
||||
"defaultRsa": "RSA (default)",
|
||||
"loginKey": "Key",
|
||||
"alias": "Account name",
|
||||
"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? Make one more RSA key below for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -234,5 +234,10 @@
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Open home in Files",
|
||||
"copyUsername": "Copy username",
|
||||
"copyEmail": "Copy email"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
"download": "Tải về",
|
||||
"install": "Cài",
|
||||
|
||||
@@ -596,6 +596,7 @@
|
||||
"fx.trashAt": "Thùng rác ({{name}})",
|
||||
"fx.trashFoldersHint": "Mỗi thư mục là Thùng rác của một user — mở ra để khôi phục hoặc xoá vĩnh viễn.",
|
||||
"fx.trashFolderEmpty": "Trống",
|
||||
"fx.openTrash": "Mở Thùng rác",
|
||||
"fx.purged": "Đã xoá vĩnh viễn.",
|
||||
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?",
|
||||
"fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||
|
||||
@@ -937,7 +937,21 @@
|
||||
"otherHost": "Host",
|
||||
"otherUser": "Tên user",
|
||||
"otherSaved": "Đã lưu SSH user cho {{host}}",
|
||||
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server."
|
||||
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server.",
|
||||
"anyKey": "Mọi key",
|
||||
"defaultEd": "ED25519 (mặc định)",
|
||||
"defaultRsa": "RSA (mặc định)",
|
||||
"loginKey": "Key",
|
||||
"alias": "Tên tài khoản",
|
||||
"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? Tạo thêm một RSA key bên dưới cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -234,5 +234,10 @@
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Mở home trong Files",
|
||||
"copyUsername": "Sao chép username",
|
||||
"copyEmail": "Sao chép email"
|
||||
}
|
||||
}
|
||||
|
||||
+32
-11
@@ -2720,22 +2720,30 @@ export const api = {
|
||||
* Make the SSH key; `replace` swaps an existing one for a new pair.
|
||||
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||
*/
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace, type }),
|
||||
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
|
||||
}),
|
||||
/** DELETE /api/me/git/ssh-key — the key of `type`, or every key. */
|
||||
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
|
||||
removeKey: (type?: "ed25519" | "rsa") =>
|
||||
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
|
||||
/** 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" }),
|
||||
/** 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 }) }),
|
||||
/** PUT /api/me/git/ssh-user — the SSH login name for a host (CodeCommit: the SSH key ID). */
|
||||
setSshUser: (body: { host: string; user: string }) =>
|
||||
/**
|
||||
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
|
||||
* the user): for the host itself, or with `alias` one more account on it
|
||||
* (`ssh://<alias>/…`); `key` = the only key it uses.
|
||||
*/
|
||||
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
|
||||
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
removeSshUser: (host: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias. */
|
||||
removeSshUser: (from: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -4266,12 +4274,25 @@ export interface GitIdentity {
|
||||
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
|
||||
terminal?: "on" | "off" | "foreign";
|
||||
} | null;
|
||||
/** Every key the account holds (ED25519 first); absent from an older server. */
|
||||
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
|
||||
/**
|
||||
* 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;
|
||||
}[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/** SSH login name per host (`url.<…>.insteadOf` in `~/.gitconfig`); absent from an older server. */
|
||||
sshUsers?: { host: string; user: string }[];
|
||||
/**
|
||||
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
|
||||
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
|
||||
* it uses, null = every key. Absent from an older server.
|
||||
*/
|
||||
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
/** The suggested email while none is set: `<username>@smartgift.vn`. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user