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
26 changes: 13 additions & 13 deletions app/src/app/(dashboard)/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1103,19 +1103,7 @@ export default function ConnectionsPage() {

<div className="mt-6">
<LoadingOverlay loading={isLoading} text="Loading connections...">
{!connections?.length ? (
<EmptyState
icon={<Database className="h-12 w-12" />}
title="No connections yet"
description="Add your first database connection to start querying data."
action={
<Button onClick={() => openCreateDialog()}>
<Plus className="mr-2 h-4 w-4" />
Add your first connection
</Button>
}
/>
) : (
{connections?.length ? (
<div className="space-y-3">
{connections.map((c) => {
const status = getConnectionStatus(c.id);
Expand Down Expand Up @@ -1148,6 +1136,18 @@ export default function ConnectionsPage() {
);
})}
</div>
) : (
<EmptyState
icon={<Database className="h-12 w-12" />}
title="No connections yet"
description="Add your first database connection to start querying data."
action={
<Button onClick={() => openCreateDialog()}>
<Plus className="mr-2 h-4 w-4" />
Add your first connection
</Button>
}
/>
)}
</LoadingOverlay>
</div>
Expand Down
16 changes: 16 additions & 0 deletions app/src/app/api/connections/[id]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ vi.mock("@/lib/connector/schema-prefetch", () => ({
vi.mock("@/lib/db/connection-usage", () => ({
getConnectionUsage: mockGetConnectionUsage,
}));
const mockCloseConnection = vi.fn();
vi.mock("@/lib/query/query-executor", () => ({
closeConnection: mockCloseConnection,
}));
vi.mock("next/server", () => nextResponseMockFactory());
vi.mock("@/lib/auth/errors", () => ({ UnauthorizedError, ForbiddenError }));

Expand Down Expand Up @@ -315,6 +319,11 @@ describe("PATCH /api/connections/[id]", () => {

it("re-encrypts config and triggers prefetch", async () => {
mockRequireSession.mockResolvedValue(SESSION);
const existing = {
configEncrypted: "enc:existing",
type: "neo4j",
};
mockDb.select.mockReturnValue(makeSelectChain([existing]));
const updated = {
id: "c1",
name: "Neo4j",
Expand All @@ -339,6 +348,13 @@ describe("PATCH /api/connections/[id]", () => {
username: "neo4j",
password: "newpass",
});
expect(mockCloseConnection).toHaveBeenCalledWith("neo4j", {
uri: "bolt://localhost:7687",
username: "neo4j",
password: "secret",
database: "neo4j",
connectionTimeout: 5000,
});
expect(mockPrefetchSchema).toHaveBeenCalledWith("neo4j", {
uri: "bolt://new-host",
username: "neo4j",
Expand Down
4 changes: 3 additions & 1 deletion app/src/app/api/connections/[id]/reassign/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
validateBody,
notFound,
badRequest,
forbidden,
handleRouteError,
} from "@/lib/api/api-utils";
import { apiSuccess } from "@/lib/api/api-response";
Expand Down Expand Up @@ -40,7 +41,8 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId, role, tenantId } = await requireSession();
const { userId, role, canWrite, tenantId } = await requireSession();
if (!canWrite) return forbidden("Write permission required");
const { id } = await params;
const isAdmin = role === "admin";

Expand Down
52 changes: 45 additions & 7 deletions app/src/app/api/connections/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { connections } from "@/lib/db/schema";
import { requireSession } from "@/lib/auth/session";
import { encryptJson, decryptJson } from "@/lib/crypto/crypto";
import { prefetchSchema } from "@/lib/connector/schema-prefetch";
import { closeConnection } from "@/lib/query/query-executor";
import type { ConnectionCredentials } from "@/lib/query/query-executor";
import { updateConnectionSchema } from "@/lib/shared/schemas";
import type { ConnectorType } from "@/lib/connector/connector-types";
import {
Expand Down Expand Up @@ -98,11 +100,16 @@ export async function PATCH(
const updates: Record<string, unknown> = {};
if (result.data.name) updates.name = result.data.name;

// Fetch the existing row — needed for password merge and cache eviction.
let oldCredentials: ConnectionCredentials | null = null;
let finalConfig = result.data.config;
if (finalConfig && !finalConfig.password) {
// Password omitted — merge with existing encrypted config

if (finalConfig) {
const [existing] = await db
.select({ configEncrypted: connections.configEncrypted })
.select({
configEncrypted: connections.configEncrypted,
type: connections.type,
})
.from(connections)
.where(
and(
Expand All @@ -112,22 +119,26 @@ export async function PATCH(
),
)
.limit(1);

if (existing?.configEncrypted) {
try {
const prev = decryptJson<Record<string, unknown>>(
const prev = decryptJson<ConnectionCredentials>(
existing.configEncrypted,
);
finalConfig = { ...finalConfig, password: prev.password as string };
oldCredentials = prev;
if (!finalConfig.password) {
finalConfig = { ...finalConfig, password: prev.password };
}
} catch {
// Stored config is corrupted/unreadable — user must re-enter password
return badRequest(
"Stored credentials could not be decrypted. Please re-enter the password.",
);
}
}
}

if (finalConfig) updates.configEncrypted = encryptJson(finalConfig);
updates.configEncrypted = encryptJson(finalConfig);
}

const [connection] = await db
.update(connections)
Expand All @@ -151,6 +162,11 @@ export async function PATCH(
return notFound();
}

// Evict the old cached driver so stale credentials aren't reused
if (oldCredentials) {
closeConnection(connection.type as ConnectorType, oldCredentials);
}

// Fire-and-forget: re-warm the schema cache after credential update
if (finalConfig?.password) {
prefetchSchema(
Expand Down Expand Up @@ -212,6 +228,16 @@ export async function DELETE(
eq(connections.tenantId, tenantId),
);

// Fetch credentials before deletion so we can evict the cached driver
const [toDelete] = await db
.select({
type: connections.type,
configEncrypted: connections.configEncrypted,
})
.from(connections)
.where(whereClause)
.limit(1);

const deleted = await db
.delete(connections)
.where(whereClause)
Expand All @@ -221,6 +247,18 @@ export async function DELETE(
return notFound();
}

// Evict the cached driver so the connection pool is closed
if (toDelete?.configEncrypted) {
try {
const creds = decryptJson<ConnectionCredentials>(
toDelete.configEncrypted,
);
closeConnection(toDelete.type as ConnectorType, creds);
} catch {
// Corrupted credentials — nothing to evict
}
}

return apiSuccess({ deleted: true });
} catch (error) {
return handleRouteError(error, "Failed to delete connection");
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/api/query/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ async function handleReadQuery(request: Request): Promise<Response> {

const queryStart = performance.now();
const result = await runPipeline(ctx, async (pipelineCtx) =>
executeQuery(pipelineCtx.connectionType as DbType, credentials, {
executeQuery(pipelineCtx.connectionType, credentials, {
query: pipelineCtx.query,
params: pipelineCtx.params,
}),
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/api/query/write/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async function handleWriteQuery(request: Request): Promise<Response> {
const queryStart = performance.now();
const result = await runPipeline(ctx, async (pipelineCtx) =>
executeQuery(
pipelineCtx.connectionType as DbType,
pipelineCtx.connectionType,
credentials,
{ query: pipelineCtx.query, params: pipelineCtx.params },
{ accessMode: "WRITE" },
Expand Down
4 changes: 2 additions & 2 deletions app/src/components/widget-editor/template-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import {
CodePreview,
} from "@neoboard/components";

interface TemplateBrowserProps {
type TemplateBrowserProps = Readonly<{
templates: WidgetTemplate[] | undefined;
loading: boolean;
connectorType: ConnectorType | null;
onApply: (template: WidgetTemplate) => void;
onBack: () => void;
}
}>;

export function TemplateBrowser({
templates,
Expand Down
Loading
Loading