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
+177 -2
View File
@@ -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. // The clipboard outlives the dialog and a workspace switch, like a desktop's.
let clipboard: Clip | null = null; 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. */ /** The workspace id of a place, or null for a home. */
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane); 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. */ /** 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 fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null); const folderInput = useRef<HTMLInputElement>(null);
const ctx = useContextMenu<FsEntry | 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) — // 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 // 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); const item = trash.find((i) => i.id === id);
await run(() => api.files.purge(target, id, item?.store), t("fx.purged")); 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 restoreItem = (id: string) => {
const item = trash.find((i) => i.id === id); const item = trash.find((i) => i.id === id);
void run(() => api.files.restore(target, id, item?.store), t("fx.restored")); void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
@@ -772,6 +792,7 @@ export function FileBrowser({
<button <button
type="button" type="button"
onClick={() => go("", { home: "me" })} onClick={() => go("", { home: "me" })}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
className={placeClass( className={placeClass(
view === "files" && isHomePlace(target) && target.home === "me" view === "files" && isHomePlace(target) && target.home === "me"
)} )}
@@ -794,6 +815,7 @@ export function FileBrowser({
type="button" type="button"
title={t("fx.readOnlyHome")} title={t("fx.readOnlyHome")}
onClick={() => go("", { home: h.id })} onClick={() => go("", { home: h.id })}
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
className={placeClass( className={placeClass(
view === "files" && isHomePlace(target) && target.home === h.id view === "files" && isHomePlace(target) && target.home === h.id
)} )}
@@ -814,6 +836,7 @@ export function FileBrowser({
type="button" type="button"
title={l.cwd} title={l.cwd}
onClick={() => go("", l.id)} onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={placeClass( className={placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)} )}
@@ -902,6 +925,7 @@ export function FileBrowser({
<button <button
type="button" type="button"
onClick={() => go("")} onClick={() => go("")}
onContextMenu={(e) => crumbCtx.open(e, "")}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, "")} 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 ${ 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 <button
type="button" type="button"
onClick={() => go(crumbs.slice(0, i + 1).join("/"))} onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))} onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${ className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -1266,6 +1291,8 @@ export function FileBrowser({
loading={loading} loading={loading}
homeName={homeName} homeName={homeName}
onOpen={openTrash} onOpen={openTrash}
onEmpty={(home, count) => void emptyTrashOf(home, count)}
onRefresh={() => void load()}
/> />
) : ( ) : (
<TrashView <TrashView
@@ -1274,6 +1301,7 @@ export function FileBrowser({
laneName={laneName} laneName={laneName}
onRestore={restoreItem} onRestore={restoreItem}
onPurge={(id) => void purge(id)} onPurge={(id) => void purge(id)}
onRefresh={() => void load()}
/> />
)} )}
@@ -1449,6 +1477,78 @@ export function FileBrowser({
</ContextMenu> </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 && ( {ask && (
<div <div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50" className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
@@ -1763,6 +1863,8 @@ function TrashFolders({
loading, loading,
homeName, homeName,
onOpen, onOpen,
onEmpty,
onRefresh,
}: { }: {
homes: FsHome[]; homes: FsHome[];
/** Every Trash's items, merged, each tagged with its `home`. */ /** Every Trash's items, merged, each tagged with its `home`. */
@@ -1773,13 +1875,20 @@ function TrashFolders({
loading: boolean; loading: boolean;
homeName: (id: string) => string; homeName: (id: string) => string;
onOpen: (home: string) => void; onOpen: (home: string) => void;
onEmpty: (home: string, count: number) => void;
onRefresh: () => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<string | null>();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1); 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")]; const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
return ( 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"> <div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
{t("fx.trashFoldersHint")} {t("fx.trashFoldersHint")}
</div> </div>
@@ -1799,6 +1908,7 @@ function TrashFolders({
type="button" type="button"
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })} title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
onClick={() => onOpen(h.id)} 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" 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" data-testid="fx-trash-folder"
> >
@@ -1833,6 +1943,32 @@ function TrashFolders({
); );
})} })}
</div> </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> </div>
); );
} }
@@ -1843,16 +1979,23 @@ function TrashView({
laneName, laneName,
onRestore, onRestore,
onPurge, onPurge,
onRefresh,
}: { }: {
items: FsTrashItem[]; items: FsTrashItem[];
error: string | null; error: string | null;
onRefresh: () => void;
laneName: (id: number) => string; laneName: (id: number) => string;
onRestore: (id: string) => void; onRestore: (id: string) => void;
onPurge: (id: string) => void; onPurge: (id: string) => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<FsTrashItem | null>();
return ( 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 && ( {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"> <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} {error}
@@ -1885,6 +2028,7 @@ function TrashView({
key={i.id} key={i.id}
className="border-b border-border/40 hover:bg-surface-2" className="border-b border-border/40 hover:bg-surface-2"
data-testid="fx-trash-row" data-testid="fx-trash-row"
onContextMenu={(e) => menu.open(e, i)}
> >
<td className="truncate px-4 py-1.5"> <td className="truncate px-4 py-1.5">
<span className="flex items-center gap-2" title={i.path}> <span className="flex items-center gap-2" title={i.path}>
@@ -1932,6 +2076,37 @@ function TrashView({
</div> </div>
)} )}
</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> </div>
); );
} }
@@ -297,6 +297,91 @@ describe("FileExplorer", () => {
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2); 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 () => { 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")); vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
const user = userEvent.setup(); const user = userEvent.setup();
@@ -10,7 +10,10 @@
* it, and whether git typed in the account's own terminal uses it too (a * 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 * 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 * 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 * a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials * 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 * 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. // ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only. // 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 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 generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type); const taken = !!keyOf(type);
if ( if (
@@ -260,7 +290,7 @@ export function GitIdentitySettings() {
return m ? [{ ...r, region: m[1] ?? "" }] : []; return m ? [{ ...r, region: m[1] ?? "" }] : [];
}); });
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host)); 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 () => { const saveAws = async () => {
let user = aws.keyId.trim(); let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`; let host = `git-codecommit.${aws.region}.amazonaws.com`;
@@ -270,23 +300,55 @@ export function GitIdentitySettings() {
user = m[1]; user = m[1];
host = m[2].toLowerCase(); host = m[2].toLowerCase();
} }
const alias = aws.alias.trim().toLowerCase();
const ok = await run( 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 () => { const saveOther = async () => {
let host = other.host.trim(); let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host); const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1]; if (m?.[1]) host = m[1];
const alias = other.alias.trim().toLowerCase();
const ok = await run( 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) => { const removeLogin = async (host: string) => {
if ( if (
@@ -423,17 +485,20 @@ export function GitIdentitySettings() {
{id.sshUsers === undefined ? null : ( {id.sshUsers === undefined ? null : (
<> <>
{awsRows.map((r) => ( {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"> <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> </span>
{id.writable && ( {id.writable && (
<button <button
type="button" type="button"
className={`${BTN} hover:text-status-danger`} className={`${BTN} hover:text-status-danger`}
disabled={busy} disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.host}`} aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))} onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -442,7 +507,7 @@ export function GitIdentitySettings() {
))} ))}
{id.writable && ( {id.writable && (
<form <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) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void saveAws(); void saveAws();
@@ -468,11 +533,22 @@ export function GitIdentitySettings() {
</option> </option>
))} ))}
</select> </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()}> <button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")} {t("gitIdentity.awsSave")}
</button> </button>
</form> </form>
)} )}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</> </>
)} )}
</Step> </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"> <div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")} {t("gitIdentity.awsReady")}
{awsRows.map((r) => ( {awsRows.map((r) => (
<Code key={r.host}>git clone ssh://{r.host}/v1/repos/&lt;repo&gt;</Code> <Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))} ))}
</div> </div>
)} )}
</KeyCard> </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 && ( {terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2"> <div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1"> <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="mt-1.5 space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div> <div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => ( {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"> <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> </span>
{id.writable && ( {id.writable && (
<button <button
type="button" type="button"
className={`${BTN} hover:text-status-danger`} className={`${BTN} hover:text-status-danger`}
disabled={busy} disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.host}`} aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))} onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -532,7 +718,7 @@ export function GitIdentitySettings() {
))} ))}
{id.writable && ( {id.writable && (
<form <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) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void saveOther(); void saveOther();
@@ -553,6 +739,16 @@ export function GitIdentitySettings() {
onChange={(e) => setOther({ ...other, user: e.target.value })} onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`} 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 <button
type="submit" type="submit"
className={BTN} className={BTN}
@@ -4,7 +4,9 @@
* first, and a member (S3) adds an HTTPS login of their own while the host's * 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 * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -21,6 +23,7 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(), get: vi.fn(),
generateKey: vi.fn(), generateKey: vi.fn(),
removeKey: vi.fn(), removeKey: vi.fn(),
removeKeyById: vi.fn(),
setTerminalSsh: vi.fn(), setTerminalSsh: vi.fn(),
setSshUser: vi.fn(), setSshUser: vi.fn(),
removeSshUser: vi.fn(), removeSshUser: vi.fn(),
@@ -143,7 +146,7 @@ describe("GitIdentitySettings", () => {
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1"); await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
await userEvent.click(within(aws).getByRole("button", { name: "Save" })); await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" }); 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(); expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts". // A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull(); expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
@@ -166,7 +169,7 @@ describe("GitIdentitySettings", () => {
host: "git.example.com", host: "git.example.com",
user: "deploy", 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 () => { 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: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).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", "shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub", "unshareDone": "Removed from the Hub",
"open": "Open", "open": "Open",
"copyLink": "Copy link",
"preview": "Preview", "preview": "Preview",
"download": "Download", "download": "Download",
"install": "Install", "install": "Install",
+1
View File
@@ -596,6 +596,7 @@
"fx.trashAt": "Trash ({{name}})", "fx.trashAt": "Trash ({{name}})",
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.", "fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
"fx.trashFolderEmpty": "Empty", "fx.trashFolderEmpty": "Empty",
"fx.openTrash": "Open Trash",
"fx.purged": "Deleted forever.", "fx.purged": "Deleted forever.",
"fx.emptyTrashTitle": "Empty the Trash?", "fx.emptyTrashTitle": "Empty the Trash?",
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.", "fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
+15 -1
View File
@@ -937,7 +937,21 @@
"otherHost": "Host", "otherHost": "Host",
"otherUser": "User name", "otherUser": "User name",
"otherSaved": "SSH user saved for {{host}}", "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": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
+5
View File
@@ -234,5 +234,10 @@
"errors": { "errors": {
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server." "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", "shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub", "unshareDone": "Đã gỡ khỏi Hub",
"open": "Mở", "open": "Mở",
"copyLink": "Sao chép link",
"preview": "Xem", "preview": "Xem",
"download": "Tải về", "download": "Tải về",
"install": "Cài", "install": "Cài",
+1
View File
@@ -596,6 +596,7 @@
"fx.trashAt": "Thùng rác ({{name}})", "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.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.trashFolderEmpty": "Trống",
"fx.openTrash": "Mở Thùng rác",
"fx.purged": "Đã xoá vĩnh viễn.", "fx.purged": "Đã xoá vĩnh viễn.",
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?", "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.", "fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
+15 -1
View File
@@ -937,7 +937,21 @@
"otherHost": "Host", "otherHost": "Host",
"otherUser": "Tên user", "otherUser": "Tên user",
"otherSaved": "Đã lưu SSH user cho {{host}}", "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": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
+5
View File
@@ -234,5 +234,10 @@
"errors": { "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." "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
View File
@@ -2720,22 +2720,30 @@ export const api = {
* Make the SSH key; `replace` swaps an existing one for a new pair. * 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). * `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", { request<GitIdentity>("/me/git/ssh-key", {
method: "POST", 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") => removeKey: (type?: "ed25519" | "rsa") =>
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }), 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. */ /** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) => setTerminalSsh: (on: boolean) =>
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }), 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) }), request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
removeSshUser: (host: string) => /** DELETE a login by its host (the host's own rule) or its alias. */
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(host)}`, { method: "DELETE" }), removeSshUser: (from: string) =>
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) => saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }), request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) => 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. */ /** 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"; terminal?: "on" | "off" | "foreign";
} | null; } | 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. */ /** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null; 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. */ /** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null }; author: { name: string | null; email: string | null };
/** The suggested email while none is set: `<username>@smartgift.vn`. */ /** The suggested email while none is set: `<username>@smartgift.vn`. */
+55 -7
View File
@@ -11,7 +11,8 @@
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub * admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
* installs off for everyone. Previews of a file are * installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a * 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> * @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 { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm"; import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog"; import { useDialog } from "../hooks/useDialog";
import { copyText } from "../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { import {
FileUploadPanel, FileUploadPanel,
PluginAddPanel, PluginAddPanel,
@@ -325,12 +328,20 @@ function ArtifactCard({
}) { }) {
const { t } = useTranslation("artifacts"); const { t } = useTranslation("artifacts");
const Icon = KIND_ICON[a.kind]; 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 = 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"; "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 ( return (
<li <li
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3" className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
data-testid={`artifact-${a.id}`} data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" /> <Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
@@ -363,12 +374,8 @@ function ArtifactCard({
{t("open")} {t("open")}
</a> </a>
)} )}
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && ( {canPreview && (
<button <button type="button" className={btn} onClick={() => onPreview(previewPath)}>
type="button"
className={btn}
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
>
<Eye className="h-3 w-3" /> <Eye className="h-3 w-3" />
{t("preview")} {t("preview")}
</button> </button>
@@ -410,6 +417,47 @@ function ArtifactCard({
)} )}
</span> </span>
</div> </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> </li>
); );
} }
+49 -2
View File
@@ -13,14 +13,15 @@
* tmux server beside the installed version, restartable by an admin * tmux server beside the installed version, restartable by an admin
* (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the * (`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 * 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 * 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. * (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; 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 { useTranslation } from "react-i18next";
import { import {
AlertCircle, AlertCircle,
@@ -52,6 +53,8 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard"; import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { MemberTmuxCard } from "../components/users/MemberTmuxCard"; import { MemberTmuxCard } from "../components/users/MemberTmuxCard";
import { TableRowSkeleton } from "../components/Skeleton"; import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"]; type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"]; const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -67,6 +70,8 @@ export function Users() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null); const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const rowMenu = useContextMenu<ServerUserUsage>();
const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null); const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false); const [measuring, setMeasuring] = useState(false);
@@ -231,6 +236,7 @@ export function Users() {
return ( return (
<tr <tr
key={u.id} key={u.id}
onContextMenu={(e) => rowMenu.open(e, u)}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`} className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
> >
<td className="px-5 py-3"> <td className="px-5 py-3">
@@ -298,6 +304,47 @@ export function Users() {
</table> </table>
</div> </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} /> <AccountPoolCard isAdmin={isAdmin} />
<MemberTmuxCard 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 * 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 * 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. * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { Artifacts } from "../Artifacts"; import { Artifacts } from "../Artifacts";
@@ -81,7 +82,13 @@ beforeEach(() => {
vi.mocked(api.artifacts.list).mockResolvedValue({ vi.mocked(api.artifacts.list).mockResolvedValue({
items: [ items: [
item({}), 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(); vi.mocked(confirmDialog).mockReset();
@@ -145,7 +152,9 @@ describe("Adding", () => {
await addMenu(user, "Skill"); await addMenu(user, "Skill");
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" }); const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("skill-files"), md); 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/); const name = screen.getByLabelText(/Skill name/);
await waitFor(() => expect(name).toHaveValue("notes")); await waitFor(() => expect(name).toHaveValue("notes"));
await user.clear(name); await user.clear(name);
@@ -229,4 +238,19 @@ describe("Artifacts", () => {
expect(frame.getAttribute("sandbox")).toBe(""); expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html"); 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 * 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 * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 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"; import type { AuthState, ServerUserUsage } from "../../lib/api";
let authState: AuthState = { mode: "local" }; let authState: AuthState = { mode: "local" };
@@ -60,12 +61,17 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { Users } from "../Users"; import { Users } from "../Users";
function FilesProbe() {
return <div data-testid="files">{useLocation().search}</div>;
}
function renderPage() { function renderPage() {
return render( return render(
<MemoryRouter initialEntries={["/users"]}> <MemoryRouter initialEntries={["/users"]}>
<Routes> <Routes>
<Route path="/users" element={<Users />} /> <Route path="/users" element={<Users />} />
<Route path="/" element={<div data-testid="home" />} /> <Route path="/" element={<div data-testid="home" />} />
<Route path="/files" element={<FilesProbe />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>
); );
@@ -192,4 +198,23 @@ describe("Users page", () => {
expect(screen.getByText("@mia")).toBeTruthy(); expect(screen.getByText("@mia")).toBeTruthy();
expect(screen.getByText("mia@x.vn")).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();
});
}); });
+6 -6
View File
@@ -1067,12 +1067,12 @@ cannot collide on `index.lock`.
#### Your git identity #### Your git identity
``` ```
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ type, publicKey, fingerprint }] (ed25519 first), sshTerminal, https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail } GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ id, type, name, publicKey, fingerprint }] (default ed25519, default rsa, then named keys by name; `name` null for a default key, `id` = its file name), sshTerminal, sshUsers: [{ host, user, alias, key }], https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail }
POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete the key of `?type=ed25519|rsa`, or every key without it (404 ENOTFOUND, 400 EBADKEYTYPE) — also removes our terminal `core.sshCommand` DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
PUT /api/me/git/ssh-terminal Use the key for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (ours first, then `~/.ssh/id_*`); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user The SSH login name for one host — Body: { host, user } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` to the account's `~/.gitconfig` (replacing ours for that host), so a plain `ssh://<host>/…` URL logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`); 400 EBADHOST / EBADSSHUSER. Listed as `sshUsers: [{host, user}]` in GET /api/me/git PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:host Drop that rule (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
POST /api/me/git/https Save an https login — Body: { host, username, password } POST /api/me/git/https Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND) DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account. A new SSH key is labelled with that email, else `defaultEmail` (`<username>@smartgift.vn`; `AGENTHUB_EMAIL_DOMAIN` overrides the domain), also returned by GET /api/me/git as the email form's suggestion PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account. A new SSH key is labelled with that email, else `defaultEmail` (`<username>@smartgift.vn`; `AGENTHUB_EMAIL_DOMAIN` overrides the domain), also returned by GET /api/me/git as the email form's suggestion
+1 -1
View File
File diff suppressed because one or more lines are too long
+323 -4
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.7.7 version: 2.8.5
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -3952,6 +3952,10 @@ components:
enum: enum:
- true - true
example: true example: true
archived:
type: string
nullable: true
description: 'Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).'
LaneWorktreeCreateRequest: LaneWorktreeCreateRequest:
type: object type: object
required: required:
@@ -4819,6 +4823,10 @@ components:
email: email:
type: string type: string
nullable: true nullable: true
avatarAt:
type: string
nullable: true
description: When the avatar was last set (null = none)
createdAt: createdAt:
type: string type: string
nullable: true nullable: true
@@ -11205,6 +11213,14 @@ paths:
format: uuid format: uuid
description: Run id (RunHandle.id). description: Run id (RunHandle.id).
example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c
- name: archive
in: query
required: false
schema:
type: string
enum:
- '1'
description: '`1` (the UI''s Stop) also archives the session the pane held, so it leaves its workspace''s lists. Omitted (the UI''s Sleep, the CLI) the session stays listed, to be resumed later.'
responses: responses:
'200': '200':
description: Kill issued (or run already terminated). description: Kill issued (or run already terminated).
@@ -14282,6 +14298,94 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/me/avatar:
get:
tags:
- Auth
operationId: getMyAvatar
summary: The account's own picture (nosniff, sandbox CSP)
responses:
'200':
description: The image (PNG, JPEG, GIF or WebP)
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOAVATAR
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
tags:
- Auth
operationId: setMyAvatar
summary: Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'200':
description: Set
content:
application/json:
schema:
$ref: '#/components/schemas/MyAccount'
'400':
description: EBADIMAGE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: EAUTH — no login cookie (a pane token is refused)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'413':
description: ETOOBIG
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
tags:
- Auth
operationId: removeMyAvatar
summary: Remove the account's picture
responses:
'200':
description: Removed
content:
application/json:
schema:
$ref: '#/components/schemas/MyAccount'
'401':
description: EAUTH — no login cookie (a pane token is refused)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users: /api/users:
get: get:
tags: tags:
@@ -14391,6 +14495,118 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/users/tmux:
get:
tags:
- Auth
operationId: listMemberTmux
summary: 'Each member''s own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)'
responses:
'200':
description: '{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3'
'403':
description: EADMIN — a developer
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/tmux-restart:
post:
tags:
- Auth
operationId: restartMemberTmux
summary: End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
currentPassword:
type: string
responses:
'200':
description: '{user}'
'403':
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/tmux-restart-idle:
post:
tags:
- Auth
operationId: restartIdleMemberTmux
summary: Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
currentPassword:
type: string
responses:
'200':
description: '{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}'
'403':
description: EREAUTH / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/avatar:
get:
tags:
- Auth
operationId: getUserAvatar
summary: One account's picture, for the sidebar's owner groups (admin/analyst)
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: The image (PNG, JPEG, GIF or WebP)
'403':
description: EADMIN — a developer
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOAVATAR
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}: /api/users/{id}:
get: get:
tags: tags:
@@ -14741,6 +14957,10 @@ paths:
type: number type: number
spend7d: spend7d:
type: number type: number
tokens5h:
type: integer
tokens7d:
type: integer
quota: quota:
type: object type: object
nullable: true nullable: true
@@ -14770,6 +14990,10 @@ paths:
type: number type: number
spend7d: spend7d:
type: number type: number
tokens5h:
type: integer
tokens7d:
type: integer
quota: quota:
type: object type: object
nullable: true nullable: true
@@ -15761,12 +15985,12 @@ paths:
operationId: getGitIdentity operationId: getGitIdentity
responses: responses:
'200': '200':
description: '{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}' description: '{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}'
/api/me/git/ssh-key: /api/me/git/ssh-key:
post: post:
tags: tags:
- Git identity - Git identity
summary: Make (or with `replace`, replace) the SSH key summary: Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it
operationId: generateSshKey operationId: generateSshKey
requestBody: requestBody:
required: true required: true
@@ -15778,17 +16002,112 @@ paths:
properties: properties:
replace: replace:
type: boolean type: boolean
type:
type: string
enum:
- ed25519
- rsa
name:
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,31}$
responses: responses:
'201': '201':
description: The identity. description: The identity.
'400':
description: EBADKEYNAME
'409':
description: EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS
delete: delete:
tags: tags:
- Git identity - Git identity
summary: Delete the SSH key summary: Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key
operationId: deleteSshKey operationId: deleteSshKey
parameters:
- name: type
in: query
required: false
schema:
type: string
enum:
- ed25519
- rsa
- name: id
in: query
required: false
schema:
type: string
responses: responses:
'200': '200':
description: The identity. description: The identity.
/api/me/git/ssh-terminal:
put:
tags:
- Git identity
summary: Use the SSH key for git in the account's own terminal (core.sshCommand)
operationId: setTerminalSsh
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- 'on'
properties:
'on':
type: boolean
responses:
'200':
description: The identity; `ssh.terminal` is on, off or foreign.
'409':
description: EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand.
/api/me/git/ssh-user:
put:
tags:
- Git identity
summary: 'An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key'
operationId: setSshUser
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- host
- user
properties:
host:
type: string
user:
type: string
alias:
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
key:
type: string
responses:
'200':
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
'400':
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
/api/me/git/ssh-user/{from}:
delete:
tags:
- Git identity
summary: Drop the SSH login of a host or alias (and its key link)
operationId: deleteSshUser
parameters:
- name: from
in: path
required: true
schema:
type: string
responses:
'200':
description: The identity.
'404':
description: ENOTFOUND
/api/me/git/https: /api/me/git/https:
post: post:
tags: tags:
+128 -4
View File
@@ -3,7 +3,9 @@
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that * `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
* becomes a workspace, the SSH key persisted into the clone, 409 * becomes a workspace, the SSH key persisted into the clone, 409
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once, * ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
* only the public half returned, 0600 on disk), and the member (S3) path of * only the public half returned, 0600 on disk; named keys beside the default
* ones, SSH logins per alias tied to one key, which a clone then offers
* alone), and the member (S3) path of
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches * `server/lib/git-identity.js` with run-as and git stubbed — what reaches
* git's argv, and that a login typed without "remember" leaves the member's * git's argv, and that a login typed without "remember" leaves the member's
* store as it was. * store as it was.
@@ -411,7 +413,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
user: "APKAEXAMPLE1", user: "APKAEXAMPLE1",
}); });
assert.equal(r.status, 200); assert.equal(r.status, 200);
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]); assert.deepEqual(r.body.sshUsers, [
{ host: HOST, user: "APKAEXAMPLE1", alias: null, key: null },
]);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`); assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x"); assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
// A second save replaces, never stacks a second rewrite. // A second save replaces, never stacks a second rewrite.
@@ -419,7 +423,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
host: HOST, host: HOST,
user: "APKAEXAMPLE2", user: "APKAEXAMPLE2",
}); });
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]); assert.deepEqual(again.body.sshUsers, [
{ host: HOST, user: "APKAEXAMPLE2", alias: null, key: null },
]);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`); assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`); const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
assert.deepEqual(del.body.sshUsers, []); assert.deepEqual(del.body.sshUsers, []);
@@ -441,7 +447,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
assert.equal(bad.body.error.code, "EBADSSHUSER"); assert.equal(bad.body.error.code, "EBADSSHUSER");
// Ours lists only our shape; a rule the user wrote for another host is kept. // Ours lists only our shape; a rule the user wrote for another host is kept.
const r = await request("GET", "/api/me/git"); const r = await request("GET", "/api/me/git");
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]); assert.deepEqual(r.body.sshUsers, [
{ host: "mine.test", user: "me", alias: null, key: null },
]);
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404); assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x"); assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
} finally { } finally {
@@ -450,6 +458,122 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
}); });
}); });
describe("several accounts: named keys and SSH logins per alias", () => {
const getUrl = (url) =>
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
const HOST = "git-codecommit.us-east-1.amazonaws.com";
it("keeps named keys beside the default ones, and deletes one by id", async () => {
await request("DELETE", "/api/me/git/ssh-key");
const a = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "AWS-Prod" });
assert.equal(a.status, 201, JSON.stringify(a.body));
const b = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
const d = await request("POST", "/api/me/git/ssh-key", {});
assert.deepEqual(
d.body.sshKeys.map((k) => [k.id, k.type, k.name]),
[
["id_ed25519", "ed25519", null],
["id_rsa_aws-dev", "rsa", "aws-dev"],
["id_rsa_aws-prod", "rsa", "aws-prod"],
]
);
assert.notEqual(b.body.sshKeys[0].publicKey, a.body.sshKeys[0].publicKey);
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa_aws-prod")).mode & 0o777, 0o600);
// One name per key, whatever its type; a bad name is refused.
const dup = await request("POST", "/api/me/git/ssh-key", { name: "aws-dev" });
assert.equal(dup.status, 409);
const bad = await request("POST", "/api/me/git/ssh-key", { name: "../x" });
assert.equal(bad.body.error.code, "EBADKEYNAME");
const gone = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev");
assert.deepEqual(
gone.body.sshKeys.map((k) => k.id),
["id_ed25519", "id_rsa_aws-prod"]
);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev")).status, 404);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=../x")).status, 400);
// The terminal command names every key once switched on, and follows them.
await request("PUT", "/api/me/git/ssh-terminal", { on: true });
const cmd = () =>
execFileSync("git", ["config", "--global", "core.sshCommand"], { encoding: "utf8" });
assert.match(cmd(), /id_rsa_aws-prod'/);
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
assert.equal(again.body.sshTerminal, "on");
assert.match(cmd(), /id_rsa_aws-dev'/);
await request("PUT", "/api/me/git/ssh-terminal", { on: false });
});
it("logs each alias in as its own user, and a clone of it uses that key alone", async () => {
const prod = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "APKAPROD",
alias: "AWS-Prod",
key: "id_rsa_aws-prod",
});
assert.equal(prod.status, 200, JSON.stringify(prod.body));
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEV", alias: "aws-dev" });
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEFAULT" });
const rows = (await request("GET", "/api/me/git")).body.sshUsers;
assert.deepEqual(
rows.map((r) => [r.alias, r.user, r.key]).sort(),
[
["aws-dev", "APKADEV", null],
["aws-prod", "APKAPROD", "id_rsa_aws-prod"],
[null, "APKADEFAULT", null],
].sort()
);
assert.equal(getUrl("ssh://aws-prod/v1/repos/x"), `ssh://APKAPROD@${HOST}/v1/repos/x`);
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), `ssh://APKADEV@${HOST}/v1/repos/x`);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKADEFAULT@${HOST}/v1/repos/x`);
// Refused: an alias with a dot (it could shadow a real host), a key that is not there.
const dot = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "A",
alias: "a.b",
});
assert.equal(dot.body.error.code, "EBADALIAS");
const nokey = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "A",
alias: "x",
key: "id_rsa_nope",
});
assert.equal(nokey.body.error.code, "ENOKEY");
// The test forge stands in for CodeCommit: a longer insteadOf wins.
const bare = path.join(ROOT, "bare.git");
execFileSync("git", [
"config",
"--global",
"--add",
`url.file://${bare}.insteadOf`,
"ssh://aws-prod/v1/repos/r",
]);
const r = await request("POST", "/api/lanes/clone", {
url: "ssh://aws-prod/v1/repos/r",
parent: WORK,
name: "via-alias",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const cmd = execFileSync(
"git",
["-C", path.join(WORK, "via-alias"), "config", "core.sshCommand"],
{ encoding: "utf8" }
).trim();
assert.match(cmd, /^ssh -o IdentityFile='.*\/id_rsa_aws-prod' -o IdentitiesOnly=yes$/);
// Deleting the key unties the login; deleting the login drops the rule.
const unkeyed = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-prod");
assert.equal(unkeyed.body.sshUsers.find((x) => x.alias === "aws-prod").key, null);
for (const f of ["aws-prod", "aws-dev", HOST]) {
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${f}`)).status, 200);
}
assert.deepEqual((await request("GET", "/api/me/git")).body.sshUsers, []);
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), "ssh://aws-dev/v1/repos/x");
await request("DELETE", "/api/me/git/ssh-key");
});
});
describe("git-identity, member account (S3, stubbed)", () => { describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as"); const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree"); const worktree = require("../lib/worktree");
+212 -43
View File
@@ -23,8 +23,14 @@
* A plain `git` typed in a terminal reads none of that, so the account's own * A plain `git` typed in a terminal reads none of that, so the account's own
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on * `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
* by default for a member, a switch for the host; a user's own is never * by default for a member, a switch for the host; a user's own is never
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit * overwritten), plus SSH logins (`setSshUser`: AWS CodeCommit wants the IAM
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there. * SSH key ID as the user) as `url.<…>.insteadOf` rules there — one per host,
* or one per ALIAS (`ssh://aws-prod/…`), so several accounts on one host
* (two AWS accounts, two GitHub users) each log in as themselves. Besides
* the default key of each type, an account may hold named keys
* (`ssh/id_<type>_<name>`), and a login may name the key it uses
* (`agenthub.<from>.sshKey` in the same file): a clone of that login's URL
* offers ONLY that key and keeps it in the clone's config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -74,6 +80,12 @@ function accountForRequest(req) {
*/ */
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" }; const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" }; const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
/** A named key's name, and every key's id (= its file name in `sshDir`). */
const KEY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
const KEY_ID_RE = /^id_(ed25519|rsa)(?:_([a-z0-9][a-z0-9-]{0,31}))?$/;
/** ssh offers every key in turn and servers stop after ~6 tries. */
const MAX_KEYS = 10;
const keyIdFor = (type, name) => (name ? `${KEY_FILES[type]}_${name}` : KEY_FILES[type]);
function paths(account) { function paths(account) {
if (account.kind === "member") { if (account.kind === "member") {
@@ -214,17 +226,32 @@ function fingerprint(publicKey) {
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`; return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
} }
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */ /**
* `[{id, type, name, file, publicKey}]` of the account's keys: the default
* ed25519 and rsa ones first (`name` null), then the named ones by name.
*/
function currentKeys(account) { function currentKeys(account) {
const { sshDir } = paths(account); const { sshDir } = paths(account);
let ids;
try {
ids = fs.readdirSync(sshDir).filter((n) => KEY_ID_RE.test(n));
} catch {
return [];
}
const rank = (id) => {
const i = Object.values(KEY_FILES).indexOf(id);
return i < 0 ? Object.keys(KEY_FILES).length : i;
};
ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
const keys = []; const keys = [];
for (const type of Object.keys(KEY_FILES)) { for (const id of ids) {
const file = path.join(sshDir, KEY_FILES[type]); const [, type, name = null] = KEY_ID_RE.exec(id);
const file = path.join(sshDir, id);
const publicKey = readText(account, `${file}.pub`); const publicKey = readText(account, `${file}.pub`);
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue; if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
// The private half is only looked at, never read into this process. // The private half is only looked at, never read into this process.
if (!isOwnFile(account, file)) continue; if (!isOwnFile(account, file)) continue;
keys.push({ type, file, publicKey: publicKey.trim() }); keys.push({ id, type, name, file, publicKey: publicKey.trim() });
} }
return keys; return keys;
} }
@@ -235,10 +262,12 @@ const keyFiles = (account) => currentKeys(account).map((k) => k.file);
/** ssh options naming `files` — `-i` each, in order. */ /** ssh options naming `files` — `-i` each, in order. */
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join(""); const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */ /** `[{id, type, name, publicKey, fingerprint}]` of the account's keys — never a private half. */
function sshKeysInfo(account) { function sshKeysInfo(account) {
return currentKeys(account).map((k) => ({ return currentKeys(account).map((k) => ({
id: k.id,
type: k.type, type: k.type,
name: k.name,
publicKey: k.publicKey, publicKey: k.publicKey,
fingerprint: fingerprint(k.publicKey), fingerprint: fingerprint(k.publicKey),
})); }));
@@ -248,14 +277,30 @@ function sshKeysInfo(account) {
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null; const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
/** /**
* Make the account's key of `type` (ed25519, the default, or rsa); an * Make the account's key of `type` (ed25519, the default, or rsa) — the
* existing one OF THAT TYPE is replaced only with `replace`. A key of the * default one of that type, or with `name` one more beside it (another AWS
* other type is left alone — a host refusing one type keeps the other. * account, another GitHub user). An existing key of that id is replaced only
* with `replace`; every other key is left alone.
*/ */
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) { async function generateSshKey(
account,
{ replace = false, comment, type = "ed25519", name = null } = {}
) {
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa"); if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
if (currentKeys(account).some((k) => k.type === type) && !replace) { const keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`); if (keyName !== null && !KEY_NAME_RE.test(keyName)) {
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
}
const id = keyIdFor(type, keyName);
const keys = currentKeys(account);
if (keys.some((k) => k.id === id) && !replace) {
throw err("EEXISTS", `the SSH key ${keyName ?? type} already exists — replace it explicitly`);
}
if (keyName !== null && keys.some((k) => k.name === keyName && k.id !== id)) {
throw err("EEXISTS", `a key named ${keyName} already exists`);
}
if (!keys.some((k) => k.id === id) && keys.length >= MAX_KEYS) {
throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
} }
const label = const label =
String(comment || "agenthub") String(comment || "agenthub")
@@ -264,29 +309,44 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
const { privateKey, publicKey } = const { privateKey, publicKey } =
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label); type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
const { sshDir } = paths(account); const { sshDir } = paths(account);
const file = path.join(sshDir, KEY_FILES[type]); const file = path.join(sshDir, id);
writeText(account, file, privateKey, { secret: true }); writeText(account, file, privateKey, { secret: true });
writeText(account, `${file}.pub`, publicKey, { secret: false }); writeText(account, `${file}.pub`, publicKey, { secret: false });
// A member's home is AgentHub's to set up: their terminal git uses the key // A member's home is AgentHub's to set up: their terminal git uses the key
// too, unless they wrote a core.sshCommand of their own. The host's is // too, unless they wrote a core.sshCommand of their own. The host's is
// its admin's choice (the Settings switch). // its admin's choice (the Settings switch) — once on, it follows the keys.
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {}); if (account.kind === "member" || (await terminalSshState(account)) === "on") {
return sshKeysInfo(account).find((k) => k.type === type) ?? null; await setTerminalSsh(account, true).catch(() => {});
}
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
} }
/** /**
* Delete the account's key of `type`, or every key when `type` is omitted; * Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
* false when there was none. The terminal sshCommand goes with the last key. * neither is given; false when there was none. A login naming a deleted key
* goes back to offering every key; the terminal sshCommand follows the keys
* and goes with the last one.
*/ */
async function removeSshKey(account, type) { async function removeSshKey(account, which) {
const { type, id } = typeof which === "string" ? { type: which } : which || {};
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa"); if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
const gone = currentKeys(account).filter((k) => type == null || k.type === type); if (id != null && !KEY_ID_RE.test(id)) throw err("EBADKEYNAME", "no such key id");
const gone = currentKeys(account).filter((k) =>
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
);
if (!gone.length) return false; if (!gone.length) return false;
for (const k of gone) { for (const k of gone) {
removeFile(account, k.file); removeFile(account, k.file);
removeFile(account, `${k.file}.pub`); removeFile(account, `${k.file}.pub`);
} }
const ids = new Set(gone.map((k) => k.id));
for (const [from, key] of await keyLinks(account)) {
if (ids.has(key)) await unlinkKey(account, from);
}
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {}); if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
else if ((await terminalSshState(account)) === "on") {
await setTerminalSsh(account, true).catch(() => {});
}
return true; return true;
} }
@@ -427,11 +487,28 @@ async function setAuthor(account, { name, email }) {
*/ */
function terminalSshCommand(account) { function terminalSshCommand(account) {
const { sshDir } = paths(account); const { sshDir } = paths(account);
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`); const files = new Set([
...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)),
...currentKeys(account).map((k) => k.file),
]);
const ours = [...files].map((f) => `-o IdentityFile=${shq(f)}`);
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`); const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
return ["ssh", ...ours, ...defaults].join(" "); return ["ssh", ...ours, ...defaults].join(" ");
} }
/**
* Whether `cmd` is a terminal sshCommand WE wrote — of our shape, naming
* key files in this account's AgentHub folder (any set of them: it follows
* the keys as they are made and deleted) and nothing else but the defaults.
*/
function isOurTerminal(account, cmd) {
const m = /^ssh((?: -o IdentityFile=(?:'[^']*'|~\/\.ssh\/id_[a-z0-9]+))+)$/.exec(cmd || "");
if (!m) return false;
const files = [...m[1].matchAll(/IdentityFile='([^']*)'/g)].map((x) => x[1]);
const { sshDir } = paths(account);
return files.length > 0 && files.every((f) => path.dirname(f) === sshDir);
}
/** The account's global `core.sshCommand`, or "" when unset. */ /** The account's global `core.sshCommand`, or "" when unset. */
async function globalSshCommand(account) { async function globalSshCommand(account) {
const { git } = require("./worktree"); const { git } = require("./worktree");
@@ -449,7 +526,7 @@ async function globalSshCommand(account) {
async function terminalSshState(account) { async function terminalSshState(account) {
const cmd = await globalSshCommand(account); const cmd = await globalSshCommand(account);
if (!cmd) return "off"; if (!cmd) return "off";
return cmd === terminalSshCommand(account) ? "on" : "foreign"; return isOurTerminal(account, cmd) ? "on" : "foreign";
} }
/** /**
@@ -464,7 +541,8 @@ async function setTerminalSsh(account, on) {
if (state === "foreign") { if (state === "foreign") {
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is"); throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
} }
if (state === "off") { // Off, or ours naming another set of keys than the account holds now.
if (state === "off" || (await globalSshCommand(account)) !== terminalSshCommand(account)) {
await git(homeOf(account), [ await git(homeOf(account), [
"config", "config",
"--global", "--global",
@@ -487,9 +565,47 @@ async function setTerminalSsh(account, on) {
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The // Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
// user name is no secret (an ID, not a key), so it may cross sudo's argv. // user name is no secret (an ID, not a key), so it may cross sudo's argv.
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/; const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
// An alias has no dot, so it can never shadow a real host name.
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`; const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */ /** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
async function keyLinks(account) {
const { git } = require("./worktree");
const out = await git(homeOf(account), [
"config",
"--global",
"--get-regexp",
"^agenthub\\..*\\.sshkey$",
]).then(
(r) => r.stdout,
() => ""
);
const links = new Map();
for (const line of String(out).split("\n")) {
const m = /^agenthub\.(.+)\.sshkey (\S+)$/.exec(line);
if (m && KEY_ID_RE.test(m[2])) links.set(m[1], m[2]);
}
return links;
}
async function unlinkKey(account, from) {
const { git } = require("./worktree");
await git(homeOf(account), [
"config",
"--global",
"--unset-all",
`agenthub.${from}.sshKey`,
]).catch(() => {});
}
/**
* `[{host, user, alias, key}]` — only the rules of exactly our shape; any
* other insteadOf is the user's. `alias` is null for the host's own rule
* (`ssh://<host>/` logs in as `user`), else the name standing for that
* user@host (`ssh://<alias>/`); `key` is the key id the login uses, or null
* for every key.
*/
async function listSshUsers(account) { async function listSshUsers(account) {
const { git } = require("./worktree"); const { git } = require("./worktree");
const out = await git(homeOf(account), [ const out = await git(homeOf(account), [
@@ -501,21 +617,35 @@ async function listSshUsers(account) {
(r) => r.stdout, (r) => r.stdout,
() => "" () => ""
); );
const links = await keyLinks(account);
const rows = []; const rows = [];
for (const line of out.split("\n")) { for (const line of out.split("\n")) {
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line); const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) { if (
rows.push({ host: m[2], user: m[1] }); m &&
(m[2] === m[3] || ALIAS_RE.test(m[3])) &&
SSH_USER_RE.test(m[1]) &&
HOST_RE.test(m[2])
) {
rows.push({
host: m[2],
user: m[1],
alias: m[2] === m[3] ? null : m[3],
key: links.get(m[3]) ?? null,
});
} }
} }
return rows; return rows;
} }
/** Drop our rule for `host` (whatever its user); false when there was none. */ /**
async function removeSshUser(account, host) { * Drop our rule for `from` — a host's own rule, or an alias's — and the key
* it names; false when there was none.
*/
async function removeSshUser(account, from) {
const { git } = require("./worktree"); const { git } = require("./worktree");
const h = String(host || "").toLowerCase(); const f = String(from || "").toLowerCase();
const rows = (await listSshUsers(account)).filter((r) => r.host === h); const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
for (const r of rows) { for (const r of rows) {
await git(homeOf(account), [ await git(homeOf(account), [
"config", "config",
@@ -523,34 +653,68 @@ async function removeSshUser(account, host) {
"--fixed-value", "--fixed-value",
"--unset-all", "--unset-all",
`url.${insteadOfBase(r.host, r.user)}.insteadOf`, `url.${insteadOfBase(r.host, r.user)}.insteadOf`,
`ssh://${r.host}/`, `ssh://${f}/`,
]); ]);
} }
if (rows.length) await unlinkKey(account, f);
return rows.length > 0; return rows.length > 0;
} }
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */ /**
async function setSshUser(account, { host, user } = {}) { * Log in to `host` over SSH as `user` — for `ssh://<host>/` itself, or, with
* `alias`, for `ssh://<alias>/` (one more account on the same host) — with
* the key `key` only, or every key when it is null. Replaces our earlier rule
* for that host or alias.
*/
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
const h = String(host || "") const h = String(host || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
const u = String(user || "").trim(); const u = String(user || "").trim();
const a = alias == null || alias === "" ? null : String(alias).trim().toLowerCase();
if (!HOST_RE.test(h)) { if (!HOST_RE.test(h)) {
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com"); throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
} }
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -"); if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
await removeSshUser(account, h); if (a !== null && !ALIAS_RE.test(a)) {
throw err("EBADALIAS", "an account name of lowercase letters, digits and - (no dot)");
}
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
throw err("ENOKEY", "no such SSH key");
}
const from = a ?? h;
await removeSshUser(account, from);
const { git } = require("./worktree"); const { git } = require("./worktree");
await git(homeOf(account), [ await git(homeOf(account), [
"config", "config",
"--global", "--global",
"--add", "--add",
`url.${insteadOfBase(h, u)}.insteadOf`, `url.${insteadOfBase(h, u)}.insteadOf`,
`ssh://${h}/`, `ssh://${from}/`,
]); ]);
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${from}.sshKey`, key]);
return listSshUsers(account); return listSshUsers(account);
} }
/**
* The key file a clone of `url` must use: the one its login names, when the
* URL is `ssh://<host or alias>/…` with no user of its own (the only shape
* our insteadOf rules rewrite). Null: offer every key.
*/
async function linkedKeyFile(account, url) {
if (!url.startsWith("ssh://")) return null;
let from;
try {
const u = new URL(url);
if (u.username) return null;
from = u.host.toLowerCase();
} catch {
return null;
}
const row = (await listSshUsers(account)).find((r) => (r.alias ?? r.host) === from && r.key);
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ───────── // ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/** /**
@@ -582,7 +746,7 @@ async function sshConfigFor(dir) {
} }
// Our own terminal sshCommand (global) is not the repo's choice: it has no // Our own terminal sshCommand (global) is not the repo's choice: it has no
// BatchMode, so it would leave a dashboard fetch waiting on a prompt. // BatchMode, so it would leave a dashboard fetch waiting on a prompt.
if (own && own !== terminalSshCommand(account)) return []; if (own && !isOurTerminal(account, own)) return [];
return [ return [
"-c", "-c",
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`, `core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
@@ -692,18 +856,23 @@ async function cloneRepo({ url, parent, name, once = null }) {
if (remote.scheme === "ssh") { if (remote.scheme === "ssh") {
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key"); if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
const keys = keyFiles(account); // A login naming its key (another AWS account, a second GitHub user)
// offers that key ONLY — another key could log in as the wrong account.
const linked = await linkedKeyFile(account, url);
const keys = linked ? [linked] : keyFiles(account);
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh"; const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact // BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails. // records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`); pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
if (keys.length) { if (keys.length) {
// Persisted by FILE name, both types: a key made later is offered too // Persisted by FILE name — both default types plus every named key:
// (ssh skips a missing IdentityFile silently, where `-i` would warn). // a default key made later is offered too (ssh skips a missing
// IdentityFile silently, where `-i` would warn). A linked key alone.
const { sshDir } = paths(account); const { sshDir } = paths(account);
const all = Object.values(KEY_FILES) const files = linked
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`) ? [linked]
.join(""); : [...new Set([...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...keys])];
const all = files.map((f) => ` -o IdentityFile=${shq(f)}`).join("");
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`); persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
} }
} else if (account.kind === "member") { } else if (account.kind === "member") {
+40 -3
View File
@@ -276,17 +276,23 @@ const paths = {
"/api/me/git/ssh-key": { "/api/me/git/ssh-key": {
post: { post: {
tags: ["Git identity"], tags: ["Git identity"],
summary: "Make (or with `replace`, replace) the SSH key of `type`; the other type is kept", summary:
"Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it",
operationId: "generateSshKey", operationId: "generateSshKey",
requestBody: json({ requestBody: json({
replace: { type: "boolean" }, replace: { type: "boolean" },
type: { type: "string", enum: ["ed25519", "rsa"] }, type: { type: "string", enum: ["ed25519", "rsa"] },
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
}), }),
responses: { 201: { description: "The identity." } }, responses: {
201: { description: "The identity." },
400: { description: "EBADKEYNAME" },
409: { description: "EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS" },
},
}, },
delete: { delete: {
tags: ["Git identity"], tags: ["Git identity"],
summary: "Delete the SSH key of ?type=ed25519|rsa, or every key", summary: "Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key",
operationId: "deleteSshKey", operationId: "deleteSshKey",
parameters: [ parameters: [
{ {
@@ -295,6 +301,7 @@ const paths = {
required: false, required: false,
schema: { type: "string", enum: ["ed25519", "rsa"] }, schema: { type: "string", enum: ["ed25519", "rsa"] },
}, },
{ name: "id", in: "query", required: false, schema: { type: "string" } },
], ],
responses: { 200: { description: "The identity." } }, responses: { 200: { description: "The identity." } },
}, },
@@ -311,6 +318,36 @@ const paths = {
}, },
}, },
}, },
"/api/me/git/ssh-user": {
put: {
tags: ["Git identity"],
summary:
"An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key",
operationId: "setSshUser",
requestBody: json(
{
host: { type: "string" },
user: { type: "string" },
alias: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,62}$" },
key: { type: "string" },
},
["host", "user"]
),
responses: {
200: { description: "The identity; `sshUsers: [{host, user, alias, key}]`." },
400: { description: "EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY" },
},
},
},
"/api/me/git/ssh-user/{from}": {
delete: {
tags: ["Git identity"],
summary: "Drop the SSH login of a host or alias (and its key link)",
operationId: "deleteSshUser",
parameters: [{ name: "from", in: "path", required: true, schema: { type: "string" } }],
responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } },
},
},
"/api/me/git/https": { "/api/me/git/https": {
post: { post: {
tags: ["Git identity"], tags: ["Git identity"],
+20 -10
View File
@@ -1,10 +1,12 @@
/** /**
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`), * @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete — * mounted at `/api/me/git`: the SSH keys a clone uses — a default ed25519
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete — * and/or rsa one plus named ones (`name`, deleted by `?id=`) for more
* `hasToken` only, never a password), whether the account's own terminal * accounts (make, replace, delete — only the PUBLIC key is ever returned) and
* git uses that key (`core.sshCommand`), the SSH login name per host * the HTTPS logins (save, delete — `hasToken` only, never a password),
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` / * whether the account's own terminal git uses the keys (`core.sshCommand`),
* the SSH logins per host or alias (`url.<…>.insteadOf` — AWS CodeCommit's
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail * `user.email` of the account's global git config — a member's commits fail
* without one; a new SSH key is labelled with that email, else * without one; a new SSH key is labelled with that email, else
* `<username>@smartgift.vn`). The account is the signed-in user's * `<username>@smartgift.vn`). The account is the signed-in user's
@@ -33,6 +35,10 @@ const STATUS = {
EBADKEYTYPE: 400, EBADKEYTYPE: 400,
EFOREIGNSSH: 409, EFOREIGNSSH: 409,
EBADSSHUSER: 400, EBADSSHUSER: 400,
EBADKEYNAME: 400,
ETOOMANYKEYS: 409,
EBADALIAS: 400,
ENOKEY: 400,
}; };
function fail(res, e) { function fail(res, e) {
@@ -87,9 +93,10 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
await identity.generateSshKey(account, { await identity.generateSshKey(account, {
replace: req.body?.replace === true, replace: req.body?.replace === true,
type: req.body?.type === "rsa" ? "rsa" : "ed25519", type: req.body?.type === "rsa" ? "rsa" : "ed25519",
// A named key sits beside the default one of its type.
name: typeof req.body?.name === "string" ? req.body.name : null,
// The key is labelled with the commit author's email, else the default one. // The key is labelled with the commit author's email, else the default one.
comment: comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
(await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
}); });
res.status(201).json(await view(account, usernameOf(req))); res.status(201).json(await view(account, usernameOf(req)));
} catch (e) { } catch (e) {
@@ -101,7 +108,8 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
try { try {
const account = accountOf(req, { write: true }); const account = accountOf(req, { write: true });
const type = req.query.type === undefined ? undefined : String(req.query.type); const type = req.query.type === undefined ? undefined : String(req.query.type);
if (!(await identity.removeSshKey(account, type))) { const id = req.query.id === undefined ? undefined : String(req.query.id);
if (!(await identity.removeSshKey(account, { type, id }))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } }); return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
} }
res.json(await view(account, usernameOf(req))); res.json(await view(account, usernameOf(req)));
@@ -128,8 +136,10 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
}); });
/** /**
* The SSH user name for one host (`{host, user}`) — e.g. AWS CodeCommit's SSH * An SSH login (`{host, user, alias?, key?}`) — e.g. AWS CodeCommit's SSH key
* key ID, so a plain `ssh://git-codecommit…/` URL logs in. DELETE drops it. * ID, so a plain `ssh://git-codecommit…/` URL logs in; with `alias`, one more
* account on that host (`ssh://<alias>/…`); with `key`, the only key it uses.
* DELETE `/ssh-user/<host or alias>` drops it.
*/ */
router.put("/ssh-user", sameOriginGuard, async (req, res) => { router.put("/ssh-user", sameOriginGuard, async (req, res) => {
try { try {