Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions app/src/app/(dashboard)/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
useCreateConnection,
useUpdateConnection,
useDeleteConnection,
useReassignConnection,
useTestConnection,
useTestInlineConnection,
} from "@/hooks/use-connections";
Expand Down Expand Up @@ -88,6 +89,12 @@
// count before the user commits. Hook is disabled when deleteTarget is
// null, so it only fires on the "open delete dialog" transition.
const deleteUsage = useConnectionUsage(deleteTarget);
// Reassign dialog state — opened from the delete dialog when the user
// chooses to migrate widgets instead of deleting them.
const [reassignTarget, setReassignTarget] = useState<string | null>(null);
const [reassignChoice, setReassignChoice] = useState<string>("");
const [reassignError, setReassignError] = useState<string | null>(null);
const reassignConnection = useReassignConnection();
const [showAdvanced, setShowAdvanced] = useState(false);
const autoTestedRef = useRef(false);
const editTargetIdRef = useRef<string | null>(null);
Expand Down Expand Up @@ -963,6 +970,19 @@
</li>
)}
</ul>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!deleteTarget) return;
setReassignTarget(deleteTarget);
setReassignChoice("");
setReassignError(null);
setDeleteTarget(null);
}}
>
Re-assign widgets to another connection…
</Button>
</div>
) : (
"This connection is not used by any widget. It will be permanently deleted."
Expand All @@ -985,6 +1005,102 @@
}}
/>

<Dialog
open={reassignTarget !== null}
onOpenChange={(open: boolean) => {
if (!open) {
setReassignTarget(null);
setReassignChoice("");
setReassignError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Re-assign widgets</DialogTitle>
</DialogHeader>
{(() => {
const sourceConn =
reassignTarget != null

Check warning on line 1024 in app/src/app/(dashboard)/connections/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ2s6Lg-s4jV0PpjwvlE&open=AZ2s6Lg-s4jV0PpjwvlE&pullRequest=588
? connections?.find((c) => c.id === reassignTarget)
: null;
const compatible = (connections ?? []).filter(
(c) =>
c.id !== reassignTarget &&
sourceConn &&
c.type === sourceConn.type,
);
return (
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
Pick a {sourceConn?.type ?? ""} connection to migrate widgets
to. Queries on widgets are not validated against the target
schema — broken queries will show their usual error state.
</p>
{compatible.length === 0 ? (
<Alert>
<AlertDescription>
No compatible {sourceConn?.type ?? ""} connections
available. Create one first.
</AlertDescription>
</Alert>
) : (
<div className="space-y-1.5">
<Label htmlFor="reassign-target">Target connection</Label>
<select
id="reassign-target"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
value={reassignChoice}
onChange={(e) => setReassignChoice(e.target.value)}
>
<option value="">Select a connection…</option>
{compatible.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
)}
{reassignError && (
<Alert variant="destructive">
<AlertDescription>{reassignError}</AlertDescription>
</Alert>
)}
</div>
);
})()}
<DialogFooter>
<Button variant="outline" onClick={() => setReassignTarget(null)}>
Cancel
</Button>
<LoadingButton
loading={reassignConnection.isPending}
loadingText="Re-assigning…"
disabled={!reassignChoice || reassignConnection.isPending}
onClick={async () => {
if (!reassignTarget || !reassignChoice) return;
setReassignError(null);
try {
await reassignConnection.mutateAsync({
fromId: reassignTarget,
targetConnectionId: reassignChoice,
});
setReassignTarget(null);
setReassignChoice("");
} catch (err) {
setReassignError(
err instanceof Error ? err.message : "Re-assign failed",
);
}
}}
>
Re-assign
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>

<div className="mt-6">
<LoadingOverlay loading={isLoading} text="Loading connections...">
{!connections?.length ? (
Expand Down
214 changes: 214 additions & 0 deletions app/src/app/api/connections/[id]/reassign/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { makeSelectChain } from "@/__tests__/helpers/drizzle-mocks";
import { makeParams, makeRequest } from "@/__tests__/helpers/request-helpers";
import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

const mockRequireSession = vi.fn<
() => Promise<{
userId: string;
role: string;
canWrite: boolean;
tenantId: string;
}>
>();
const mockReassignConnectionWidgets = vi.fn();
const mockDb = { select: vi.fn() };

class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
}
}
class ForbiddenError extends Error {
constructor() {
super("Forbidden");
}
}

vi.mock("@/lib/auth/session", () => ({ requireSession: mockRequireSession }));
vi.mock("@/lib/db", () => ({ db: mockDb }));
vi.mock("@/lib/db/connection-reassign", () => ({
reassignConnectionWidgets: mockReassignConnectionWidgets,
}));
vi.mock("next/server", () => nextResponseMockFactory());
vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));

const SESSION = {
userId: "user-1",
role: "creator",
canWrite: true,
tenantId: "t1",
};
const ADMIN_SESSION = {
userId: "admin-1",
role: "admin",
canWrite: true,
tenantId: "t1",
};

describe("POST /api/connections/[id]/reassign", () => {
let POST: (
req: Request,
ctx: { params: Promise<{ id: string }> },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>;

beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
const mod = await import("../route");
POST = mod.POST;
});

it("returns 401 when unauthenticated", async () => {
mockRequireSession.mockRejectedValue(new UnauthorizedError());
const res = await POST(
makeRequest({ targetConnectionId: "t" }),
makeParams("c1"),
);
expect(res.status).toBe(401);
});

it("returns 400 when body is missing targetConnectionId", async () => {
mockRequireSession.mockResolvedValue(SESSION);
const res = await POST(makeRequest({}), makeParams("c1"));
expect(res.status).toBe(400);
});

it("returns 400 when source and target are the same connection", async () => {
mockRequireSession.mockResolvedValue(SESSION);
const res = await POST(
makeRequest({ targetConnectionId: "c1" }),
makeParams("c1"),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.message).toMatch(/different/i);
});

it("returns 404 when source connection is not owned", async () => {
mockRequireSession.mockResolvedValue(SESSION);
mockDb.select.mockReturnValueOnce(makeSelectChain([]));
const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(404);
});

it("returns 404 when target connection does not exist", async () => {
mockRequireSession.mockResolvedValue(SESSION);
mockDb.select
.mockReturnValueOnce(makeSelectChain([{ id: "c1", type: "postgresql" }]))
.mockReturnValueOnce(makeSelectChain([]));
const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error.message).toMatch(/target/i);
});

it("returns 400 when target type differs from source", async () => {
mockRequireSession.mockResolvedValue(SESSION);
mockDb.select
.mockReturnValueOnce(makeSelectChain([{ id: "c1", type: "postgresql" }]))
.mockReturnValueOnce(makeSelectChain([{ id: "c2", type: "neo4j" }]));
const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.message).toMatch(/neo4j/);
expect(body.error.message).toMatch(/postgresql/);
});

it("succeeds and returns reassign counts for a non-admin owner", async () => {
mockRequireSession.mockResolvedValue(SESSION);
mockDb.select
.mockReturnValueOnce(makeSelectChain([{ id: "c1", type: "postgresql" }]))
.mockReturnValueOnce(makeSelectChain([{ id: "c2", type: "postgresql" }]));
mockReassignConnectionWidgets.mockResolvedValue({
dashboardsUpdated: 3,
widgetsReassigned: 7,
});

const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ dashboardsUpdated: 3, widgetsReassigned: 7 });
expect(mockReassignConnectionWidgets).toHaveBeenCalledWith(
"c1",
"c2",
"user-1",
false,
"t1",
);
});

