diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx index 51e94bf8..8c9caeba 100644 --- a/app/src/app/(dashboard)/connections/page.tsx +++ b/app/src/app/(dashboard)/connections/page.tsx @@ -1103,19 +1103,7 @@ export default function ConnectionsPage() {
- {!connections?.length ? ( - } - title="No connections yet" - description="Add your first database connection to start querying data." - action={ - - } - /> - ) : ( + {connections?.length ? (
{connections.map((c) => { const status = getConnectionStatus(c.id); @@ -1148,6 +1136,18 @@ export default function ConnectionsPage() { ); })}
+ ) : ( + } + title="No connections yet" + description="Add your first database connection to start querying data." + action={ + + } + /> )}
diff --git a/app/src/app/api/connections/[id]/__tests__/route.test.ts b/app/src/app/api/connections/[id]/__tests__/route.test.ts index b73482ad..1fa0b0dc 100644 --- a/app/src/app/api/connections/[id]/__tests__/route.test.ts +++ b/app/src/app/api/connections/[id]/__tests__/route.test.ts @@ -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 })); @@ -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", @@ -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", diff --git a/app/src/app/api/connections/[id]/reassign/route.ts b/app/src/app/api/connections/[id]/reassign/route.ts index f4ce83b2..9d0d2f32 100644 --- a/app/src/app/api/connections/[id]/reassign/route.ts +++ b/app/src/app/api/connections/[id]/reassign/route.ts @@ -7,6 +7,7 @@ import { validateBody, notFound, badRequest, + forbidden, handleRouteError, } from "@/lib/api/api-utils"; import { apiSuccess } from "@/lib/api/api-response"; @@ -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"; diff --git a/app/src/app/api/connections/[id]/route.ts b/app/src/app/api/connections/[id]/route.ts index 8fee0b36..84fdea65 100644 --- a/app/src/app/api/connections/[id]/route.ts +++ b/app/src/app/api/connections/[id]/route.ts @@ -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 { @@ -98,11 +100,16 @@ export async function PATCH( const updates: Record = {}; 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( @@ -112,12 +119,16 @@ export async function PATCH( ), ) .limit(1); + if (existing?.configEncrypted) { try { - const prev = decryptJson>( + const prev = decryptJson( 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( @@ -125,9 +136,9 @@ export async function PATCH( ); } } - } - if (finalConfig) updates.configEncrypted = encryptJson(finalConfig); + updates.configEncrypted = encryptJson(finalConfig); + } const [connection] = await db .update(connections) @@ -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( @@ -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) @@ -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( + 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"); diff --git a/app/src/app/api/query/route.ts b/app/src/app/api/query/route.ts index a4c45769..d7f0eeb1 100644 --- a/app/src/app/api/query/route.ts +++ b/app/src/app/api/query/route.ts @@ -140,7 +140,7 @@ async function handleReadQuery(request: Request): Promise { 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, }), diff --git a/app/src/app/api/query/write/route.ts b/app/src/app/api/query/write/route.ts index 26484709..dbad7366 100644 --- a/app/src/app/api/query/write/route.ts +++ b/app/src/app/api/query/write/route.ts @@ -84,7 +84,7 @@ async function handleWriteQuery(request: Request): Promise { 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" }, diff --git a/app/src/components/widget-editor/template-browser.tsx b/app/src/components/widget-editor/template-browser.tsx index 6601014c..a5b1f537 100644 --- a/app/src/components/widget-editor/template-browser.tsx +++ b/app/src/components/widget-editor/template-browser.tsx @@ -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, diff --git a/app/src/components/widget-editor/widget-preview-panel.tsx b/app/src/components/widget-editor/widget-preview-panel.tsx index 77f2a10f..89f931df 100644 --- a/app/src/components/widget-editor/widget-preview-panel.tsx +++ b/app/src/components/widget-editor/widget-preview-panel.tsx @@ -23,7 +23,7 @@ interface PreviewData { resultId: string; } -interface WidgetPreviewPanelProps { +type WidgetPreviewPanelProps = Readonly<{ chartType: string; connectionId: string; query: string; @@ -59,6 +59,150 @@ interface WidgetPreviewPanelProps { }; initialPreviewData: PreviewData | undefined; onRunPreview: () => void; +}>; + +function renderMarkdown(chartOptions: Record) { + return ( + + ); +} + +function renderIframe(chartOptions: Record) { + return ( + + ); +} + +function renderParamSelect(props: { + paramUIType: ParamUIType; + dateSub: DateSubType; + multiSelect: boolean; + paramWidgetName: string; + chartOptions: Record; + seedPreviewOptions: { value: string; label: string }[] | null; + seedQueryPending: boolean; + seedQueryError: string | null; +}) { + return ( + + ); +} + +function renderForm( + formFields: FormFieldDef[], + chartOptions: Record, +) { + if (formFields.length > 0) { + return ( +
+ {formFields.map((f) => ( +
+ +
+ {f.parameterType} +
+
+ ))} + +
+ ); + } + return ( +
+ Add fields in the Fields section below to see the form preview +
+ ); +} + +function renderChart(props: { + chartType: string; + connectionId: string; + query: string; + title: string; + chartOptions: Record; + colorScales: Array<{ column: string; minColor: string; maxColor: string }>; + transforms: Transform[]; + transformsEnabled: boolean; + buildStylingConfig: () => StylingConfig | undefined; + previewQuery: WidgetPreviewPanelProps["previewQuery"]; + initialPreviewData: PreviewData | undefined; +}) { + const { + chartType, + connectionId, + query, + title, + chartOptions, + colorScales, + transforms, + transformsEnabled, + buildStylingConfig, + previewQuery, + initialPreviewData, + } = props; + + return ( + <> + {previewQuery.isPending && ( +
+
+
+ )} + {previewQuery.isError && !previewQuery.data && !initialPreviewData ? ( +
+ +

Query failed

+

+ {previewQuery.error?.message} +

+
+ ) : previewQuery.data || initialPreviewData ? ( + + ) : connectionId && query.trim() && !previewQuery.isError ? ( +
+
+
+ ) : ( +
+ Run a query to see the preview +
+ )} + + ); } export function WidgetPreviewPanel({ @@ -89,6 +233,37 @@ export function WidgetPreviewPanel({ initialPreviewData, onRunPreview, }: WidgetPreviewPanelProps) { + function renderPreviewContent() { + if (isMarkdown) return renderMarkdown(chartOptions); + if (isIframe) return renderIframe(chartOptions); + if (isParamSelect) { + return renderParamSelect({ + paramUIType, + dateSub, + multiSelect, + paramWidgetName, + chartOptions, + seedPreviewOptions, + seedQueryPending, + seedQueryError, + }); + } + if (isForm) return renderForm(formFields, chartOptions); + return renderChart({ + chartType, + connectionId, + query, + title, + chartOptions, + colorScales, + transforms, + transformsEnabled, + buildStylingConfig, + previewQuery, + initialPreviewData, + }); + } + return (
@@ -135,102 +310,7 @@ export function WidgetPreviewPanel({ data-testid="widget-preview" className="h-[500px] flex-shrink-0 overflow-hidden border rounded-lg relative" > - {isMarkdown ? ( - - ) : isIframe ? ( - - ) : isParamSelect ? ( - - ) : isForm ? ( - formFields.length > 0 ? ( -
- {formFields.map((f) => ( -
- -
- {f.parameterType} -
-
- ))} - -
- ) : ( -
- Add fields in the Fields section below to see the form preview -
- ) - ) : ( - <> - {previewQuery.isPending && ( -
-
-
- )} - {previewQuery.isError && - !previewQuery.data && - !initialPreviewData ? ( -
- -

- Query failed -

-

- {previewQuery.error?.message} -

-
- ) : previewQuery.data || initialPreviewData ? ( - - ) : connectionId && query.trim() && !previewQuery.isError ? ( -
-
-
- ) : ( -
- Run a query to see the preview -
- )} - - )} + {renderPreviewContent()}
); diff --git a/app/src/lib/db/connection-reassign.ts b/app/src/lib/db/connection-reassign.ts index f5d2b0bf..ee8535b1 100644 --- a/app/src/lib/db/connection-reassign.ts +++ b/app/src/lib/db/connection-reassign.ts @@ -12,8 +12,8 @@ export interface ReassignResult { /** * Common WHERE clause that scopes dashboards to ones the caller can - * actually edit. Non-admins need ownership OR a shared editor/owner - * role; public read access does NOT grant edit rights. + * actually edit. Non-admins need ownership OR a shared editor role; + * public read access does NOT grant edit rights. */ function editableDashboardsScope( userId: string, @@ -32,7 +32,7 @@ function editableDashboardsScope( WHERE s."dashboardId" = d.id AND s."userId" = ${userId} AND s.tenant_id = ${tenantId} - AND s.role IN ('editor', 'owner') + AND s.role = 'editor' ) ) `; @@ -84,7 +84,7 @@ async function countReassignable( * Scoping for editing is narrower than for viewing: * - Admin → every dashboard in the tenant * - Non-admin → dashboards the user owns OR has been shared with an - * 'editor' or 'owner' role. Public read access does NOT grant edit. + * 'editor' role. Public read access does NOT grant edit. * * Query compatibility is NOT validated here — that's documented in * the issue spec (#510). The caller is responsible for enforcing type @@ -127,19 +127,23 @@ export async function reassignConnectionWidgets( jsonb_set( page, '{widgets}', - ( - SELECT jsonb_agg( - CASE - WHEN widget->>'connectionId' = ${fromConnectionId} - THEN jsonb_set(widget, '{connectionId}', to_jsonb(${toConnectionId}::text)) - ELSE widget - END - ) - FROM jsonb_array_elements(page->'widgets') AS widget + COALESCE( + ( + SELECT jsonb_agg( + CASE + WHEN widget->>'connectionId' = ${fromConnectionId} + THEN jsonb_set(widget, '{connectionId}', to_jsonb(${toConnectionId}::text)) + ELSE widget + END + ) + FROM jsonb_array_elements(page->'widgets') AS widget + ), + '[]'::jsonb ) ) + ORDER BY page_ord ) - FROM jsonb_array_elements(d."layoutJson"->'pages') AS page + FROM jsonb_array_elements(d."layoutJson"->'pages') WITH ORDINALITY AS t(page, page_ord) ) ) WHERE ${editableDashboardsScope(userId, isAdmin, tenantId)} diff --git a/app/src/lib/log-anonymizer.ts b/app/src/lib/log-anonymizer.ts index 2536dc09..9f39e59c 100644 --- a/app/src/lib/log-anonymizer.ts +++ b/app/src/lib/log-anonymizer.ts @@ -93,7 +93,7 @@ export function anonymizeLogRecord( continue; } if (isPlainObject(value)) { - output[key] = anonymizeLogRecord(value as Record); + output[key] = anonymizeLogRecord(value); continue; } output[key] = value; diff --git a/cli/src/commands/demo.ts b/cli/src/commands/demo.ts index d7bdfdbd..b0e77b3d 100644 --- a/cli/src/commands/demo.ts +++ b/cli/src/commands/demo.ts @@ -50,7 +50,7 @@ export async function runDemoSeed(opts?: { only?: string }): Promise { } const targets = onlyKeys - ? manifest.SHOWCASES.filter((s) => onlyKeys!.includes(s.key)) + ? manifest.SHOWCASES.filter((s) => onlyKeys.includes(s.key)) : manifest.SHOWCASES; info( diff --git a/scripts/__tests__/generate-plugin-imports.test.mjs b/scripts/__tests__/generate-plugin-imports.test.mjs index 25ada69e..05b12547 100644 --- a/scripts/__tests__/generate-plugin-imports.test.mjs +++ b/scripts/__tests__/generate-plugin-imports.test.mjs @@ -69,6 +69,41 @@ describe("validateEntry", () => { it("includes the index in error messages", () => { assert.match(validateEntry({}, 5), /plugins\[5\]/); }); + + it("rejects package with whitespace", () => { + assert.match( + validateEntry({ package: "foo bar" }, 0), + /must not contain whitespace, quotes, or backslashes/, + ); + }); + + it("rejects package with quotes", () => { + assert.match( + validateEntry({ package: 'foo"bar' }, 0), + /must not contain whitespace, quotes, or backslashes/, + ); + }); + + it("rejects package with backslashes", () => { + assert.match( + validateEntry({ package: "foo\\bar" }, 0), + /must not contain whitespace, quotes, or backslashes/, + ); + }); + + it("rejects invalid JS identifier for export", () => { + assert.match( + validateEntry({ package: "foo", export: "not-valid" }, 0), + /must be a valid JavaScript identifier/, + ); + }); + + it("allows 'default' as export without identifier check", () => { + assert.equal( + validateEntry({ package: "foo", export: "default" }, 0), + null, + ); + }); }); // --------------------------------------------------------------------------- diff --git a/scripts/generate-plugin-imports.mjs b/scripts/generate-plugin-imports.mjs index cd722333..614fdea0 100644 --- a/scripts/generate-plugin-imports.mjs +++ b/scripts/generate-plugin-imports.mjs @@ -48,12 +48,22 @@ export function validateEntry(entry, index) { if (typeof e.package !== "string" || e.package.trim() === "") { return `plugins[${index}].package must be a non-empty string`; } + if (/[\s"'\\]/.test(e.package)) { + return `plugins[${index}].package must not contain whitespace, quotes, or backslashes`; + } if ( e.export !== undefined && (typeof e.export !== "string" || e.export.trim() === "") ) { return `plugins[${index}].export must be a non-empty string when provided`; } + if ( + e.export !== undefined && + e.export !== "default" && + !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e.export) + ) { + return `plugins[${index}].export must be a valid JavaScript identifier`; + } if (e.overrides !== undefined && typeof e.overrides !== "boolean") { return `plugins[${index}].overrides must be a boolean when provided`; } @@ -139,10 +149,11 @@ export const EXTERNAL_PLUGINS: ExternalPluginEntry[] = []; const imports = entries .map((e, i) => { const alias = `externalPlugin${i}`; + const specifier = JSON.stringify(e.package); if (e.export === "default") { - return `import ${alias} from "${e.package}";`; + return `import ${alias} from ${specifier};`; } - return `import { ${e.export} as ${alias} } from "${e.package}";`; + return `import { ${e.export} as ${alias} } from ${specifier};`; }) .join("\n");