feat(connections): edit connections via PUT — backend + UI + blank-secret merge

Backend: PUT /api/connections/{id} with UpdateConnectionRequest {Name, Config}.
Provider is immutable; a secret field left blank in the incoming config is
merged from the stored row so users can rename or rotate non-secret fields
without re-typing every credential. Invalidates the ConnectionInstanceRegistry
cache on update so the next GetConnection() re-opens with the fresh config.
409 on unique-name-per-provider collision, 404 on unknown id, 400 on non-
object config. 6 new xUnit tests cover the merge, overwrite, 404, 409, 400
paths plus the happy-path round-trip.

Frontend: ConnectionsPage gains an Edit pencil per row. The form is shared
between create and edit modes; provider select is disabled in edit; secret
fields render blank with an inline hint that leaving them blank keeps the
stored value. One new Vitest covers the edit flow.
This commit is contained in:
Claude
2026-08-28 19:50:08 +00:00
parent d12581ad12
commit 4b30d4a44e
7 changed files with 428 additions and 20 deletions

View File

@@ -29,6 +29,44 @@ describe("ConnectionsPage", () => {
expect(screen.getByText(/nats/i)).toBeInTheDocument();
});
it("edits an existing connection via PUT, preserving blank secret", async () => {
let latestPut: { name?: string; config?: { url?: string; pass?: string } } | null = null;
mswServer.use(
http.get("/api/connection-providers", () => HttpResponse.json(providers)),
http.get("/api/connections", () =>
HttpResponse.json([
{ id: "c1", providerId: "nats", name: "local", config: { url: "nats://old" } },
] as never)),
http.put("/api/connections/c1", async ({ request }) => {
latestPut = await request.json() as never;
return HttpResponse.json({
id: "c1", providerId: "nats", name: latestPut!.name, config: { url: latestPut!.config?.url },
} as never);
}),
);
renderWithProviders(<ConnectionsPage />);
await userEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
// Provider select must be disabled in edit mode (provider is immutable).
expect(screen.getByLabelText(/provider/i)).toBeDisabled();
// Rename + change URL; leave password blank (secret stays via backend merge).
const nameInput = screen.getByLabelText(/^name$/i) as HTMLInputElement;
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "local-renamed");
const urlInput = screen.getByLabelText(/server url/i) as HTMLInputElement;
await userEvent.clear(urlInput);
await userEvent.type(urlInput, "nats://new");
await userEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
expect(latestPut).not.toBeNull();
expect(latestPut!.name).toBe("local-renamed");
expect(latestPut!.config?.url).toBe("nats://new");
// Password field left blank in the form — the frontend sends nothing (or "")
// and the backend merges the stored secret. Either way, no plaintext leaks
// from the frontend to the wire in the "leave blank" flow.
expect(latestPut!.config?.pass ?? "").toBe("");
});
});
it("creates a new connection using the provider's schema", async () => {
let created: unknown = null;
mswServer.use(

View File

@@ -1,50 +1,91 @@
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useConnections, useInvalidate, useProviders } from "@/api/hooks";
import { SchemaForm } from "@/forms/SchemaForm";
import type { ConnectionDto } from "@/api/types";
import type { ConnectionDto, JsonSchemaObject } from "@/api/types";
type ProviderDto = { id: string; displayName: string; configSchema: JsonSchemaObject };
export default function ConnectionsPage() {
const providers = useProviders();
const conns = useConnections();
const invalidate = useInvalidate();
const [creating, setCreating] = useState(false);
// null = closed; "new" = create form; string = editing that connection id.
const [openForm, setOpenForm] = useState<null | "new" | string>(null);
const create = useApiMutation(
(body: { providerId: string; name: string; config: Record<string, unknown> }) =>
apiFetch<ConnectionDto>("/api/connections", { method: "POST", body }),
{ onSuccess: () => { invalidate(qk.connections); setCreating(false); } },
{ onSuccess: () => { invalidate(qk.connections); setOpenForm(null); } },
);
const update = useApiMutation(
(input: { id: string; body: { name: string; config: Record<string, unknown> } }) =>
apiFetch<ConnectionDto>(`/api/connections/${input.id}`, { method: "PUT", body: input.body }),
{ onSuccess: () => { invalidate(qk.connections); setOpenForm(null); } },
);
const remove = useApiMutation(
(id: string) => apiFetch<void>(`/api/connections/${id}`, { method: "DELETE" }),
{ onSuccess: () => invalidate(qk.connections) },
);
const editing = openForm && openForm !== "new"
? conns.data?.find((c) => c.id === openForm) ?? null
: null;
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Connections</h1>
<button
onClick={() => setCreating(true)}
onClick={() => setOpenForm("new")}
className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm flex items-center gap-2"
>
<Plus size={14} /> New connection
</button>
</div>
{creating && providers.data && (
<CreateForm
{openForm === "new" && providers.data && (
<ConnectionForm
mode="create"
providers={providers.data}
onCreate={(body) => create.mutate(body)}
onCancel={() => setCreating(false)}
initialProviderId={providers.data[0]?.id ?? ""}
initialName=""
initialConfig={{}}
submitting={create.isPending}
onSubmit={(body) => create.mutate(body)}
onCancel={() => setOpenForm(null)}
/>
)}
{editing && providers.data && (
<ConnectionForm
mode="edit"
providers={providers.data}
initialProviderId={editing.providerId}
initialName={editing.name}
initialConfig={editing.config as Record<string, unknown>}
submitting={update.isPending}
onSubmit={(body) =>
update.mutate({ id: editing.id, body: { name: body.name, config: body.config } })
}
onCancel={() => setOpenForm(null)}
/>
)}
<ul className="divide-y divide-slate-800 border border-slate-800 rounded overflow-hidden">
{conns.data?.map((c) => (
<li key={c.id} className="flex items-center gap-3 p-3 hover:bg-slate-900">
<span className="flex-1">{c.name}</span>
<span className="text-xs text-slate-400">{c.providerId}</span>
<button
onClick={() => setOpenForm(c.id)}
className="text-slate-400 hover:text-slate-200"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => confirm(`Delete "${c.name}"?`) && remove.mutate(c.id)}
className="text-slate-400 hover:text-red-400"
@@ -62,30 +103,39 @@ export default function ConnectionsPage() {
);
}
function CreateForm({
providers, onCreate, onCancel, submitting,
function ConnectionForm({
mode, providers, initialProviderId, initialName, initialConfig,
submitting, onSubmit, onCancel,
}: {
providers: { id: string; displayName: string; configSchema: import("@/api/types").JsonSchemaObject }[];
onCreate: (b: { providerId: string; name: string; config: Record<string, unknown> }) => void;
onCancel: () => void;
mode: "create" | "edit";
providers: ProviderDto[];
initialProviderId: string;
initialName: string;
initialConfig: Record<string, unknown>;
submitting: boolean;
onSubmit: (b: { providerId: string; name: string; config: Record<string, unknown> }) => void;
onCancel: () => void;
}) {
const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
const [name, setName] = useState("");
const [config, setConfig] = useState<Record<string, unknown>>({});
const [providerId, setProviderId] = useState(initialProviderId);
const [name, setName] = useState(initialName);
const [config, setConfig] = useState<Record<string, unknown>>(initialConfig);
const provider = providers.find((p) => p.id === providerId);
return (
<form
className="bg-slate-900 border border-slate-800 rounded p-4 space-y-3"
onSubmit={(e) => { e.preventDefault(); if (providerId && name.trim()) onCreate({ providerId, name: name.trim(), config }); }}
onSubmit={(e) => {
e.preventDefault();
if (providerId && name.trim()) onSubmit({ providerId, name: name.trim(), config });
}}
>
<label className="block text-sm space-y-1">
<span className="text-slate-300">Provider</span>
<select
value={providerId}
onChange={(e) => { setProviderId(e.target.value); setConfig({}); }}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
disabled={mode === "edit"}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm disabled:opacity-60"
>
{providers.map((p) => (<option key={p.id} value={p.id}>{p.displayName}</option>))}
</select>
@@ -99,13 +149,19 @@ function CreateForm({
/>
</label>
{provider && <SchemaForm schema={provider.configSchema} value={config} onChange={setConfig} />}
{mode === "edit" && (
<p className="text-xs text-slate-500">
Secret fields (password etc.) come back blank on load — leave them blank to keep the
existing value, or type a new value to rotate it.
</p>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={submitting}
className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm"
>
Create
{mode === "edit" ? "Save" : "Create"}
</button>
<button type="button" onClick={onCancel} className="text-sm text-slate-400">Cancel</button>
</div>