Add application edit actions

This commit is contained in:
Dotta committed 2026-06-10 15:36:42 +00:00
1 parent 401d82f6c4
commit fb4d7cf221
5 files changed
+400 -16

No files matched your search

@@ -53,11 +53,11 @@ function mockToolsList(tools: unknown[]) {
} as Response);
}
function createRouteApp(db: ReturnType<typeof createDb>) {
function createRouteApp(db: ReturnType<typeof createDb>, actor?: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
req.actor = actor ?? {
type: "board",
userId: "board-user",
userName: "Board User",
@@ -470,6 +470,90 @@ describeEmbeddedPostgres("tool access service", () => {
});
});
it("updates tool applications through the board route and records activity", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const application = await service.createApplication(company.id, {
name: "Editable app",
description: "Before",
type: "mcp_http",
});
const res = await request(app)
.patch(`/api/tool-applications/${application.id}`)
.send({ name: "Edited app", description: "After" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: application.id,
companyId: company.id,
name: "Edited app",
description: "After",
type: "mcp_http",
});
const activities = await db.select().from(activityLog).where(eq(activityLog.entityId, application.id));
expect(activities).toEqual([
expect.objectContaining({
action: "tool_application.updated",
companyId: company.id,
details: expect.objectContaining({ name: "Edited app" }),
}),
]);
});
it("returns 409 instead of 500 when an application update collides with a duplicate name", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
await service.createApplication(company.id, { name: "Existing app", type: "mcp_http" });
const application = await service.createApplication(company.id, { name: "Editable app", type: "mcp_http" });
const res = await request(app)
.patch(`/api/tool-applications/${application.id}`)
.send({ name: "Existing app" });
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
error: "A tool access record with that name already exists",
});
});
it("returns 403 for cross-company application updates and 404 for missing applications", async () => {
const allowedCompany = await createCompany(db);
const otherCompany = await createCompany(db);
const application = await toolAccessService(db).createApplication(otherCompany.id, {
name: "Other company app",
type: "mcp_http",
});
const app = createRouteApp(db, {
type: "board",
userId: "member-user",
userName: "Member User",
userEmail: null,
companyIds: [allowedCompany.id],
memberships: [
{
companyId: allowedCompany.id,
membershipRole: "owner",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
});
const forbiddenRes = await request(app)
.patch(`/api/tool-applications/${application.id}`)
.send({ name: "Forbidden edit" });
const missingRes = await request(createRouteApp(db))
.patch(`/api/tool-applications/${randomUUID()}`)
.send({ name: "Missing edit" });
expect(forbiddenRes.status).toBe(403);
expect(missingRes.status).toBe(404);
});
it("links run tool decisions to invocations, audit events, and pending action requests", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
+15 -11
View File
@@ -127,17 +127,21 @@ export function toolAccessRoutes(
assertBoard(req);
const existing = await svc.getApplication(req.params.applicationId as string);
assertCompanyAccess(req, existing.companyId);
const application = await svc.updateApplication(existing.id, req.body);
await logActivity(db, {
companyId: application.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.updated",
entityType: "tool_application",
entityId: application.id,
details: { status: application.status, name: application.name },
});
res.json(application);
try {
const application = await svc.updateApplication(existing.id, req.body);
await logActivity(db, {
companyId: application.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.updated",
entityType: "tool_application",
entityId: application.id,
details: { status: application.status, name: application.name },
});
res.json(application);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.get("/companies/:companyId/tools/connections", async (req, res) => {
+30 -1
View File
@@ -2431,7 +2431,36 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
},
ensureNoDuplicateNameError: (error: unknown) => {
if (error instanceof Error && /duplicate key value/.test(error.message)) {
const maybeRecord = typeof error === "object" && error !== null ? error as Record<string, unknown> : null;
const cause = maybeRecord?.cause;
const maybeCause = typeof cause === "object" && cause !== null ? cause as Record<string, unknown> : null;
const message = [
error instanceof Error ? error.message : String(error),
maybeRecord && typeof maybeRecord.detail === "string" ? maybeRecord.detail : null,
maybeCause instanceof Error ? maybeCause.message : null,
maybeCause && typeof maybeCause.detail === "string" ? maybeCause.detail : null,
].filter(Boolean).join("\n");
const code =
maybeRecord && typeof maybeRecord.code === "string"
? maybeRecord.code
: maybeCause && typeof maybeCause.code === "string"
? maybeCause.code
: null;
const constraint =
maybeRecord && typeof maybeRecord.constraint === "string"
? maybeRecord.constraint
: maybeRecord && typeof maybeRecord.constraint_name === "string"
? maybeRecord.constraint_name
: maybeCause && typeof maybeCause.constraint === "string"
? maybeCause.constraint
: maybeCause && typeof maybeCause.constraint_name === "string"
? maybeCause.constraint_name
: null;
if (
code === "23505" ||
constraint?.includes("tool_applications") ||
/duplicate key value|unique constraint|tool_applications_company_id_name_unique/i.test(message)
) {
throw conflict("A tool access record with that name already exists");
}
throw error;
+108
View File
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanySecret, ToolApplication, ToolConnection } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "../../context/ToastContext";
import { ApiError } from "../../api/client";
import { ApplicationsTab } from "./ApplicationsTab";
const mockToolsApi = vi.hoisted(() => ({
@@ -17,6 +18,7 @@ const mockToolsApi = vi.hoisted(() => ({
refreshCatalog: vi.fn(),
createConnection: vi.fn(),
updateConnection: vi.fn(),
updateApplication: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
@@ -48,6 +50,26 @@ async function flushReact() {
});
}
async function typeInputValue(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushReact();
}
async function typeTextareaValue(textarea: HTMLTextAreaElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
valueSetter?.call(textarea, value);
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
textarea.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushReact();
}
function makeConnection(overrides: Partial<ToolConnection>): ToolConnection {
return {
id: "conn-1",
@@ -222,4 +244,90 @@ describe("ApplicationsTab", () => {
expect(body).toContain("Credential references");
expect(body).toContain("Free-text secrets are not accepted");
});
it("opens the row actions edit dialog and saves application details", async () => {
mockToolsApi.updateApplication.mockResolvedValue(
makeApp({ name: "GitHub Enterprise", description: "Internal MCP" }),
);
await render();
const actions = Array.from(container.querySelectorAll("button")).find(
(button) => button.getAttribute("aria-label") === "Actions for GitHub",
);
expect(actions).toBeTruthy();
await act(() => {
actions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
actions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const edit = Array.from(document.body.querySelectorAll("[role='menuitem']")).find(
(item) => item.textContent === "Edit",
);
expect(edit).toBeTruthy();
await act(() => {
edit!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
edit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent ?? "").toContain("Edit application");
expect(document.body.textContent ?? "").toContain("MCP HTTP");
const nameInput = document.body.querySelector<HTMLInputElement>("#tool-application-name");
const descriptionInput = document.body.querySelector<HTMLTextAreaElement>("#tool-application-description");
expect(nameInput).toBeTruthy();
expect(descriptionInput).toBeTruthy();
await typeInputValue(nameInput!, "GitHub Enterprise");
await typeTextareaValue(descriptionInput!, "Internal MCP");
const save = Array.from(document.body.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Save changes"),
);
expect(save).toBeTruthy();
await act(() => {
save!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockToolsApi.updateApplication).toHaveBeenCalledWith("app-1", {
name: "GitHub Enterprise",
description: "Internal MCP",
});
});
it("keeps duplicate-name conflicts inline in the edit dialog", async () => {
mockToolsApi.updateApplication.mockRejectedValue(new ApiError("duplicate", 409, { error: "duplicate" }));
await render();
const actions = Array.from(container.querySelectorAll("button")).find(
(button) => button.getAttribute("aria-label") === "Actions for GitHub",
);
await act(() => {
actions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
actions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const edit = Array.from(document.body.querySelectorAll("[role='menuitem']")).find(
(item) => item.textContent === "Edit",
);
await act(() => {
edit!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
edit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const save = Array.from(document.body.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Save changes"),
);
await act(() => {
save!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(document.body.textContent ?? "").toContain("Another application already uses that name.");
});
});
+161 -2
View File
@@ -5,6 +5,8 @@ import {
Boxes,
ChevronDown,
ChevronRight,
MoreHorizontal,
Pencil,
Globe,
ListTree,
Network,
@@ -24,7 +26,22 @@ import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -32,6 +49,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/context/ToastContext";
import { EmptyState } from "@/components/EmptyState";
import { cn } from "@/lib/utils";
@@ -93,6 +111,78 @@ function AppIcon({ type }: { type: ToolApplicationType }) {
);
}
interface EditApplicationDialogProps {
application: ToolApplication;
error: string | null;
isSaving: boolean;
onClose: () => void;
onSubmit: (input: { name: string; description: string | null }) => void;
}
function EditApplicationDialog({ application, error, isSaving, onClose, onSubmit }: EditApplicationDialogProps) {
const [name, setName] = useState(application.name);
const [description, setDescription] = useState(application.description ?? "");
const trimmedName = name.trim();
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit application</DialogTitle>
<DialogDescription>Update the application details agents see in tool access surfaces.</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!trimmedName) return;
onSubmit({
name: trimmedName,
description: description.trim() ? description.trim() : null,
});
}}
>
<div className="space-y-2">
<Label htmlFor="tool-application-name">Name</Label>
<Input
id="tool-application-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="tool-application-description">Description</Label>
<Textarea
id="tool-application-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tool-application-type">Type</Label>
<Input id="tool-application-type" value={typeLabel(application.type)} readOnly />
</div>
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !trimmedName}>
{isSaving ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function ApplicationsTab({ companyId }: { companyId: string }) {
const qc = useQueryClient();
const { pushToast } = useToast();
@@ -100,6 +190,8 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
const [defaultApplicationId, setDefaultApplicationId] = useState<string | null>(null);
const [expandedAppId, setExpandedAppId] = useState<string | null>(null);
const [catalogFor, setCatalogFor] = useState<ToolConnection | null>(null);
const [editingApplication, setEditingApplication] = useState<ToolApplication | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("__all");
@@ -158,6 +250,31 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
}, [visibleConnList, catalogs]);
const invalidateConnections = () => qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) });
const invalidateApplications = () => qc.invalidateQueries({ queryKey: queryKeys.tools.applications(companyId) });
const updateApplication = useMutation({
mutationFn: ({ applicationId, input }: { applicationId: string; input: { name: string; description: string | null } }) =>
toolsApi.updateApplication(applicationId, input),
onSuccess: () => {
invalidateApplications();
setEditingApplication(null);
setEditError(null);
pushToast({ title: "Application updated", tone: "success" });
},
onError: (err) => {
const message = err instanceof ApiError ? err.message : String(err);
if (err instanceof ApiError && err.status === 409) {
setEditError("Another application already uses that name.");
return;
}
setEditError(message);
pushToast({
title: "Could not update application",
body: message,
tone: "error",
});
},
});
const healthCheck = useMutation({
mutationFn: (id: string) => toolsApi.checkConnectionHealth(id),
@@ -326,6 +443,7 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
<th className="px-3 py-2.5 text-right font-medium">Connections</th>
<th className="px-3 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 text-right font-medium">Updated</th>
<th className="w-10 px-2 py-2.5 font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -372,11 +490,36 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
<td className="px-4 py-3 text-right text-xs">
<RelativeTime value={app.updatedAt} />
</td>
<td className="px-2 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={`Actions for ${app.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
setEditError(null);
setEditingApplication(app);
}}
>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
{isExpanded ? (
<tr className="bg-muted/20">
<td className="px-2 py-2" />
<td colSpan={6} className="px-4 py-3">
<td colSpan={7} className="px-4 py-3">
{appConnections.length === 0 ? (
<div className="flex items-center justify-between gap-3 py-1 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
@@ -479,7 +622,7 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
})}
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-muted-foreground">
<td colSpan={8} className="px-4 py-8 text-center text-sm text-muted-foreground">
No applications match the current filters.
</td>
</tr>
@@ -492,6 +635,22 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
)}
{catalogFor ? <CatalogDialog connection={catalogFor} onClose={() => setCatalogFor(null)} /> : null}
{editingApplication ? (
<EditApplicationDialog
application={editingApplication}
error={editError}
isSaving={updateApplication.isPending}
onClose={() => {
if (updateApplication.isPending) return;
setEditingApplication(null);
setEditError(null);
}}
onSubmit={(input) => {
setEditError(null);
updateApplication.mutate({ applicationId: editingApplication.id, input });
}}
/>
) : null}
{open ? (
<AddConnectionDialog
companyId={companyId}