it("allows admins to reassign any connection in their tenant", async () => {
mockRequireSession.mockResolvedValue(ADMIN_SESSION);
mockDb.select
.mockReturnValueOnce(makeSelectChain([{ id: "c1", type: "neo4j" }]))
.mockReturnValueOnce(makeSelectChain([{ id: "c2", type: "neo4j" }]));
mockReassignConnectionWidgets.mockResolvedValue({
dashboardsUpdated: 0,
widgetsReassigned: 0,
});

const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(200);
expect(mockReassignConnectionWidgets).toHaveBeenCalledWith(
"c1",
"c2",
"admin-1",
true,
"t1",
);
});

it("returns zero counts when nothing uses the source connection", async () => {
mockRequireSession.mockResolvedValue(SESSION);
mockDb.select
.mockReturnValueOnce(makeSelectChain([{ id: "c1", type: "postgresql" }]))
.mockReturnValueOnce(makeSelectChain([{ id: "c2", type: "postgresql" }]));
mockReassignConnectionWidgets.mockResolvedValue({
dashboardsUpdated: 0,
widgetsReassigned: 0,
});

const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ dashboardsUpdated: 0, widgetsReassigned: 0 });
});

it("returns 500 when the reassign function throws", async () => {
mockRequireSession.mockResolvedValue(SESSION);
mockDb.select
.mockReturnValueOnce(makeSelectChain([{ id: "c1", type: "postgresql" }]))
.mockReturnValueOnce(makeSelectChain([{ id: "c2", type: "postgresql" }]));
mockReassignConnectionWidgets.mockRejectedValue(new Error("DB down"));

const res = await POST(
makeRequest({ targetConnectionId: "c2" }),
makeParams("c1"),
);
expect(res.status).toBe(500);
});
});
Loading
Loading