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
30 changes: 30 additions & 0 deletions .claude/hooks/check-migration-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
# Hook: Prevent editing existing migration files (forward-only migrations)
# Rule: "Forward-only. Idempotent." — CLAUDE.md
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -er '.tool_input.file_path // .tool_input.filePath // empty') || {
echo "BLOCKED: invalid hook payload (missing/invalid tool_input.file_path)" >&2
exit 2
}
[ -z "$FILE_PATH" ] && exit 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Only check migration files
case "$FILE_PATH" in
*migrations/*.sql|*migrations/*.ts)
# Allow creating NEW migration files (Write tool with no existing file)
TOOL_NAME=$(echo "$INPUT" | jq -er '.tool_name // empty') || TOOL_NAME=""
if [ "$TOOL_NAME" = "Write" ] && [ ! -f "$FILE_PATH" ]; then
exit 0
fi
# Block editing existing migration files
if [ -f "$FILE_PATH" ]; then
echo "BLOCKED: Cannot edit existing migration file: $(basename "$FILE_PATH")" >&2
echo "Rule: Migrations are forward-only. Create a new migration instead." >&2
echo "Use: npm run db:generate" >&2
exit 2
fi
;;
esac

exit 0
71 changes: 71 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# NeoBoard — Environment Variables
# Copy to app/.env.local and fill in values.
# For dev setup, scripts/setup.sh generates these automatically.

# PostgreSQL connection (required)
DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard

# 32-byte hex key for AES-256-GCM credential encryption (required)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WARNING: losing this key makes all stored credentials unrecoverable.
ENCRYPTION_KEY=

# Previous encryption key — set this when rotating ENCRYPTION_KEY (optional)
# Rotation flow: 1) copy current ENCRYPTION_KEY to ENCRYPTION_KEY_OLD,
# 2) generate and set a new ENCRYPTION_KEY, 3) restart the app,
# 4) call POST /api/admin/rotate-key (admin only) to re-encrypt all credentials,
# 5) remove ENCRYPTION_KEY_OLD after successful rotation.
# ENCRYPTION_KEY_OLD=

# Auth.js session secret (required)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
NEXTAUTH_SECRET=

# Application URL (required)
NEXTAUTH_URL=http://localhost:3000

# One-time token for creating the first admin account via /signup (optional, dev only)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ADMIN_BOOTSTRAP_TOKEN=

# HMAC secret for API key hashing — required if using API keys (optional)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
API_KEY_HMAC_SECRET=

# Self-registration toggle — set to "false" to disable /signup (optional, default: true)
# REGISTRATION_ENABLED=true

# Tenant ID — defaults to "default" if unset (optional)
# TENANT_ID=default

# Session max age in seconds — defaults to 28800 (8 hours) if unset (optional)
# SESSION_MAX_AGE=28800

# Log level — one of: fatal, error, warn, info, debug, trace (optional, default: info)
# LOG_LEVEL=info

# Per-user query rate limit — max queries per minute per user (optional, default: 60)
# QUERY_RATE_LIMIT=60

# ── SSO / OIDC (optional) ────────────────────────────────────────────────────
# Set all four required vars to enable a single OIDC provider via env.
# Requires NEOBOARD_EDITION=enterprise.
# For multiple providers, use the Admin UI (Settings > Authentication).

# Required (all four must be set to activate SSO)
# NEOBOARD_EDITION=enterprise
# OIDC_ISSUER=https://myorg.okta.com
# OIDC_CLIENT_ID=neoboard
# OIDC_CLIENT_SECRET=your-client-secret

# Optional
# OIDC_DISPLAY_NAME=Company SSO
# OIDC_SCOPES=openid profile email
# OIDC_CLAIM_KEY=groups
# OIDC_ADMIN_VALUE=neoboard-admins
# OIDC_CREATOR_VALUE=neoboard-editors
# OIDC_READER_VALUE=neoboard-viewers
# OIDC_AUTO_PROVISION=true
# OIDC_DEFAULT_ROLE=creator
# OIDC_ENFORCE_SSO=false

2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ on:
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [main, dev, 'release/*']
branches: [main, dev, 'release/*', 'feat/*']
paths:
- 'app/**'
- 'component/**'
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "enterprise"]
path = enterprise
url = https://github.com/alfredo1996/neoboard-enterprise.git
7 changes: 7 additions & 0 deletions app/e2e/widget-lab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,13 @@ test.describe("Widget Lab", () => {
// ── Widget Lab consumption: duplicate, filter, search ───────────────

test.describe("Widget Lab consumption", () => {
// Tests in this block share the same template names ("Neo4j Bar Template",
// "PostgreSQL Table Template") in their beforeEach. With fullyParallel and
// 2 CI workers, two tests' beforeEach can race → two templates with the
// same name → strict-mode locator violation. Force serial execution so
// each test's beforeEach/afterEach owns the templates exclusively.
test.describe.configure({ mode: "serial" });

let templateIds: string[] = [];

test.beforeEach(async ({ page }) => {
Expand Down
13 changes: 9 additions & 4 deletions app/src/app/api/query/write/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,27 +242,32 @@ describe("POST /api/query/write", () => {
);
});

it("returns 500 when executeQuery throws", async () => {
it("returns 500 with sanitized message when executeQuery throws (no driver leak)", async () => {
mockRequireSession.mockResolvedValue(writerSession);
mockConnectionAndDashboard();
mockDecryptJson.mockReturnValue({
uri: "bolt://localhost",
username: "neo4j",
password: "pass",
});
mockExecuteQuery.mockRejectedValue(new Error("Driver error"));
// Driver errors echo user-supplied SQL — must never bleed into the
// response body (security/PII consideration).
mockExecuteQuery.mockRejectedValue(
new Error('syntax error at or near "THIS"'),
);

const res = await POST(
makeRequest({
connectionId: "c1",
query: "CREATE (n:Test)",
query: "THIS IS NOT VALID SQL",
widgetId: "w1",
dashboardId: "d1",
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toBe("Driver error");
expect(body.error.message).toBe("Write query execution failed");
expect(body.error.message).not.toMatch(/syntax error/i);
});

it("returns 404 when connection belongs to another user", async () => {
Expand Down
5 changes: 4 additions & 1 deletion app/src/app/api/query/write/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ async function handleWriteQuery(request: Request): Promise<Response> {
},
"write_query_failed",
);
return handleRouteError(error, "Write query execution failed");
// safeMessage: write queries echo user SQL in driver errors — never leak.
return handleRouteError(error, "Write query execution failed", {
safeMessage: true,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,23 @@
DashboardGrid: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dashboard-grid">{children}</div>
),
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange?: (open: boolean) => void;
}) =>
open ? (
<div data-testid="fullscreen-dialog" role="dialog">
<button
data-testid="fullscreen-dialog-close"
onClick={() => onOpenChange?.(false)}
>
close
</button>
{children}
</div>
) : null,
Expand Down Expand Up @@ -542,6 +556,88 @@
);
});

it("closes the fullscreen dialog and clears the pending ready timer", () => {
vi.useFakeTimers();
try {
renderWithProviders(<DashboardContainer page={makePage()} />);
const fullscreenBtn = screen.getByText("Fullscreen").closest("button");
act(() => {
fireEvent.click(fullscreenBtn!);
});
expect(screen.getByTestId("fullscreen-dialog")).toBeDefined();
// Close before the 250ms ready-timer fires — exercises closeFullscreen's
// clearTimeout branch.
act(() => {
fireEvent.click(screen.getByTestId("fullscreen-dialog-close"));
});
expect(screen.queryByTestId("fullscreen-dialog")).toBeNull();
// Advance past the ready-timer; if it weren't cleared it would attempt a
// setState on the now-closed dialog. No throw = success.
act(() => {
vi.advanceTimersByTime(500);
});
} finally {
vi.useRealTimers();
}
});

it("re-arming fullscreen clears the previous ready timer", () => {
vi.useFakeTimers();
try {
// Two widgets so we can open fullscreen on each in succession.
renderWithProviders(
<DashboardContainer
page={makePage([
makeWidget({ id: "w1", settings: { title: "Widget One" } }),
makeWidget({ id: "w2", settings: { title: "Widget Two" } }),
])}
/>,
);
const buttons = screen.getAllByText("Fullscreen");
act(() => {
fireEvent.click(buttons[0].closest("button")!);
});
expect(screen.getByTestId("fullscreen-title").textContent).toBe(
"Widget One",
);
// Re-arm before the first 250ms timer fires — exercises openFullscreen's
// clearTimeout branch (line: clear previous ref before setting new).
act(() => {
fireEvent.click(buttons[1].closest("button")!);
});
expect(screen.getByTestId("fullscreen-title").textContent).toBe(
"Widget Two",
);
act(() => {
vi.advanceTimersByTime(500);
});
} finally {
vi.useRealTimers();
}
});

it("unmounting with a pending fullscreen-ready timer clears it cleanly", () => {

Check failure on line 619 in app/src/components/__tests__/dashboard-container-branches.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add at least one assertion to this test case.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ4uZ4RN55rYU4Sfqs94&open=AZ4uZ4RN55rYU4Sfqs94&pullRequest=768
vi.useFakeTimers();
try {
const { unmount } = renderWithProviders(
<DashboardContainer page={makePage()} />,
);
const fullscreenBtn = screen.getByText("Fullscreen").closest("button");
act(() => {
fireEvent.click(fullscreenBtn!);
});
// Unmount before the 250ms timer fires — exercises the useEffect cleanup
// branch. Without it, the timer would call setState on a torn-down tree.
unmount();
act(() => {
vi.advanceTimersByTime(500);
});
// No unhandled "window is not defined" / "setState on unmounted" = pass.
} finally {
vi.useRealTimers();
}
});

it("renders the template-outdated RefreshCw header extra and opens sync dialog on click", () => {
mockIsTemplateOutdated.mockReturnValue(true);
const onSyncWidget = vi.fn();
Expand Down
48 changes: 43 additions & 5 deletions app/src/components/dashboard-container.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CardContainer } from "./card-container";
import {
Expand Down Expand Up @@ -109,15 +109,41 @@ export function DashboardContainer({
// settles. Without this, NVL reads the canvas dimensions mid-animation
// (at ~95% of final size) and the hit-test coordinates are permanently offset.
const [fullscreenReady, setFullscreenReady] = useState(false);
// Track the deferred-ready timer so we can clear it on unmount or when the
// dialog is closed before the animation settles. Without this, the timer
// can fire after the component unmounts and call setState on a torn-down
// tree (jsdom: "window is not defined"; browser: React unmounted-update
// warning).
const fullscreenReadyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const openFullscreen = useCallback((w: DashboardWidget) => {
setFullscreenReady(false);
setFullscreenWidget(w);
setTimeout(() => setFullscreenReady(true), 250);
if (fullscreenReadyTimerRef.current !== null) {
clearTimeout(fullscreenReadyTimerRef.current);
}
fullscreenReadyTimerRef.current = setTimeout(() => {
fullscreenReadyTimerRef.current = null;
setFullscreenReady(true);
}, 250);
}, []);
const closeFullscreen = useCallback(() => {
if (fullscreenReadyTimerRef.current !== null) {
clearTimeout(fullscreenReadyTimerRef.current);
fullscreenReadyTimerRef.current = null;
}
setFullscreenWidget(null);
setFullscreenReady(false);
}, []);
useEffect(() => {
return () => {
if (fullscreenReadyTimerRef.current !== null) {
clearTimeout(fullscreenReadyTimerRef.current);
fullscreenReadyTimerRef.current = null;
}
};
}, []);
const [pendingSyncWidget, setPendingSyncWidget] =
useState<DashboardWidget | null>(null);
const parameters = useParameterStore((s) => s.parameters);
Expand Down Expand Up @@ -282,14 +308,26 @@ export function DashboardContainer({
onRefresh={
showRefresh
? () => {
// Invalidate all TanStack Query entries matching this widget's
// connection + query combo. This triggers a refetch.
// Invalidate the TanStack Query entry for this widget so
// it refetches. We must mirror the prefix shape used by
// useWidgetQuery exactly:
// ["widget-query", connectionId, database, query, params, staleTime]
// Earlier we omitted `database`, which made position 2
// mismatch (null vs query string), so invalidation never
// matched and the refresh button silently no-op'd.
//
// We intentionally stop the prefix at `query` — the hook
// merges $param_xxx values into `params` at call time, so
// `widget.params` here is not deep-equal to the hook's
// mergedParams when parameters are referenced. Stopping
// at `query` guarantees prefix match for both the
// parameterless and parameterised cases.
void queryClient.invalidateQueries({
queryKey: [
"widget-query",
widget.connectionId,
widget.database ?? null,
widget.query,
widget.params,
],
});
}
Expand Down
Loading
Loading