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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user