feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
@@ -131,6 +132,38 @@ export function UserEditDialog({
|
||||
}
|
||||
};
|
||||
|
||||
/** Delete the user for good — asked first, with what goes, in red. */
|
||||
const remove = async () => {
|
||||
if (self || busy) return;
|
||||
if (!current) {
|
||||
setError(t("delete.needsPassword"));
|
||||
return;
|
||||
}
|
||||
const name = user.displayName || user.username;
|
||||
const ok = await confirmDialog({
|
||||
title: t("delete.title", { name }),
|
||||
message: t("delete.body", { name, count: owned?.length ?? 0, user: user.username }),
|
||||
confirmLabel: t("delete.confirm"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.remove(user.id, current);
|
||||
onDone(t("done.deleted", { name }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
@@ -459,8 +492,21 @@ export function UserEditDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!self && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove()}
|
||||
disabled={busy}
|
||||
data-testid="user-delete"
|
||||
className="mr-auto rounded-md border border-status-danger/50 px-3 py-1.5 text-sm text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
|
||||
>
|
||||
{t("delete.button")}
|
||||
</button>
|
||||
)}
|
||||
{!changed && (
|
||||
<span className="mr-auto text-xs text-fg-muted">{t("edit.noChanges")}</span>
|
||||
<span className={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
|
||||
{t("edit.noChanges")}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="btn-ghost">
|
||||
{t("common:cancel")}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
|
||||
const confirmDialog = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -122,6 +124,45 @@ describe("UserEditDialog", () => {
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
|
||||
users.remove.mockReset().mockResolvedValue({ user: mia });
|
||||
confirmDialog.mockReset().mockResolvedValue(true);
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
// No password yet: asked for it, nothing sent.
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
destructive: true,
|
||||
message: expect.stringContaining("/srv/agenthub/users/mia"),
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
|
||||
users.remove.mockReset();
|
||||
confirmDialog.mockReset().mockResolvedValue(false);
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has no Delete button on your own account", async () => {
|
||||
open(mia, true);
|
||||
await screen.findByText("api");
|
||||
expect(screen.queryByRole("button", { name: "Delete user" })).toBeNull();
|
||||
});
|
||||
|
||||
it("generates a 16-character password", async () => {
|
||||
expect(generatePassword()).toHaveLength(16);
|
||||
const ue = userEvent.setup();
|
||||
|
||||
Reference in New Issue
Block a user